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