echoss-common 2.1.0__tar.gz → 2.2.0__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-2.1.0/echoss_common.egg-info → echoss_common-2.2.0}/PKG-INFO +12 -6
- {echoss_common-2.1.0 → echoss_common-2.2.0}/README.md +11 -5
- {echoss_common-2.1.0 → echoss_common-2.2.0}/echoss_common/echoss_logger.py +49 -4
- {echoss_common-2.1.0 → echoss_common-2.2.0/echoss_common.egg-info}/PKG-INFO +12 -6
- {echoss_common-2.1.0 → echoss_common-2.2.0}/pyproject.toml +1 -1
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/test_echoss_logger_2x.py +2 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/test_echoss_logger_rotation_modes.py +126 -8
- {echoss_common-2.1.0 → echoss_common-2.2.0}/LICENSE +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/MANIFEST.in +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/echoss_common/__init__.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/echoss_common/config_fileformat.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/echoss_common.egg-info/SOURCES.txt +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/echoss_common.egg-info/dependency_links.txt +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/echoss_common.egg-info/requires.txt +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/echoss_common.egg-info/top_level.txt +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/setup.cfg +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/scripts/check_xml_samples.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/scripts/hold_file.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/scripts/spike_default_log_file.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/scripts/verify_xml_edge_cases.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/test_config_fileformat.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/test_config_fileformat_2x.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/test_echoss_logger_1.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/tests/test_echoss_logger_2.py +0 -0
- {echoss_common-2.1.0 → echoss_common-2.2.0}/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: 2.
|
|
3
|
+
Version: 2.2.0
|
|
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
|
|
@@ -34,11 +34,17 @@ pip install echoss-common
|
|
|
34
34
|
- Python >= 3.12
|
|
35
35
|
- pyyaml > 6.0.2
|
|
36
36
|
|
|
37
|
+
## 2.2.0 변경 사항
|
|
38
|
+
|
|
39
|
+
- **크기 회전 파일 이름이 바뀌었습니다.** `myapp.log.50m.1`(가장 최근) … 처럼 회전마다 번호를 미는 대신, `myapp.log.50m.2026-11-01.001` 처럼 회전한 날짜와 그날의 일련번호를 붙이고 다시 바꾸지 않습니다. 2.1.0 이 남긴 `myapp.log.50m.1` 같은 파일은 세지도 지우지도 않으니 직접 정리합니다.
|
|
40
|
+
- 크기 회전의 `backup_count=0` 은 자정 회전과 같이 「회전하고 전부 보관」입니다(2.1.0 은 회전하지 않았습니다).
|
|
41
|
+
- 회전 중 지울 옛 로그 파일을 다른 프로그램이 열고 있으면(Windows `PermissionError`) 지우기를 건너뛰고 계속 씁니다. 2.1.0 은 크기 회전에서 이후 기록을 잃고, 자정 회전에서 회전당 한 줄을 잃었습니다(아래 「로그 회전」).
|
|
42
|
+
|
|
37
43
|
## 2.1.0 변경 사항
|
|
38
44
|
|
|
39
45
|
- `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
|
|
40
46
|
- `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
|
|
41
|
-
- `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로
|
|
47
|
+
- `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다(2.2.0 에서 이름 규칙 변경). 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
|
|
42
48
|
- **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
|
|
43
49
|
|
|
44
50
|
## 2.0.0 변경 사항
|
|
@@ -135,13 +141,13 @@ modify_loggers_by_prefix(
|
|
|
135
141
|
| `rotation` | 동작 | 회전 파일 | `backup_count` |
|
|
136
142
|
|---|---|---|---|
|
|
137
143
|
| `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
|
|
138
|
-
| `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.
|
|
144
|
+
| `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.2026-11-01.001`, `.002` … (회전한 날짜와 그날의 일련번호) | 0 이면 전부 보관, N 이면 가장 새 N 개 |
|
|
139
145
|
| `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
|
|
140
146
|
|
|
141
|
-
-
|
|
147
|
+
- 크기 회전의 일련번호 자리수는 크기 숫자의 자리수로 정합니다 — `1m`~`9m` 4 자리, `10m`~`99m` 3 자리, `100m`~`999m` 2 자리, `1000m` 이상 1 자리. 하루 약 10 GB 를 넘게 써서 자리수를 넘으면 번호가 그대로 길어집니다. 보관 순서는 숫자로 비교하므로 맞고, 리눅스 `ls` 화면 순서만 어긋납니다(`ls -v` 로 봅니다).
|
|
142
148
|
- 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
|
|
143
149
|
- Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
|
|
144
|
-
-
|
|
150
|
+
- 회전 파일 이름은 한 번 붙이면 바꾸지 않습니다. 회전할 때 `backup_count` 를 넘는 옛 회전 파일을 지우는데, 다른 프로그램이 그 파일을 열고 있어 막히면(Windows `PermissionError`) 지우기를 건너뛰고 다음 회전에서 다시 지웁니다. 기록은 잃지 않고, 핸들러마다 처음 한 번 `RuntimeWarning` 을 냅니다. 그동안 회전 파일이 `backup_count` 보다 많을 수 있습니다.
|
|
145
151
|
- 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
|
|
146
152
|
- **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
|
|
147
153
|
|
|
@@ -268,7 +274,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
268
274
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
269
275
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
270
276
|
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
271
|
-
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (
|
|
277
|
+
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
|
|
272
278
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
273
279
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
274
280
|
| `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
|
|
@@ -19,11 +19,17 @@ pip install echoss-common
|
|
|
19
19
|
- Python >= 3.12
|
|
20
20
|
- pyyaml > 6.0.2
|
|
21
21
|
|
|
22
|
+
## 2.2.0 변경 사항
|
|
23
|
+
|
|
24
|
+
- **크기 회전 파일 이름이 바뀌었습니다.** `myapp.log.50m.1`(가장 최근) … 처럼 회전마다 번호를 미는 대신, `myapp.log.50m.2026-11-01.001` 처럼 회전한 날짜와 그날의 일련번호를 붙이고 다시 바꾸지 않습니다. 2.1.0 이 남긴 `myapp.log.50m.1` 같은 파일은 세지도 지우지도 않으니 직접 정리합니다.
|
|
25
|
+
- 크기 회전의 `backup_count=0` 은 자정 회전과 같이 「회전하고 전부 보관」입니다(2.1.0 은 회전하지 않았습니다).
|
|
26
|
+
- 회전 중 지울 옛 로그 파일을 다른 프로그램이 열고 있으면(Windows `PermissionError`) 지우기를 건너뛰고 계속 씁니다. 2.1.0 은 크기 회전에서 이후 기록을 잃고, 자정 회전에서 회전당 한 줄을 잃었습니다(아래 「로그 회전」).
|
|
27
|
+
|
|
22
28
|
## 2.1.0 변경 사항
|
|
23
29
|
|
|
24
30
|
- `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
|
|
25
31
|
- `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
|
|
26
|
-
- `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로
|
|
32
|
+
- `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다(2.2.0 에서 이름 규칙 변경). 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
|
|
27
33
|
- **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
|
|
28
34
|
|
|
29
35
|
## 2.0.0 변경 사항
|
|
@@ -120,13 +126,13 @@ modify_loggers_by_prefix(
|
|
|
120
126
|
| `rotation` | 동작 | 회전 파일 | `backup_count` |
|
|
121
127
|
|---|---|---|---|
|
|
122
128
|
| `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
|
|
123
|
-
| `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.
|
|
129
|
+
| `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.2026-11-01.001`, `.002` … (회전한 날짜와 그날의 일련번호) | 0 이면 전부 보관, N 이면 가장 새 N 개 |
|
|
124
130
|
| `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
|
|
125
131
|
|
|
126
|
-
-
|
|
132
|
+
- 크기 회전의 일련번호 자리수는 크기 숫자의 자리수로 정합니다 — `1m`~`9m` 4 자리, `10m`~`99m` 3 자리, `100m`~`999m` 2 자리, `1000m` 이상 1 자리. 하루 약 10 GB 를 넘게 써서 자리수를 넘으면 번호가 그대로 길어집니다. 보관 순서는 숫자로 비교하므로 맞고, 리눅스 `ls` 화면 순서만 어긋납니다(`ls -v` 로 봅니다).
|
|
127
133
|
- 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
|
|
128
134
|
- Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
|
|
129
|
-
-
|
|
135
|
+
- 회전 파일 이름은 한 번 붙이면 바꾸지 않습니다. 회전할 때 `backup_count` 를 넘는 옛 회전 파일을 지우는데, 다른 프로그램이 그 파일을 열고 있어 막히면(Windows `PermissionError`) 지우기를 건너뛰고 다음 회전에서 다시 지웁니다. 기록은 잃지 않고, 핸들러마다 처음 한 번 `RuntimeWarning` 을 냅니다. 그동안 회전 파일이 `backup_count` 보다 많을 수 있습니다.
|
|
130
136
|
- 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
|
|
131
137
|
- **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
|
|
132
138
|
|
|
@@ -253,7 +259,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
253
259
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
254
260
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
255
261
|
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
256
|
-
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (
|
|
262
|
+
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
|
|
257
263
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
258
264
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
259
265
|
| `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
|
|
@@ -5,6 +5,7 @@ import re
|
|
|
5
5
|
import shutil
|
|
6
6
|
import sys
|
|
7
7
|
import time
|
|
8
|
+
import warnings
|
|
8
9
|
from collections.abc import Mapping
|
|
9
10
|
from functools import cache
|
|
10
11
|
from logging import StreamHandler
|
|
@@ -70,6 +71,17 @@ def _stream_replaced(handler) -> bool:
|
|
|
70
71
|
return (mine.st_dev, mine.st_ino) != (disk.st_dev, disk.st_ino)
|
|
71
72
|
|
|
72
73
|
|
|
74
|
+
# A rotation step hit a file another process keeps open (Windows: PermissionError).
|
|
75
|
+
# Keep every record in the current file; warn once per handler.
|
|
76
|
+
def _keep_writing_after_held_file(handler, exc):
|
|
77
|
+
if not getattr(handler, "_held_warned", False):
|
|
78
|
+
handler._held_warned = True
|
|
79
|
+
warnings.warn(f"log rotation step skipped, a file is held open by another process: {exc}",
|
|
80
|
+
RuntimeWarning, stacklevel=2)
|
|
81
|
+
if handler.stream is None:
|
|
82
|
+
handler.stream = handler._open()
|
|
83
|
+
|
|
84
|
+
|
|
73
85
|
# On Windows, renaming a log file that another handle keeps open fails with PermissionError (WinError 32).
|
|
74
86
|
# Fall back to copy-then-truncate so no record is lost, and always move rolloverAt forward.
|
|
75
87
|
class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
|
|
@@ -97,7 +109,10 @@ class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
|
|
|
97
109
|
self.stream = self._open()
|
|
98
110
|
self.rolloverAt = self.computeRollover(int(time.time()))
|
|
99
111
|
return
|
|
100
|
-
|
|
112
|
+
try:
|
|
113
|
+
super().doRollover()
|
|
114
|
+
except PermissionError as exc: # an old dated file to delete is held open
|
|
115
|
+
_keep_writing_after_held_file(self, exc)
|
|
101
116
|
now = int(time.time())
|
|
102
117
|
if self.rolloverAt <= now:
|
|
103
118
|
self.rolloverAt = self.computeRollover(now)
|
|
@@ -106,15 +121,45 @@ class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
|
|
|
106
121
|
_SIZE_ROTATION = re.compile(r"[1-9][0-9]*m") # MiB, lowercase m only
|
|
107
122
|
|
|
108
123
|
|
|
109
|
-
# Size rotation
|
|
110
|
-
#
|
|
124
|
+
# Size rotation into names that never change: a.log.50m.2026-11-01.001 (the day's largest number + 1).
|
|
125
|
+
# Windows blocks renaming or deleting a file another process holds; with no renaming chain,
|
|
126
|
+
# only deleting an old file can be blocked, as in midnight rotation.
|
|
111
127
|
class SizeCopyTruncateRotatingHandler(RotatingFileHandler):
|
|
112
128
|
rotate = CopyTruncateRotatingHandler.rotate
|
|
113
129
|
|
|
114
130
|
def __init__(self, filename, size, backup_count):
|
|
115
131
|
super().__init__(filename, maxBytes=int(size[:-1]) * 1024 * 1024,
|
|
116
132
|
backupCount=backup_count, encoding="utf-8")
|
|
117
|
-
self.
|
|
133
|
+
self.size = size
|
|
134
|
+
self.width = max(1, 5 - len(size[:-1])) # 1-9m: 4 digits, 10-99m: 3, 100-999m: 2, 1000m+: 1
|
|
135
|
+
self.pattern = re.compile(re.escape(os.path.basename(self.baseFilename))
|
|
136
|
+
+ rf"\.{size}\.(\d{{4}}-\d{{2}}-\d{{2}})\.(\d+)")
|
|
137
|
+
|
|
138
|
+
# (date, number, path) of this handler's rotated files, oldest first
|
|
139
|
+
def rotated_files(self):
|
|
140
|
+
folder = os.path.dirname(self.baseFilename)
|
|
141
|
+
found = []
|
|
142
|
+
for name in os.listdir(folder):
|
|
143
|
+
m = self.pattern.fullmatch(name)
|
|
144
|
+
if m:
|
|
145
|
+
found.append((m[1], int(m[2]), os.path.join(folder, name)))
|
|
146
|
+
return sorted(found)
|
|
147
|
+
|
|
148
|
+
def doRollover(self):
|
|
149
|
+
if self.stream:
|
|
150
|
+
self.stream.close()
|
|
151
|
+
self.stream = None
|
|
152
|
+
files = self.rotated_files()
|
|
153
|
+
today = time.strftime("%Y-%m-%d")
|
|
154
|
+
number = max((n for day, n, _ in files if day == today), default=0) + 1
|
|
155
|
+
self.rotate(self.baseFilename, f"{self.baseFilename}.{self.size}.{today}.{number:0{self.width}d}")
|
|
156
|
+
self.stream = self._open()
|
|
157
|
+
if self.backupCount > 0: # 0 keeps every rotated file, as in midnight rotation
|
|
158
|
+
try:
|
|
159
|
+
for *_, path in files[:len(files) + 1 - self.backupCount]:
|
|
160
|
+
os.remove(path)
|
|
161
|
+
except PermissionError as exc: # an old rotated file is held open
|
|
162
|
+
_keep_writing_after_held_file(self, exc)
|
|
118
163
|
|
|
119
164
|
|
|
120
165
|
def _check_rotation(rotation):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: echoss-common
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
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
|
|
@@ -34,11 +34,17 @@ pip install echoss-common
|
|
|
34
34
|
- Python >= 3.12
|
|
35
35
|
- pyyaml > 6.0.2
|
|
36
36
|
|
|
37
|
+
## 2.2.0 변경 사항
|
|
38
|
+
|
|
39
|
+
- **크기 회전 파일 이름이 바뀌었습니다.** `myapp.log.50m.1`(가장 최근) … 처럼 회전마다 번호를 미는 대신, `myapp.log.50m.2026-11-01.001` 처럼 회전한 날짜와 그날의 일련번호를 붙이고 다시 바꾸지 않습니다. 2.1.0 이 남긴 `myapp.log.50m.1` 같은 파일은 세지도 지우지도 않으니 직접 정리합니다.
|
|
40
|
+
- 크기 회전의 `backup_count=0` 은 자정 회전과 같이 「회전하고 전부 보관」입니다(2.1.0 은 회전하지 않았습니다).
|
|
41
|
+
- 회전 중 지울 옛 로그 파일을 다른 프로그램이 열고 있으면(Windows `PermissionError`) 지우기를 건너뛰고 계속 씁니다. 2.1.0 은 크기 회전에서 이후 기록을 잃고, 자정 회전에서 회전당 한 줄을 잃었습니다(아래 「로그 회전」).
|
|
42
|
+
|
|
37
43
|
## 2.1.0 변경 사항
|
|
38
44
|
|
|
39
45
|
- `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
|
|
40
46
|
- `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
|
|
41
|
-
- `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로
|
|
47
|
+
- `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다(2.2.0 에서 이름 규칙 변경). 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
|
|
42
48
|
- **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
|
|
43
49
|
|
|
44
50
|
## 2.0.0 변경 사항
|
|
@@ -135,13 +141,13 @@ modify_loggers_by_prefix(
|
|
|
135
141
|
| `rotation` | 동작 | 회전 파일 | `backup_count` |
|
|
136
142
|
|---|---|---|---|
|
|
137
143
|
| `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
|
|
138
|
-
| `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.
|
|
144
|
+
| `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.2026-11-01.001`, `.002` … (회전한 날짜와 그날의 일련번호) | 0 이면 전부 보관, N 이면 가장 새 N 개 |
|
|
139
145
|
| `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
|
|
140
146
|
|
|
141
|
-
-
|
|
147
|
+
- 크기 회전의 일련번호 자리수는 크기 숫자의 자리수로 정합니다 — `1m`~`9m` 4 자리, `10m`~`99m` 3 자리, `100m`~`999m` 2 자리, `1000m` 이상 1 자리. 하루 약 10 GB 를 넘게 써서 자리수를 넘으면 번호가 그대로 길어집니다. 보관 순서는 숫자로 비교하므로 맞고, 리눅스 `ls` 화면 순서만 어긋납니다(`ls -v` 로 봅니다).
|
|
142
148
|
- 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
|
|
143
149
|
- Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
|
|
144
|
-
-
|
|
150
|
+
- 회전 파일 이름은 한 번 붙이면 바꾸지 않습니다. 회전할 때 `backup_count` 를 넘는 옛 회전 파일을 지우는데, 다른 프로그램이 그 파일을 열고 있어 막히면(Windows `PermissionError`) 지우기를 건너뛰고 다음 회전에서 다시 지웁니다. 기록은 잃지 않고, 핸들러마다 처음 한 번 `RuntimeWarning` 을 냅니다. 그동안 회전 파일이 `backup_count` 보다 많을 수 있습니다.
|
|
145
151
|
- 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
|
|
146
152
|
- **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
|
|
147
153
|
|
|
@@ -268,7 +274,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
|
|
|
268
274
|
| `logger_name` | str | `'echoss'` | 로거 이름 |
|
|
269
275
|
| `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
|
|
270
276
|
| `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
|
|
271
|
-
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (
|
|
277
|
+
| `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
|
|
272
278
|
| `use_console` | bool | `True` | 콘솔 출력 여부 |
|
|
273
279
|
| `level` | str\|int | `'DEBUG'` | 로그 레벨 |
|
|
274
280
|
| `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
# 패키지 메타데이터
|
|
7
7
|
[project]
|
|
8
8
|
name="echoss-common"
|
|
9
|
-
version="2.
|
|
9
|
+
version="2.2.0"
|
|
10
10
|
description="echoss AI Bigdata Center 공통 유틸리티 라이브러리 (Logger, JsonLogDict, config_fileformat)"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = "Apache-2.0"
|
|
@@ -207,6 +207,8 @@ def test_two_handlers_lose_and_duplicate_no_record(tmp_path):
|
|
|
207
207
|
|
|
208
208
|
|
|
209
209
|
# T-M3
|
|
210
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Windows blocks the rename while the second handler keeps "
|
|
211
|
+
"the file open, so copy-truncate keeps A3 and B3 in the current file (T-M1/T-M2 cover no loss)")
|
|
210
212
|
def test_stale_stream_check_off_keeps_2_0_0_layout(tmp_path, monkeypatch):
|
|
211
213
|
monkeypatch.setattr(echoss_logger, "_STALE_STREAM_CHECK", False, raising=False)
|
|
212
214
|
current, rotated = _write_through_two_handlers(tmp_path)
|
|
@@ -3,6 +3,8 @@ import logging
|
|
|
3
3
|
import os
|
|
4
4
|
import subprocess
|
|
5
5
|
import sys
|
|
6
|
+
import time
|
|
7
|
+
import warnings
|
|
6
8
|
from contextlib import contextmanager
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
@@ -46,6 +48,11 @@ def _numbers(paths):
|
|
|
46
48
|
return found
|
|
47
49
|
|
|
48
50
|
|
|
51
|
+
def _rotated(name, *numbers):
|
|
52
|
+
today = time.strftime("%Y-%m-%d")
|
|
53
|
+
return [f"{name}.1m.{today}.{n:04d}" for n in numbers]
|
|
54
|
+
|
|
55
|
+
|
|
49
56
|
def _write(logger, count, start=0):
|
|
50
57
|
for n in range(start, start + count):
|
|
51
58
|
logger.info(f"{n} {LINE}")
|
|
@@ -69,7 +76,7 @@ def test_size_rotation_names_limit_and_backup_count(tmp_path):
|
|
|
69
76
|
_write(logger, written)
|
|
70
77
|
|
|
71
78
|
files = sorted(tmp_path.iterdir())
|
|
72
|
-
assert [f.name for f in files] == ["s.log"
|
|
79
|
+
assert [f.name for f in files] == ["s.log"] + _rotated("s.log", 2, 3) # 3 rotations, the 2 newest kept
|
|
73
80
|
assert all(f.stat().st_size <= MIB for f in files)
|
|
74
81
|
kept = sorted(_numbers(files))
|
|
75
82
|
assert kept == list(range(written - len(kept), written)) # newest records, no gap
|
|
@@ -120,7 +127,7 @@ def test_size_rotation_falls_back_to_copy_truncate_when_rename_is_blocked(tmp_pa
|
|
|
120
127
|
_write(logger, written)
|
|
121
128
|
|
|
122
129
|
files = sorted(tmp_path.iterdir())
|
|
123
|
-
assert "blk.log
|
|
130
|
+
assert _rotated("blk.log", 1)[0] in [f.name for f in files]
|
|
124
131
|
assert sorted(_numbers(files)) == list(range(written))
|
|
125
132
|
|
|
126
133
|
|
|
@@ -179,7 +186,7 @@ def test_modify_loggers_by_prefix_shares_one_file_handler(tmp_path, rotation):
|
|
|
179
186
|
files = sorted(tmp_path.iterdir())
|
|
180
187
|
kept = sorted(_numbers(files))
|
|
181
188
|
if rotation == "1m":
|
|
182
|
-
assert [f.name for f in files] == ["shared.log"
|
|
189
|
+
assert [f.name for f in files] == ["shared.log"] + _rotated("shared.log", 2, 3)
|
|
183
190
|
assert all(f.stat().st_size <= MIB for f in files)
|
|
184
191
|
assert kept == list(range(count - len(kept), count)) # newest records of all three loggers, no gap
|
|
185
192
|
else:
|
|
@@ -260,16 +267,17 @@ def test_size_rotation_keeps_every_record_while_another_process_holds_the_curren
|
|
|
260
267
|
# T-R9
|
|
261
268
|
def test_size_rotation_keeps_every_record_while_another_process_holds_a_numbered_file(tmp_path):
|
|
262
269
|
log_file = tmp_path / "h9.log"
|
|
263
|
-
logger = _size_logger("rm-h9", log_file,
|
|
264
|
-
first = 1200 # one rotation, so
|
|
270
|
+
logger = _size_logger("rm-h9", log_file, 1)
|
|
271
|
+
first = 1200 # one rotation, so the first rotated file exists
|
|
265
272
|
_write(logger, first)
|
|
266
|
-
numbered = tmp_path / "h9.log
|
|
273
|
+
numbered = tmp_path / _rotated("h9.log", 1)[0]
|
|
267
274
|
assert numbered.exists()
|
|
268
|
-
more = 2400 # two more rotations
|
|
275
|
+
more = 2400 # two more rotations try to delete it while it is held
|
|
269
276
|
with _held(numbered):
|
|
270
277
|
_write(logger, more, start=first)
|
|
271
278
|
|
|
272
|
-
|
|
279
|
+
kept = sorted(_numbers(sorted(tmp_path.iterdir()))) # Linux deletes, Windows keeps the held file
|
|
280
|
+
assert kept == list(range(first + more - len(kept), first + more)) # newest records, no gap
|
|
273
281
|
|
|
274
282
|
|
|
275
283
|
# T-R10
|
|
@@ -281,3 +289,113 @@ def test_none_rotation_keeps_every_record_while_another_process_holds_the_file(t
|
|
|
281
289
|
_write(logger, 500)
|
|
282
290
|
|
|
283
291
|
assert _numbers([log_file]) == list(range(500))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# T-W1 ~ T-W4: a file to shift or delete is held open (Linux stand-in: PermissionError raised by a patched os call)
|
|
295
|
+
def _held_warnings(caught):
|
|
296
|
+
return [w for w in caught if issubclass(w.category, RuntimeWarning) and "held open" in str(w.message)]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# T-W1
|
|
300
|
+
def test_size_rotation_keeps_rotating_while_an_old_rotated_file_cannot_be_deleted(tmp_path, monkeypatch):
|
|
301
|
+
log_file = tmp_path / "w1.log"
|
|
302
|
+
logger = _size_logger("rm-w1", log_file, 1)
|
|
303
|
+
first = 1200 # one rotation, so the first rotated file exists
|
|
304
|
+
_write(logger, first)
|
|
305
|
+
|
|
306
|
+
def blocked_remove(path):
|
|
307
|
+
raise PermissionError("simulated: another handle keeps the file open")
|
|
308
|
+
|
|
309
|
+
monkeypatch.setattr(os, "remove", blocked_remove)
|
|
310
|
+
more = 2400 # two more rotations, each trying to delete the oldest rotated file
|
|
311
|
+
with pytest.warns(RuntimeWarning) as caught:
|
|
312
|
+
_write(logger, more, start=first)
|
|
313
|
+
|
|
314
|
+
files = sorted(tmp_path.iterdir())
|
|
315
|
+
assert len(_held_warnings(caught)) == 1
|
|
316
|
+
assert [f.name for f in files] == ["w1.log"] + _rotated("w1.log", 1, 2, 3) # kept past backup_count
|
|
317
|
+
assert all(f.stat().st_size <= MIB for f in files)
|
|
318
|
+
assert sorted(_numbers(files)) == list(range(first + more))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# T-W3
|
|
322
|
+
def test_midnight_rotation_keeps_every_record_while_an_old_dated_file_cannot_be_deleted(tmp_path, monkeypatch):
|
|
323
|
+
log_file = tmp_path / "w3.log"
|
|
324
|
+
logger = echoss_logger.get_logger("rm-w3", logger_format="%(message)s", file_path=str(log_file),
|
|
325
|
+
backup_count=1, use_console=False, level="INFO")
|
|
326
|
+
[handler] = _file_handlers(logger)
|
|
327
|
+
for day in ("2020-01-01", "2020-01-02"): # old dated files the rotation will try to delete
|
|
328
|
+
(tmp_path / f"w3.log.{day}").write_text("", encoding="utf-8")
|
|
329
|
+
|
|
330
|
+
def blocked_remove(path):
|
|
331
|
+
raise PermissionError("simulated: another handle keeps the file open")
|
|
332
|
+
|
|
333
|
+
monkeypatch.setattr(os, "remove", blocked_remove)
|
|
334
|
+
_write(logger, 10)
|
|
335
|
+
handler.rolloverAt = int(time.time()) - 1 # one rotation; a second one the same day finds its dated name taken
|
|
336
|
+
with pytest.warns(RuntimeWarning) as caught:
|
|
337
|
+
_write(logger, 10, start=10)
|
|
338
|
+
|
|
339
|
+
assert len(_held_warnings(caught)) == 1
|
|
340
|
+
assert sorted(_numbers(sorted(tmp_path.iterdir()))) == list(range(20))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# T-W5 (Windows: on Linux nothing is blocked)
|
|
344
|
+
def test_midnight_rotation_keeps_every_record_while_another_process_holds_an_old_dated_file(tmp_path):
|
|
345
|
+
log_file = tmp_path / "w5.log"
|
|
346
|
+
logger = echoss_logger.get_logger("rm-w5", logger_format="%(message)s", file_path=str(log_file),
|
|
347
|
+
backup_count=1, use_console=False, level="INFO")
|
|
348
|
+
[handler] = _file_handlers(logger)
|
|
349
|
+
old = tmp_path / "w5.log.2020-01-01"
|
|
350
|
+
old.write_text("", encoding="utf-8")
|
|
351
|
+
_write(logger, 10)
|
|
352
|
+
with _held(old):
|
|
353
|
+
handler.rolloverAt = int(time.time()) - 1
|
|
354
|
+
_write(logger, 10, start=10)
|
|
355
|
+
|
|
356
|
+
assert sorted(_numbers(sorted(tmp_path.iterdir()))) == list(range(20))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# T-W4
|
|
360
|
+
def test_size_rotation_backup_count_zero_keeps_every_rotated_file(tmp_path):
|
|
361
|
+
log_file = tmp_path / "w4.log"
|
|
362
|
+
with warnings.catch_warnings():
|
|
363
|
+
warnings.simplefilter("error")
|
|
364
|
+
logger = _size_logger("rm-w4", log_file, 0)
|
|
365
|
+
_write(logger, 3500)
|
|
366
|
+
|
|
367
|
+
files = sorted(tmp_path.iterdir())
|
|
368
|
+
assert [f.name for f in files] == ["w4.log"] + _rotated("w4.log", 1, 2, 3)
|
|
369
|
+
assert sorted(_numbers(files)) == list(range(3500))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# T-W6
|
|
373
|
+
@pytest.mark.parametrize("size, width", [("1m", 4), ("9m", 4), ("10m", 3), ("100m", 2), ("1000m", 1), ("2048m", 1)])
|
|
374
|
+
def test_size_rotation_number_width_follows_size_digits(tmp_path, size, width):
|
|
375
|
+
handler = echoss_logger.SizeCopyTruncateRotatingHandler(str(tmp_path / "x.log"), size, 5)
|
|
376
|
+
try:
|
|
377
|
+
handler.emit(logging.makeLogRecord({"msg": "0 a"}))
|
|
378
|
+
handler.doRollover()
|
|
379
|
+
finally:
|
|
380
|
+
handler.close()
|
|
381
|
+
|
|
382
|
+
assert [f.name for f in tmp_path.iterdir() if f.name != "x.log"] == \
|
|
383
|
+
[f"x.log.{size}.{time.strftime('%Y-%m-%d')}.{1:0{width}d}"]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# T-W6
|
|
387
|
+
def test_size_rotation_takes_the_days_largest_number_plus_one(tmp_path):
|
|
388
|
+
today = time.strftime("%Y-%m-%d")
|
|
389
|
+
for name in (f"x.log.1m.{today}.0009", f"x.log.1m.{today}.12345", "x.log.1m.2020-01-01.0500",
|
|
390
|
+
"x.log.1m.7", "x.log.50m.2020-01-01.001"): # gap, overflow, another day, 2.1.0 name, other size
|
|
391
|
+
(tmp_path / name).write_text("", encoding="utf-8")
|
|
392
|
+
handler = echoss_logger.SizeCopyTruncateRotatingHandler(str(tmp_path / "x.log"), "1m", 10)
|
|
393
|
+
try:
|
|
394
|
+
handler.emit(logging.makeLogRecord({"msg": "0 a"}))
|
|
395
|
+
handler.doRollover()
|
|
396
|
+
rotated = [os.path.basename(path) for *_, path in handler.rotated_files()]
|
|
397
|
+
finally:
|
|
398
|
+
handler.close()
|
|
399
|
+
|
|
400
|
+
assert rotated == ["x.log.1m.2020-01-01.0500", f"x.log.1m.{today}.0009", f"x.log.1m.{today}.12345",
|
|
401
|
+
f"x.log.1m.{today}.12346"]
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|