Metadata-Version: 2.5
Name: rscc-common
Version: 0.4.0
Summary: RSCC 공통 Python 라이브러리 — Java/JS 와 와이어 계약을 공유하는 응답 봉투·에러 코드·X-Trace-Id·ASGI 미들웨어, 회복탄력성(재시도·서킷 브레이커·격벽·TTL 캐시), 보안(JWT·AES-GCM·웹훅 서명·로그 리댁션), 한국 도메인 유틸
Project-URL: Homepage, https://github.com/Jeonghyeon-Ryu/r-common
Project-URL: Repository, https://github.com/Jeonghyeon-Ryu/r-common
Project-URL: Changelog, https://github.com/Jeonghyeon-Ryu/r-common/releases
Author: Jeonghyeon-Ryu
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: crypto
Requires-Dist: cryptography>=42; extra == 'crypto'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
Requires-Dist: httpx>=0.27; extra == 'fastapi'
Requires-Dist: pydantic>=2; extra == 'fastapi'
Description-Content-Type: text/markdown

# rscc-common

RSCC 공통 Python 라이브러리. 응답 봉투, 에러 코드, X-Trace-Id 전파, ASGI 미들웨어,
회복탄력성(재시도·서킷 브레이커·격벽·TTL 캐시), 보안(JWT·AES-GCM·웹훅 서명·로그 리댁션),
한국 도메인 유틸을 제공한다.

- **기본 설치는 의존성 0** (표준 라이브러리만). FastAPI/pydantic 통합과 암호화는 extra 로 opt-in.
- Java(`com.rscc:rscc-common-*`)·JS(`@rscc/common-*`) 구현과 **와이어 계약(응답 봉투·에러 코드·
  헤더 규약 등)과 공유 테스트 벡터**를 맞춘 폴리글랏 라이브러리의 Python 구현이다. 계약의 핵심
  규칙은 각 모듈 docstring 에 요약돼 있어 이 패키지만으로 사용할 수 있다.
- Python **>= 3.11** (상한 없음), 타입 힌트 포함(`py.typed`), 라이선스 MIT.
- **시간 단위는 초** (Java/JS 는 ms — `초 × 1000 == ms`).

## 설치

```bash
pip install rscc-common                    # 기본 — 의존성 0
pip install "rscc-common[fastapi]"         # + fastapi>=0.110, pydantic>=2, httpx>=0.27
pip install "rscc-common[crypto]"          # + cryptography>=42 (AES-256/GCM)
pip install "rscc-common[fastapi,crypto]"  # 둘 다
```

| extra | 설치되는 것 | 이 extra 가 있어야 임포트되는 모듈 |
|---|---|---|
| (없음) | — | 아래 표에서 `*` 표시가 없는 모든 모듈 |
| `fastapi` | fastapi>=0.110, **pydantic>=2** (v2 전용 API 사용), httpx>=0.27 | `response`, `handlers`, `health_fastapi` |
| `crypto` | cryptography>=42 (네이티브 휠) | `crypto` |

extra 없이 해당 모듈을 임포트하면 그 모듈만 `ImportError`/`ModuleNotFoundError` 이고,
패키지 자체(`import rscc_common`)와 나머지 모듈은 계속 동작한다. 순수 ASGI 미들웨어
(`install_exception_handlers` 를 제외한 `install_*`)와 httpx 훅은 fastapi/httpx 를 임포트하지
않으므로 기본 설치로도 임포트된다.

PyPI 대신 git 태그로 직설치할 수도 있다 (`X.Y.Z` 는 설치할 릴리스 버전):

```bash
pip install "rscc-common[fastapi] @ git+https://github.com/Jeonghyeon-Ryu/r-common.git@py-vX.Y.Z#subdirectory=python/rscc-common"
```

## 모듈 개요

`*` = extra 필요. 무의존 모듈은 패키지 최상위에서 서브모듈 네임스페이스로도 쓸 수 있고
(`from rscc_common import jwt`), 자주 쓰는 심볼은 최상위에 플랫 재노출된다
(`configure_logging`, `TraceIdFilter`, `install_*` 계열, `get_trace_id`/`set_trace_id`/
`reset_trace_id`/`new_trace_id`, `trace_request_hook`, `UserContext`/`get_user_context`,
`ResultCode`, `CommonException` 등).

### 웹·와이어 계약 (ASGI / FastAPI)

| 모듈 | 주요 API |
|---|---|
| `trace` | X-Trace-Id 규약 — `install_trace_middleware(app)`(수신 값 `[A-Za-z0-9._-]{1,64}` 이면 재사용, 아니면 8자리 hex 생성 → contextvar 바인딩 → 응답 에코), `get_trace_id`/`set_trace_id`/`reset_trace_id`/`new_trace_id`, httpx 발신 훅 `trace_request_hook`/`async_trace_request_hook` |
| `user_context` | 내부 `X-User-*` 헤더 → `UserContext` contextvar — `install_user_context_middleware(app)`, `get_user_context()`, httpx 훅 `user_context_request_hook` |
| `errors` | `ResultCode` enum(`.code` 문자열 / `.http_status` / `.message`), 비즈니스 예외 `CommonException(result_code, custom_message=None)` |
| `response` * | `CommonResponse` 봉투(`success/code/message/data`) — `ok(data)`/`fail(result_code, message=None, data=None)`, `to_wire()`(data 없으면 키 생략), `WireDateTime`, `BulkResultModel` |
| `handlers` * | `install_exception_handlers(app, *, validation_to_400=True)` — `CommonException`·검증 오류(기본 400)·`HTTPException`·catch-all 500 을 실패 봉투로 변환 |
| `validation` | `flatten_loc(loc)` — pydantic loc → 필드 경로(`items[0].name`) |
| `access_log` | `install_access_log(app, *, headers=(), include_query_string=False, slow_threshold_ms=None)` — 요청당 1라인, 민감 헤더 마스킹 |
| `security_headers` | `install_security_headers(app, *, hsts=False)` — 보안 응답 헤더 프리셋(앱이 실은 헤더 우선) |
| `rate_limit` | `install_rate_limit(app, *, capacity=20, refill_per_second=10.0)` — IP 키별 429 게이트, `TokenBucket` |
| `client_ip` | `resolve_client_ip`/`resolve_from_scope(scope, trusted_hops=…/trusted_proxies=…)` — 안전 기본값 0홉(전달 헤더 무시) |
| `idempotency` | `install_idempotency(app, store=None, …)` — `Idempotency-Key` 중복 시 409, `IdempotencyStore` Protocol / `InMemoryIdempotencyStore` |
| `bulk` | 부분 실패 봉투 — `BulkResultBuilder`, `BulkResult`/`BulkItem`, `to_wire`/`from_wire` |
| `query` | `parse_sort`(허용 목록 밖 → 400) / `parse_page`(관용 클램프) |
| `upload` | `validate_upload(filename, size, head, *, max_size_bytes, allowed_extensions)` — 크기→확장자→매직바이트 3중 검증, `sniff` |
| `health` / `health_fastapi` * | `HealthRegistry`, `CheckResult` / `create_health_router(registry)` — liveness·readiness(DOWN → 503) |
| `sse` | SSE 프레임 빌더 — `frame_delta`/`frame_sources`/`frame_error`/`frame_done` 등 |
| `datetimes` | 오프셋 없는 와이어 datetime — `to_wire_datetime`/`parse_wire_datetime`(Z·오프셋은 변환 없이 버림)/`strip_zone` |
| `feature_flags` | `FeatureFlags(source)` + `is_enabled(key)` — 참 집합 `{"true","1","on","yes"}`, `RSCC_FLAG_*` 환경변수 폴백 |
| `metrics` | 표준 메트릭·라벨 이름 문자열 상수 (계측 바인딩 없음) |

### 회복탄력성

| 모듈 | 주요 API |
|---|---|
| `retry` | `retry_call(fn, *, retries=3, should_retry=…, override_delay=…, max_elapsed=…)` / `retry_call_async` — 지수 백오프 + full jitter, `parse_retry_after`, `is_retryable_status` |
| `circuit_breaker` | `CircuitBreaker(failure_threshold=, open_duration=)` — **`call(fn, *args, **kwargs)` / `await call_async(fn, …)` 권장**(게이트 + 보고 1회 보장, 차단 시 `CircuitOpenError`), 수동 `allow_request`/`on_success`/`on_failure`/`on_ignore`, `CircuitState` |
| `bulkhead` | `Bulkhead(max_concurrent=, max_queue=0)`(`with`) / `AsyncBulkhead`(`async with`) — 만석 즉시 `BulkheadFullError` |
| `cache` | `TtlCache(ttl=, max_entries=)` / `AsyncTtlCache` — `get(key, loader)` single-flight(동시 호출은 로더 1회 공유, 실패·None 비캐시) |
| `rate_limit` | `TokenBucket(capacity=, refill_per_second=)` — `try_acquire(n=1)` 부족 시 대기 없이 False |

### 로깅·보안

| 모듈 | 주요 API |
|---|---|
| `logging` | `configure_logging(log_dir, level, app_name, *, include_trace_id=True, clear_root_handlers=False, per_pid_file=False, quiet_uvicorn=False, noisy_loggers=())` — 콘솔 + 자정 롤링 파일(gzip, 30일), 로그 라인에 traceId. `TraceIdFilter` — 자체 로깅 구성용 |
| `log_redact` | `install_secret_redaction()` / `redact_secrets(s)` — Bearer/JWT/API 키/주민번호/카드번호 → `[REDACTED:<type>]` |
| `log_sanitize` | `sanitize_log_value(value, max_length=…)` — 제어 문자 치환 + 절단(로그 인젝션 방지) |
| `jwt` | HS256 — `generate_token(user_id, secret, role=None, *, kid=None)`, `decode_token(token, secret=None, *, keys=None)`(kid 로테이션), `extract_subject`/`extract_role`, `JwtError` |
| `crypto` * | `encrypt(plain, key)` / `decrypt(cipher, key)` — AES-256/GCM, Base64(IV‖암호문‖태그), 실패 시 `CryptoError` |
| `webhook` | `sign(secret, payload)` → `t=…,v1=…` / `verify(secrets, header, payload)` — HMAC-SHA256, 다중 시크릿 로테이션, 스큐 300초 |
| `api_key` | `issue("live")`(또는 `"test"`), `is_well_formed`, `checksum_ok`, `sha256_hex` — `rscc_live_…` 48자 키 |
| `constant_time` | `compare_digest` — 시크릿·해시 상수시간 비교 |
| `masking` | `mask_name`/`mask_phone`/`mask_email`/`mask_card_number`/`mask_secret` |

### 한국 도메인 유틸

| 모듈 | 주요 API |
|---|---|
| `rrn` | `is_valid_rrn`(형식·성별코드·생년월일, 체크섬 미포함), `is_foreigner_rrn`, `birth_date_of`, `checksum_ok_legacy` |
| `bizno` | `is_valid_business_number`(사업자 10자리) / `is_valid_corporate_number`(법인 13자리) |
| `phone` | `normalize_phone`, `classify_phone`(mobile/landline/…), `format_phone`, `to_e164` |
| `age` | `age_man`(만 나이) / `age_by_year`(연 나이) / `age_insurance`(보험 나이) |
| `money` | `to_korean_words`, `to_formal_notation`, `abbreviate_amount`(예: `1.2억`) |
| `business_days` | `BusinessDays(holidays=…)` — 영업일 가감·판정(공휴일 주입형, 내장 테이블 없음) |
| `text.chosung` / `text.jamo` / `text.josa` | 초성 변환 `of`·`is_chosung_query` / 자모 분해·결합·혼합 질의 `matches` / 조사 선택 `pick`·`attach` |

## 빠른 시작

```python
import logging

import httpx
from fastapi import FastAPI

from rscc_common import configure_logging, install_trace_middleware, trace_request_hook
from rscc_common.errors import CommonException, ResultCode
from rscc_common.handlers import install_exception_handlers   # [fastapi]
from rscc_common.response import CommonResponse               # [fastapi]

configure_logging(log_dir="logs", level=logging.INFO, app_name="order-api", quiet_uvicorn=True)
# → [2026-09-26 12:00:00] INFO  [3f9a1c2e] order_api - 주문 조회: 7
#   (traceId 는 요청 컨텍스트 밖이면 "-". uvicorn --workers > 1 이면 per_pid_file=True 필수)

app = FastAPI()
install_trace_middleware(app)      # X-Trace-Id 수신 재사용/생성 → contextvar → 응답 에코
install_exception_handlers(app)    # 예외 → 실패 봉투 (CommonException·검증 400·HTTPException·500)

client = httpx.Client(event_hooks={"request": [trace_request_hook]})  # 다운스트림 전파
log = logging.getLogger("order_api")

@app.get("/orders/{order_id}")
def get_order(order_id: int):
    log.info("주문 조회: %s", order_id)
    if order_id > 1000:
        raise CommonException(ResultCode.NOT_FOUND)             # → 404 + {"success": false, "code": "404", ...}
    return CommonResponse.ok({"id": order_id}).to_wire()        # 반드시 to_wire() 반환
```

회복탄력성 — 재시도 안에서 서킷 브레이커로 게이트하고, OPEN 차단은 재시도하지 않는다:

```python
from rscc_common.circuit_breaker import CircuitBreaker, CircuitOpenError
from rscc_common.retry import retry_call

cb = CircuitBreaker(failure_threshold=5, open_duration=30.0)

data = retry_call(
    lambda attempt: cb.call(fetch_inventory, item_id),   # 비동기는 await cb.call_async(afetch, item_id)
    should_retry=lambda exc, attempt: not isinstance(exc, CircuitOpenError),
)
```

`call`/`call_async` 는 정상 반환 → 성공, `Exception` → 실패(같은 예외 재전파), 취소·인터럽트 등
그 밖의 `BaseException` → 판정 없는 반납(`on_ignore`)으로 **정확히 1회** 보고한다.

요청 밖(백그라운드 태스크 등)에서 traceId 잇기:

```python
from rscc_common import new_trace_id, reset_trace_id, set_trace_id

token = set_trace_id(captured_trace_id or new_trace_id())
try:
    do_background_work()     # 이 안의 로그 라인·httpx 발신에 traceId 가 실린다
finally:
    reset_trace_id(token)
```

자체 로깅 구성(dictConfig 등)을 쓰는 앱은 `configure_logging` 대신 `TraceIdFilter` 를
**핸들러**에 붙이고 포맷에 `%(trace_id)s` 를 넣는다:

```python
from rscc_common.logging import TraceIdFilter

handler = logging.StreamHandler()
handler.addFilter(TraceIdFilter())    # dictConfig: {"()": "rscc_common.logging.TraceIdFilter"}
handler.setFormatter(logging.Formatter("[%(asctime)s] %(levelname)-5s [%(trace_id)s] %(name)s - %(message)s"))
```

## 알려진 제약

- **미처리 예외의 500 응답과 X-Trace-Id** — 트레이스 미들웨어는 Starlette
  `ServerErrorMiddleware` 안쪽에서 실행되므로 500 응답에 에코하지 못한다.
  `install_exception_handlers` 의 catch-all 이 **수신 `X-Trace-Id` 가 유효하면 그 값을 500
  응답 헤더에 직접 에코**해 부분 보완한다. 수신 값이 없어 미들웨어가 새로 생성한 경우엔 500
  응답에 헤더가 없고, catch-all 의 에러 로그 라인도 traceId 가 `-` 로 찍힌다(그 시점엔
  contextvar 가 이미 정리됨). `HTTPException`·검증 오류 등 핸들러 처리 경로(4xx)는 정상 에코된다.
- `CommonResponse` 모델을 FastAPI 핸들러에서 직접 반환하지 말고 **`.to_wire()`** 를 반환할 것 —
  직접 반환하면 `data: null` 이 실려 "데이터 없으면 키 생략" 계약이 깨진다.
- `configure_logging`
  - 기본 파일명(`{app_name}.log`)은 프로세스 1개 전제 — uvicorn `--workers` > 1 등 멀티
    프로세스면 **`per_pid_file=True`** (`{app_name}.{pid}.log`) 필수.
  - 재호출 시 **자신이 부착한 핸들러만** 교체하고 다른 루트 핸들러(OTel·Sentry 등)는 보존한다
    (`clear_root_handlers=True` 면 전부 제거 — 이전 버전의 동작). 기본 포맷에 `[traceId]` 가
    들어간다(`include_trace_id=False` 로 이전 포맷).
  - `level` 기본값은 `logging.DEBUG` — 운영은 `logging.INFO` 권장.
  - `install_secret_redaction()` 은 `configure_logging` **이후**에 호출(재구성마다 다시 호출).
- 인메모리 상태(`install_rate_limit`, `install_idempotency` 기본 스토어, `TtlCache`,
  `CircuitBreaker`)는 **프로세스(워커) 단위**다 — 인스턴스 간 공유가 필요하면 분산 스토어를
  `IdempotencyStore` Protocol 로 구현해 `store=` 로 주입한다.
- `CircuitBreaker` 를 수동 게이트(`allow_request` + 보고)로 쓸 때 보고가 누락되면 HALF_OPEN
  프로브가 풀리지 않는다 — `call`/`call_async` 를 쓰면 보고가 보장된다.
- `jwt` 는 HS256 시크릿이 **32바이트(UTF-8) 미만이면 `ValueError`** (fail-fast,
  `allow_weak_secret=True` 는 교체 전 임시 옵트아웃).
- `log_redact` 는 정규식 기반 best-effort — 미탐·오탐이 공존한다. `log_sanitize` 와 병용 시
  반드시 **redact → sanitize(절단)** 순서.
- `rrn.is_valid_rrn` 은 체크섬을 검사하지 않는다 — 2020-10 이후 발급분은 뒷자리가 임의번호라
  mod-11 이 성립하지 않는다(`checksum_ok_legacy` 는 그 이전 발급분 전용). 저장 시 암호화 의무.
- `text.chosung.of()` 의 공백 스킵은 스페이스/탭만(Java 파리티).

## 버전·릴리스

버전의 단일 소스는 `rscc_common.__version__` 이다. `py-vX.Y.Z` 태그 push 시 CI 가 태그와
버전 정합을 검증한 뒤 PyPI 에 배포한다(Trusted Publishing). 변경 이력은 소스 저장소의
`CHANGELOG.md` 에 있다.
