10%와 0.1이 섞인 CSV 비율을 Python으로 같은 기준으로 바꾸기

fixtures/main.csv의 합성 입력을 설명하는 도해이며 실제 앱 실행 화면이 아닙니다. R1의 10%·percent와 R2의 0.1·ratio는 JSON canonical_ratio에 숫자가 아닌 십진 문자열 "0.1"로 남고, R3의 10·unknown은 HOLD와 null로 남습니다. 값의 크기나 % 기호만으로 배율을 추정하지 않습니다.

CSV의 10%와 0.1을 같은 비율로 읽으려면 입력의 배율부터 확인해야 합니다. 이 실습은 scale=percent인 10%와 scale=ratio인 0.1을 정확한 문자열 "0.1"로 바꿉니다. 배율이 unknown인 10은 계산하지 않고 HOLD, unknown_scale, null로 남깁니다.

값이 1보다 크다는 이유만으로 100으로 나누면 잘못된 배율도 그럴듯한 숫자가 됩니다. 이번 코드는 값 크기나 % 문자만 보고 배율을 추정하지 않습니다. 가상 CSV를 읽고 원문과 변환 근거를 새 JSON 하나에 남기는 Python 3.12 표준 라이브러리 실습입니다. 실제 세율·수수료·고객 자료나 외부 서비스 호출은 없습니다.

명시적 비율 배율 실습 ZIP 다운로드

1 percent와 ratio의 표기 계약을 먼저 정합니다

이 예제에서 scale은 원자료 제공자가 명시한 배율 정보입니다. 허용한 값은 정확히 소문자 percent와 ratio뿐입니다.

scale받는 raw_rate숫자 부분 범위canonical_ratio 만드는 방법
percent숫자 뒤 ASCII % 필수, 예: 10%0 이상 100 이하문자열 Decimal 값 ÷ 100
ratio% 없는 숫자, 예: 0.10 이상 1 이하문자열 Decimal 값을 그대로 사용
빈 문자열계산 보류검사하지 않음null, missing_scale
unknown 등 그 밖의 값계산 보류검사하지 않음null, unknown_scale

10,percent는 %가 없어서 missing_percent_suffix이고, 10%,ratio는 명시 배율과 기호가 충돌해서 unit_scale_conflict입니다. 10%,unknown도 %만으로 percent라고 승인하지 않습니다. Percent와 percent를 자동으로 정리해 받지도 않습니다.

이 계약은 0~1로 표현할 가상 비율을 선택한 연습입니다. 증가율처럼 100%를 넘거나 음수가 의미 있는 업무도 있으므로 모든 비율에 이 범위를 적용하지 마세요. 그런 업무는 범위와 부호 의미를 먼저 별도로 확정해야 합니다. 여기서는 초과값을 1로 맞추거나 음수를 0으로 바꾸지 않습니다.

숫자 부분은 ASCII 숫자 한 자리 이상, 선택적으로 점과 소수 한 자리 이상입니다. 앞자리 0은 허용하되 자릿수에 포함합니다. 전체 숫자는 최대 24자리, 소수는 최대 18자리입니다. +1, -0, .1, 1., 1e-1, NaN, Inf, 밑줄·공백·전각 숫자는 승인하지 않습니다. 원문 공백을 지우거나 값 일부만 잘라 쓰는 단계도 없습니다.

노트북 옆 서로 다른 색 탭이 붙은 자료 두 장을 비교하는 성인 손의 AI 연출 사진
AI로 만든 연출 이미지. 두 자료의 비율 표기 기준을 대조하는 업무 맥락이며, 실제 CSV 값이나 변환·검산 결과를 보여 주지 않습니다.

2 같은 세 행의 정답을 독립적으로 적습니다

ZIP을 새 빈 로컬 연습 폴더에 풀고 fixtures/main.csv를 텍스트 편집기로 엽니다. 다음 세 행은 모두 합성 입력입니다.

id,raw_rate,scale
R1,10%,percent
R2,0.1,ratio
R3,10,unknown
ID원문과 배율독립 계산statusreasoncanonical_ratio
R110%, percent10 ÷ 100 = 0.1PASSnull문자열 "0.1"
R20.1, ratio0.1 그대로PASSnull문자열 "0.1"
R310, unknown계산하지 않음HOLDunknown_scalenull
합성 입력 R1 10%·percent는 10÷100으로, R2 0.1·ratio는 그대로 canonical_ratio의 십진 문자열 "0.1"이 되고 R3 10·unknown은 계산하지 않아 HOLD·unknown_scale·null로 남는 설명용 도해. 입력 3행·PASS 2·HOLD 1이며 실제 앱 실행 화면이 아닙니다.
fixtures/main.csv와 expected/main.json의 합성 3행을 비교한 설명용 도해이며 실제 앱 실행 화면이 아닙니다. R1과 R2의 canonical_ratio는 각각 JSON 숫자가 아닌 십진 문자열 "0.1"이고, R3은 raw_rate="10", scale="unknown", status="HOLD", reason="unknown_scale", canonical_ratio=null로 보존됩니다. 입력 3행·PASS 2·HOLD 1이며 배율 추정과 반올림은 하지 않습니다.

정답은 입력 3행, PASS 2, HOLD 1입니다. R3을 0.1이나 10으로 변환한 결과는 이 실습의 정답이 아닙니다. R1과 R2의 결과가 같아도 원래 raw_rate와 scale은 서로 다른 채로 남아야 합니다.

expected/main.json은 실행 결과를 복사한 정답이 아니라 이 입력·계약에서 직접 명시한 기대 보고서입니다. expected/hand_check.txt에도 10/100=1/10과 경계값의 계산을 따로 적었습니다. 기대 JSON에서 source_sha256만 제외하며 해당 해시는 입력 파일 바이트에서 별도로 계산해 대조합니다.

파일 전체에는 다음 조건도 적용합니다.

  • UTF-8 또는 시작 UTF-8 BOM, 쉼표 구분 CSV이며 머리글은 정확히 id,raw_rate,scale 순서입니다
  • 모든 데이터 레코드는 세 열, 데이터 1~10,000레코드, 파일 1,000,000바이트까지 받습니다
  • ID는 ASCII 영문으로 시작하고 영문·숫자·밑줄·하이픈만 1~32자입니다. 대소문자를 구분하고 중복을 거부합니다
  • CSV 파서의 필드 한도는 4,096자입니다. 빈 레코드와 머리글만 있는 파일은 전체 오류입니다

Python csv.reader는 기본적으로 필드 문자열을 반환합니다. 자동 float 변환 옵션을 사용하지 않고 문자열 상태에서 배율과 숫자 표기를 먼저 검사합니다. strict=True가 감지하는 CSV 구문 오류 외에 머리글·열 수·ID 조건은 코드에서 따로 확인합니다.

3 시험을 실행하고 HOLD 보고서를 만듭니다

압축을 푼 README.txt가 있는 폴더에서 터미널을 엽니다. 추가 패키지와 API 키는 필요 없습니다. 실제 검수 환경은 Linux CPython 3.12.14입니다. Windows·macOS와 Microsoft Excel 앱의 실행·가져오기·화면 표시 동작은 시험하지 않았습니다.

python --version
python tests/verify_checksums.py
python -m unittest discover -s tests -v
python tests/run_fixture_checks.py

검수 버전은 Python 3.12.14, 단위시험의 마지막은 Ran 53 tests와 OK입니다. CLI 재검산기는 scenario_count=22, fixture_count=16, all_passed=true, all_source_bytes_unchanged=true를 반환합니다. python3가 같은 인터프리터를 가리킨다면 명령의 첫 단어만 바꿀 수 있습니다. 다른 환경에서는 제공 시험을 먼저 실행해 실패 원인을 확인한 뒤 진행하세요. unittest discover 안내

아직 없는 새 출력 이름으로 주 입력을 실행합니다. 출력 부모 폴더는 미리 있어야 하며 아래 명령은 현재 폴더에 JSON을 만듭니다.

python code/explicit_rates.py --input fixtures/main.csv --out main_result.json

실제로 확인한 콘솔은 다음과 같습니다.

HOLD 입력 3 / PASS 2 / HOLD 1 / 원본 쓰기 0

종료코드는 1이며 완성된 main_result.json이 생깁니다. 파일이 없어야 하는 저장 실패와 다릅니다. 보고서의 세 행을 끝까지 읽고 다음을 대조하세요.

  • status="HOLD", input_rows=3, pass_count=2, hold_count=1
  • R1과 R2는 각각 canonical_ratio="0.1", reason=null
  • R3은 raw_rate="10", scale="unknown", status="HOLD", reason="unknown_scale", canonical_ratio=null
  • policy.scale_inference=false, policy.rounding=false, source_changes_written=0

canonical_ratio는 JSON 숫자가 아니라 십진 문자열입니다. 다음 시스템에서도 정확한 십진 계산을 원한다면 이 문자열을 Decimal로 읽는 계약을 유지해야 합니다. JSON의 null을 가져온 앱이 빈칸으로 보여도 보류 이유까지 남았는지는 별도 확인하세요.

종료코드 1을 예상하는 명령을 &&로 연결하면 다음 명령이 실행되지 않을 수 있습니다. 아래의 반례 명령은 한 줄씩 실행합니다.

4 문자열 Decimal과 정확한 나눗셈을 사용합니다

Decimal(number_text)처럼 CSV에서 읽은 문자열을 바로 전달합니다. float로 먼저 바꾸지 않습니다. Decimal 생성자 문서는 float를 전달했을 때 이진 부동소수점의 정확한 값이 들어갈 수 있다고 설명합니다. 생성자는 지수·특수값·일부 Unicode 숫자도 받아들이므로 이 실습의 더 좁은 ASCII 문법을 먼저 검사합니다.

localcontext(Context(prec=32))는 이 계산에 독립된 유효숫자 정밀도를 적용합니다. 소수 32자리로 반올림하겠다는 뜻이 아닙니다. 입력 숫자가 최대 24자리이고 100으로 나누는 연산은 십진점 위치를 두 자리 옮기므로 유효숫자를 늘리지 않습니다. 결과는 소수가 최대 20자리까지 될 수 있지만 32자리 정밀도 안에서 정확히 표현할 수 있습니다.

의도하지 않은 값 손실은 Inexact trap으로 중단합니다. localcontext, Inexact 신호의 공식 설명에 근거한 구현입니다. 코드에는 반올림·quantize·clamp가 없습니다. 0.1000을 0.1로 저장하는 것은 끝의 0을 줄인 같은 값의 표기이며 원래 0.1000은 raw_rate에 남습니다.

5 ZIP과 같은 전체 코드

다음은 code/explicit_rates.py 전체입니다. 일부 함수만 복사하지 말고 같은 파일 이름으로 저장하세요.

"""Convert synthetic rates only when their declared scale is unambiguous."""
import argparse
import csv
import hashlib
import io
import json
import os
from pathlib import Path
import re
import stat
import sys
import tempfile
from decimal import Context, Decimal, DecimalException, FloatOperation, Inexact, localcontext

HEADER = ['id', 'raw_rate', 'scale']
ID = re.compile(r'[A-Za-z][A-Za-z0-9_-]{0,31}')
NUMBER = re.compile(r'[0-9]+(?:\.[0-9]+)?')
MAX_BYTES, MAX_ROWS, MAX_FIELD = 1_000_000, 10_000, 4096
POLICY = {'contract_version': 'explicit-percent-v1',
          'percent_requires_suffix': True, 'ratio_forbids_suffix': True,
          'ratio_min': '0', 'ratio_max': '1',
          'percent_min': '0', 'percent_max': '100',
          'max_digits': 24, 'max_fraction_digits': 18,
          'context_precision': 32, 'scale_inference': False, 'rounding': False}


def rate_value(raw, scale):
    if not isinstance(raw, str) or not isinstance(scale, str):
        raise ValueError('비율과 배율은 문자열로 전달하세요')
    if scale == '':
        return None, 'missing_scale'
    if scale not in ('percent', 'ratio'):
        return None, 'unknown_scale'
    if raw == '':
        return None, 'empty_rate'
    if any(char.isspace() for char in raw):
        return None, 'whitespace'
    if scale == 'ratio' and '%' in raw:
        return None, 'unit_scale_conflict'
    if scale == 'percent' and not raw.endswith('%'):
        return None, 'missing_percent_suffix'
    number_text = raw[:-1] if scale == 'percent' else raw
    if number_text.startswith('-'):
        return None, 'negative_not_allowed'
    if not NUMBER.fullmatch(number_text):
        return None, 'rate_format'
    digits = number_text.replace('.', '')
    fraction = number_text.partition('.')[2]
    if len(digits) > POLICY['max_digits']:
        return None, 'digit_limit'
    if len(fraction) > POLICY['max_fraction_digits']:
        return None, 'fraction_limit'
    # At most 24 input digits. Division by 100 shifts the decimal point only.
    with localcontext(Context(prec=POLICY['context_precision'])) as context:
        context.traps[FloatOperation] = True
        context.traps[Inexact] = True
        value = Decimal(number_text)
        maximum = Decimal('100' if scale == 'percent' else '1')
        if not value.is_finite() or not Decimal('0') <= value <= maximum:
            return None, 'out_of_range'
        ratio = value / Decimal('100') if scale == 'percent' else value
    text = format(ratio, 'f')
    if '.' in text:
        text = text.rstrip('0').rstrip('.')
    return ('0' if ratio == 0 else text), None


def plain_path(value, directory=False):
    path = Path(value)
    if '..' in path.parts:
        raise ValueError('.. 대신 직접 경로를 지정하세요')
    path = path.absolute()
    for node in [*reversed(path.parents), path]:
        info = node.lstat()
        if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
            raise ValueError('링크·reparse 경로는 사용하지 않습니다')
    info = path.stat()
    valid = stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)
    if not valid:
        raise ValueError('일반 폴더가 필요합니다' if directory else '일반 입력 파일이 필요합니다')
    if not directory and info.st_nlink != 1:
        raise ValueError('하드 링크 입력 대신 직접 복사한 파일을 사용하세요')
    return path


def read_source(value):
    path = plain_path(value)
    before = path.stat()
    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 (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
            raise ValueError('읽기 전 입력 파일이 바뀌었습니다')
        if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1:
            raise ValueError('입력 파일 종류·링크 수를 확인하세요')
        raw = handle.read(MAX_BYTES + 1)
        after = os.fstat(handle.fileno())
    if len(raw) > MAX_BYTES:
        raise ValueError('입력 한도 1,000,000바이트 초과')
    stamp = lambda s: (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns)
    if stamp(opened) != stamp(after) or len(raw) != opened.st_size:
        raise ValueError('읽는 중 입력 파일이 바뀌었습니다')
    return raw


def inspect(source):
    raw = read_source(source)
    try:
        text = raw.decode('utf-8-sig', errors='strict')
    except UnicodeDecodeError as error:
        raise ValueError('UTF-8 해석 실패: 제공자의 인코딩을 확인하세요') from error
    rows, seen = [], set()
    old_limit = 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('머리글은 정확히 id,raw_rate,scale 순서여야 합니다')
        for record, fields in enumerate(reader, 2):
            if record - 1 > MAX_ROWS:
                raise ValueError('데이터 레코드 한도 10,000행 초과')
            if len(fields) != 3:
                raise ValueError(f'레코드 {record}: 정확히 세 열이 필요합니다')
            ident, rate, scale = fields
            if not ID.fullmatch(ident) or ident in seen:
                raise ValueError(f'레코드 {record}: ID 형식 또는 중복을 확인하세요')
            seen.add(ident)
            canonical, reason = rate_value(rate, scale)
            rows.append({'source_record': record, 'id': ident,
                         'raw_rate': rate, 'scale': scale,
                         'status': 'PASS' if reason is None else 'HOLD',
                         'reason': reason, 'canonical_ratio': canonical})
    except csv.Error as error:
        raise ValueError('CSV 구문 또는 필드 길이 오류: ' + str(error)) from error
    except DecimalException as error:
        raise ValueError('Decimal 계산 실패: 범위·정밀도 계약을 확인하세요') from error
    finally:
        csv.field_size_limit(old_limit)
    if not rows:
        raise ValueError('데이터 레코드가 1개 이상 필요합니다')
    passed = sum(row['status'] == 'PASS' for row in rows)
    return {'policy': POLICY.copy(), 'status': 'PASS' if passed == len(rows) else 'HOLD',
            'input_rows': len(rows), 'pass_count': passed, 'hold_count': len(rows) - passed,
            'source_changes_written': 0, 'rows': rows,
            'source_sha256': hashlib.sha256(raw).hexdigest()}


def convert(source, target):
    if sys.version_info < (3, 12):
        raise ValueError('Python 3.12 이상이 필요합니다')
    source = plain_path(source)
    target = Path(target)
    if '..' in target.parts:
        raise ValueError('.. 대신 직접 출력 경로를 지정하세요')
    target = target.absolute()
    if source == target:
        raise ValueError('입력과 출력은 다른 경로여야 합니다')
    if os.path.lexists(target):
        raise ValueError('기존 출력 보존: 새 --out 이름을 지정하세요')
    if target.suffix.lower() != '.json':
        raise ValueError('출력은 새 .json 파일이어야 합니다')
    parent = plain_path(target.parent, directory=True)
    report = inspect(source)
    rendered = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + '\n'
    if hashlib.sha256(read_source(source)).hexdigest() != report['source_sha256']:
        raise ValueError('입력이 변경됐습니다: 동시 수정 없는 사본에서 다시 실행하세요')
    stage, published = None, False
    try:
        with tempfile.NamedTemporaryFile('w', encoding='utf-8', newline='\n',
                dir=parent, prefix='.p29-', suffix='.tmp', delete=False) as handle:
            stage = Path(handle.name)
            handle.write(rendered)
            handle.flush()
            os.fsync(handle.fileno())
        if json.loads(stage.read_text(encoding='utf-8')) != report:
            raise ValueError('임시 JSON 재읽기 결과가 다릅니다')
        try:
            os.link(stage, target)
        except FileExistsError as error:
            raise ValueError('기존 출력 보존: 확정 중 충돌, 새 이름을 지정하세요') from error
        published = True
    finally:
        if stage is not None:
            try:
                stage.unlink(missing_ok=True)
            except OSError as error:
                if not published:
                    raise
                print('경고: 완성 출력은 저장됐지만 임시 파일 정리에 실패했습니다: '
                      + str(error), file=sys.stderr)
    return report


def main(argv=None):
    parser = argparse.ArgumentParser(description='명시된 percent·ratio 배율로만 가상 CSV 변환')
    parser.add_argument('--input', required=True)
    parser.add_argument('--out', required=True)
    args = parser.parse_args(argv)
    try:
        report = convert(args.input, args.out)
    except (ValueError, OSError) as error:
        print('실행 실패: ' + str(error), file=sys.stderr)
        return 2
    print(f"{report['status']} 입력 {report['input_rows']} / PASS {report['pass_count']} / "
          f"HOLD {report['hold_count']} / 원본 쓰기 0")
    return 0 if report['status'] == 'PASS' else 1


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

rate_value()는 배율을 먼저 확인합니다. 모르는 배율이면 숫자처럼 보이는 값도 계산하지 않습니다. 여러 문제가 겹치면 코드 위쪽의 이유 하나가 우선합니다. 예를 들어 NaN,unknown은 unknown_scale, 빈 비율·빈 배율은 missing_scale입니다. 모든 문제를 동시에 열거하는 도구는 아닙니다.

raw_rate는 CSV 파서가 해석한 필드 값이며 CSV 구분용 따옴표까지 포함한 바이트 원문은 아닙니다. 파일 바이트는 변경하지 않습니다. source_record는 머리글을 1로 센 논리 레코드 번호이며 인용 필드 안 줄바꿈이 있으면 물리 줄 번호와 다를 수 있습니다. CSV 형식 옵션과 레코드 설명

시작 BOM은 utf-8-sig codec으로 처리합니다. 해석할 수 없는 UTF-8을 무시하거나 대체 문자로 바꿔 저장하지 않습니다.

6 0과 100% 및 가장 작은 소수를 대조합니다

다음 두 명령도 아직 없는 새 이름을 사용합니다.

python code/explicit_rates.py --input fixtures/normal.csv --out normal_result.json
python code/explicit_rates.py --input fixtures/boundary.csv --out boundary_result.json

normal은 2행 모두 PASS, boundary는 10행 모두 PASS이며 둘 다 종료코드 0입니다. 경계 파일의 주요 독립 기대값은 다음과 같습니다.

raw_ratescalecanonical_ratio확인할 경계
0%percent"0"명시된 0은 정상
100%percent"1"percent 상한 포함
0ratio"0"ratio 하한 포함
1ratio"1"ratio 상한 포함
99.999999999999999999%percent"0.99999999999999999999"끝의 소수를 반올림하지 않음
0.000000000000000001%percent"0.00000000000000000001"입력 소수 18자리, 결과 소수 20자리
100.000000000000000000%percent"1"상한의 소수 표기도 허용
000100.000000000000000000%percent"1"정수 6자리+소수 18자리=숫자 24자리
000000000000000000000010%percent"0.1"앞자리 0을 포함한 숫자 24자리
0.123456789012345678ratio"0.123456789012345678"ratio 소수 18자리 그대로

100.000000000000000001%와 1.000000000000000001,ratio는 각각 상한보다 조금 큽니다. 이를 100%나 1로 반올림해 통과시키지 않습니다. 숫자 25자리와 소수 19자리도 작은 값이라고 예외로 받지 않습니다. 허용 입력 길이와 실제 값 범위를 모두 검사합니다.

7 배율 모순과 숫자 오류를 HOLD로 남깁니다

fixtures/holds.csv에는 25개 보류 반례가 있습니다.

python code/explicit_rates.py --input fixtures/holds.csv --out holds_result.json

기대값은 HOLD 입력 25 / PASS 0 / HOLD 25 / 원본 쓰기 0, 종료코드 1입니다. 구조는 유효하므로 25행을 모두 담은 새 보고서가 생깁니다. 모든 보류 행에서 canonical_ratio=null이고 원문과 scale은 그대로입니다.

reason대표 입력원본을 보존하며 복구할 순서
unknown_scale / missing_scale10, unknown / 10, 빈 배율제공자가 배율을 확인할 때까지 계산 보류
missing_percent_suffix10, percent이 계약의 표기와 공급 규칙을 확인한 새 사본 준비
unit_scale_conflict10%, ratio기호와 명시 배율 중 어느 쪽이 원자료와 맞는지 확인
out_of_range100.000000000000000001%, percent업무상 범위와 단위를 확인, clamp로 숨기지 않음
negative_not_allowed-1%, percent / -0, ratio음수의 의미 확인, 임의 절댓값·0 대체 금지
rate_format / whitespace / empty_rateNaN%, percent / 앞 공백 / 빈 비율원자료의 숫자 표기·공백·누락 여부 확인
digit_limit / fraction_limit숫자 25자리 / 소수 19자리정밀도 계약 확인, 앞자리 0도 조용히 삭제하지 않음

범위가 맞더라도 배율이 잘못 선언되어 있으면 코드는 그 업무상 진위를 알아낼 수 없습니다. 0.1%,percent는 이 계약에서 정확히 0.001이며 0.1,ratio와 다릅니다. 같은 작은 값이라는 이유로 모두 0.1을 만들지 않습니다.

R3의 배율을 공급자가 percent로 확인하고 10% 표기를 승인했다는 가상 전제의 별도 사본이 fixtures/recovery.csv입니다. 원본 main.csv를 프로그램이 고친 결과가 아닙니다.

python code/explicit_rates.py --input fixtures/recovery.csv --out recovery_result.json

복구 사본은 3행 모두 PASS, 각각 "0.1", 종료코드 0입니다. 실제 파일에서도 배율의 근거를 확인한 뒤 승인된 새 사본을 만들고 이전 HOLD 보고서와 다른 이름으로 비교하세요. 결과를 먼저 정하고 원본 배율을 억지로 맞추지 않습니다.

8 파일 오류는 부분 결과 없이 전체 중단합니다

종료코드는 다음 세 경우를 구분합니다.

종료코드이번 실행의 최종 JSON의미
0새 완성 보고서 있음모든 행이 이 배율 계약에서 PASS
1새 완성 보고서 있음확인할 HOLD 행이 남음, 원문·scale·reason 확인
2새 최종 보고서 없음옵션·구조·인코딩·입력·접근·저장 오류

다음 네 명령은 각각 종료코드 2이며 지정한 새 파일이 없어야 합니다.

python code/explicit_rates.py --input fixtures/failures/bad_header.csv --out rejected_header.json
python code/explicit_rates.py --input fixtures/failures/duplicate_id.csv --out rejected_duplicate.json
python code/explicit_rates.py --input fixtures/failures/invalid_utf8.csv --out rejected_encoding.json
python code/explicit_rates.py --input fixtures/not_here.csv --out rejected_missing.json
  • 머리글·열 수 오류는 공급 규칙과 정확한 세 열을 확인한 별도 사본으로 전체 재실행합니다
  • 중복 ID는 행의 실제 식별 근거부터 확인합니다. 임의로 하나를 삭제하거나 합치지 않습니다
  • UTF-8 오류는 제공자의 인코딩을 확인한 뒤 새 UTF-8 사본의 내용까지 대조합니다
  • 누락 파일은 터미널의 작업 폴더와 실제 파일 위치를 확인합니다
  • late_unclosed.csv처럼 후반 인용문이 잘못되면 앞쪽 PASS 행만 완성 결과로 저장하지 않습니다

fixtures/failures의 10개 파일에는 위 반례 외에 잘못된 ID, 추가·누락 열, 머리글만 있음, 빈 파일·빈 레코드도 있습니다. 필수 옵션 누락과 승인하지 않은 --guess-scale도 argparse가 종료코드 2로 거부합니다. 자동 배율 추정으로 복구하는 옵션은 없습니다.

9 원본과 이전 결과를 보존하고 다시 실행합니다

앞에서 만든 main_result.json으로 같은 명령을 반복하면 기존 결과를 보존하고 종료코드 2로 멈춥니다. 다음 새 이름의 실행은 원래와 같은 HOLD 보고서, 종료코드 1입니다.

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

새 JSON은 출력 폴더의 NamedTemporaryFile에 끝까지 쓰고 flush·fsync·JSON 재읽기 대조를 마친 뒤 os.link로 새 최종 이름에 연결합니다. 확정 순간 이름이 선점되면 기존 내용을 보존합니다. 하드 링크 미지원 저장소에서는 중단하고 덮어쓰기 방식으로 바꾸지 않습니다.

입력과 출력의 같은 경로, 기존 파일·폴더·끊어진 심볼릭 링크, 입력·부모 경로의 심볼릭 링크와 검출 가능한 reparse 표식, 하드 링크 입력, ..가 있는 경로는 거부합니다. 경로 확인에는 Path.lstat를 씁니다. 본인에게 허용된 일반 로컬 연습 폴더와 직접 복사한 입력을 사용하세요.

시험 53개에서 정확한 배율·표기·범위·24/18 자릿수와 그 초과, 1,000,000바이트 및 10,000레코드 경계, 필드 길이·ID·BOM·CRLF, 원문·JSON 자료형과 기존 출력·재실행을 확인했습니다. 정밀도를 고의로 2로 낮춘 시험에서는 Inexact가 발생해 값 손실을 잡았습니다. 부분 쓰기·fsync·최종 링크 실패·JSON 재읽기 차이·읽기 권한 오류·입력 변경·이름 선점은 시험에서 통제해 만든 오류입니다. 실제 저장장치 사고를 경험한 사례가 아닙니다.

이 처리된 오류에서는 새 최종 출력이 없거나 선점된 기존 내용이 그대로였습니다. 확정 후 임시파일 정리만 실패하면 완성 JSON을 보존하고 경고를 냅니다. .p29- 임시파일은 완성 결과로 사용하지 마세요. 정리 권한도 실패하거나 강제 종료·정전이 있으면 임시파일이 남을 수 있습니다.

제공 입력 CSV 16개의 실행 전후 SHA-256이 같았으며 최종 ZIP을 별도 폴더에 풀어 같은 53개 시험과 22건 CLI 대조를 다시 실행했습니다. 본문·HTML·ZIP의 전체 코드도 동일하게 대조했습니다. 원본 해시는 SHA-256으로 읽은 바이트의 지문을 남긴 것입니다. 파일 잠금·지속 감시·작성자 인증 기능은 아닙니다.

적용 범위와 한계

이 글의 완료 조건은 명시 배율에 따라 같은 기준의 십진 문자열을 만들고, 배율 미확정과 모순을 원문·사유와 함께 보류하는 것입니다. 금액 반올림·수량×단가 관계 검산·결측 토큰의 업무 의미를 이 변환으로 해결하지 않습니다. 여러 비율의 합계·평균이나 실제 배율 선언의 진위도 판정하지 않습니다.

전체 입력과 보고서를 메모리에 보관하므로 대용량 성능을 측정하지 않았습니다. 한도를 늘리려면 자원 사용·정밀도 근거·경계 시험을 다시 정하세요. 다른 프로그램이 파일·폴더를 동시에 바꾸지 않는 개인 로컬 연습 폴더에서 한 작업씩 실행하는 전제입니다. 악의적인 동시 경로 교체, 공유·동기화 폴더의 변경, 정전·강제 종료까지 보호하는 보안 격리나 복구 보장 도구는 아닙니다.

실습 ZIP에는 전체 코드, 합성 CSV와 실패 반례 16개, 독립 기대 JSON 6개·손계산, 테스트·README·SHA-256 목록만 있습니다. 실제 출력과 검수용 로그는 미리 넣지 않았습니다.

자주 묻는 질문

10이 1보다 크니 100으로 나누면 되지 않나요

배율의 근거가 없습니다. unknown의 10은 이 실습에서 null로 보류합니다. percent가 확인돼도 %가 필수인 표기 계약을 함께 충족해야 합니다.

10%에서 기호를 떼면 충분하지 않나요

기호와 명시 배율이 함께 맞아야 합니다. ratio로 선언된 10%는 모순이라 보류하며, unknown의 10%도 배율을 추정하지 않습니다.

100% 초과를 무조건 오류로 처리하나요

이번 가상 입력 계약에서만 보류합니다. 다른 업무에서는 150%가 정상 증가율일 수 있으므로 범위·부호·배율 정책을 먼저 별도로 정해야 합니다.

왜 변환 결과를 JSON 문자열로 저장하나요

float를 거치지 않은 십진 표기를 유지하기 위해서입니다. 다음 시스템이 문자열을 다시 float로 바꾸면 이 계약이 이어지는지 새로 확인해야 합니다. 보류 null을 0으로 바꾸면 미확정과 실제 0의 구분이 사라집니다.

공식 자료와 관련 실습

배율을 맞춘 뒤에도 계산 관계는 별도로 확인합니다. 값이 비어 있는 이유부터 정해야 한다면 빈칸·0·미정 구분을 먼저 보세요. 수량×단가와 기재금액이 같은지 확인하는 방법은 행별 금액 검산에서 연습할 수 있습니다.

비율 변환의 PASS는 세금·할인 계산의 정확성을 뜻하지 않습니다. 금액 검산 예제도 세금·할인을 계산하지 않으므로 이 JSON을 바로 입력하거나 비율 열만 추가해 사용하지 마세요. 실제 적용에는 계산식·적용 순서·반올림 규칙과 새 기대값을 먼저 정해야 합니다. unknown 배율의 HOLD도 0%로 채우지 않습니다.

숫자와 표시 문자열을 나누는 TEXT 함수 검산에서는 원본 숫자와 보고용 문자열을 비교합니다. 이번 예제는 입력 배율이 다른 문자열을 같은 수치 기준으로 옮기는 작업입니다. 금액의 처리 자릿수와 단계는 Decimal CSV 반올림 기준의 별도 검산 목표를 확인하세요.

명시적 비율 배율 실습 ZIP 다운로드

공식 기술 자료 확인일은 2026-10-07 UTC입니다. 열람한 Python 3.12 계열 문서의 표제는 3.12.15였고 실제 실행 인터프리터는 CPython 3.12.14입니다. 공식 문서의 동작 설명, 이 글에서 선택한 입력 계약, 합성 자료의 실제 로컬 실행과 미실행 앱 환경을 구분했습니다.

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

작성자 소개