echoss-common 1.2.0__tar.gz → 2.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {echoss_common-1.2.0/echoss_common.egg-info → echoss_common-2.0.1}/PKG-INFO +41 -4
- {echoss_common-1.2.0 → echoss_common-2.0.1}/README.md +40 -3
- {echoss_common-1.2.0 → echoss_common-2.0.1}/echoss_common/config_fileformat.py +14 -16
- {echoss_common-1.2.0 → echoss_common-2.0.1}/echoss_common/echoss_logger.py +65 -8
- {echoss_common-1.2.0 → echoss_common-2.0.1/echoss_common.egg-info}/PKG-INFO +41 -4
- {echoss_common-1.2.0 → echoss_common-2.0.1}/echoss_common.egg-info/SOURCES.txt +5 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/pyproject.toml +1 -1
- echoss_common-2.0.1/tests/scripts/check_xml_samples.py +38 -0
- echoss_common-2.0.1/tests/scripts/hold_file.py +11 -0
- echoss_common-2.0.1/tests/scripts/spike_default_log_file.py +34 -0
- echoss_common-2.0.1/tests/test_config_fileformat_2x.py +56 -0
- echoss_common-2.0.1/tests/test_echoss_logger_2x.py +238 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/LICENSE +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/MANIFEST.in +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/echoss_common/__init__.py +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/echoss_common.egg-info/dependency_links.txt +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/echoss_common.egg-info/requires.txt +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/echoss_common.egg-info/top_level.txt +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/setup.cfg +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/tests/scripts/verify_xml_edge_cases.py +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/tests/test_config_fileformat.py +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/tests/test_echoss_logger_1.py +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/tests/test_echoss_logger_2.py +0 -0
- {echoss_common-1.2.0 → echoss_common-2.0.1}/tests/test_echoss_logger_json.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: echoss-common
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: echoss AI Bigdata Center 공통 유틸리티 라이브러리 (Logger, JsonLogDict, config_fileformat)
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -18,6 +18,11 @@ Dynamic: license-file
|
|
|
18
18
|
echoss AI Bigdata Center 공통 유틸리티 라이브러리입니다.
|
|
19
19
|
로깅(Logger)과 config 파일 포맷 읽기/쓰기 기능을 제공합니다.
|
|
20
20
|
|
|
21
|
+
## 문서 지도
|
|
22
|
+
|
|
23
|
+
- [`prior-work.jsonl`](prior-work.jsonl) — 작업원장. 이전 작업의 결론·이미 해본 것·백로그 (조사 착수 전 조회)
|
|
24
|
+
- [`docs/`](docs/) — PDCA 계획·설계·분석 문서
|
|
25
|
+
|
|
21
26
|
## 설치
|
|
22
27
|
|
|
23
28
|
```bash
|
|
@@ -29,6 +34,23 @@ pip install echoss-common
|
|
|
29
34
|
- Python >= 3.12
|
|
30
35
|
- pyyaml > 6.0.2
|
|
31
36
|
|
|
37
|
+
## 2.0.0 변경 사항
|
|
38
|
+
|
|
39
|
+
호출 방식(함수 이름·인자 이름·인자 순서)은 1.2.0 과 같습니다. 아래 네 가지는 **동작이 바뀌는 호환 단절**입니다.
|
|
40
|
+
|
|
41
|
+
| 바뀐 것 | 1.2.0 | 2.0.0 | 대응 |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| 기본 로그 파일 | `file_path` 를 주지 않으면 `logs/echoss.log` 에 기록 | 파일을 만들지 않고 콘솔만 | 파일 로그가 필요하면 `file_path` 를 지정 (`modify_loggers_by_prefix` 는 `new_path`) |
|
|
44
|
+
| 없는 설정 파일 | `dict_load` 가 오류 로그를 남기고 빈 dict 반환 | `FileNotFoundError` | 경로를 확인하거나 예외를 처리 |
|
|
45
|
+
| YAML 저장 키 순서 | 키를 정렬해 저장 | 넣은 순서대로 저장 | — |
|
|
46
|
+
| 설정 파일 인코딩 | 운영체제 기본 인코딩(Windows 는 CP949 등) | 항상 UTF-8 로 읽고 씀 | UTF-8 이 아닌 설정 파일은 UTF-8 로 변환 |
|
|
47
|
+
|
|
48
|
+
그 밖의 변경:
|
|
49
|
+
|
|
50
|
+
- `import echoss_common` 이 더 이상 파일이나 폴더를 만들지 않습니다(1.2.0 은 import 만으로 `logs/echoss.log` 를 만들어, 쓰기 금지 폴더에서는 import 가 실패했습니다).
|
|
51
|
+
- 로그 회전은 Windows 에서 다른 프로세스가 로그 파일을 열고 있어도 기록을 잃지 않습니다(아래 「로그 회전」).
|
|
52
|
+
- 지원하지 않는 형식을 읽으면 호출마다 새 빈 dict 를 돌려줍니다(1.2.0 은 같은 dict 객체를 공유했습니다).
|
|
53
|
+
|
|
32
54
|
---
|
|
33
55
|
|
|
34
56
|
## 기능
|
|
@@ -63,7 +85,7 @@ from echoss_common import get_logger, get_app_logger, use_logger, set_logger_lev
|
|
|
63
85
|
logger = get_logger(
|
|
64
86
|
logger_name='myapp',
|
|
65
87
|
logger_format=LOG_FORMAT_DETAIL, # 상세 포맷 사용 (선택)
|
|
66
|
-
file_path='logs/myapp.log', # 로그 파일 경로 (기본:
|
|
88
|
+
file_path='logs/myapp.log', # 로그 파일 경로 (기본: None — 파일 없이 콘솔만)
|
|
67
89
|
backup_count=7, # 보관할 로그 파일 수 (0이면 전부 보관)
|
|
68
90
|
use_console=True, # 콘솔 출력 여부
|
|
69
91
|
level='DEBUG' # 로그 레벨
|
|
@@ -92,6 +114,14 @@ modify_loggers_by_prefix(
|
|
|
92
114
|
)
|
|
93
115
|
```
|
|
94
116
|
|
|
117
|
+
#### 로그 회전
|
|
118
|
+
|
|
119
|
+
파일 로그는 매일 자정에 날짜를 붙인 파일(`myapp.log.2026-10-10`)로 회전합니다.
|
|
120
|
+
|
|
121
|
+
- 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다.
|
|
122
|
+
- Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 날짜 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다. 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
|
|
123
|
+
- 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
|
|
124
|
+
|
|
95
125
|
#### JsonLogDict 사용 예시
|
|
96
126
|
|
|
97
127
|
로그 메시지에 dict, list 등 복잡한 데이터를 JSON 형태로 출력할 때 사용합니다.
|
|
@@ -169,6 +199,11 @@ XML을 읽을 때는 문서의 루트 태그를 포함한 딕셔너리를 반환
|
|
|
169
199
|
반복 요소는 리스트로 표현합니다. XML을 쓸 때는 `xml_tag`를 지정하거나 딕셔너리에 루트 키가
|
|
170
200
|
하나 있어야 합니다.
|
|
171
201
|
|
|
202
|
+
- 파일은 항상 UTF-8 로 읽고 씁니다(XML 은 문서 선언을 따릅니다).
|
|
203
|
+
- 없는 파일을 읽으면 `FileNotFoundError` 가 납니다. 지원하지 않는 형식은 파일을 열지 않고 오류 로그와 함께 빈 dict 를 돌려줍니다.
|
|
204
|
+
- JSON 파일의 최상위가 배열이면 `list` 를 반환합니다.
|
|
205
|
+
- YAML 로 저장할 때 키 순서를 유지합니다.
|
|
206
|
+
|
|
172
207
|
#### 사용 예시
|
|
173
208
|
|
|
174
209
|
```python
|
|
@@ -209,7 +244,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
209
244
|
|----------|------|--------|------|
|
|
210
245
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
211
246
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
212
|
-
| `file_path` | str | `
|
|
247
|
+
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
213
248
|
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
|
|
214
249
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
215
250
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
@@ -222,7 +257,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
222
257
|
|----------|------|--------|------|
|
|
223
258
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
224
259
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
225
|
-
| `file_path` | str | `
|
|
260
|
+
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
226
261
|
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 |
|
|
227
262
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
228
263
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
@@ -243,6 +278,8 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
243
278
|
| `file_path` | str | — | 읽을 파일 경로 |
|
|
244
279
|
| `file_format` | str | `None` | 포맷 강제 지정 (미지정 시 확장자 자동 인식) |
|
|
245
280
|
|
|
281
|
+
반환은 `dict` 입니다(JSON 최상위가 배열이면 `list`). 파일이 없으면 `FileNotFoundError` 를 냅니다.
|
|
282
|
+
|
|
246
283
|
### `dict_dump(config, file_path, file_format, force_write, xml_tag, **kwargs)`
|
|
247
284
|
|
|
248
285
|
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
echoss AI Bigdata Center 공통 유틸리티 라이브러리입니다.
|
|
4
4
|
로깅(Logger)과 config 파일 포맷 읽기/쓰기 기능을 제공합니다.
|
|
5
5
|
|
|
6
|
+
## 문서 지도
|
|
7
|
+
|
|
8
|
+
- [`prior-work.jsonl`](prior-work.jsonl) — 작업원장. 이전 작업의 결론·이미 해본 것·백로그 (조사 착수 전 조회)
|
|
9
|
+
- [`docs/`](docs/) — PDCA 계획·설계·분석 문서
|
|
10
|
+
|
|
6
11
|
## 설치
|
|
7
12
|
|
|
8
13
|
```bash
|
|
@@ -14,6 +19,23 @@ pip install echoss-common
|
|
|
14
19
|
- Python >= 3.12
|
|
15
20
|
- pyyaml > 6.0.2
|
|
16
21
|
|
|
22
|
+
## 2.0.0 변경 사항
|
|
23
|
+
|
|
24
|
+
호출 방식(함수 이름·인자 이름·인자 순서)은 1.2.0 과 같습니다. 아래 네 가지는 **동작이 바뀌는 호환 단절**입니다.
|
|
25
|
+
|
|
26
|
+
| 바뀐 것 | 1.2.0 | 2.0.0 | 대응 |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| 기본 로그 파일 | `file_path` 를 주지 않으면 `logs/echoss.log` 에 기록 | 파일을 만들지 않고 콘솔만 | 파일 로그가 필요하면 `file_path` 를 지정 (`modify_loggers_by_prefix` 는 `new_path`) |
|
|
29
|
+
| 없는 설정 파일 | `dict_load` 가 오류 로그를 남기고 빈 dict 반환 | `FileNotFoundError` | 경로를 확인하거나 예외를 처리 |
|
|
30
|
+
| YAML 저장 키 순서 | 키를 정렬해 저장 | 넣은 순서대로 저장 | — |
|
|
31
|
+
| 설정 파일 인코딩 | 운영체제 기본 인코딩(Windows 는 CP949 등) | 항상 UTF-8 로 읽고 씀 | UTF-8 이 아닌 설정 파일은 UTF-8 로 변환 |
|
|
32
|
+
|
|
33
|
+
그 밖의 변경:
|
|
34
|
+
|
|
35
|
+
- `import echoss_common` 이 더 이상 파일이나 폴더를 만들지 않습니다(1.2.0 은 import 만으로 `logs/echoss.log` 를 만들어, 쓰기 금지 폴더에서는 import 가 실패했습니다).
|
|
36
|
+
- 로그 회전은 Windows 에서 다른 프로세스가 로그 파일을 열고 있어도 기록을 잃지 않습니다(아래 「로그 회전」).
|
|
37
|
+
- 지원하지 않는 형식을 읽으면 호출마다 새 빈 dict 를 돌려줍니다(1.2.0 은 같은 dict 객체를 공유했습니다).
|
|
38
|
+
|
|
17
39
|
---
|
|
18
40
|
|
|
19
41
|
## 기능
|
|
@@ -48,7 +70,7 @@ from echoss_common import get_logger, get_app_logger, use_logger, set_logger_lev
|
|
|
48
70
|
logger = get_logger(
|
|
49
71
|
logger_name='myapp',
|
|
50
72
|
logger_format=LOG_FORMAT_DETAIL, # 상세 포맷 사용 (선택)
|
|
51
|
-
file_path='logs/myapp.log', # 로그 파일 경로 (기본:
|
|
73
|
+
file_path='logs/myapp.log', # 로그 파일 경로 (기본: None — 파일 없이 콘솔만)
|
|
52
74
|
backup_count=7, # 보관할 로그 파일 수 (0이면 전부 보관)
|
|
53
75
|
use_console=True, # 콘솔 출력 여부
|
|
54
76
|
level='DEBUG' # 로그 레벨
|
|
@@ -77,6 +99,14 @@ modify_loggers_by_prefix(
|
|
|
77
99
|
)
|
|
78
100
|
```
|
|
79
101
|
|
|
102
|
+
#### 로그 회전
|
|
103
|
+
|
|
104
|
+
파일 로그는 매일 자정에 날짜를 붙인 파일(`myapp.log.2026-10-10`)로 회전합니다.
|
|
105
|
+
|
|
106
|
+
- 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다.
|
|
107
|
+
- Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 날짜 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다. 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
|
|
108
|
+
- 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
|
|
109
|
+
|
|
80
110
|
#### JsonLogDict 사용 예시
|
|
81
111
|
|
|
82
112
|
로그 메시지에 dict, list 등 복잡한 데이터를 JSON 형태로 출력할 때 사용합니다.
|
|
@@ -154,6 +184,11 @@ XML을 읽을 때는 문서의 루트 태그를 포함한 딕셔너리를 반환
|
|
|
154
184
|
반복 요소는 리스트로 표현합니다. XML을 쓸 때는 `xml_tag`를 지정하거나 딕셔너리에 루트 키가
|
|
155
185
|
하나 있어야 합니다.
|
|
156
186
|
|
|
187
|
+
- 파일은 항상 UTF-8 로 읽고 씁니다(XML 은 문서 선언을 따릅니다).
|
|
188
|
+
- 없는 파일을 읽으면 `FileNotFoundError` 가 납니다. 지원하지 않는 형식은 파일을 열지 않고 오류 로그와 함께 빈 dict 를 돌려줍니다.
|
|
189
|
+
- JSON 파일의 최상위가 배열이면 `list` 를 반환합니다.
|
|
190
|
+
- YAML 로 저장할 때 키 순서를 유지합니다.
|
|
191
|
+
|
|
157
192
|
#### 사용 예시
|
|
158
193
|
|
|
159
194
|
```python
|
|
@@ -194,7 +229,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
194
229
|
|----------|------|--------|------|
|
|
195
230
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
196
231
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
197
|
-
| `file_path` | str | `
|
|
232
|
+
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
198
233
|
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
|
|
199
234
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
200
235
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
@@ -207,7 +242,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
207
242
|
|----------|------|--------|------|
|
|
208
243
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
209
244
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
210
|
-
| `file_path` | str | `
|
|
245
|
+
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
211
246
|
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 |
|
|
212
247
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
213
248
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
@@ -228,6 +263,8 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
228
263
|
| `file_path` | str | — | 읽을 파일 경로 |
|
|
229
264
|
| `file_format` | str | `None` | 포맷 강제 지정 (미지정 시 확장자 자동 인식) |
|
|
230
265
|
|
|
266
|
+
반환은 `dict` 입니다(JSON 최상위가 배열이면 `list`). 파일이 없으면 `FileNotFoundError` 를 냅니다.
|
|
267
|
+
|
|
231
268
|
### `dict_dump(config, file_path, file_format, force_write, xml_tag, **kwargs)`
|
|
232
269
|
|
|
233
270
|
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
@@ -4,14 +4,13 @@
|
|
|
4
4
|
import configparser
|
|
5
5
|
import io
|
|
6
6
|
import json
|
|
7
|
+
import logging
|
|
7
8
|
import os
|
|
8
9
|
import xml.etree.ElementTree as ET
|
|
9
10
|
import yaml
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
logger = get_logger("echoss_fileformat")
|
|
14
|
-
EMPTY_DICT = dict()
|
|
12
|
+
# no handler here: importing must not create files (echoss-fileformat attaches handlers to this name)
|
|
13
|
+
logger = logging.getLogger("echoss_fileformat")
|
|
15
14
|
|
|
16
15
|
|
|
17
16
|
def _xml_element_to_dict(element: ET.Element) -> dict:
|
|
@@ -74,21 +73,20 @@ def dict_load(file_path: str, file_format: str | None = None, **kwargs) -> dict:
|
|
|
74
73
|
file_format : file extension name to read
|
|
75
74
|
kwargs : keyword arguments
|
|
76
75
|
Return:
|
|
77
|
-
dict
|
|
76
|
+
dict (list if a json file's top level is an array)
|
|
77
|
+
Raises:
|
|
78
|
+
FileNotFoundError: if file_path does not exist (an unsupported format returns {} without opening the file)
|
|
78
79
|
"""
|
|
79
80
|
if file_format is None:
|
|
80
81
|
_, ext = os.path.splitext(file_path)
|
|
81
82
|
file_format = ext[1:].lower() if ext else None
|
|
82
|
-
if not os.path.exists(file_path):
|
|
83
|
-
logger.error(f"load config file [{file_path}] is not exist")
|
|
84
|
-
return EMPTY_DICT
|
|
85
83
|
|
|
86
84
|
if "yaml" == file_format or "yml" == file_format :
|
|
87
|
-
with open(file_path, 'r') as f:
|
|
85
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
88
86
|
yaml_dict = yaml.safe_load(f)
|
|
89
87
|
return yaml_dict
|
|
90
88
|
elif "json" == file_format:
|
|
91
|
-
with open(file_path, 'r') as f:
|
|
89
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
92
90
|
return json.load(f, **kwargs)
|
|
93
91
|
elif "xml" == file_format:
|
|
94
92
|
root = ET.parse(file_path).getroot()
|
|
@@ -96,7 +94,7 @@ def dict_load(file_path: str, file_format: str | None = None, **kwargs) -> dict:
|
|
|
96
94
|
elif 'properties' == file_format:
|
|
97
95
|
config = configparser.ConfigParser()
|
|
98
96
|
properties_dict = {}
|
|
99
|
-
with open(file_path, 'r') as f:
|
|
97
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
100
98
|
file_content = f.read()
|
|
101
99
|
if not file_content.strip().startswith('['):
|
|
102
100
|
file_content = '[DEFAULT]\n' + file_content
|
|
@@ -111,7 +109,7 @@ def dict_load(file_path: str, file_format: str | None = None, **kwargs) -> dict:
|
|
|
111
109
|
return properties_dict
|
|
112
110
|
else:
|
|
113
111
|
logger.error(f"Unsupported file format [{file_format}]")
|
|
114
|
-
return
|
|
112
|
+
return {}
|
|
115
113
|
|
|
116
114
|
def dict_dump(config: dict, file_path: str, file_format: str | None = None, force_write: bool = True, xml_tag: str | None = None, **kwargs):
|
|
117
115
|
"""config dict write to file
|
|
@@ -133,10 +131,10 @@ def dict_dump(config: dict, file_path: str, file_format: str | None = None, forc
|
|
|
133
131
|
return
|
|
134
132
|
|
|
135
133
|
if "yaml" == file_format or "yml" == file_format :
|
|
136
|
-
with open(file_path, 'w') as f:
|
|
137
|
-
yaml.dump(config, f, default_style=False, allow_unicode=True)
|
|
134
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
135
|
+
yaml.dump(config, f, default_style=False, allow_unicode=True, sort_keys=False)
|
|
138
136
|
elif "json" == file_format:
|
|
139
|
-
with open(file_path, 'w') as f:
|
|
137
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
140
138
|
indent = kwargs.pop('indent', 4)
|
|
141
139
|
json.dump(config, f, indent=indent, **kwargs)
|
|
142
140
|
elif "xml" == file_format:
|
|
@@ -165,7 +163,7 @@ def dict_dump(config: dict, file_path: str, file_format: str | None = None, forc
|
|
|
165
163
|
# parser.add_section('DEFAULT')
|
|
166
164
|
for k, v in config.items():
|
|
167
165
|
parser.set('DEFAULT', k, str(v))
|
|
168
|
-
with open(file_path, 'w') as f:
|
|
166
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
169
167
|
if have_section:
|
|
170
168
|
parser.write(f)
|
|
171
169
|
else:
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
4
5
|
import sys
|
|
6
|
+
import time
|
|
5
7
|
from collections.abc import Mapping
|
|
6
8
|
from functools import cache
|
|
7
9
|
from logging import StreamHandler
|
|
@@ -50,6 +52,56 @@ class JsonLineFormatter(logging.Formatter):
|
|
|
50
52
|
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
51
53
|
|
|
52
54
|
|
|
55
|
+
# Windows cannot rename a file another handle keeps open, so a replaced stream only arises
|
|
56
|
+
# there through copy-truncate, which keeps the inode. Keep the 2.0.0 behaviour on Windows.
|
|
57
|
+
_STALE_STREAM_CHECK = os.name != "nt"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# True when another handler already rotated the file this handler is still writing to.
|
|
61
|
+
def _stream_replaced(handler) -> bool:
|
|
62
|
+
if not _STALE_STREAM_CHECK or handler.stream is None:
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
mine = os.fstat(handler.stream.fileno())
|
|
66
|
+
disk = os.stat(handler.baseFilename)
|
|
67
|
+
except OSError:
|
|
68
|
+
return False
|
|
69
|
+
return (mine.st_dev, mine.st_ino) != (disk.st_dev, disk.st_ino)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# On Windows, renaming a log file that another handle keeps open fails with PermissionError (WinError 32).
|
|
73
|
+
# Fall back to copy-then-truncate so no record is lost, and always move rolloverAt forward.
|
|
74
|
+
class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
|
|
75
|
+
def rotate(self, source, dest):
|
|
76
|
+
if not os.path.exists(source):
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
os.rename(source, dest)
|
|
80
|
+
return
|
|
81
|
+
except PermissionError:
|
|
82
|
+
pass
|
|
83
|
+
try:
|
|
84
|
+
# "x": another process already rotated into dest - do not overwrite
|
|
85
|
+
with open(dest, "xb") as out, open(source, "rb") as src:
|
|
86
|
+
shutil.copyfileobj(src, out)
|
|
87
|
+
except FileExistsError:
|
|
88
|
+
return
|
|
89
|
+
with open(source, "r+b") as f:
|
|
90
|
+
f.truncate(0)
|
|
91
|
+
|
|
92
|
+
def doRollover(self):
|
|
93
|
+
if _stream_replaced(self):
|
|
94
|
+
self.stream.close()
|
|
95
|
+
self.stream = None # a failed _open() must leave None so the next emit reopens, as the stdlib does
|
|
96
|
+
self.stream = self._open()
|
|
97
|
+
self.rolloverAt = self.computeRollover(int(time.time()))
|
|
98
|
+
return
|
|
99
|
+
super().doRollover()
|
|
100
|
+
now = int(time.time())
|
|
101
|
+
if self.rolloverAt <= now:
|
|
102
|
+
self.rolloverAt = self.computeRollover(now)
|
|
103
|
+
|
|
104
|
+
|
|
53
105
|
def _get_console_handler(formatter):
|
|
54
106
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
55
107
|
console_handler.setFormatter(formatter)
|
|
@@ -58,7 +110,7 @@ def _get_console_handler(formatter):
|
|
|
58
110
|
|
|
59
111
|
|
|
60
112
|
def _get_file_handler(formatter, file_path, backup_count):
|
|
61
|
-
file_handler =
|
|
113
|
+
file_handler = CopyTruncateRotatingHandler(file_path, when="midnight", backupCount=backup_count, encoding='utf-8')
|
|
62
114
|
file_handler.setFormatter(formatter)
|
|
63
115
|
return file_handler
|
|
64
116
|
|
|
@@ -124,12 +176,12 @@ def use_logger(logger_name='echoss'):
|
|
|
124
176
|
|
|
125
177
|
|
|
126
178
|
@cache
|
|
127
|
-
def get_app_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=
|
|
179
|
+
def get_app_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=None, backup_count=0,
|
|
128
180
|
use_console=True, level="DEBUG"):
|
|
129
181
|
return get_logger(logger_name, logger_format, file_path, backup_count, use_console, level)
|
|
130
182
|
|
|
131
183
|
|
|
132
|
-
def get_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=
|
|
184
|
+
def get_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=None, backup_count=0,
|
|
133
185
|
use_console=True, level="DEBUG"):
|
|
134
186
|
"""
|
|
135
187
|
create new echoss logger or replace old logger
|
|
@@ -159,9 +211,10 @@ def get_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=LOG_FIL
|
|
|
159
211
|
logger.propagate = False
|
|
160
212
|
|
|
161
213
|
# make file_path parent directory
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
214
|
+
if file_path is not None:
|
|
215
|
+
parent_dir = os.path.dirname(file_path)
|
|
216
|
+
if parent_dir:
|
|
217
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
165
218
|
|
|
166
219
|
# 콘솔 핸들러 get 또는 생성
|
|
167
220
|
console_handler = None
|
|
@@ -178,7 +231,11 @@ def get_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=LOG_FIL
|
|
|
178
231
|
file_handler = _get_app_file_handler(logger_name)
|
|
179
232
|
|
|
180
233
|
# Check if the existing handler path matches the current file_path
|
|
181
|
-
if
|
|
234
|
+
if file_path is None:
|
|
235
|
+
# no file logging: the cached file handler (if any) is removed below as unknown
|
|
236
|
+
file_handler = None
|
|
237
|
+
_logger_file_handler_cache.pop(logger_name, None)
|
|
238
|
+
elif file_handler is not None and os.path.abspath(file_handler.baseFilename) == os.path.abspath(file_path):
|
|
182
239
|
file_handler.setFormatter(formatter)
|
|
183
240
|
else:
|
|
184
241
|
file_handler = _get_file_handler(formatter, file_path, backup_count)
|
|
@@ -221,7 +278,7 @@ def set_logger_level(logger, level):
|
|
|
221
278
|
logger.setLevel(numeric_level)
|
|
222
279
|
|
|
223
280
|
|
|
224
|
-
def modify_loggers_by_prefix(prefix, new_format: str = LOG_FORMAT, new_path: str =
|
|
281
|
+
def modify_loggers_by_prefix(prefix, new_format: str = LOG_FORMAT, new_path: str | None = None,
|
|
225
282
|
backup_count: int = 0, use_console=True, level="DEBUG"):
|
|
226
283
|
"""
|
|
227
284
|
Modify loggers that logger name starting with a specific prefix.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: echoss-common
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: echoss AI Bigdata Center 공통 유틸리티 라이브러리 (Logger, JsonLogDict, config_fileformat)
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -18,6 +18,11 @@ Dynamic: license-file
|
|
|
18
18
|
echoss AI Bigdata Center 공통 유틸리티 라이브러리입니다.
|
|
19
19
|
로깅(Logger)과 config 파일 포맷 읽기/쓰기 기능을 제공합니다.
|
|
20
20
|
|
|
21
|
+
## 문서 지도
|
|
22
|
+
|
|
23
|
+
- [`prior-work.jsonl`](prior-work.jsonl) — 작업원장. 이전 작업의 결론·이미 해본 것·백로그 (조사 착수 전 조회)
|
|
24
|
+
- [`docs/`](docs/) — PDCA 계획·설계·분석 문서
|
|
25
|
+
|
|
21
26
|
## 설치
|
|
22
27
|
|
|
23
28
|
```bash
|
|
@@ -29,6 +34,23 @@ pip install echoss-common
|
|
|
29
34
|
- Python >= 3.12
|
|
30
35
|
- pyyaml > 6.0.2
|
|
31
36
|
|
|
37
|
+
## 2.0.0 변경 사항
|
|
38
|
+
|
|
39
|
+
호출 방식(함수 이름·인자 이름·인자 순서)은 1.2.0 과 같습니다. 아래 네 가지는 **동작이 바뀌는 호환 단절**입니다.
|
|
40
|
+
|
|
41
|
+
| 바뀐 것 | 1.2.0 | 2.0.0 | 대응 |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| 기본 로그 파일 | `file_path` 를 주지 않으면 `logs/echoss.log` 에 기록 | 파일을 만들지 않고 콘솔만 | 파일 로그가 필요하면 `file_path` 를 지정 (`modify_loggers_by_prefix` 는 `new_path`) |
|
|
44
|
+
| 없는 설정 파일 | `dict_load` 가 오류 로그를 남기고 빈 dict 반환 | `FileNotFoundError` | 경로를 확인하거나 예외를 처리 |
|
|
45
|
+
| YAML 저장 키 순서 | 키를 정렬해 저장 | 넣은 순서대로 저장 | — |
|
|
46
|
+
| 설정 파일 인코딩 | 운영체제 기본 인코딩(Windows 는 CP949 등) | 항상 UTF-8 로 읽고 씀 | UTF-8 이 아닌 설정 파일은 UTF-8 로 변환 |
|
|
47
|
+
|
|
48
|
+
그 밖의 변경:
|
|
49
|
+
|
|
50
|
+
- `import echoss_common` 이 더 이상 파일이나 폴더를 만들지 않습니다(1.2.0 은 import 만으로 `logs/echoss.log` 를 만들어, 쓰기 금지 폴더에서는 import 가 실패했습니다).
|
|
51
|
+
- 로그 회전은 Windows 에서 다른 프로세스가 로그 파일을 열고 있어도 기록을 잃지 않습니다(아래 「로그 회전」).
|
|
52
|
+
- 지원하지 않는 형식을 읽으면 호출마다 새 빈 dict 를 돌려줍니다(1.2.0 은 같은 dict 객체를 공유했습니다).
|
|
53
|
+
|
|
32
54
|
---
|
|
33
55
|
|
|
34
56
|
## 기능
|
|
@@ -63,7 +85,7 @@ from echoss_common import get_logger, get_app_logger, use_logger, set_logger_lev
|
|
|
63
85
|
logger = get_logger(
|
|
64
86
|
logger_name='myapp',
|
|
65
87
|
logger_format=LOG_FORMAT_DETAIL, # 상세 포맷 사용 (선택)
|
|
66
|
-
file_path='logs/myapp.log', # 로그 파일 경로 (기본:
|
|
88
|
+
file_path='logs/myapp.log', # 로그 파일 경로 (기본: None — 파일 없이 콘솔만)
|
|
67
89
|
backup_count=7, # 보관할 로그 파일 수 (0이면 전부 보관)
|
|
68
90
|
use_console=True, # 콘솔 출력 여부
|
|
69
91
|
level='DEBUG' # 로그 레벨
|
|
@@ -92,6 +114,14 @@ modify_loggers_by_prefix(
|
|
|
92
114
|
)
|
|
93
115
|
```
|
|
94
116
|
|
|
117
|
+
#### 로그 회전
|
|
118
|
+
|
|
119
|
+
파일 로그는 매일 자정에 날짜를 붙인 파일(`myapp.log.2026-10-10`)로 회전합니다.
|
|
120
|
+
|
|
121
|
+
- 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다.
|
|
122
|
+
- Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 날짜 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다. 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
|
|
123
|
+
- 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
|
|
124
|
+
|
|
95
125
|
#### JsonLogDict 사용 예시
|
|
96
126
|
|
|
97
127
|
로그 메시지에 dict, list 등 복잡한 데이터를 JSON 형태로 출력할 때 사용합니다.
|
|
@@ -169,6 +199,11 @@ XML을 읽을 때는 문서의 루트 태그를 포함한 딕셔너리를 반환
|
|
|
169
199
|
반복 요소는 리스트로 표현합니다. XML을 쓸 때는 `xml_tag`를 지정하거나 딕셔너리에 루트 키가
|
|
170
200
|
하나 있어야 합니다.
|
|
171
201
|
|
|
202
|
+
- 파일은 항상 UTF-8 로 읽고 씁니다(XML 은 문서 선언을 따릅니다).
|
|
203
|
+
- 없는 파일을 읽으면 `FileNotFoundError` 가 납니다. 지원하지 않는 형식은 파일을 열지 않고 오류 로그와 함께 빈 dict 를 돌려줍니다.
|
|
204
|
+
- JSON 파일의 최상위가 배열이면 `list` 를 반환합니다.
|
|
205
|
+
- YAML 로 저장할 때 키 순서를 유지합니다.
|
|
206
|
+
|
|
172
207
|
#### 사용 예시
|
|
173
208
|
|
|
174
209
|
```python
|
|
@@ -209,7 +244,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
209
244
|
|----------|------|--------|------|
|
|
210
245
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
211
246
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
212
|
-
| `file_path` | str | `
|
|
247
|
+
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
213
248
|
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
|
|
214
249
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
215
250
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
@@ -222,7 +257,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
222
257
|
|----------|------|--------|------|
|
|
223
258
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
224
259
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
225
|
-
| `file_path` | str | `
|
|
260
|
+
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
226
261
|
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 |
|
|
227
262
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
228
263
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
@@ -243,6 +278,8 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
243
278
|
| `file_path` | str | — | 읽을 파일 경로 |
|
|
244
279
|
| `file_format` | str | `None` | 포맷 강제 지정 (미지정 시 확장자 자동 인식) |
|
|
245
280
|
|
|
281
|
+
반환은 `dict` 입니다(JSON 최상위가 배열이면 `list`). 파일이 없으면 `FileNotFoundError` 를 냅니다.
|
|
282
|
+
|
|
246
283
|
### `dict_dump(config, file_path, file_format, force_write, xml_tag, **kwargs)`
|
|
247
284
|
|
|
248
285
|
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
@@ -11,7 +11,12 @@ echoss_common.egg-info/dependency_links.txt
|
|
|
11
11
|
echoss_common.egg-info/requires.txt
|
|
12
12
|
echoss_common.egg-info/top_level.txt
|
|
13
13
|
tests/test_config_fileformat.py
|
|
14
|
+
tests/test_config_fileformat_2x.py
|
|
14
15
|
tests/test_echoss_logger_1.py
|
|
15
16
|
tests/test_echoss_logger_2.py
|
|
17
|
+
tests/test_echoss_logger_2x.py
|
|
16
18
|
tests/test_echoss_logger_json.py
|
|
19
|
+
tests/scripts/check_xml_samples.py
|
|
20
|
+
tests/scripts/hold_file.py
|
|
21
|
+
tests/scripts/spike_default_log_file.py
|
|
17
22
|
tests/scripts/verify_xml_edge_cases.py
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
# 패키지 메타데이터
|
|
7
7
|
[project]
|
|
8
8
|
name="echoss-common"
|
|
9
|
-
version="
|
|
9
|
+
version="2.0.1"
|
|
10
10
|
description="echoss AI Bigdata Center 공통 유틸리티 라이브러리 (Logger, JsonLogDict, config_fileformat)"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = "Apache-2.0"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/home/ubuntu/miniconda3/envs/index_rag/bin/python
|
|
2
|
+
"""Load echoss-fileformat sample XML files with echoss_common.dict_load.
|
|
3
|
+
|
|
4
|
+
Checks that no non-string key (e.g. a comment node function) appears in the result.
|
|
5
|
+
Usage: check_xml_samples.py <xml file> [<xml file> ...]
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from echoss_common import dict_load
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def non_str_keys(value, path="$"):
|
|
13
|
+
found = []
|
|
14
|
+
if isinstance(value, dict):
|
|
15
|
+
for key, child in value.items():
|
|
16
|
+
if not isinstance(key, str):
|
|
17
|
+
found.append(f"{path}: {key!r}")
|
|
18
|
+
found.extend(non_str_keys(child, f"{path}.{key}"))
|
|
19
|
+
elif isinstance(value, list):
|
|
20
|
+
for index, item in enumerate(value):
|
|
21
|
+
found.extend(non_str_keys(item, f"{path}[{index}]"))
|
|
22
|
+
return found
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
failed = False
|
|
26
|
+
for file_path in sys.argv[1:]:
|
|
27
|
+
try:
|
|
28
|
+
result = dict_load(file_path)
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
failed = True
|
|
31
|
+
print(f"FAIL {file_path}: {type(exc).__name__} {exc}")
|
|
32
|
+
continue
|
|
33
|
+
bad = non_str_keys(result)
|
|
34
|
+
root = next(iter(result)) if result else None
|
|
35
|
+
print(f"{'FAIL' if bad else 'OK '} {file_path}: root={root!r} non_str_keys={bad}")
|
|
36
|
+
failed = failed or bool(bad)
|
|
37
|
+
|
|
38
|
+
sys.exit(1 if failed else 0)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/home/ubuntu/miniconda3/envs/index_rag/bin/python
|
|
2
|
+
"""Keep a file open (read handle) until stdin is closed.
|
|
3
|
+
|
|
4
|
+
Used by tests as the "second process" that holds a log file during rotation.
|
|
5
|
+
Usage: hold_file.py <path>
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
with open(sys.argv[1], "rb"):
|
|
10
|
+
print("ready", flush=True)
|
|
11
|
+
sys.stdin.read()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/home/ubuntu/miniconda3/envs/index_rag/bin/python
|
|
2
|
+
"""Spike: does importing/declaring echoss logger create logs/echoss.log in the CWD?
|
|
3
|
+
|
|
4
|
+
Run from an empty directory with PYTHONPATH pointing at the repo root.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def exists_default():
|
|
11
|
+
return os.path.exists(os.path.join("logs", "echoss.log"))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
print("cwd:", os.getcwd())
|
|
15
|
+
print("1 before import, logs/echoss.log exists:", exists_default())
|
|
16
|
+
|
|
17
|
+
import echoss_common # noqa: E402
|
|
18
|
+
|
|
19
|
+
print("2 after `import echoss_common`, exists:", exists_default())
|
|
20
|
+
|
|
21
|
+
from echoss_common import get_logger # noqa: E402
|
|
22
|
+
|
|
23
|
+
logger = get_logger("myapp", file_path="custom/my.log")
|
|
24
|
+
logger.info("hello")
|
|
25
|
+
print("3 after get_logger(file_path='custom/my.log'), default exists:", exists_default(),
|
|
26
|
+
"| custom exists:", os.path.exists("custom/my.log"))
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
get_logger("nofile", file_path=None)
|
|
30
|
+
print("4 get_logger(file_path=None): OK")
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
print("4 get_logger(file_path=None) raised:", type(exc).__name__, exc)
|
|
33
|
+
|
|
34
|
+
sys.exit(0)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from echoss_common import dict_dump, dict_load
|
|
10
|
+
|
|
11
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
KOREAN = {"이름": "홍길동", "설명": {"지역": "서울"}}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# T-10
|
|
16
|
+
@pytest.mark.parametrize("file_name, text", [
|
|
17
|
+
("ko.yaml", "이름: 홍길동\n설명:\n 지역: 서울\n"),
|
|
18
|
+
("ko.json", json.dumps(KOREAN, ensure_ascii=False)),
|
|
19
|
+
])
|
|
20
|
+
def test_dict_load_reads_utf8_regardless_of_locale(tmp_path, file_name, text):
|
|
21
|
+
path = tmp_path / file_name
|
|
22
|
+
path.write_bytes(text.encode("utf-8"))
|
|
23
|
+
# non-UTF-8 default encoding: Windows ANSI code page, or the C locale without coercion on Linux
|
|
24
|
+
env = dict(os.environ, PYTHONPATH=str(REPO_ROOT), PYTHONUTF8="0", PYTHONCOERCECLOCALE="0", LC_ALL="C")
|
|
25
|
+
code = "import json, sys; from echoss_common import dict_load; print(json.dumps(dict_load(sys.argv[1])))"
|
|
26
|
+
|
|
27
|
+
result = subprocess.run([sys.executable, "-c", code, str(path)], env=env, capture_output=True, text=True)
|
|
28
|
+
|
|
29
|
+
assert result.returncode == 0, result.stderr
|
|
30
|
+
assert json.loads(result.stdout) == KOREAN
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# T-11
|
|
34
|
+
def test_empty_result_is_a_new_dict_each_call(tmp_path):
|
|
35
|
+
first = dict_load(str(tmp_path / "a.unknown"))
|
|
36
|
+
first["x"] = 1
|
|
37
|
+
|
|
38
|
+
second = dict_load(str(tmp_path / "b.unknown"))
|
|
39
|
+
|
|
40
|
+
assert second == {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# T-12
|
|
44
|
+
def test_dict_dump_yaml_keeps_key_order(tmp_path):
|
|
45
|
+
path = tmp_path / "order.yaml"
|
|
46
|
+
|
|
47
|
+
dict_dump({"이름": 1, "b": 2, "a": 3}, str(path))
|
|
48
|
+
|
|
49
|
+
assert list(dict_load(str(path))) == ["이름", "b", "a"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# T-13
|
|
53
|
+
@pytest.mark.parametrize("ext", ["yaml", "json", "xml", "properties"])
|
|
54
|
+
def test_dict_load_missing_file_raises(tmp_path, ext):
|
|
55
|
+
with pytest.raises(FileNotFoundError):
|
|
56
|
+
dict_load(str(tmp_path / f"missing.{ext}"))
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from logging.handlers import TimedRotatingFileHandler
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import echoss_common
|
|
13
|
+
import echoss_common.echoss_logger as echoss_logger
|
|
14
|
+
|
|
15
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
HOLD_FILE = REPO_ROOT / "tests" / "scripts" / "hold_file.py"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(autouse=True)
|
|
20
|
+
def reset_logging_state():
|
|
21
|
+
def clear():
|
|
22
|
+
for cache_name in (
|
|
23
|
+
"_logger_cache",
|
|
24
|
+
"_logger_console_handler_cache",
|
|
25
|
+
"_logger_file_handler_cache",
|
|
26
|
+
):
|
|
27
|
+
getattr(echoss_logger, cache_name).clear()
|
|
28
|
+
for logger in logging.Logger.manager.loggerDict.values():
|
|
29
|
+
if isinstance(logger, logging.Logger):
|
|
30
|
+
for handler in list(logger.handlers):
|
|
31
|
+
logger.removeHandler(handler)
|
|
32
|
+
handler.close()
|
|
33
|
+
|
|
34
|
+
clear()
|
|
35
|
+
yield
|
|
36
|
+
clear()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run_python(code, cwd):
|
|
40
|
+
env = dict(os.environ, PYTHONPATH=str(REPO_ROOT))
|
|
41
|
+
return subprocess.run([sys.executable, "-c", code], cwd=cwd, env=env, capture_output=True, text=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _file_handlers(logger):
|
|
45
|
+
return [h for h in logger.handlers if isinstance(h, TimedRotatingFileHandler)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# T-01
|
|
49
|
+
def test_import_creates_nothing(tmp_path):
|
|
50
|
+
result = _run_python("import echoss_common", tmp_path)
|
|
51
|
+
|
|
52
|
+
assert result.returncode == 0, result.stderr
|
|
53
|
+
assert list(tmp_path.iterdir()) == []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# T-02
|
|
57
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="chmod does not make a directory read-only on Windows")
|
|
58
|
+
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root ignores directory permissions")
|
|
59
|
+
def test_import_and_log_in_read_only_directory(tmp_path):
|
|
60
|
+
read_only = tmp_path / "ro"
|
|
61
|
+
read_only.mkdir()
|
|
62
|
+
read_only.chmod(0o555)
|
|
63
|
+
try:
|
|
64
|
+
result = _run_python(
|
|
65
|
+
"from echoss_common import get_logger; get_logger('x').info('hello')", read_only
|
|
66
|
+
)
|
|
67
|
+
finally:
|
|
68
|
+
read_only.chmod(0o755)
|
|
69
|
+
|
|
70
|
+
assert result.returncode == 0, result.stderr
|
|
71
|
+
assert list(read_only.iterdir()) == []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# T-03
|
|
75
|
+
@pytest.mark.parametrize("kwargs", [{}, {"file_path": None}])
|
|
76
|
+
def test_no_file_path_means_console_only(tmp_path, monkeypatch, kwargs):
|
|
77
|
+
monkeypatch.chdir(tmp_path)
|
|
78
|
+
|
|
79
|
+
logger = echoss_logger.get_logger("console-only", **kwargs)
|
|
80
|
+
logger.info("hello")
|
|
81
|
+
|
|
82
|
+
assert _file_handlers(logger) == []
|
|
83
|
+
assert len(logger.handlers) == 1
|
|
84
|
+
assert list(tmp_path.iterdir()) == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# T-04
|
|
88
|
+
def test_file_path_none_removes_existing_file_handler(tmp_path):
|
|
89
|
+
logger = echoss_logger.get_logger("switch", file_path=str(tmp_path / "switch.log"))
|
|
90
|
+
old_handler = _file_handlers(logger)[0]
|
|
91
|
+
|
|
92
|
+
logger = echoss_logger.get_logger("switch", file_path=None)
|
|
93
|
+
|
|
94
|
+
assert _file_handlers(logger) == []
|
|
95
|
+
assert old_handler.stream is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# T-05
|
|
99
|
+
def test_explicit_file_path_creates_only_that_file(tmp_path, monkeypatch):
|
|
100
|
+
monkeypatch.chdir(tmp_path)
|
|
101
|
+
|
|
102
|
+
logger = echoss_logger.get_logger("explicit", file_path="my.log")
|
|
103
|
+
logger.info("hello")
|
|
104
|
+
|
|
105
|
+
assert sorted(p.name for p in tmp_path.iterdir()) == ["my.log"]
|
|
106
|
+
assert isinstance(_file_handlers(logger)[0], echoss_logger.CopyTruncateRotatingHandler)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# T-06
|
|
110
|
+
def test_rotation_keeps_every_record_while_another_process_holds_the_file(tmp_path):
|
|
111
|
+
log_file = tmp_path / "held.log"
|
|
112
|
+
handler = echoss_logger.CopyTruncateRotatingHandler(str(log_file), when="S", interval=1, encoding="utf-8")
|
|
113
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
114
|
+
logger = logging.getLogger("held")
|
|
115
|
+
logger.propagate = False
|
|
116
|
+
logger.setLevel(logging.INFO)
|
|
117
|
+
logger.addHandler(handler)
|
|
118
|
+
|
|
119
|
+
holder = subprocess.Popen(
|
|
120
|
+
[sys.executable, str(HOLD_FILE), str(log_file)],
|
|
121
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
assert holder.stdout.readline().strip() == "ready"
|
|
125
|
+
written = 0
|
|
126
|
+
deadline = time.time() + 3
|
|
127
|
+
while time.time() < deadline:
|
|
128
|
+
logger.info(str(written))
|
|
129
|
+
written += 1
|
|
130
|
+
time.sleep(0.02)
|
|
131
|
+
finally:
|
|
132
|
+
logger.removeHandler(handler)
|
|
133
|
+
handler.close()
|
|
134
|
+
holder.stdin.close()
|
|
135
|
+
holder.wait(timeout=10)
|
|
136
|
+
|
|
137
|
+
rotated = sorted(tmp_path.glob("held.log.*"))
|
|
138
|
+
lines = []
|
|
139
|
+
for path in [log_file, *rotated]:
|
|
140
|
+
lines.extend(path.read_text(encoding="utf-8").splitlines())
|
|
141
|
+
|
|
142
|
+
assert len(rotated) >= 1
|
|
143
|
+
assert sorted(int(line) for line in lines) == list(range(written))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# T-07
|
|
147
|
+
def test_existing_dated_file_is_kept_and_rollover_moves_forward(tmp_path):
|
|
148
|
+
log_file = tmp_path / "pre.log"
|
|
149
|
+
handler = echoss_logger.CopyTruncateRotatingHandler(str(log_file), when="S", interval=1, encoding="utf-8")
|
|
150
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
151
|
+
handler.rolloverAt = int(time.time()) - 1
|
|
152
|
+
dated = Path(handler.rotation_filename(
|
|
153
|
+
handler.baseFilename + "." + time.strftime(handler.suffix, time.localtime(handler.rolloverAt - handler.interval))
|
|
154
|
+
))
|
|
155
|
+
dated.write_text("pre-existing\n", encoding="utf-8")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
handler.emit(logging.makeLogRecord({"msg": "after"}))
|
|
159
|
+
now = int(time.time())
|
|
160
|
+
finally:
|
|
161
|
+
handler.close()
|
|
162
|
+
|
|
163
|
+
assert dated.read_text(encoding="utf-8") == "pre-existing\n"
|
|
164
|
+
assert handler.rolloverAt > now
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Two loggers, one handler each, one file, rotating every second: writes A1 B1 | A2 B2 | A3 B3
|
|
168
|
+
def _write_through_two_handlers(tmp_path):
|
|
169
|
+
log_file = tmp_path / "e.log"
|
|
170
|
+
loggers = []
|
|
171
|
+
for name in ("two_a", "two_b"):
|
|
172
|
+
handler = echoss_logger.CopyTruncateRotatingHandler(str(log_file), when="S", interval=1, encoding="utf-8")
|
|
173
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
174
|
+
logger = logging.getLogger(name)
|
|
175
|
+
logger.propagate = False
|
|
176
|
+
logger.setLevel(logging.INFO)
|
|
177
|
+
logger.addHandler(handler)
|
|
178
|
+
loggers.append(logger)
|
|
179
|
+
try:
|
|
180
|
+
for i in (1, 2, 3):
|
|
181
|
+
loggers[0].info("A%d", i)
|
|
182
|
+
loggers[1].info("B%d", i)
|
|
183
|
+
if i < 3:
|
|
184
|
+
time.sleep(1.2)
|
|
185
|
+
finally:
|
|
186
|
+
for logger in loggers:
|
|
187
|
+
for handler in list(logger.handlers):
|
|
188
|
+
logger.removeHandler(handler)
|
|
189
|
+
handler.close()
|
|
190
|
+
rotated = sorted(tmp_path.glob("e.log.*"))
|
|
191
|
+
return log_file.read_text(encoding="utf-8").split(), [p.read_text(encoding="utf-8").split() for p in rotated]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# T-M1
|
|
195
|
+
def test_two_handlers_on_one_file_write_current_file_after_rotation(tmp_path):
|
|
196
|
+
current, rotated = _write_through_two_handlers(tmp_path)
|
|
197
|
+
|
|
198
|
+
assert current == ["A3", "B3"]
|
|
199
|
+
assert rotated == [["A1", "B1"], ["A2", "B2"]]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# T-M2
|
|
203
|
+
def test_two_handlers_lose_and_duplicate_no_record(tmp_path):
|
|
204
|
+
current, rotated = _write_through_two_handlers(tmp_path)
|
|
205
|
+
|
|
206
|
+
assert sorted(current + [line for lines in rotated for line in lines]) == ["A1", "A2", "A3", "B1", "B2", "B3"]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# T-M3
|
|
210
|
+
def test_stale_stream_check_off_keeps_2_0_0_layout(tmp_path, monkeypatch):
|
|
211
|
+
monkeypatch.setattr(echoss_logger, "_STALE_STREAM_CHECK", False, raising=False)
|
|
212
|
+
current, rotated = _write_through_two_handlers(tmp_path)
|
|
213
|
+
|
|
214
|
+
assert current == ["A3"]
|
|
215
|
+
assert rotated == [["A1", "B1", "B2", "B3"], ["A2"]]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# T-08
|
|
219
|
+
@pytest.mark.parametrize("func, params", [
|
|
220
|
+
(echoss_common.get_app_logger,
|
|
221
|
+
["logger_name", "logger_format", "file_path", "backup_count", "use_console", "level"]),
|
|
222
|
+
(echoss_common.get_logger,
|
|
223
|
+
["logger_name", "logger_format", "file_path", "backup_count", "use_console", "level"]),
|
|
224
|
+
(echoss_common.use_logger, ["logger_name"]),
|
|
225
|
+
(echoss_common.set_logger_level, ["logger", "level"]),
|
|
226
|
+
(echoss_common.modify_loggers_by_prefix,
|
|
227
|
+
["prefix", "new_format", "new_path", "backup_count", "use_console", "level"]),
|
|
228
|
+
(echoss_common.dict_load, ["file_path", "file_format", "kwargs"]),
|
|
229
|
+
(echoss_common.dict_dump, ["config", "file_path", "file_format", "force_write", "xml_tag", "kwargs"]),
|
|
230
|
+
])
|
|
231
|
+
def test_public_signatures_unchanged_from_1_2_0(func, params):
|
|
232
|
+
assert list(inspect.signature(func).parameters) == params
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_log_format_detail_unchanged_from_1_2_0():
|
|
236
|
+
assert echoss_common.LOG_FORMAT_DETAIL == (
|
|
237
|
+
"[%(asctime)s.%(msecs)03d] %(levelname)-7s %(module)s.%(funcName)s.%(lineno)d : %(message)s"
|
|
238
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|