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