echoss-common 2.0.0__tar.gz → 2.1.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.
Files changed (25) hide show
  1. {echoss_common-2.0.0/echoss_common.egg-info → echoss_common-2.1.0}/PKG-INFO +33 -8
  2. {echoss_common-2.0.0 → echoss_common-2.1.0}/README.md +32 -7
  3. {echoss_common-2.0.0 → echoss_common-2.1.0}/echoss_common/echoss_logger.py +93 -21
  4. {echoss_common-2.0.0 → echoss_common-2.1.0/echoss_common.egg-info}/PKG-INFO +33 -8
  5. {echoss_common-2.0.0 → echoss_common-2.1.0}/echoss_common.egg-info/SOURCES.txt +1 -0
  6. {echoss_common-2.0.0 → echoss_common-2.1.0}/pyproject.toml +1 -1
  7. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/test_echoss_logger_2x.py +54 -3
  8. echoss_common-2.1.0/tests/test_echoss_logger_rotation_modes.py +283 -0
  9. {echoss_common-2.0.0 → echoss_common-2.1.0}/LICENSE +0 -0
  10. {echoss_common-2.0.0 → echoss_common-2.1.0}/MANIFEST.in +0 -0
  11. {echoss_common-2.0.0 → echoss_common-2.1.0}/echoss_common/__init__.py +0 -0
  12. {echoss_common-2.0.0 → echoss_common-2.1.0}/echoss_common/config_fileformat.py +0 -0
  13. {echoss_common-2.0.0 → echoss_common-2.1.0}/echoss_common.egg-info/dependency_links.txt +0 -0
  14. {echoss_common-2.0.0 → echoss_common-2.1.0}/echoss_common.egg-info/requires.txt +0 -0
  15. {echoss_common-2.0.0 → echoss_common-2.1.0}/echoss_common.egg-info/top_level.txt +0 -0
  16. {echoss_common-2.0.0 → echoss_common-2.1.0}/setup.cfg +0 -0
  17. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/scripts/check_xml_samples.py +0 -0
  18. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/scripts/hold_file.py +0 -0
  19. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/scripts/spike_default_log_file.py +0 -0
  20. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/scripts/verify_xml_edge_cases.py +0 -0
  21. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/test_config_fileformat.py +0 -0
  22. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/test_config_fileformat_2x.py +0 -0
  23. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/test_echoss_logger_1.py +0 -0
  24. {echoss_common-2.0.0 → echoss_common-2.1.0}/tests/test_echoss_logger_2.py +0 -0
  25. {echoss_common-2.0.0 → echoss_common-2.1.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.0.0
3
+ Version: 2.1.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,6 +34,13 @@ pip install echoss-common
34
34
  - Python >= 3.12
35
35
  - pyyaml > 6.0.2
36
36
 
37
+ ## 2.1.0 변경 사항
38
+
39
+ - `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
40
+ - `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
41
+ - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다. 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
42
+ - **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
43
+
37
44
  ## 2.0.0 변경 사항
38
45
 
39
46
  호출 방식(함수 이름·인자 이름·인자 순서)은 1.2.0 과 같습니다. 아래 네 가지는 **동작이 바뀌는 호환 단절**입니다.
@@ -88,9 +95,16 @@ logger = get_logger(
88
95
  file_path='logs/myapp.log', # 로그 파일 경로 (기본: None — 파일 없이 콘솔만)
89
96
  backup_count=7, # 보관할 로그 파일 수 (0이면 전부 보관)
90
97
  use_console=True, # 콘솔 출력 여부
91
- level='DEBUG' # 로그 레벨
98
+ level='DEBUG', # 로그 레벨
99
+ rotation='midnight' # 회전 방식: 'midnight'(기본) | 'none' | '50m'
92
100
  )
93
101
 
102
+ # 회전하지 않고 한 파일에 계속 쌓기
103
+ event_logger = get_logger('events', file_path='logs/events.jsonl', rotation='none')
104
+
105
+ # 50 MiB 마다 회전, 회전 파일 10개 보관
106
+ big_logger = get_logger('big', file_path='logs/big.log', rotation='50m', backup_count=10)
107
+
94
108
  logger.info("애플리케이션 시작")
95
109
  logger.debug("디버그 메시지")
96
110
  logger.error("오류 발생")
@@ -116,11 +130,20 @@ modify_loggers_by_prefix(
116
130
 
117
131
  #### 로그 회전
118
132
 
119
- 파일 로그는 매일 자정에 날짜를 붙인 파일(`myapp.log.2026-10-10`)로 회전합니다.
133
+ `rotation` 인자로 고릅니다.
120
134
 
121
- - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다.
122
- - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 날짜 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다. 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
135
+ | `rotation` | 동작 | 회전 파일 | `backup_count` |
136
+ |---|---|---|---|
137
+ | `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
138
+ | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.1`(가장 최근) … `myapp.log.50m.N` | **1 이상을 주어야 회전합니다.** 0 이면 회전하지 않습니다(Python 표준 크기 회전과 같음) |
139
+ | `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
140
+
141
+ - 회전 없이 쌓으려면 `'none'` 을 씁니다(`'50m'` 에 `backup_count=0` 으로 쓰지 않습니다).
142
+ - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
143
+ - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
144
+ - 알려진 한계(크기 회전, Windows 확인 전): 회전할 때 `.50m.1` → `.50m.2` 처럼 번호 파일도 이름을 바꾸거나 지웁니다. 다른 프로그램이 그 번호 파일을 열고 있을 때 Windows 에서 어떻게 되는지는 2.1.0 배포 뒤 확인하고, 문제가 있으면 2.1.1 에서 고칩니다.
123
145
  - 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
146
+ - **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
124
147
 
125
148
  #### JsonLogDict 사용 예시
126
149
 
@@ -238,18 +261,19 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
238
261
 
239
262
  ## API 참조
240
263
 
241
- ### `get_logger(logger_name, logger_format, file_path, backup_count, use_console, level)`
264
+ ### `get_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)`
242
265
 
243
266
  | 파라미터 | 타입 | 기본값 | 설명 |
244
267
  |----------|------|--------|------|
245
268
  | `logger_name` | str | `'echoss'` | 로거 이름 |
246
269
  | `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
247
270
  | `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
248
- | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
271
+ | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (자정 회전은 0이면 전부 보관, 크기 회전은 1 이상이어야 회전) |
249
272
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
250
273
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
274
+ | `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
251
275
 
252
- ### `get_app_logger(logger_name, logger_format, file_path, backup_count, use_console, level)`
276
+ ### `get_app_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)`
253
277
 
254
278
  `get_logger()`와 동일한 파라미터를 받지만, `@cache` 데코레이터로 감싸져 있어 동일한 인자로 호출할 경우 항상 같은 로거 인스턴스를 반환합니다. 애플리케이션 전역에서 하나의 로거를 공유할 때 적합합니다.
255
279
 
@@ -261,6 +285,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
261
285
  | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 |
262
286
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
263
287
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
288
+ | `rotation` | str | `'midnight'` | 회전 방식 (`get_logger` 와 같음) |
264
289
 
265
290
  ### `JsonLogDict(data)`
266
291
 
@@ -19,6 +19,13 @@ pip install echoss-common
19
19
  - Python >= 3.12
20
20
  - pyyaml > 6.0.2
21
21
 
22
+ ## 2.1.0 변경 사항
23
+
24
+ - `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
25
+ - `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
26
+ - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다. 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
27
+ - **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
28
+
22
29
  ## 2.0.0 변경 사항
23
30
 
24
31
  호출 방식(함수 이름·인자 이름·인자 순서)은 1.2.0 과 같습니다. 아래 네 가지는 **동작이 바뀌는 호환 단절**입니다.
@@ -73,9 +80,16 @@ logger = get_logger(
73
80
  file_path='logs/myapp.log', # 로그 파일 경로 (기본: None — 파일 없이 콘솔만)
74
81
  backup_count=7, # 보관할 로그 파일 수 (0이면 전부 보관)
75
82
  use_console=True, # 콘솔 출력 여부
76
- level='DEBUG' # 로그 레벨
83
+ level='DEBUG', # 로그 레벨
84
+ rotation='midnight' # 회전 방식: 'midnight'(기본) | 'none' | '50m'
77
85
  )
78
86
 
87
+ # 회전하지 않고 한 파일에 계속 쌓기
88
+ event_logger = get_logger('events', file_path='logs/events.jsonl', rotation='none')
89
+
90
+ # 50 MiB 마다 회전, 회전 파일 10개 보관
91
+ big_logger = get_logger('big', file_path='logs/big.log', rotation='50m', backup_count=10)
92
+
79
93
  logger.info("애플리케이션 시작")
80
94
  logger.debug("디버그 메시지")
81
95
  logger.error("오류 발생")
@@ -101,11 +115,20 @@ modify_loggers_by_prefix(
101
115
 
102
116
  #### 로그 회전
103
117
 
104
- 파일 로그는 매일 자정에 날짜를 붙인 파일(`myapp.log.2026-10-10`)로 회전합니다.
118
+ `rotation` 인자로 고릅니다.
105
119
 
106
- - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다.
107
- - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 날짜 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다. 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
120
+ | `rotation` | 동작 | 회전 파일 | `backup_count` |
121
+ |---|---|---|---|
122
+ | `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
123
+ | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.1`(가장 최근) … `myapp.log.50m.N` | **1 이상을 주어야 회전합니다.** 0 이면 회전하지 않습니다(Python 표준 크기 회전과 같음) |
124
+ | `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
125
+
126
+ - 회전 없이 쌓으려면 `'none'` 을 씁니다(`'50m'` 에 `backup_count=0` 으로 쓰지 않습니다).
127
+ - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
128
+ - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
129
+ - 알려진 한계(크기 회전, Windows 확인 전): 회전할 때 `.50m.1` → `.50m.2` 처럼 번호 파일도 이름을 바꾸거나 지웁니다. 다른 프로그램이 그 번호 파일을 열고 있을 때 Windows 에서 어떻게 되는지는 2.1.0 배포 뒤 확인하고, 문제가 있으면 2.1.1 에서 고칩니다.
108
130
  - 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
131
+ - **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
109
132
 
110
133
  #### JsonLogDict 사용 예시
111
134
 
@@ -223,18 +246,19 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
223
246
 
224
247
  ## API 참조
225
248
 
226
- ### `get_logger(logger_name, logger_format, file_path, backup_count, use_console, level)`
249
+ ### `get_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)`
227
250
 
228
251
  | 파라미터 | 타입 | 기본값 | 설명 |
229
252
  |----------|------|--------|------|
230
253
  | `logger_name` | str | `'echoss'` | 로거 이름 |
231
254
  | `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
232
255
  | `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
233
- | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
256
+ | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (자정 회전은 0이면 전부 보관, 크기 회전은 1 이상이어야 회전) |
234
257
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
235
258
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
259
+ | `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
236
260
 
237
- ### `get_app_logger(logger_name, logger_format, file_path, backup_count, use_console, level)`
261
+ ### `get_app_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)`
238
262
 
239
263
  `get_logger()`와 동일한 파라미터를 받지만, `@cache` 데코레이터로 감싸져 있어 동일한 인자로 호출할 경우 항상 같은 로거 인스턴스를 반환합니다. 애플리케이션 전역에서 하나의 로거를 공유할 때 적합합니다.
240
264
 
@@ -246,6 +270,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
246
270
  | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 |
247
271
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
248
272
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
273
+ | `rotation` | str | `'midnight'` | 회전 방식 (`get_logger` 와 같음) |
249
274
 
250
275
  ### `JsonLogDict(data)`
251
276
 
@@ -1,13 +1,14 @@
1
1
  import json
2
2
  import logging
3
3
  import os
4
+ import re
4
5
  import shutil
5
6
  import sys
6
7
  import time
7
8
  from collections.abc import Mapping
8
9
  from functools import cache
9
10
  from logging import StreamHandler
10
- from logging.handlers import TimedRotatingFileHandler
11
+ from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
11
12
  from typing import Any
12
13
 
13
14
  LOG_FORMAT = "[%(asctime)s.%(msecs)03d] %(levelname)-7s %(name)s : %(message)s"
@@ -22,7 +23,7 @@ os.environ["PYTHONIOENCODING"] = "utf-8"
22
23
  # Created By echoss_logger.py logger dict
23
24
  _logger_cache: dict[str, logging.Logger] = {}
24
25
  _logger_console_handler_cache: dict[str, StreamHandler] = {}
25
- _logger_file_handler_cache: dict[str, TimedRotatingFileHandler] = {}
26
+ _logger_file_handler_cache: dict[str, logging.FileHandler] = {}
26
27
 
27
28
 
28
29
  class JsonLogDict:
@@ -52,6 +53,23 @@ class JsonLineFormatter(logging.Formatter):
52
53
  return json.dumps(payload, ensure_ascii=False, default=str)
53
54
 
54
55
 
56
+ # Windows cannot rename a file another handle keeps open, so a replaced stream only arises
57
+ # there through copy-truncate, which keeps the inode. Keep the 2.0.0 behaviour on Windows.
58
+ _STALE_STREAM_CHECK = os.name != "nt"
59
+
60
+
61
+ # True when another handler already rotated the file this handler is still writing to.
62
+ def _stream_replaced(handler) -> bool:
63
+ if not _STALE_STREAM_CHECK or handler.stream is None:
64
+ return False
65
+ try:
66
+ mine = os.fstat(handler.stream.fileno())
67
+ disk = os.stat(handler.baseFilename)
68
+ except OSError:
69
+ return False
70
+ return (mine.st_dev, mine.st_ino) != (disk.st_dev, disk.st_ino)
71
+
72
+
55
73
  # On Windows, renaming a log file that another handle keeps open fails with PermissionError (WinError 32).
56
74
  # Fall back to copy-then-truncate so no record is lost, and always move rolloverAt forward.
57
75
  class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
@@ -73,12 +91,39 @@ class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
73
91
  f.truncate(0)
74
92
 
75
93
  def doRollover(self):
94
+ if _stream_replaced(self):
95
+ self.stream.close()
96
+ self.stream = None # a failed _open() must leave None so the next emit reopens, as the stdlib does
97
+ self.stream = self._open()
98
+ self.rolloverAt = self.computeRollover(int(time.time()))
99
+ return
76
100
  super().doRollover()
77
101
  now = int(time.time())
78
102
  if self.rolloverAt <= now:
79
103
  self.rolloverAt = self.computeRollover(now)
80
104
 
81
105
 
106
+ _SIZE_ROTATION = re.compile(r"[1-9][0-9]*m") # MiB, lowercase m only
107
+
108
+
109
+ # Size rotation with rotated files named a.log.50m.1 (newest) .. a.log.50m.N.
110
+ # The current file -> .1 step reuses the copy-truncate fallback of the midnight handler.
111
+ class SizeCopyTruncateRotatingHandler(RotatingFileHandler):
112
+ rotate = CopyTruncateRotatingHandler.rotate
113
+
114
+ def __init__(self, filename, size, backup_count):
115
+ super().__init__(filename, maxBytes=int(size[:-1]) * 1024 * 1024,
116
+ backupCount=backup_count, encoding="utf-8")
117
+ self.namer = lambda name: f"{self.baseFilename}.{size}{name[len(self.baseFilename):]}"
118
+
119
+
120
+ def _check_rotation(rotation):
121
+ if rotation in ("midnight", "none"):
122
+ return
123
+ if not (isinstance(rotation, str) and _SIZE_ROTATION.fullmatch(rotation)):
124
+ raise ValueError(f"rotation must be 'midnight', 'none' or '<MB>m' like '50m': {rotation!r}")
125
+
126
+
82
127
  def _get_console_handler(formatter):
83
128
  console_handler = logging.StreamHandler(sys.stdout)
84
129
  console_handler.setFormatter(formatter)
@@ -86,8 +131,14 @@ def _get_console_handler(formatter):
86
131
  return console_handler
87
132
 
88
133
 
89
- def _get_file_handler(formatter, file_path, backup_count):
90
- file_handler = CopyTruncateRotatingHandler(file_path, when="midnight", backupCount=backup_count, encoding='utf-8')
134
+ def _get_file_handler(formatter, file_path, backup_count, rotation):
135
+ if rotation == "midnight":
136
+ file_handler = CopyTruncateRotatingHandler(file_path, when="midnight", backupCount=backup_count, encoding='utf-8')
137
+ elif rotation == "none":
138
+ file_handler = logging.FileHandler(file_path, encoding='utf-8')
139
+ else:
140
+ file_handler = SizeCopyTruncateRotatingHandler(file_path, rotation, backup_count)
141
+ file_handler.rotation = rotation # compared when get_logger is called again with the same path
91
142
  file_handler.setFormatter(formatter)
92
143
  return file_handler
93
144
 
@@ -111,7 +162,7 @@ def _get_app_console_handler(logger_name: str) -> StreamHandler | None:
111
162
 
112
163
 
113
164
  # Retrieve a cached file handler if it exists and is still valid
114
- def _get_app_file_handler(logger_name: str) -> TimedRotatingFileHandler | None:
165
+ def _get_app_file_handler(logger_name: str) -> logging.FileHandler | None:
115
166
  handler = _logger_file_handler_cache.get(logger_name)
116
167
  if handler is None or not _is_valid_file_handler(handler):
117
168
  return None
@@ -129,9 +180,9 @@ def _is_valid_stream_handler(handler: StreamHandler):
129
180
 
130
181
 
131
182
  # Validate that the cached file handler is still open and usable
132
- def _is_valid_file_handler(handler: TimedRotatingFileHandler):
183
+ def _is_valid_file_handler(handler: logging.FileHandler):
133
184
  return (
134
- isinstance(handler, TimedRotatingFileHandler)
185
+ isinstance(handler, logging.FileHandler)
135
186
  and getattr(handler, "stream", None) is not None
136
187
  and not getattr(handler.stream, "closed", False)
137
188
  )
@@ -154,24 +205,33 @@ def use_logger(logger_name='echoss'):
154
205
 
155
206
  @cache
156
207
  def get_app_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=None, backup_count=0,
157
- use_console=True, level="DEBUG"):
158
- return get_logger(logger_name, logger_format, file_path, backup_count, use_console, level)
208
+ use_console=True, level="DEBUG", rotation="midnight"):
209
+ return get_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)
159
210
 
160
211
 
161
212
  def get_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=None, backup_count=0,
162
- use_console=True, level="DEBUG"):
213
+ use_console=True, level="DEBUG", rotation="midnight"):
163
214
  """
164
215
  create new echoss logger or replace old logger
165
216
  :param logger_name: logger name
166
217
  :param logger_format: logging format
167
218
  :param file_path: file path if File logging else None
168
- :param backup_count: if 0 keep all timed rotating log files, else only keep backup count number of files
219
+ :param backup_count: midnight: 0 keeps all dated files, else only backup_count files.
220
+ size rotation: number of numbered files to keep; 0 means the file never rotates (stdlib behaviour)
169
221
  :param use_console: use console output or not
170
222
  :param level: (str or int) set logging level
223
+ :param rotation: 'midnight' (default), 'none' (never rotate) or '<MB>m' like '50m' (rotate by size)
171
224
 
172
225
  :returns: generated logger
173
- :raises None
226
+ :raises ValueError if rotation is not one of the forms above
174
227
  """
228
+ return _get_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)
229
+
230
+
231
+ # shared_file_handler: modify_loggers_by_prefix passes one file handler for all of its loggers
232
+ def _get_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation,
233
+ shared_file_handler=None):
234
+ _check_rotation(rotation)
175
235
  logger = _get_app_logger(logger_name)
176
236
  if logger is None:
177
237
  logger = logging.getLogger(logger_name)
@@ -212,10 +272,17 @@ def get_logger(logger_name='echoss', logger_format=LOG_FORMAT, file_path=None, b
212
272
  # no file logging: the cached file handler (if any) is removed below as unknown
213
273
  file_handler = None
214
274
  _logger_file_handler_cache.pop(logger_name, None)
215
- elif file_handler is not None and os.path.abspath(file_handler.baseFilename) == os.path.abspath(file_path):
275
+ elif shared_file_handler is not None:
276
+ file_handler = shared_file_handler
277
+ if file_handler not in logger.handlers:
278
+ logger.addHandler(file_handler)
279
+ _logger_file_handler_cache[logger_name] = file_handler
280
+ elif (file_handler is not None
281
+ and os.path.abspath(file_handler.baseFilename) == os.path.abspath(file_path)
282
+ and getattr(file_handler, "rotation", "midnight") == rotation):
216
283
  file_handler.setFormatter(formatter)
217
284
  else:
218
- file_handler = _get_file_handler(formatter, file_path, backup_count)
285
+ file_handler = _get_file_handler(formatter, file_path, backup_count, rotation)
219
286
  logger.addHandler(file_handler)
220
287
  _logger_file_handler_cache[logger_name] = file_handler
221
288
 
@@ -256,31 +323,36 @@ def set_logger_level(logger, level):
256
323
 
257
324
 
258
325
  def modify_loggers_by_prefix(prefix, new_format: str = LOG_FORMAT, new_path: str | None = None,
259
- backup_count: int = 0, use_console=True, level="DEBUG"):
326
+ backup_count: int = 0, use_console=True, level="DEBUG", rotation="midnight"):
260
327
  """
261
328
  Modify loggers that logger name starting with a specific prefix.
262
329
  :param prefix: prefix of lgger name
263
330
  :param new_format: new logging format
264
331
  :param new_path: new file path if File logging else None
265
- :param backup_count: if 0 keep all timed rotating log files, else only keep backup count number of files
332
+ :param backup_count: same as get_logger
266
333
  :param use_console: use console output or not
267
334
  :param level: set logging level by level name or level number
335
+ :param rotation: same as get_logger
268
336
  :return: first logger
269
- :raises None
337
+ :raises ValueError if rotation is invalid
338
+
339
+ All matched loggers share one file handler, so they write to one file in time order and rotate once.
270
340
  """
271
341
 
342
+ _check_rotation(rotation) # also when no logger matches the prefix
343
+
272
344
  # Iterate over all loggers in the logging manager
273
345
  is_first = True
274
346
  first_logger = None
347
+ shared_file_handler = None
275
348
  for logger_name in logging.Logger.manager.loggerDict.keys():
276
349
  if logger_name.startswith(prefix):
277
- current_logger = get_logger(logger_name, logger_format=new_format, file_path=new_path,
278
- backup_count=backup_count, use_console=use_console, level=level)
350
+ current_logger = _get_logger(logger_name, new_format, new_path, backup_count, use_console, level,
351
+ rotation, shared_file_handler)
279
352
 
280
353
  if is_first:
281
354
  is_first = False
282
355
  first_logger = current_logger
283
- if backup_count != 0:
284
- backup_count = 0
356
+ shared_file_handler = _logger_file_handler_cache.get(logger_name)
285
357
 
286
358
  return first_logger
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echoss-common
3
- Version: 2.0.0
3
+ Version: 2.1.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,6 +34,13 @@ pip install echoss-common
34
34
  - Python >= 3.12
35
35
  - pyyaml > 6.0.2
36
36
 
37
+ ## 2.1.0 변경 사항
38
+
39
+ - `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
40
+ - `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
41
+ - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다. 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
42
+ - **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
43
+
37
44
  ## 2.0.0 변경 사항
38
45
 
39
46
  호출 방식(함수 이름·인자 이름·인자 순서)은 1.2.0 과 같습니다. 아래 네 가지는 **동작이 바뀌는 호환 단절**입니다.
@@ -88,9 +95,16 @@ logger = get_logger(
88
95
  file_path='logs/myapp.log', # 로그 파일 경로 (기본: None — 파일 없이 콘솔만)
89
96
  backup_count=7, # 보관할 로그 파일 수 (0이면 전부 보관)
90
97
  use_console=True, # 콘솔 출력 여부
91
- level='DEBUG' # 로그 레벨
98
+ level='DEBUG', # 로그 레벨
99
+ rotation='midnight' # 회전 방식: 'midnight'(기본) | 'none' | '50m'
92
100
  )
93
101
 
102
+ # 회전하지 않고 한 파일에 계속 쌓기
103
+ event_logger = get_logger('events', file_path='logs/events.jsonl', rotation='none')
104
+
105
+ # 50 MiB 마다 회전, 회전 파일 10개 보관
106
+ big_logger = get_logger('big', file_path='logs/big.log', rotation='50m', backup_count=10)
107
+
94
108
  logger.info("애플리케이션 시작")
95
109
  logger.debug("디버그 메시지")
96
110
  logger.error("오류 발생")
@@ -116,11 +130,20 @@ modify_loggers_by_prefix(
116
130
 
117
131
  #### 로그 회전
118
132
 
119
- 파일 로그는 매일 자정에 날짜를 붙인 파일(`myapp.log.2026-10-10`)로 회전합니다.
133
+ `rotation` 인자로 고릅니다.
120
134
 
121
- - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다.
122
- - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 날짜 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다. 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
135
+ | `rotation` | 동작 | 회전 파일 | `backup_count` |
136
+ |---|---|---|---|
137
+ | `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
138
+ | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.1`(가장 최근) … `myapp.log.50m.N` | **1 이상을 주어야 회전합니다.** 0 이면 회전하지 않습니다(Python 표준 크기 회전과 같음) |
139
+ | `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
140
+
141
+ - 회전 없이 쌓으려면 `'none'` 을 씁니다(`'50m'` 에 `backup_count=0` 으로 쓰지 않습니다).
142
+ - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
143
+ - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
144
+ - 알려진 한계(크기 회전, Windows 확인 전): 회전할 때 `.50m.1` → `.50m.2` 처럼 번호 파일도 이름을 바꾸거나 지웁니다. 다른 프로그램이 그 번호 파일을 열고 있을 때 Windows 에서 어떻게 되는지는 2.1.0 배포 뒤 확인하고, 문제가 있으면 2.1.1 에서 고칩니다.
123
145
  - 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
146
+ - **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
124
147
 
125
148
  #### JsonLogDict 사용 예시
126
149
 
@@ -238,18 +261,19 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
238
261
 
239
262
  ## API 참조
240
263
 
241
- ### `get_logger(logger_name, logger_format, file_path, backup_count, use_console, level)`
264
+ ### `get_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)`
242
265
 
243
266
  | 파라미터 | 타입 | 기본값 | 설명 |
244
267
  |----------|------|--------|------|
245
268
  | `logger_name` | str | `'echoss'` | 로거 이름 |
246
269
  | `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
247
270
  | `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
248
- | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
271
+ | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (자정 회전은 0이면 전부 보관, 크기 회전은 1 이상이어야 회전) |
249
272
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
250
273
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
274
+ | `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
251
275
 
252
- ### `get_app_logger(logger_name, logger_format, file_path, backup_count, use_console, level)`
276
+ ### `get_app_logger(logger_name, logger_format, file_path, backup_count, use_console, level, rotation)`
253
277
 
254
278
  `get_logger()`와 동일한 파라미터를 받지만, `@cache` 데코레이터로 감싸져 있어 동일한 인자로 호출할 경우 항상 같은 로거 인스턴스를 반환합니다. 애플리케이션 전역에서 하나의 로거를 공유할 때 적합합니다.
255
279
 
@@ -261,6 +285,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
261
285
  | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 |
262
286
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
263
287
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
288
+ | `rotation` | str | `'midnight'` | 회전 방식 (`get_logger` 와 같음) |
264
289
 
265
290
  ### `JsonLogDict(data)`
266
291
 
@@ -16,6 +16,7 @@ tests/test_echoss_logger_1.py
16
16
  tests/test_echoss_logger_2.py
17
17
  tests/test_echoss_logger_2x.py
18
18
  tests/test_echoss_logger_json.py
19
+ tests/test_echoss_logger_rotation_modes.py
19
20
  tests/scripts/check_xml_samples.py
20
21
  tests/scripts/hold_file.py
21
22
  tests/scripts/spike_default_log_file.py
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  # 패키지 메타데이터
7
7
  [project]
8
8
  name="echoss-common"
9
- version="2.0.0"
9
+ version="2.1.0"
10
10
  description="echoss AI Bigdata Center 공통 유틸리티 라이브러리 (Logger, JsonLogDict, config_fileformat)"
11
11
  readme = "README.md"
12
12
  license = "Apache-2.0"
@@ -164,16 +164,67 @@ def test_existing_dated_file_is_kept_and_rollover_moves_forward(tmp_path):
164
164
  assert handler.rolloverAt > now
165
165
 
166
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
+
167
218
  # T-08
168
219
  @pytest.mark.parametrize("func, params", [
169
220
  (echoss_common.get_app_logger,
170
- ["logger_name", "logger_format", "file_path", "backup_count", "use_console", "level"]),
221
+ ["logger_name", "logger_format", "file_path", "backup_count", "use_console", "level", "rotation"]),
171
222
  (echoss_common.get_logger,
172
- ["logger_name", "logger_format", "file_path", "backup_count", "use_console", "level"]),
223
+ ["logger_name", "logger_format", "file_path", "backup_count", "use_console", "level", "rotation"]),
173
224
  (echoss_common.use_logger, ["logger_name"]),
174
225
  (echoss_common.set_logger_level, ["logger", "level"]),
175
226
  (echoss_common.modify_loggers_by_prefix,
176
- ["prefix", "new_format", "new_path", "backup_count", "use_console", "level"]),
227
+ ["prefix", "new_format", "new_path", "backup_count", "use_console", "level", "rotation"]),
177
228
  (echoss_common.dict_load, ["file_path", "file_format", "kwargs"]),
178
229
  (echoss_common.dict_dump, ["config", "file_path", "file_format", "force_write", "xml_tag", "kwargs"]),
179
230
  ])
@@ -0,0 +1,283 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ import echoss_common.echoss_logger as echoss_logger
12
+
13
+ MIB = 1024 * 1024
14
+ LINE = "x" * 1000
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def reset_logging_state():
19
+ def clear():
20
+ for cache_name in (
21
+ "_logger_cache",
22
+ "_logger_console_handler_cache",
23
+ "_logger_file_handler_cache",
24
+ ):
25
+ getattr(echoss_logger, cache_name).clear()
26
+ for logger in logging.Logger.manager.loggerDict.values():
27
+ if isinstance(logger, logging.Logger):
28
+ for handler in list(logger.handlers):
29
+ logger.removeHandler(handler)
30
+ handler.close()
31
+
32
+ clear()
33
+ yield
34
+ clear()
35
+
36
+
37
+ def _file_handlers(logger):
38
+ return [h for h in logger.handlers if isinstance(h, logging.FileHandler)]
39
+
40
+
41
+ def _numbers(paths):
42
+ found = []
43
+ for path in paths:
44
+ for line in path.read_text(encoding="utf-8").splitlines():
45
+ found.append(int(line.split(" ", 1)[0]))
46
+ return found
47
+
48
+
49
+ def _write(logger, count, start=0):
50
+ for n in range(start, start + count):
51
+ logger.info(f"{n} {LINE}")
52
+
53
+
54
+ # T-R1
55
+ def test_default_rotation_is_midnight(tmp_path):
56
+ logger = echoss_logger.get_logger("rm-default", file_path=str(tmp_path / "d.log"), use_console=False)
57
+
58
+ [handler] = _file_handlers(logger)
59
+ assert type(handler) is echoss_logger.CopyTruncateRotatingHandler
60
+ assert handler.when == "MIDNIGHT"
61
+
62
+
63
+ # T-R2
64
+ def test_size_rotation_names_limit_and_backup_count(tmp_path):
65
+ log_file = tmp_path / "s.log"
66
+ logger = echoss_logger.get_logger("rm-size", logger_format="%(message)s", file_path=str(log_file),
67
+ backup_count=2, use_console=False, rotation="1m", level="INFO")
68
+ written = 3500 # about 3.4 MiB: enough for more rotations than backup_count
69
+ _write(logger, written)
70
+
71
+ files = sorted(tmp_path.iterdir())
72
+ assert [f.name for f in files] == ["s.log", "s.log.1m.1", "s.log.1m.2"]
73
+ assert all(f.stat().st_size <= MIB for f in files)
74
+ kept = sorted(_numbers(files))
75
+ assert kept == list(range(written - len(kept), written)) # newest records, no gap
76
+
77
+
78
+ # T-R3
79
+ @pytest.mark.parametrize("rotation", ["1g", "1k", "50", 50, "1M", "0m", "01m", "50mb", " 50m", "size"])
80
+ def test_invalid_rotation_raises(tmp_path, rotation):
81
+ with pytest.raises(ValueError):
82
+ echoss_logger.get_logger("rm-bad", file_path=str(tmp_path / "b.log"), use_console=False, rotation=rotation)
83
+
84
+
85
+ @pytest.mark.parametrize("rotation", ["1m", "50m", "2048m", "midnight", "none"])
86
+ def test_valid_rotation_accepted(tmp_path, rotation):
87
+ logger = echoss_logger.get_logger("rm-ok", file_path=str(tmp_path / "ok.log"), use_console=False,
88
+ rotation=rotation)
89
+ assert len(_file_handlers(logger)) == 1
90
+
91
+
92
+ # T-R4
93
+ def test_none_rotation_keeps_one_file(tmp_path):
94
+ log_file = tmp_path / "n.log"
95
+ logger = echoss_logger.get_logger("rm-none", logger_format="%(message)s", file_path=str(log_file),
96
+ use_console=False, rotation="none", level="INFO")
97
+ [handler] = _file_handlers(logger)
98
+ assert type(handler) is logging.FileHandler
99
+
100
+ _write(logger, 1200)
101
+
102
+ assert [f.name for f in tmp_path.iterdir()] == ["n.log"]
103
+ assert _numbers([log_file]) == list(range(1200))
104
+
105
+
106
+ # T-R5 (Linux stand-in for Windows: the real blocked path is judged by the Windows run)
107
+ def test_size_rotation_falls_back_to_copy_truncate_when_rename_is_blocked(tmp_path, monkeypatch):
108
+ log_file = tmp_path / "blk.log"
109
+ real_rename = os.rename
110
+
111
+ def blocked_rename(src, dst):
112
+ if os.path.abspath(src) == str(log_file):
113
+ raise PermissionError("simulated: another handle keeps the file open")
114
+ return real_rename(src, dst)
115
+
116
+ monkeypatch.setattr(os, "rename", blocked_rename)
117
+ logger = echoss_logger.get_logger("rm-blk", logger_format="%(message)s", file_path=str(log_file),
118
+ backup_count=10, use_console=False, rotation="1m", level="INFO")
119
+ written = 2500
120
+ _write(logger, written)
121
+
122
+ files = sorted(tmp_path.iterdir())
123
+ assert "blk.log.1m.1" in [f.name for f in files]
124
+ assert sorted(_numbers(files)) == list(range(written))
125
+
126
+
127
+ # T-R6
128
+ @pytest.mark.parametrize("rotation", ["midnight", "1m", "none"])
129
+ def test_jsonl_path_writes_json_lines_in_every_mode(tmp_path, rotation):
130
+ log_file = tmp_path / "j.jsonl"
131
+ logger = echoss_logger.get_logger("rm-jsonl", file_path=str(log_file), use_console=False,
132
+ rotation=rotation, level="INFO")
133
+ logger.info("hello")
134
+ logger.info({"k": 1})
135
+
136
+ lines = log_file.read_text(encoding="utf-8").splitlines()
137
+ assert len(lines) == 2
138
+ assert all(isinstance(json.loads(line), dict) for line in lines)
139
+
140
+
141
+ # T-R7
142
+ def test_changing_rotation_on_same_path_replaces_handler(tmp_path):
143
+ log_file = str(tmp_path / "c.log")
144
+ logger = echoss_logger.get_logger("rm-change", file_path=log_file, use_console=False)
145
+ [old] = _file_handlers(logger)
146
+
147
+ logger = echoss_logger.get_logger("rm-change", file_path=log_file, use_console=False, rotation="none")
148
+
149
+ [new] = _file_handlers(logger)
150
+ assert new is not old
151
+ assert type(new) is logging.FileHandler
152
+ assert old.stream is None # closed
153
+
154
+
155
+ def _third_party_loggers(prefix):
156
+ loggers = [logging.getLogger(f"{prefix}{name}") for name in ("a", "b", "c")]
157
+ for logger in loggers:
158
+ logger.addHandler(logging.StreamHandler()) # each package's own handler, to be replaced
159
+ return loggers
160
+
161
+
162
+ # T-R11
163
+ @pytest.mark.parametrize("rotation", ["midnight", "1m", "none"])
164
+ def test_modify_loggers_by_prefix_shares_one_file_handler(tmp_path, rotation):
165
+ log_file = tmp_path / "shared.log"
166
+ loggers = _third_party_loggers(f"rm11{rotation}.")
167
+
168
+ echoss_logger.modify_loggers_by_prefix(f"rm11{rotation}.", new_format="%(message)s", new_path=str(log_file),
169
+ backup_count=2, use_console=False, level="INFO", rotation=rotation)
170
+
171
+ handlers = [_file_handlers(logger) for logger in loggers]
172
+ assert all(len(h) == 1 for h in handlers)
173
+ assert handlers[0][0] is handlers[1][0] is handlers[2][0]
174
+ assert all(len(logger.handlers) == 1 for logger in loggers) # package handlers removed
175
+
176
+ count = 3500 if rotation == "1m" else 30 # 1m: more rotations than backup_count=2
177
+ for n in range(count):
178
+ loggers[n % 3].info(f"{n} {LINE}")
179
+ files = sorted(tmp_path.iterdir())
180
+ kept = sorted(_numbers(files))
181
+ if rotation == "1m":
182
+ assert [f.name for f in files] == ["shared.log", "shared.log.1m.1", "shared.log.1m.2"]
183
+ assert all(f.stat().st_size <= MIB for f in files)
184
+ assert kept == list(range(count - len(kept), count)) # newest records of all three loggers, no gap
185
+ else:
186
+ assert [f.name for f in files] == ["shared.log"]
187
+ assert kept == list(range(count))
188
+
189
+
190
+ # T-R3 (modify_loggers_by_prefix checks rotation even when no logger matches)
191
+ def test_modify_loggers_by_prefix_rejects_invalid_rotation_without_match():
192
+ with pytest.raises(ValueError):
193
+ echoss_logger.modify_loggers_by_prefix("rm-no-such-prefix.", rotation="1g")
194
+
195
+
196
+ # T-R12
197
+ def test_reconfiguring_one_shared_logger_keeps_the_others_writing(tmp_path):
198
+ log_file = tmp_path / "shared.log"
199
+ a, b, c = _third_party_loggers("rm12.")
200
+ echoss_logger.modify_loggers_by_prefix("rm12.", new_format="%(message)s", new_path=str(log_file),
201
+ use_console=False, level="INFO")
202
+
203
+ echoss_logger.get_logger("rm12.a", logger_format="%(message)s", file_path=str(tmp_path / "other.log"),
204
+ use_console=False, level="INFO")
205
+ b.info("1 b")
206
+ c.info("2 c")
207
+ a.info("3 a")
208
+
209
+ assert _numbers([log_file]) == [1, 2]
210
+ assert _numbers([tmp_path / "other.log"]) == [3]
211
+
212
+
213
+ # T-R13
214
+ def test_modify_loggers_by_prefix_twice_does_not_duplicate_handlers(tmp_path):
215
+ log_file = str(tmp_path / "shared.log")
216
+ loggers = _third_party_loggers("rm13.")
217
+ for _ in range(2):
218
+ echoss_logger.modify_loggers_by_prefix("rm13.", new_path=log_file, backup_count=1, use_console=False)
219
+
220
+ handlers = [_file_handlers(logger) for logger in loggers]
221
+ assert all(len(h) == 1 for h in handlers)
222
+ assert handlers[0][0] is handlers[1][0] is handlers[2][0]
223
+
224
+
225
+ # T-R8 ~ T-R10: another process holds a file. On Linux nothing is blocked, so only the Windows run judges them.
226
+ REPO_ROOT = Path(__file__).resolve().parents[1]
227
+ HOLD_FILE = REPO_ROOT / "tests" / "scripts" / "hold_file.py"
228
+
229
+
230
+ @contextmanager
231
+ def _held(path):
232
+ holder = subprocess.Popen([sys.executable, str(HOLD_FILE), str(path)],
233
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
234
+ try:
235
+ assert holder.stdout.readline().strip() == "ready"
236
+ yield
237
+ finally:
238
+ holder.stdin.close()
239
+ holder.wait(timeout=10)
240
+
241
+
242
+ def _size_logger(name, log_file, backup_count):
243
+ return echoss_logger.get_logger(name, logger_format="%(message)s", file_path=str(log_file),
244
+ backup_count=backup_count, use_console=False, rotation="1m", level="INFO")
245
+
246
+
247
+ # T-R8
248
+ def test_size_rotation_keeps_every_record_while_another_process_holds_the_current_file(tmp_path):
249
+ log_file = tmp_path / "h8.log"
250
+ logger = _size_logger("rm-h8", log_file, 10)
251
+ written = 3500
252
+ with _held(log_file):
253
+ _write(logger, written)
254
+
255
+ files = sorted(tmp_path.iterdir())
256
+ assert any(f.name.startswith("h8.log.1m.") for f in files)
257
+ assert sorted(_numbers(files)) == list(range(written))
258
+
259
+
260
+ # T-R9
261
+ def test_size_rotation_keeps_every_record_while_another_process_holds_a_numbered_file(tmp_path):
262
+ log_file = tmp_path / "h9.log"
263
+ logger = _size_logger("rm-h9", log_file, 10)
264
+ first = 1200 # one rotation, so h9.log.1m.1 exists
265
+ _write(logger, first)
266
+ numbered = tmp_path / "h9.log.1m.1"
267
+ assert numbered.exists()
268
+ more = 2400 # two more rotations shift .1m.1 while it is held
269
+ with _held(numbered):
270
+ _write(logger, more, start=first)
271
+
272
+ assert sorted(_numbers(sorted(tmp_path.iterdir()))) == list(range(first + more))
273
+
274
+
275
+ # T-R10
276
+ def test_none_rotation_keeps_every_record_while_another_process_holds_the_file(tmp_path):
277
+ log_file = tmp_path / "h10.log"
278
+ logger = echoss_logger.get_logger("rm-h10", logger_format="%(message)s", file_path=str(log_file),
279
+ use_console=False, rotation="none", level="INFO")
280
+ with _held(log_file):
281
+ _write(logger, 500)
282
+
283
+ assert _numbers([log_file]) == list(range(500))
File without changes
File without changes
File without changes