Metadata-Version: 2.4
Name: echoss-common
Version: 2.1.0
Summary: echoss AI Bigdata Center 공통 유틸리티 라이브러리 (Logger, JsonLogDict, config_fileformat)
License-Expression: Apache-2.0
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyyaml>6.0.2
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Dynamic: license-file

# echoss-common

echoss AI Bigdata Center 공통 유틸리티 라이브러리입니다.
로깅(Logger)과 config 파일 포맷 읽기/쓰기 기능을 제공합니다.

## 문서 지도

- [`prior-work.jsonl`](prior-work.jsonl) — 작업원장. 이전 작업의 결론·이미 해본 것·백로그 (조사 착수 전 조회)
- [`docs/`](docs/) — PDCA 계획·설계·분석 문서

## 설치

```bash
pip install echoss-common
```

## 요구사항

- Python >= 3.12
- pyyaml > 6.0.2

## 2.1.0 변경 사항

- `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
  - `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
  - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다. 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
- **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.

## 2.0.0 변경 사항

호출 방식(함수 이름·인자 이름·인자 순서)은 1.2.0 과 같습니다. 아래 네 가지는 **동작이 바뀌는 호환 단절**입니다.

| 바뀐 것 | 1.2.0 | 2.0.0 | 대응 |
|---|---|---|---|
| 기본 로그 파일 | `file_path` 를 주지 않으면 `logs/echoss.log` 에 기록 | 파일을 만들지 않고 콘솔만 | 파일 로그가 필요하면 `file_path` 를 지정 (`modify_loggers_by_prefix` 는 `new_path`) |
| 없는 설정 파일 | `dict_load` 가 오류 로그를 남기고 빈 dict 반환 | `FileNotFoundError` | 경로를 확인하거나 예외를 처리 |
| YAML 저장 키 순서 | 키를 정렬해 저장 | 넣은 순서대로 저장 | — |
| 설정 파일 인코딩 | 운영체제 기본 인코딩(Windows 는 CP949 등) | 항상 UTF-8 로 읽고 씀 | UTF-8 이 아닌 설정 파일은 UTF-8 로 변환 |

그 밖의 변경:

- `import echoss_common` 이 더 이상 파일이나 폴더를 만들지 않습니다(1.2.0 은 import 만으로 `logs/echoss.log` 를 만들어, 쓰기 금지 폴더에서는 import 가 실패했습니다).
- 로그 회전은 Windows 에서 다른 프로세스가 로그 파일을 열고 있어도 기록을 잃지 않습니다(아래 「로그 회전」).
- 지원하지 않는 형식을 읽으면 호출마다 새 빈 dict 를 돌려줍니다(1.2.0 은 같은 dict 객체를 공유했습니다).

---

## 기능

### 1. Logger (`echoss_logger`)

타임드 로테이팅 파일 핸들러와 콘솔 핸들러를 지원하는 로거 유틸리티입니다.

#### 함수 및 클래스 목록

| 이름 | 종류 | 설명 |
|------|------|------|
| `get_logger()` | 함수 | 새 로거 생성 또는 기존 로거 갱신 |
| `get_app_logger()` | 함수 | 동일 인자로 항상 같은 로거를 반환하는 캐시 기반 로거 |
| `use_logger()` | 함수 | 이미 생성된 로거를 서브모듈에서 가져오기 |
| `set_logger_level()` | 함수 | 로거 레벨 변경 |
| `modify_loggers_by_prefix()` | 함수 | 이름 prefix로 여러 로거를 일괄 수정 |
| `JsonLogDict` | 클래스 | 임의 값을 JSON 문자열로 변환하여 로그 출력 |

#### 상수

| 상수 | 값 |
|------|----|
| `LOG_FORMAT_DETAIL` | `[날짜시간] 레벨 모듈.함수.라인 : 메시지` 형식 |

#### 사용 예시

```python
from echoss_common import get_logger, get_app_logger, use_logger, set_logger_level, modify_loggers_by_prefix, LOG_FORMAT_DETAIL, JsonLogDict

# 기본 로거 생성 (콘솔 + 파일 출력)
logger = get_logger(
    logger_name='myapp',
    logger_format=LOG_FORMAT_DETAIL,   # 상세 포맷 사용 (선택)
    file_path='logs/myapp.log',        # 로그 파일 경로 (기본: None — 파일 없이 콘솔만)
    backup_count=7,                    # 보관할 로그 파일 수 (0이면 전부 보관)
    use_console=True,                  # 콘솔 출력 여부
    level='DEBUG',                     # 로그 레벨
    rotation='midnight'                # 회전 방식: 'midnight'(기본) | 'none' | '50m'
)

# 회전하지 않고 한 파일에 계속 쌓기
event_logger = get_logger('events', file_path='logs/events.jsonl', rotation='none')

# 50 MiB 마다 회전, 회전 파일 10개 보관
big_logger = get_logger('big', file_path='logs/big.log', rotation='50m', backup_count=10)

logger.info("애플리케이션 시작")
logger.debug("디버그 메시지")
logger.error("오류 발생")

# 캐시 기반 싱글턴 로거 (동일 인자로 호출 시 항상 같은 인스턴스 반환)
app_logger = get_app_logger(logger_name='myapp', file_path='logs/myapp.log')

# 서브모듈에서 기존 로거 재사용
logger = use_logger('myapp')
logger.info("서브모듈에서 로거 사용")

# 로거 레벨만 변경
set_logger_level(logger, 'WARNING')

# prefix로 시작하는 모든 로거 일괄 수정
modify_loggers_by_prefix(
    prefix='myapp',
    new_format=LOG_FORMAT_DETAIL,
    new_path='logs/myapp.log',
    level='INFO'
)
```

#### 로그 회전

`rotation` 인자로 고릅니다.

| `rotation` | 동작 | 회전 파일 | `backup_count` |
|---|---|---|---|
| `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
| `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.1`(가장 최근) … `myapp.log.50m.N` | **1 이상을 주어야 회전합니다.** 0 이면 회전하지 않습니다(Python 표준 크기 회전과 같음) |
| `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |

- 회전 없이 쌓으려면 `'none'` 을 씁니다(`'50m'` 에 `backup_count=0` 으로 쓰지 않습니다).
- 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
- Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
- 알려진 한계(크기 회전, Windows 확인 전): 회전할 때 `.50m.1` → `.50m.2` 처럼 번호 파일도 이름을 바꾸거나 지웁니다. 다른 프로그램이 그 번호 파일을 열고 있을 때 Windows 에서 어떻게 되는지는 2.1.0 배포 뒤 확인하고, 문제가 있으면 2.1.1 에서 고칩니다.
- 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
- **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.

#### JsonLogDict 사용 예시

로그 메시지에 dict, list 등 복잡한 데이터를 JSON 형태로 출력할 때 사용합니다.
`str()` 또는 `repr()` 호출 시 JSON 문자열로 변환되며, JSON 직렬화가 불가능한 객체는 `str()`로 폴백합니다.

```python
from echoss_common import get_logger, JsonLogDict

logger = get_logger('myapp')

# dict → JSON 문자열로 로그 출력
data = {"user": "kim", "age": 20, "active": True}
logger.info("사용자 정보: %s", JsonLogDict(data))
# 출력: 사용자 정보: {"user": "kim", "age": 20, "active": true}

# list, tuple 등도 지원
logger.debug("결과 목록: %s", JsonLogDict([1, 2, 3]))
# 출력: 결과 목록: [1, 2, 3]

# 중첩 구조도 지원
payload = {"tags": ["a", "b"], "meta": {"version": 1}}
logger.info("페이로드: %s", JsonLogDict(payload))
```

#### JSONL 구조화 로그

`file_path`의 확장자가 `.jsonl`이면 각 로그 레코드는 한 줄의 JSON 객체로 저장됩니다.
구조화된 데이터를 유지하려면 `JsonLogDict`를 메시지 자체로 전달해야 합니다.

```python
from echoss_common import get_logger, JsonLogDict

logger = get_logger(
    "myapp",
    file_path="logs/myapp.jsonl",
    use_console=False,
)

logger.info(JsonLogDict({"user": "kim", "action": "login"}))
# {"timestamp": "...", "level": "INFO", "logger_name": "myapp",
#  "message": {"user": "kim", "action": "login"}}
```

다음 방식은 JSONL 행 자체는 유효하지만, `JsonLogDict`가 문자열 템플릿에 먼저 합쳐져 `message`가 문자열이 됩니다.

```python
logger.info("payload: %s", JsonLogDict({"user": "kim"}))
# message 값은 'payload: {"user": "kim"}' 문자열이 됩니다.
```

---

### 2. config_fileformat (`config_fileformat`)

설정 파일(YAML, JSON, XML, Properties)을 딕셔너리로 읽고 쓰는 유틸리티 함수입니다.

#### 함수 목록

| 함수 | 설명 |
|------|------|
| `dict_load()` | 설정 파일을 `dict`로 읽기 |
| `dict_dump()` | `dict`를 설정 파일로 쓰기 |

#### 지원 포맷

| 확장자 | 포맷 |
|--------|------|
| `.yaml`, `.yml` | YAML |
| `.json` | JSON |
| `.xml` | XML 설정 |
| `.properties` | Java Properties |

XML을 읽을 때는 문서의 루트 태그를 포함한 딕셔너리를 반환합니다. XML 속성은
`@name`, 속성 또는 자식 요소와 함께 있는 직접 본문은 `#text`, 같은 이름의
반복 요소는 리스트로 표현합니다. XML을 쓸 때는 `xml_tag`를 지정하거나 딕셔너리에 루트 키가
하나 있어야 합니다.

- 파일은 항상 UTF-8 로 읽고 씁니다(XML 은 문서 선언을 따릅니다).
- 없는 파일을 읽으면 `FileNotFoundError` 가 납니다. 지원하지 않는 형식은 파일을 열지 않고 오류 로그와 함께 빈 dict 를 돌려줍니다.
- JSON 파일의 최상위가 배열이면 `list` 를 반환합니다.
- YAML 로 저장할 때 키 순서를 유지합니다.

#### 사용 예시

```python
from echoss_common import dict_load, dict_dump

# YAML 파일 읽기
config = dict_load('config/settings.yaml')
print(config)

# JSON 파일 읽기
data = dict_load('config/params.json')

# Properties 파일 읽기
props = dict_load('config/app.properties')

# dict를 YAML 파일로 저장
settings = {'host': 'localhost', 'port': 8080, 'debug': True}
dict_dump(settings, 'config/settings.yaml')

# dict를 JSON 파일로 저장 (들여쓰기 지정 가능)
dict_dump(settings, 'config/settings.json', indent=2)

# XML 파일 읽기와 쓰기
xml_config = dict_load('config/settings.xml')
dict_dump({'settings': {'@version': '1', 'port': '8080'}}, 'config/settings.xml')

# 파일이 이미 존재할 때 덮어쓰기 방지
dict_dump(settings, 'config/settings.yaml', force_write=False)
```

---

## API 참조

### `get_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)`

| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `logger_name` | str | `'echoss'` | 로거 이름 |
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (자정 회전은 0이면 전부 보관, 크기 회전은 1 이상이어야 회전) |
| `use_console` | bool | `True` | 콘솔 출력 여부 |
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
| `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |

### `get_app_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)`

`get_logger()`와 동일한 파라미터를 받지만, `@cache` 데코레이터로 감싸져 있어 동일한 인자로 호출할 경우 항상 같은 로거 인스턴스를 반환합니다. 애플리케이션 전역에서 하나의 로거를 공유할 때 적합합니다.

| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `logger_name` | str | `'echoss'` | 로거 이름 |
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 |
| `use_console` | bool | `True` | 콘솔 출력 여부 |
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
| `rotation` | str | `'midnight'` | 회전 방식 (`get_logger` 와 같음) |

### `JsonLogDict(data)`

| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `data` | Any | JSON으로 변환할 값 (dict, list, tuple, str, int, float, bool, None, Mapping 등) |

`str()` 또는 `repr()` 호출 시 `json.dumps()`를 사용해 JSON 문자열을 반환합니다.
`Mapping` 타입은 자동으로 `dict`로 변환되며, JSON 직렬화가 불가능한 객체는 `str()`로 폴백합니다.

### `dict_load(file_path, file_format, **kwargs)`

| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `file_path` | str | — | 읽을 파일 경로 |
| `file_format` | str | `None` | 포맷 강제 지정 (미지정 시 확장자 자동 인식) |

반환은 `dict` 입니다(JSON 최상위가 배열이면 `list`). 파일이 없으면 `FileNotFoundError` 를 냅니다.

### `dict_dump(config, file_path, file_format, force_write, xml_tag, **kwargs)`

| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `config` | dict | — | 저장할 딕셔너리 |
| `file_path` | str | — | 저장할 파일 경로 |
| `file_format` | str | `None` | 포맷 강제 지정 (미지정 시 확장자 자동 인식) |
| `force_write` | bool | `True` | 기존 파일 덮어쓰기 여부 |
| `xml_tag` | str | `None` | XML 루트 태그 (미지정 시 단일 최상위 키 사용) |

---

## 라이선스

Apache License 2.0 (자세한 내용은 [LICENSE](LICENSE) 참고)

echoss AI Bigdata Center

> 참고(비공식 요청, 라이선스 조건 아님): 코드를 수정하여 재배포하실 경우 12cm에 사전 통보해 주시면 감사하겠습니다.
