CLI 검증 패턴과 Maybe Monads
Source: Dev.to
번역을 진행하려면 번역하고자 하는 전체 텍스트를 제공해 주세요. 현재는 링크만 포함되어 있어 실제 내용이 없으므로 번역이 불가능합니다. 텍스트를 복사해서 알려 주시면 바로 한국어로 번역해 드리겠습니다.
예시: 구성 파일 경로 검증
입력은 여러 검사를 통과해야 합니다:
- 경로가 존재한다
- 파일이다 (디렉터리가 아니다)
- 읽을 수 있다
.json확장자를 가진다- 필수 키가 포함된 유효한 JSON이다
import os
import json
def validate_config_file(path: str) -> dict:
if not os.path.exists(path):
raise ValueError(f"{path} does not exist")
if not os.path.isfile(path):
raise ValueError(f"{path} is not a file")
if not os.access(path, os.R_OK):
raise ValueError(f"{path} is not readable")
if not path.endswith('.json'):
raise ValueError(f"{path} must be .json")
try:
with open(path) as f:
config = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}")
required = ['database', 'api_key']
missing = [k for k in required if k not in config]
if missing:
raise ValueError(f"Missing keys: {missing}")
return config
함수는 동작하지만 구조적인 문제가 있습니다:
- 각 검사는 독립적인
if‑문으로 이루어져 있어, 검사를 추가하거나 제거하려면 여러 위치를 수정해야 합니다. - JSON 파싱이 중첩된
try/except안에 들어 있습니다. - 테스트를 위해 파일 시스템 상태를 모킹하거나 실제 파일을 생성해야 합니다.
Maybe 모나드 솔루션
Maybe 모나드는 각 검증 단계를 Success(value) 또는 Failure(error) 를 반환하는 함수로 표현합니다. 단계들은 bind 를 통해 합성되며, 이전 단계가 성공하면 값을 다음 함수에 전달하고, 실패하면 바로 중단됩니다.
from valid8r import parsers, validators
from valid8r.core.maybe import Maybe, Success, Failure
def validate_port(text: str) -> Maybe[int]:
"""Parse and validate a port number in range 1‑65535."""
return (
parsers.parse_int(text)
.bind(validators.minimum(1))
.bind(validators.maximum(65535))
)
# Usage
result = validate_port("8080")
match result:
case Success(port):
print(f"Starting server on port {port}")
case Failure(error):
print(f"Invalid port: {error}")
각 검증기는 값을 받아 Maybe[T] 를 반환하는 함수입니다. bind 메서드는 Success 를 풀어 값을 다음 함수에 전달하고 결과를 반환합니다. 현재 결과가 Failure 인 경우, bind 는 그대로 반환합니다.
체인 작동 방식
| 입력 | 단계 | 결과 |
|---|---|---|
"8080" | parsers.parse_int | Success(8080) |
.bind(validators.minimum(1)) | Success(8080) | |
.bind(validators.maximum(65535)) | Success(8080) | |
"70000" | parsers.parse_int | Success(70000) |
.bind(validators.minimum(1)) | Success(70000) | |
.bind(validators.maximum(65535)) | Failure("값은 최대 65535이어야 합니다") |
체인은 첫 번째 실패에서 중단되며, 이후 검증기는 실행되지 않습니다.
논리 연산자를 사용한 Validator 결합
Validator 클래스는 더 복잡한 규칙을 만들기 위해 & (and), | (or), ~ (not) 연산자를 지원합니다.
from valid8r import parsers, validators
from valid8r.core.maybe import Maybe
def validate_username(text: str) -> Maybe[str]:
"""Username: 3‑20 chars, alphanumeric with underscores."""
return parsers.parse_str(text).bind(
validators.length(3, 20)
& validators.matches_regex(r'^[a-zA-Z0-9_]+$')
)
def validate_age(text: str) -> Maybe[int]:
"""Age: positive integer, max 150."""
return parsers.parse_int(text).bind(
validators.minimum(0) & validators.maximum(150)
)
&는 두 validator가 모두 통과할 때만 통과하는 validator를 생성합니다.|는 두 validator 중 하나라도 통과하면 통과합니다.~는 validator를 반전시킵니다(즉, 감싸진 validator가 실패할 때 성공합니다).
from pathlib import Path
from valid8r import parsers, validators
from valid8r.core.maybe import Maybe
def validate_output_path(text: str) -> Maybe[Path]:
"""Output path: must be a directory **or** a writable file."""
return (
parsers.parse_path(text, resolve=True)
.bind(validators.exists())
.bind(validators.is_dir() | (validators.is_file() & validators.is_writable()))
)
def validate_safe_upload(text: str) -> Maybe[Path]:
"""Upload: must exist, be readable, **NOT** be executable."""
return parsers.parse_path(text).bind(
validators.exists()
& validators.is_readable()
& ~validators.is_executable()
)
~validators.is_executable() validator는 파일이 실행 가능하지 않을 때 성공하도록 원래 검사를 반전시킵니다.
파일‑시스템 검증 파이프라인
파일 시스템 검증은 종종 존재 여부, 유형, 권한, 내용 제약과 같은 여러 순차 검사를 필요로 합니다. Maybe 패턴은 이러한 검사를 조합 가능한 파이프라인으로 처리합니다.
from pathlib import Path
from valid8r import parsers, validators
from valid8r.core.maybe import Maybe, Success, Failure
def validate_config_file(path_str: str) -> Maybe[Path]:
"""Validate configuration file: exists, readable, YAML/JSON, under 1 MB."""
return (
parsers.parse_path(path_str, expand_user=True, resolve=True)
.bind(validators.exists())
.bind(validators.is_file())
.bind(validators.is_readable())
.bind(validators.has_extension(['.yaml', '.yml', '.json']))
.bind(validators.max_size(1024 * 1024))
)
def validate_upload_file(path_str: str) -> Maybe[Path]:
"""Validate uploaded file: PDF/DOCX, readable, under 10 MB."""
return (
parsers.parse_path(path_str)
.bind(validators.exists())
.bind(validators.is_file())
.bind(validators.has_extension(['.pdf', '.docx']))
.bind(validators.is_readable())
.bind(validators.max_size(10 * 1024 * 1024))
)
각 파이프라인은 왼쪽에서 오른쪽으로 자연스럽게 읽히며, 어느 단계에서든 실패가 발생하면 나머지 체인은 즉시 중단되고 도움이 되는 오류 메시지를 담은 Failure가 반환됩니다.
검증 파이프라인
d(validators.is_readable()) \
.bind(validators.has_extension(['.pdf', '.docx'])) \
.bind(validators.max_size(10 * 1024 * 1024))
CLI 핸들러에서 사용법
def handle_upload(file_path: str) -> dict:
match validate_upload_file(file_path):
case Success(path):
return {
'status': 'success',
'filename': path.name,
'size': path.stat().st_size
}
case Failure(error):
return {
'status': 'error',
'message': error
}
parse_path 함수는 expand_user=True( ~ 를 홈 디렉터리로 확장)와 resolve=True(절대 경로로 변환)와 같은 옵션을 지원합니다. 이 옵션들은 검증이 시작되기 전에 실행됩니다.
- 검증 단계를 추가하거나 제거하려면 한 줄만 변경하면 됩니다.
- 각 단계는 독립적으로 테스트할 수 있습니다.
- 오류 메시지는 어떤 검증이 실패했는지에 대한 컨텍스트와 함께 자동으로 전파됩니다.
ask를 사용한 인터랙티브 프롬프트
from valid8r import parsers, validators
from valid8r.prompt import ask
def get_user_config() -> dict:
"""검증 및 재시도를 포함한 사용자 설정 프롬프트."""
# 범위 검증이 있는 포트, 최대 3회 재시도
port_result = ask(
"Enter port (1-65535): ",
parser=parsers.parse_int,
validator=validators.between(1, 65535),
default=8080,
retry=3
)
# RFC 검증이 있는 이메일, 무제한 재시도
email_result = ask(
"Enter email: ",
parser=parsers.parse_email,
retry=True
)
# 다양한 형식(yes/no, true/false, y/n, 1/0)을 허용하는 Boolean
debug_result = ask(
"Enable debug mode? ",
parser=parsers.parse_bool,
default=False
)
return {
'port': port_result.value_or(8080),
'email': email_result.value_or(None),
'debug': debug_result.value_or(False)
}
ask 작동 방식
- 프롬프트 표시 – 기본값이 제공된 경우 기본값을 함께 보여줍니다.
- 입력 파싱 – 전달된 파서 함수를 사용해 입력을 파싱합니다.
- 검증 – (있는 경우) 검증자를 실행합니다.
- 실패 시 재시도 –
retry가True이거나 양의 정수일 때 오류를 표시하고 프롬프트를 다시 보여줍니다. - 반환 – 최종 결과를 담은
Maybe[T]를 반환합니다.
사용자 정의 검증 파이프라인
bind를 사용하여 검증자를 포함한 파서를 구성합니다:
def custom_port_parser(text: str) -> Maybe[int]:
return parsers.parse_int(text).bind(validators.between(1, 65535))
port_result = ask(
"Enter port: ",
parser=custom_port_parser,
retry=True
)
Argparse 통합
type_from_parser 어댑터는 valid8r 파서를 argparse에 연결합니다:
import argparse
from valid8r import parsers, validators
from valid8r.integrations.argparse import type_from_parser
from valid8r.core.maybe import Maybe
def port_parser(text: str) -> Maybe[int]:
return parsers.parse_int(text).bind(
validators.minimum(1) & validators.maximum(65535)
)
parser = argparse.ArgumentParser()
parser.add_argument(
'--email',
type=type_from_parser(parsers.parse_email),
required=True,
help='Email address'
)
parser.add_argument(
'--port',
type=type_from_parser(port_parser),
default=8080,
help='Port number (1-65535)'
)
args = parser.parse_args()
# args.email → EmailAddress(local='user', domain='example.com')
# args.port → int
검증에 실패하면 argparse는 Failure에서 온 오류 메시지를 표시하고 상태 2로 종료합니다. 이 메시지는 일반적인 타입 변환 오류가 아니라 검증기에서 생성된 것입니다.
Source: …
Maybe‑Monad 패턴의 트레이드‑오프
비용
| Issue | Explanation |
|---|---|
| Cognitive overhead | 모나드에 익숙하지 않은 개발자는 bind, map, Success, Failure를 배워야 합니다. 함수형 스타일은 일반적인 명령형 파이썬과 다릅니다. |
| Stack traces | 파이프라인 깊숙이 발생한 오류가 구체적인 검증 로직이 아니라 bind 내부를 가리키게 되므로 디버깅이 어려워집니다. 설명이 풍부한 오류 메시지가 필수적입니다. |
| Type inference | 복잡한 체인은 정적 타입 검사기를 혼란스럽게 할 수 있습니다; 명시적인 타입 어노테이션으로 이를 완화할 수 있습니다. |
| Overkill for simple cases | 간단한 검증의 경우 if not value: raise ValueError()와 같은 직관적인 코드가 더 명확합니다. |
효과가 나타나는 경우
- 여러 순차적인 검증 단계가 필요할 때.
- 오류 메시지를 파이프라인 전체에 걸쳐 그대로 전달해야 할 때.
- 검증 로직을 독립적으로 테스트할 수 있어야 할 때.
- 동일한 검증기들을 다양한 파이프라인에서 재사용할 때.
Maybe 모나는 검증을 흩어져 있던 조건문에서 조합 가능한 파이프라인으로 변환합니다. 각 검증기는 T → Maybe[T] 형태의 함수이며, bind를 사용해 체인합니다. 논리 연산자(&, |, ~)는 검증기를 결합하고, ask는 재시도 로직이 포함된 대화형 프롬프트를 추가합니다.
설치 및 리소스
pip install valid8r
- 소스 코드:
- 테스트된 버전:
valid8r 1.25.0Python 3.12에서.