CSV가 나뉘는 방식부터 틀리면 정상처럼 보이는 앞의 행도 먼저 처리하면 안 된다. 나는 파일 전체의 구문 검사와 행별 의미 검토를 분리해, 무엇을 멈췄고 어떤 후보가 남았는지 돌려주는 작은 합성 모듈을 만들었다. 파일을 읽거나 상품을 등록하는 프로그램은 아니다.
파일을 나누는 단계가 먼저 통과해야 한다
review_csv는 파일 경로나 업로드 객체가 아니라 이미 명시적으로 디코딩한 문자열을 받는다. 먼저 전체 입력을 CSV 기록으로 해석하고 나서 헤더와 행을 검토한다. 뒤쪽의 따옴표가 닫히지 않아도 앞의 후보를 먼저 반환하지 않는다. 파싱 도중 만든 임시 행은 파일 구문이 실패하면 공개 검토 후보로 남기지 않는다.
따옴표로 감싼 쉼표와 줄바꿈, 이중 따옴표, BOM과 CRLF를 지원한다. 일반 셀 중간의 따옴표와 닫힌 따옴표 뒤의 허용되지 않은 문자는 거절한다. 닫힌 따옴표 뒤 공백과 탭은 허용한다. 파일의 문자 인코딩을 자동 추측하는 기능은 없으며 실제 파일을 열거나 원문을 내려받지도 않는다.
세 열과 작은 실험 범위를 명시했다
헤더는 앞뒤 공백을 정리한 label,amount,note 세 열이다. 헤더의 순서가 다르거나 열이 많고 적으면 파일을 거절한다. 입력은 최대 12,000자와 데이터 기록 100개로 제한했다. label은 공백을 정리한 뒤 1~80자이고 note는 200자 이하다. 이 제한은 작은 합성 실험을 위한 것이며 실제 회사의 파일 규격을 알아맞힌 것이 아니다.
빈 줄은 건너뛰고, 따옴표 안의 줄바꿈은 셀의 내용으로 남긴다. 결과의 record는 물리적인 텍스트 줄 번호가 아니라 비어 있지 않은 논리 CSV 기록의 번호다. 여러 줄짜리 셀이 있어도 검토 위치가 어떤 단위를 가리키는지 읽을 수 있게 한 선택이다. 실제 에디터의 줄 위치로 연결하려면 별도 위치 정보를 더해야 한다.
금액의 단위를 추측하지 않고 정수로 돌려준다
amount는 양의 소수 문자열이며 소수점 아래 두 자리까지만 받는다. 올바른 천 단위 쉼표를 지원하고 999999.99를 상한으로 뒀다. Decimal로 해석한 뒤 100을 곱한 정수 amount_minor를 돌려준다. 소수 두 자리를 가진 합성 단위라는 명시적 계약이며 통화 종류나 환율을 추정하지 않는다.
문자·기호·단위·음수·0·지수 표기·잘못된 쉼표·소수 세 자리는 오류다. 원래 앱은 양의 유한 숫자를 받는 별도 계약이고 이 예제보다 넓은 소수 표현을 허용한다. 그러므로 이 패키지를 그대로 붙이면 같은 입력이 모두 통과한다고 설명하지 않는다. 호출자는 실제 금액 단위와 허용 범위를 정한 뒤 연결해야 한다.
코드와 구현 살펴보기
독립 합성 전체 구현
"""Independent synthetic CSV review. No file I/O, callbacks, storage or network."""
import re
from decimal import Decimal
class CsvSyntaxError(ValueError):
pass
def parse_records(text):
if type(text) is not str:
raise TypeError('TEXT_REQUIRED')
if len(text)>12000:
raise ValueError('TEXT_LIMIT')
text=text.removeprefix('\ufeff')
rows=[];row=[];cell=[];mode='start';index=0
def end_cell():
nonlocal cell,mode
row.append(''.join(cell));cell=[];mode='start'
def end_row():
nonlocal row
end_cell()
if any(part.strip() for part in row):rows.append(row)
row=[]
while index<len(text):
char=text[index]
if mode=='quoted':
if char=='"':
if text[index+1:index+2]=='"':cell.append('"');index+=1
else:mode='closed'
else:cell.append(char)
elif char==',':end_cell()
elif char in '\r\n':
if char=='\r' and text[index+1:index+2]=='\n':index+=1
end_row()
elif mode=='closed':
if char not in ' \t':raise CsvSyntaxError('AFTER_QUOTE')
elif char=='"':
if mode!='start':raise CsvSyntaxError('QUOTE_IN_PLAIN_CELL')
mode='quoted'
else:cell.append(char);mode='plain'
index+=1
if mode=='quoted':raise CsvSyntaxError('UNCLOSED_QUOTE')
end_row()
return rows
def amount_minor(text):
if type(text) is not str:raise ValueError('AMOUNT_TEXT')
text=text.strip()
if not re.fullmatch(r'(?:[0-9]+|[0-9]{1,3}(?:,[0-9]{3})+)(?:\.[0-9]{1,2})?',text):
raise ValueError('AMOUNT_FORMAT')
value=Decimal(text.replace(',',''))
if not 0<value<=Decimal('999999.99'):raise ValueError('AMOUNT_RANGE')
return int(value*100)
def review_csv(text):
"""The input is a small, explicitly two-decimal synthetic contract.
record numbers are logical nonblank CSV records, never physical file lines.
Even clean rows are only candidates. There is no automatic commit policy.
"""
base={'file_ok':False,'all_rows_valid':False,'candidates':[],
'issues':[],'imported_count':0,'review_only':True,'performs_actions':False}
try:rows=parse_records(text)
except CsvSyntaxError as exc:return {**base,'phase':'syntax','issues':[{'record':None,'code':str(exc)}]}
except ValueError as exc:return {**base,'phase':'limit','issues':[{'record':None,'code':str(exc)}]}
if not rows or [part.strip() for part in rows[0]]!=['label','amount','note']:
return {**base,'phase':'header','issues':[{'record':1,'code':'HEADER'}]}
if len(rows)>101:return {**base,'phase':'limit','issues':[{'record':None,'code':'RECORD_LIMIT'}]}
candidates=[];issues=[]
for number,row in enumerate(rows[1:],2):
if len(row)!=3:
issues.append({'record':number,'code':'WIDTH'});continue
label,raw,note=[part.strip() for part in row]
codes=[]
if not label or len(label)>80:codes.append('LABEL')
if len(note)>200:codes.append('NOTE')
try:value=amount_minor(raw)
except ValueError:codes.append('AMOUNT')
if codes:issues.extend({'record':number,'code':code} for code in codes)
else:candidates.append({'label':label,'amount_minor':value,'note':note})
if len(rows)==1:issues.append({'record':None,'code':'EMPTY_BATCH'})
return {**base,'file_ok':True,'phase':'rows','candidates':candidates,
'issues':issues,'all_rows_valid':bool(candidates) and not issues}
파일 통과와 모든 행 정상은 다른 값이다
file_ok는 구문·헤더·파일 제한을 통과했다는 뜻이다. 행의 열 개수나 label·금액·note가 잘못되면 그 행의 issues를 남기고 다른 정상 행의 candidates는 보존한다. 문제가 하나라도 있으면 all_rows_valid는 false다. 헤더만 있고 자료가 없으면 EMPTY_BATCH로 남겨 빈 작업을 정상 반영처럼 만들지 않는다.
후보가 모두 정상이더라도 imported_count는 0, review_only는 true, performs_actions는 false다. 이 코드는 등록 콜백이나 실제 저장을 실행하지 않는다. 일부 후보를 반영할지, 전부 멈출지, 누가 확인할지는 호스트의 정책이다. 이 예제의 file_ok나 all_rows_valid를 로그인 권한이나 반영 승인으로 사용하지 않는다.
다섯 실행 기록에서 멈춘 경계를 읽는다
정상 따옴표 입력, 뒤쪽 구문 오류, 정상 후보와 행 오류가 함께 있는 입력, 숫자가 아닌 가격, 자료 없는 헤더의 다섯 합성 기록을 만들었다. 아래 선택기는 Python을 실행하지 않고 저장된 JSON을 보여 준다. 원래 상품 정보나 업체 파일을 이 기록에 섞지 않았다.
한국어와 영어 선택기의 초기 결과와 다섯 선택을 실제 인라인 스크립트의 DOM 대역으로 대조한다. 이 검사는 기록 뷰어의 이벤트 연결에 관한 것이다. 브라우저 화면·키보드·파일 업로드나 실제 CSV 입력 처리를 웹에서 검증한 결과로 바꾸지 않는다. 파서 실행은 ZIP 안의 Python 시험으로 따로 확인한다.
기존 검토 모듈과는 책임을 나눠 연결한다
공개 ZIP에는 독립 코드, 검사 24개, 실행 예제, 다섯 결과, README와 MIT 라이선스가 있다. 원본 소스와 압축을 푼 폴더에서 같은 검사를 실행한다. 정상 따옴표 입력뿐 아니라 구문 오류 뒤 후보 없음, 행 오류와 정상 후보의 공존, 금액 형식과 범위, 입력과 결과의 분리도 확인한다.
기존 행 검토 모듈의 id·item·quantity 계약과 이 예제의 label·amount·note 계약은 다르다. 상품 후보의 필드 출처 모듈과도 자동 통합하지 않았다. 적용자는 실제 식별자·중복·수정 충돌·권한·재시도·저장을 따로 연결해야 한다. 수정·재배포·상업적 재사용은 가능하지만 실제 업체 데이터 이관이나 원제품에 설치한 패키지로 소개하지 않는다.