echoss-common 2.1.0__tar.gz → 2.3.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 (27) hide show
  1. {echoss_common-2.1.0/echoss_common.egg-info → echoss_common-2.3.0}/PKG-INFO +67 -8
  2. {echoss_common-2.1.0 → echoss_common-2.3.0}/README.md +65 -7
  3. {echoss_common-2.1.0 → echoss_common-2.3.0}/echoss_common/__init__.py +3 -1
  4. {echoss_common-2.1.0 → echoss_common-2.3.0}/echoss_common/echoss_logger.py +49 -4
  5. echoss_common-2.3.0/echoss_common/env_config.py +77 -0
  6. {echoss_common-2.1.0 → echoss_common-2.3.0/echoss_common.egg-info}/PKG-INFO +67 -8
  7. {echoss_common-2.1.0 → echoss_common-2.3.0}/echoss_common.egg-info/SOURCES.txt +2 -0
  8. {echoss_common-2.1.0 → echoss_common-2.3.0}/echoss_common.egg-info/requires.txt +1 -0
  9. {echoss_common-2.1.0 → echoss_common-2.3.0}/pyproject.toml +3 -2
  10. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/test_echoss_logger_2x.py +2 -0
  11. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/test_echoss_logger_rotation_modes.py +126 -8
  12. echoss_common-2.3.0/tests/test_env_config.py +126 -0
  13. {echoss_common-2.1.0 → echoss_common-2.3.0}/LICENSE +0 -0
  14. {echoss_common-2.1.0 → echoss_common-2.3.0}/MANIFEST.in +0 -0
  15. {echoss_common-2.1.0 → echoss_common-2.3.0}/echoss_common/config_fileformat.py +0 -0
  16. {echoss_common-2.1.0 → echoss_common-2.3.0}/echoss_common.egg-info/dependency_links.txt +0 -0
  17. {echoss_common-2.1.0 → echoss_common-2.3.0}/echoss_common.egg-info/top_level.txt +0 -0
  18. {echoss_common-2.1.0 → echoss_common-2.3.0}/setup.cfg +0 -0
  19. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/scripts/check_xml_samples.py +0 -0
  20. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/scripts/hold_file.py +0 -0
  21. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/scripts/spike_default_log_file.py +0 -0
  22. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/scripts/verify_xml_edge_cases.py +0 -0
  23. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/test_config_fileformat.py +0 -0
  24. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/test_config_fileformat_2x.py +0 -0
  25. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/test_echoss_logger_1.py +0 -0
  26. {echoss_common-2.1.0 → echoss_common-2.3.0}/tests/test_echoss_logger_2.py +0 -0
  27. {echoss_common-2.1.0 → echoss_common-2.3.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.1.0
3
+ Version: 2.3.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
@@ -9,6 +9,7 @@ Requires-Python: >=3.12
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: pyyaml>6.0.2
12
+ Requires-Dist: python-dotenv>=1.0.0
12
13
  Provides-Extra: dev
13
14
  Requires-Dist: pytest; extra == "dev"
14
15
  Dynamic: license-file
@@ -16,7 +17,7 @@ Dynamic: license-file
16
17
  # echoss-common
17
18
 
18
19
  echoss AI Bigdata Center 공통 유틸리티 라이브러리입니다.
19
- 로깅(Logger)과 config 파일 포맷 읽기/쓰기 기능을 제공합니다.
20
+ 로깅(Logger)과 config 파일 포맷 읽기/쓰기, 설정 파일의 비밀값 자리표시자 치환(`EnvConfig`) 기능을 제공합니다.
20
21
 
21
22
  ## 문서 지도
22
23
 
@@ -33,12 +34,23 @@ pip install echoss-common
33
34
 
34
35
  - Python >= 3.12
35
36
  - pyyaml > 6.0.2
37
+ - python-dotenv >= 1.0.0 (2.3.0 부터)
38
+
39
+ ## 2.3.0 변경 사항
40
+
41
+ - 비밀값을 설정 파일 밖에 두는 `EnvConfig` 를 추가했습니다(아래 「비밀값 자리표시자」). 이를 위해 `python-dotenv` 가 필수 의존으로 추가됩니다. 기존 `dict_load` · `dict_dump` 동작은 그대로입니다.
42
+
43
+ ## 2.2.0 변경 사항
44
+
45
+ - **크기 회전 파일 이름이 바뀌었습니다.** `myapp.log.50m.1`(가장 최근) … 처럼 회전마다 번호를 미는 대신, `myapp.log.50m.2026-11-01.001` 처럼 회전한 날짜와 그날의 일련번호를 붙이고 다시 바꾸지 않습니다. 2.1.0 이 남긴 `myapp.log.50m.1` 같은 파일은 세지도 지우지도 않으니 직접 정리합니다.
46
+ - 크기 회전의 `backup_count=0` 은 자정 회전과 같이 「회전하고 전부 보관」입니다(2.1.0 은 회전하지 않았습니다).
47
+ - 회전 중 지울 옛 로그 파일을 다른 프로그램이 열고 있으면(Windows `PermissionError`) 지우기를 건너뛰고 계속 씁니다. 2.1.0 은 크기 회전에서 이후 기록을 잃고, 자정 회전에서 회전당 한 줄을 잃었습니다(아래 「로그 회전」).
36
48
 
37
49
  ## 2.1.0 변경 사항
38
50
 
39
51
  - `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
40
52
  - `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
41
- - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다. 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
53
+ - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다(2.2.0 에서 이름 규칙 변경). 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
42
54
  - **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
43
55
 
44
56
  ## 2.0.0 변경 사항
@@ -135,13 +147,13 @@ modify_loggers_by_prefix(
135
147
  | `rotation` | 동작 | 회전 파일 | `backup_count` |
136
148
  |---|---|---|---|
137
149
  | `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
138
- | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.1`(가장 최근) … `myapp.log.50m.N` | **1 이상을 주어야 회전합니다.** 0 이면 회전하지 않습니다(Python 표준 크기 회전과 같음) |
150
+ | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.2026-11-01.001`, `.002` … (회전한 날짜와 그날의 일련번호) | 0 이면 전부 보관, N 이면 가장 새 N 개 |
139
151
  | `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
140
152
 
141
- - 회전 없이 쌓으려면 `'none'` 을 씁니다(`'50m'` 에 `backup_count=0` 으로 쓰지 않습니다).
153
+ - 크기 회전의 일련번호 자리수는 크기 숫자의 자리수로 정합니다 — `1m`~`9m` 4 자리, `10m`~`99m` 3 자리, `100m`~`999m` 2 자리, `1000m` 이상 1 자리. 하루 약 10 GB 를 넘게 써서 자리수를 넘으면 번호가 그대로 길어집니다. 보관 순서는 숫자로 비교하므로 맞고, 리눅스 `ls` 화면 순서만 어긋납니다(`ls -v` 로 봅니다).
142
154
  - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
143
- - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
144
- - 알려진 한계(크기 회전, Windows 확인 전): 회전할 때 `.50m.1` → `.50m.2` 처럼 번호 파일도 이름을 바꾸거나 지웁니다. 다른 프로그램이 그 번호 파일을 열고 있을 때 Windows 에서 어떻게 되는지는 2.1.0 배포 뒤 확인하고, 문제가 있으면 2.1.1 에서 고칩니다.
155
+ - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 회전 파일로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
156
+ - 회전 파일 이름은 한 번 붙이면 바꾸지 않습니다. 회전할 때 `backup_count` 를 넘는 옛 회전 파일을 지우는데, 다른 프로그램이 그 파일을 열고 있어 막히면(Windows `PermissionError`) 지우기를 건너뛰고 다음 회전에서 다시 지웁니다. 기록은 잃지 않고, 핸들러마다 처음 한 번 `RuntimeWarning` 을 냅니다. 그동안 회전 파일이 `backup_count` 보다 많을 수 있습니다.
145
157
  - 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
146
158
  - **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
147
159
 
@@ -207,6 +219,7 @@ logger.info("payload: %s", JsonLogDict({"user": "kim"}))
207
219
  |------|------|
208
220
  | `dict_load()` | 설정 파일을 `dict`로 읽기 |
209
221
  | `dict_dump()` | `dict`를 설정 파일로 쓰기 |
222
+ | `EnvConfig.load()` · `.dump()` | `${이름}` 자리표시자를 `.env` · 환경변수 값으로 바꿔 읽고, 자리표시자를 되살려 쓰기 (2.3.0, 아래 「비밀값 자리표시자」) |
210
223
 
211
224
  #### 지원 포맷
212
225
 
@@ -257,6 +270,38 @@ dict_dump({'settings': {'@version': '1', 'port': '8080'}}, 'config/settings.xml'
257
270
  dict_dump(settings, 'config/settings.yaml', force_write=False)
258
271
  ```
259
272
 
273
+ #### 비밀값 자리표시자 (`EnvConfig`, 2.3.0)
274
+
275
+ API 키·비밀번호는 설정 파일에 `${이름}` 으로 적고, 실제 값은 `.env` 또는 운영체제 환경변수에 둡니다.
276
+
277
+ ```yaml
278
+ # config.yaml (git 에 커밋)
279
+ api:
280
+ key: ${OPENAI_API_KEY}
281
+ db:
282
+ url: postgres://app:${PG_PASSWORD}@db.local/app
283
+ ```
284
+
285
+ ```
286
+ # .env (git 에 넣지 않음 — .gitignore 에 추가하고, 변수 이름만 적은 .env.example 을 커밋)
287
+ OPENAI_API_KEY=sk-...
288
+ PG_PASSWORD=...
289
+ ```
290
+
291
+ ```python
292
+ from echoss_common import EnvConfig
293
+
294
+ cfg = EnvConfig.load('config.yaml') # .env 는 작업 폴더부터 위로 찾음, env_path='...' 로 지정 가능
295
+ cfg.data['api']['key'] # 'sk-...'
296
+ cfg.data['api']['timeout'] = 60
297
+ cfg.dump('config.yaml') # ${OPENAI_API_KEY} 등 자리표시자는 그대로 저장
298
+ ```
299
+
300
+ - 문자열 값 안의 `${이름}` 을 모두 바꿉니다 — 값 전체와 값 일부(`postgres://app:${PG_PASSWORD}@...`) 모두, 중첩 dict · list 포함. 이름은 영문자 · 숫자 · 밑줄이고 숫자로 시작하지 않습니다. `$HOME`, `pa$$word` 는 바꾸지 않습니다. 이스케이프는 없습니다 — 평문 값에 `${...}` 를 쓰면 치환 대상이 됩니다.
301
+ - 값은 운영체제 환경변수에서 먼저 찾고, 없으면 `.env` 에서 찾습니다. `.env` 의 값은 프로세스 환경변수에도 들어갑니다(이미 있는 값은 바꾸지 않음). 어디에도 없으면 `ValueError` 입니다(어느 키 · 어느 변수인지 메시지에 표시).
302
+ - `dump` 는 자리표시자가 있던 자리를 원본 자리표시자로 씁니다. 그 자리 값을 프로그램이 바꿨으면 바뀐 값은 설정 파일에 쓰지 않고 `UserWarning` 을 냅니다.
303
+ - `dump` 는 기존 `.env` 를 덮어쓰지 않습니다. `.env` 가 없을 때만 자리표시자 변수로 새로 만들고, 값 전체가 자리표시자인 자리를 바꿨으면 바뀐 값을 씁니다. 새로 만든 `.env` 가 git 에 들어가지 않게 `.gitignore` 에 `.env` 를 넣어 둡니다.
304
+
260
305
  ---
261
306
 
262
307
  ## API 참조
@@ -268,7 +313,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
268
313
  | `logger_name` | str | `'echoss'` | 로거 이름 |
269
314
  | `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
270
315
  | `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
271
- | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (자정 회전은 0이면 전부 보관, 크기 회전은 1 이상이어야 회전) |
316
+ | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
272
317
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
273
318
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
274
319
  | `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
@@ -315,6 +360,20 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
315
360
  | `force_write` | bool | `True` | 기존 파일 덮어쓰기 여부 |
316
361
  | `xml_tag` | str | `None` | XML 루트 태그 (미지정 시 단일 최상위 키 사용) |
317
362
 
363
+ ### `EnvConfig.load(file_path, file_format, env_path, **kwargs)`
364
+
365
+ | 파라미터 | 타입 | 기본값 | 설명 |
366
+ |----------|------|--------|------|
367
+ | `file_path` | str | — | 읽을 설정 파일 경로 (`dict_load` 와 같음) |
368
+ | `file_format` | str | `None` | 포맷 강제 지정 |
369
+ | `env_path` | str | `None` | `.env` 경로 (미지정 시 작업 폴더부터 위로 찾고, 없으면 작업 폴더의 `.env`) |
370
+
371
+ 반환값: `EnvConfig` — `data` 는 치환된 설정, `raw` 는 파일 그대로의 설정, `env_path` 는 쓴 `.env` 경로. 자리표시자 변수가 없으면 `ValueError`.
372
+
373
+ ### `EnvConfig.dump(file_path, file_format, force_write, xml_tag, **kwargs)`
374
+
375
+ 인자는 `dict_dump` 와 같습니다. `data` 를 저장하되 자리표시자가 있던 자리는 원본 자리표시자로 씁니다. `.env` 가 없을 때만 새로 만듭니다.
376
+
318
377
  ---
319
378
 
320
379
  ## 라이선스
@@ -1,7 +1,7 @@
1
1
  # echoss-common
2
2
 
3
3
  echoss AI Bigdata Center 공통 유틸리티 라이브러리입니다.
4
- 로깅(Logger)과 config 파일 포맷 읽기/쓰기 기능을 제공합니다.
4
+ 로깅(Logger)과 config 파일 포맷 읽기/쓰기, 설정 파일의 비밀값 자리표시자 치환(`EnvConfig`) 기능을 제공합니다.
5
5
 
6
6
  ## 문서 지도
7
7
 
@@ -18,12 +18,23 @@ pip install echoss-common
18
18
 
19
19
  - Python >= 3.12
20
20
  - pyyaml > 6.0.2
21
+ - python-dotenv >= 1.0.0 (2.3.0 부터)
22
+
23
+ ## 2.3.0 변경 사항
24
+
25
+ - 비밀값을 설정 파일 밖에 두는 `EnvConfig` 를 추가했습니다(아래 「비밀값 자리표시자」). 이를 위해 `python-dotenv` 가 필수 의존으로 추가됩니다. 기존 `dict_load` · `dict_dump` 동작은 그대로입니다.
26
+
27
+ ## 2.2.0 변경 사항
28
+
29
+ - **크기 회전 파일 이름이 바뀌었습니다.** `myapp.log.50m.1`(가장 최근) … 처럼 회전마다 번호를 미는 대신, `myapp.log.50m.2026-11-01.001` 처럼 회전한 날짜와 그날의 일련번호를 붙이고 다시 바꾸지 않습니다. 2.1.0 이 남긴 `myapp.log.50m.1` 같은 파일은 세지도 지우지도 않으니 직접 정리합니다.
30
+ - 크기 회전의 `backup_count=0` 은 자정 회전과 같이 「회전하고 전부 보관」입니다(2.1.0 은 회전하지 않았습니다).
31
+ - 회전 중 지울 옛 로그 파일을 다른 프로그램이 열고 있으면(Windows `PermissionError`) 지우기를 건너뛰고 계속 씁니다. 2.1.0 은 크기 회전에서 이후 기록을 잃고, 자정 회전에서 회전당 한 줄을 잃었습니다(아래 「로그 회전」).
21
32
 
22
33
  ## 2.1.0 변경 사항
23
34
 
24
35
  - `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
25
36
  - `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
26
- - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다. 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
37
+ - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다(2.2.0 에서 이름 규칙 변경). 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
27
38
  - **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
28
39
 
29
40
  ## 2.0.0 변경 사항
@@ -120,13 +131,13 @@ modify_loggers_by_prefix(
120
131
  | `rotation` | 동작 | 회전 파일 | `backup_count` |
121
132
  |---|---|---|---|
122
133
  | `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
123
- | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.1`(가장 최근) … `myapp.log.50m.N` | **1 이상을 주어야 회전합니다.** 0 이면 회전하지 않습니다(Python 표준 크기 회전과 같음) |
134
+ | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.2026-11-01.001`, `.002` … (회전한 날짜와 그날의 일련번호) | 0 이면 전부 보관, N 이면 가장 새 N 개 |
124
135
  | `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
125
136
 
126
- - 회전 없이 쌓으려면 `'none'` 을 씁니다(`'50m'` 에 `backup_count=0` 으로 쓰지 않습니다).
137
+ - 크기 회전의 일련번호 자리수는 크기 숫자의 자리수로 정합니다 — `1m`~`9m` 4 자리, `10m`~`99m` 3 자리, `100m`~`999m` 2 자리, `1000m` 이상 1 자리. 하루 약 10 GB 를 넘게 써서 자리수를 넘으면 번호가 그대로 길어집니다. 보관 순서는 숫자로 비교하므로 맞고, 리눅스 `ls` 화면 순서만 어긋납니다(`ls -v` 로 봅니다).
127
138
  - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
128
- - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
129
- - 알려진 한계(크기 회전, Windows 확인 전): 회전할 때 `.50m.1` → `.50m.2` 처럼 번호 파일도 이름을 바꾸거나 지웁니다. 다른 프로그램이 그 번호 파일을 열고 있을 때 Windows 에서 어떻게 되는지는 2.1.0 배포 뒤 확인하고, 문제가 있으면 2.1.1 에서 고칩니다.
139
+ - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 회전 파일로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
140
+ - 회전 파일 이름은 한 번 붙이면 바꾸지 않습니다. 회전할 때 `backup_count` 를 넘는 옛 회전 파일을 지우는데, 다른 프로그램이 그 파일을 열고 있어 막히면(Windows `PermissionError`) 지우기를 건너뛰고 다음 회전에서 다시 지웁니다. 기록은 잃지 않고, 핸들러마다 처음 한 번 `RuntimeWarning` 을 냅니다. 그동안 회전 파일이 `backup_count` 보다 많을 수 있습니다.
130
141
  - 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
131
142
  - **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
132
143
 
@@ -192,6 +203,7 @@ logger.info("payload: %s", JsonLogDict({"user": "kim"}))
192
203
  |------|------|
193
204
  | `dict_load()` | 설정 파일을 `dict`로 읽기 |
194
205
  | `dict_dump()` | `dict`를 설정 파일로 쓰기 |
206
+ | `EnvConfig.load()` · `.dump()` | `${이름}` 자리표시자를 `.env` · 환경변수 값으로 바꿔 읽고, 자리표시자를 되살려 쓰기 (2.3.0, 아래 「비밀값 자리표시자」) |
195
207
 
196
208
  #### 지원 포맷
197
209
 
@@ -242,6 +254,38 @@ dict_dump({'settings': {'@version': '1', 'port': '8080'}}, 'config/settings.xml'
242
254
  dict_dump(settings, 'config/settings.yaml', force_write=False)
243
255
  ```
244
256
 
257
+ #### 비밀값 자리표시자 (`EnvConfig`, 2.3.0)
258
+
259
+ API 키·비밀번호는 설정 파일에 `${이름}` 으로 적고, 실제 값은 `.env` 또는 운영체제 환경변수에 둡니다.
260
+
261
+ ```yaml
262
+ # config.yaml (git 에 커밋)
263
+ api:
264
+ key: ${OPENAI_API_KEY}
265
+ db:
266
+ url: postgres://app:${PG_PASSWORD}@db.local/app
267
+ ```
268
+
269
+ ```
270
+ # .env (git 에 넣지 않음 — .gitignore 에 추가하고, 변수 이름만 적은 .env.example 을 커밋)
271
+ OPENAI_API_KEY=sk-...
272
+ PG_PASSWORD=...
273
+ ```
274
+
275
+ ```python
276
+ from echoss_common import EnvConfig
277
+
278
+ cfg = EnvConfig.load('config.yaml') # .env 는 작업 폴더부터 위로 찾음, env_path='...' 로 지정 가능
279
+ cfg.data['api']['key'] # 'sk-...'
280
+ cfg.data['api']['timeout'] = 60
281
+ cfg.dump('config.yaml') # ${OPENAI_API_KEY} 등 자리표시자는 그대로 저장
282
+ ```
283
+
284
+ - 문자열 값 안의 `${이름}` 을 모두 바꿉니다 — 값 전체와 값 일부(`postgres://app:${PG_PASSWORD}@...`) 모두, 중첩 dict · list 포함. 이름은 영문자 · 숫자 · 밑줄이고 숫자로 시작하지 않습니다. `$HOME`, `pa$$word` 는 바꾸지 않습니다. 이스케이프는 없습니다 — 평문 값에 `${...}` 를 쓰면 치환 대상이 됩니다.
285
+ - 값은 운영체제 환경변수에서 먼저 찾고, 없으면 `.env` 에서 찾습니다. `.env` 의 값은 프로세스 환경변수에도 들어갑니다(이미 있는 값은 바꾸지 않음). 어디에도 없으면 `ValueError` 입니다(어느 키 · 어느 변수인지 메시지에 표시).
286
+ - `dump` 는 자리표시자가 있던 자리를 원본 자리표시자로 씁니다. 그 자리 값을 프로그램이 바꿨으면 바뀐 값은 설정 파일에 쓰지 않고 `UserWarning` 을 냅니다.
287
+ - `dump` 는 기존 `.env` 를 덮어쓰지 않습니다. `.env` 가 없을 때만 자리표시자 변수로 새로 만들고, 값 전체가 자리표시자인 자리를 바꿨으면 바뀐 값을 씁니다. 새로 만든 `.env` 가 git 에 들어가지 않게 `.gitignore` 에 `.env` 를 넣어 둡니다.
288
+
245
289
  ---
246
290
 
247
291
  ## API 참조
@@ -253,7 +297,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
253
297
  | `logger_name` | str | `'echoss'` | 로거 이름 |
254
298
  | `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
255
299
  | `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
256
- | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (자정 회전은 0이면 전부 보관, 크기 회전은 1 이상이어야 회전) |
300
+ | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
257
301
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
258
302
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
259
303
  | `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
@@ -300,6 +344,20 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
300
344
  | `force_write` | bool | `True` | 기존 파일 덮어쓰기 여부 |
301
345
  | `xml_tag` | str | `None` | XML 루트 태그 (미지정 시 단일 최상위 키 사용) |
302
346
 
347
+ ### `EnvConfig.load(file_path, file_format, env_path, **kwargs)`
348
+
349
+ | 파라미터 | 타입 | 기본값 | 설명 |
350
+ |----------|------|--------|------|
351
+ | `file_path` | str | — | 읽을 설정 파일 경로 (`dict_load` 와 같음) |
352
+ | `file_format` | str | `None` | 포맷 강제 지정 |
353
+ | `env_path` | str | `None` | `.env` 경로 (미지정 시 작업 폴더부터 위로 찾고, 없으면 작업 폴더의 `.env`) |
354
+
355
+ 반환값: `EnvConfig` — `data` 는 치환된 설정, `raw` 는 파일 그대로의 설정, `env_path` 는 쓴 `.env` 경로. 자리표시자 변수가 없으면 `ValueError`.
356
+
357
+ ### `EnvConfig.dump(file_path, file_format, force_write, xml_tag, **kwargs)`
358
+
359
+ 인자는 `dict_dump` 와 같습니다. `data` 를 저장하되 자리표시자가 있던 자리는 원본 자리표시자로 씁니다. `.env` 가 없을 때만 새로 만듭니다.
360
+
303
361
  ---
304
362
 
305
363
  ## 라이선스
@@ -1,6 +1,7 @@
1
1
  # for v1.0
2
2
  from .echoss_logger import JsonLineFormatter, JsonLogDict, get_app_logger, get_logger, use_logger, LOG_FORMAT_DETAIL, set_logger_level, modify_loggers_by_prefix
3
3
  from .config_fileformat import dict_load, dict_dump
4
+ from .env_config import EnvConfig
4
5
 
5
6
  __all__ = [
6
7
  "JsonLogDict",
@@ -12,5 +13,6 @@ __all__ = [
12
13
  "set_logger_level",
13
14
  "modify_loggers_by_prefix",
14
15
  "dict_load",
15
- "dict_dump"
16
+ "dict_dump",
17
+ "EnvConfig"
16
18
  ]
@@ -5,6 +5,7 @@ import re
5
5
  import shutil
6
6
  import sys
7
7
  import time
8
+ import warnings
8
9
  from collections.abc import Mapping
9
10
  from functools import cache
10
11
  from logging import StreamHandler
@@ -70,6 +71,17 @@ def _stream_replaced(handler) -> bool:
70
71
  return (mine.st_dev, mine.st_ino) != (disk.st_dev, disk.st_ino)
71
72
 
72
73
 
74
+ # A rotation step hit a file another process keeps open (Windows: PermissionError).
75
+ # Keep every record in the current file; warn once per handler.
76
+ def _keep_writing_after_held_file(handler, exc):
77
+ if not getattr(handler, "_held_warned", False):
78
+ handler._held_warned = True
79
+ warnings.warn(f"log rotation step skipped, a file is held open by another process: {exc}",
80
+ RuntimeWarning, stacklevel=2)
81
+ if handler.stream is None:
82
+ handler.stream = handler._open()
83
+
84
+
73
85
  # On Windows, renaming a log file that another handle keeps open fails with PermissionError (WinError 32).
74
86
  # Fall back to copy-then-truncate so no record is lost, and always move rolloverAt forward.
75
87
  class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
@@ -97,7 +109,10 @@ class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
97
109
  self.stream = self._open()
98
110
  self.rolloverAt = self.computeRollover(int(time.time()))
99
111
  return
100
- 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)
101
116
  now = int(time.time())
102
117
  if self.rolloverAt <= now:
103
118
  self.rolloverAt = self.computeRollover(now)
@@ -106,15 +121,45 @@ class CopyTruncateRotatingHandler(TimedRotatingFileHandler):
106
121
  _SIZE_ROTATION = re.compile(r"[1-9][0-9]*m") # MiB, lowercase m only
107
122
 
108
123
 
109
- # Size rotation 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.
124
+ # Size rotation into names that never change: a.log.50m.2026-11-01.001 (the day's largest number + 1).
125
+ # Windows blocks renaming or deleting a file another process holds; with no renaming chain,
126
+ # only deleting an old file can be blocked, as in midnight rotation.
111
127
  class SizeCopyTruncateRotatingHandler(RotatingFileHandler):
112
128
  rotate = CopyTruncateRotatingHandler.rotate
113
129
 
114
130
  def __init__(self, filename, size, backup_count):
115
131
  super().__init__(filename, maxBytes=int(size[:-1]) * 1024 * 1024,
116
132
  backupCount=backup_count, encoding="utf-8")
117
- self.namer = lambda name: f"{self.baseFilename}.{size}{name[len(self.baseFilename):]}"
133
+ self.size = size
134
+ self.width = max(1, 5 - len(size[:-1])) # 1-9m: 4 digits, 10-99m: 3, 100-999m: 2, 1000m+: 1
135
+ self.pattern = re.compile(re.escape(os.path.basename(self.baseFilename))
136
+ + rf"\.{size}\.(\d{{4}}-\d{{2}}-\d{{2}})\.(\d+)")
137
+
138
+ # (date, number, path) of this handler's rotated files, oldest first
139
+ def rotated_files(self):
140
+ folder = os.path.dirname(self.baseFilename)
141
+ found = []
142
+ for name in os.listdir(folder):
143
+ m = self.pattern.fullmatch(name)
144
+ if m:
145
+ found.append((m[1], int(m[2]), os.path.join(folder, name)))
146
+ return sorted(found)
147
+
148
+ def doRollover(self):
149
+ if self.stream:
150
+ self.stream.close()
151
+ self.stream = None
152
+ files = self.rotated_files()
153
+ today = time.strftime("%Y-%m-%d")
154
+ number = max((n for day, n, _ in files if day == today), default=0) + 1
155
+ self.rotate(self.baseFilename, f"{self.baseFilename}.{self.size}.{today}.{number:0{self.width}d}")
156
+ self.stream = self._open()
157
+ if self.backupCount > 0: # 0 keeps every rotated file, as in midnight rotation
158
+ try:
159
+ for *_, path in files[:len(files) + 1 - self.backupCount]:
160
+ os.remove(path)
161
+ except PermissionError as exc: # an old rotated file is held open
162
+ _keep_writing_after_held_file(self, exc)
118
163
 
119
164
 
120
165
  def _check_rotation(rotation):
@@ -0,0 +1,77 @@
1
+ import copy
2
+ import os
3
+ import re
4
+ import warnings
5
+
6
+ from dotenv import find_dotenv, load_dotenv, set_key
7
+
8
+ from .config_fileformat import dict_dump, dict_load
9
+
10
+ PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
11
+
12
+
13
+ # Replace every ${NAME} inside string values; path names the key in the error.
14
+ def _substitute(value, path=""):
15
+ if isinstance(value, dict):
16
+ return {k: _substitute(v, f"{path}.{k}" if path else str(k)) for k, v in value.items()}
17
+ if isinstance(value, list):
18
+ return [_substitute(v, f"{path}[{i}]") for i, v in enumerate(value)]
19
+ if not isinstance(value, str):
20
+ return value
21
+
22
+ def lookup(m):
23
+ found = os.environ.get(m[1])
24
+ if found is None:
25
+ raise ValueError(f"[{path}] environment variable '{m[1]}' not found (.env / OS env)")
26
+ return found
27
+
28
+ return PLACEHOLDER.sub(lookup, value)
29
+
30
+
31
+ # Write the placeholder back wherever the file had one; collect variables for a new .env.
32
+ def _restore(current, raw, loaded, path, env_values, new_env):
33
+ if isinstance(current, dict) and isinstance(raw, dict) and isinstance(loaded, dict):
34
+ return {k: _restore(v, raw[k], loaded[k], f"{path}.{k}" if path else str(k), env_values, new_env)
35
+ if k in raw and k in loaded else v for k, v in current.items()}
36
+ if isinstance(current, list) and isinstance(raw, list) and isinstance(loaded, list):
37
+ return [_restore(v, raw[i], loaded[i], f"{path}[{i}]", env_values, new_env)
38
+ if i < len(raw) and i < len(loaded) else v for i, v in enumerate(current)]
39
+ names = PLACEHOLDER.findall(raw) if isinstance(raw, str) else []
40
+ if not names:
41
+ return current
42
+ for name in names:
43
+ env_values.setdefault(name, os.environ.get(name))
44
+ if current != loaded:
45
+ whole = PLACEHOLDER.fullmatch(raw)
46
+ if new_env and whole:
47
+ env_values[whole[1]] = str(current)
48
+ else:
49
+ warnings.warn(f"[{path}] changed value not saved; the placeholder {raw!r} is kept",
50
+ UserWarning, stacklevel=3)
51
+ return raw
52
+
53
+
54
+ # A config file whose ${NAME} placeholders come from the OS environment or .env.
55
+ # data is the substituted config; dump() writes the placeholders back so secrets stay out of the file.
56
+ class EnvConfig:
57
+ def __init__(self, raw, env_path):
58
+ self.raw = raw # as read from the file, placeholders kept
59
+ self.env_path = env_path # .env read at load, created at dump when missing
60
+ self.data = _substitute(raw)
61
+ self.loaded = copy.deepcopy(self.data)
62
+
63
+ @classmethod
64
+ def load(cls, file_path, file_format=None, env_path=None, **kwargs):
65
+ env_path = env_path or find_dotenv(usecwd=True) or os.path.join(os.getcwd(), ".env")
66
+ load_dotenv(env_path, override=False) # OS environment values win over .env
67
+ return cls(dict_load(file_path, file_format, **kwargs), env_path)
68
+
69
+ def dump(self, file_path, file_format=None, force_write=True, xml_tag=None, **kwargs):
70
+ new_env = not os.path.exists(self.env_path)
71
+ env_values = {}
72
+ out = _restore(self.data, self.raw, self.loaded, "", env_values, new_env)
73
+ dict_dump(out, file_path, file_format, force_write=force_write, xml_tag=xml_tag, **kwargs)
74
+ if new_env and env_values:
75
+ open(self.env_path, "x").close() # never overwrite an existing .env
76
+ for name, value in env_values.items():
77
+ set_key(self.env_path, name, value)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echoss-common
3
- Version: 2.1.0
3
+ Version: 2.3.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
@@ -9,6 +9,7 @@ Requires-Python: >=3.12
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: pyyaml>6.0.2
12
+ Requires-Dist: python-dotenv>=1.0.0
12
13
  Provides-Extra: dev
13
14
  Requires-Dist: pytest; extra == "dev"
14
15
  Dynamic: license-file
@@ -16,7 +17,7 @@ Dynamic: license-file
16
17
  # echoss-common
17
18
 
18
19
  echoss AI Bigdata Center 공통 유틸리티 라이브러리입니다.
19
- 로깅(Logger)과 config 파일 포맷 읽기/쓰기 기능을 제공합니다.
20
+ 로깅(Logger)과 config 파일 포맷 읽기/쓰기, 설정 파일의 비밀값 자리표시자 치환(`EnvConfig`) 기능을 제공합니다.
20
21
 
21
22
  ## 문서 지도
22
23
 
@@ -33,12 +34,23 @@ pip install echoss-common
33
34
 
34
35
  - Python >= 3.12
35
36
  - pyyaml > 6.0.2
37
+ - python-dotenv >= 1.0.0 (2.3.0 부터)
38
+
39
+ ## 2.3.0 변경 사항
40
+
41
+ - 비밀값을 설정 파일 밖에 두는 `EnvConfig` 를 추가했습니다(아래 「비밀값 자리표시자」). 이를 위해 `python-dotenv` 가 필수 의존으로 추가됩니다. 기존 `dict_load` · `dict_dump` 동작은 그대로입니다.
42
+
43
+ ## 2.2.0 변경 사항
44
+
45
+ - **크기 회전 파일 이름이 바뀌었습니다.** `myapp.log.50m.1`(가장 최근) … 처럼 회전마다 번호를 미는 대신, `myapp.log.50m.2026-11-01.001` 처럼 회전한 날짜와 그날의 일련번호를 붙이고 다시 바꾸지 않습니다. 2.1.0 이 남긴 `myapp.log.50m.1` 같은 파일은 세지도 지우지도 않으니 직접 정리합니다.
46
+ - 크기 회전의 `backup_count=0` 은 자정 회전과 같이 「회전하고 전부 보관」입니다(2.1.0 은 회전하지 않았습니다).
47
+ - 회전 중 지울 옛 로그 파일을 다른 프로그램이 열고 있으면(Windows `PermissionError`) 지우기를 건너뛰고 계속 씁니다. 2.1.0 은 크기 회전에서 이후 기록을 잃고, 자정 회전에서 회전당 한 줄을 잃었습니다(아래 「로그 회전」).
36
48
 
37
49
  ## 2.1.0 변경 사항
38
50
 
39
51
  - `get_logger` · `get_app_logger` · `modify_loggers_by_prefix` 에 회전 방식 인자 `rotation` 이 맨 끝에 추가됐습니다. 기본값 `'midnight'` 은 2.0.x 와 같습니다(아래 「로그 회전」).
40
52
  - `'none'` — 회전하지 않고 한 파일에 계속 씁니다.
41
- - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다. 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
53
+ - `'50m'` — 파일이 50 MiB 를 넘기 전에 `myapp.log.50m.1`(가장 최근), `.50m.2` … 로 회전합니다(2.2.0 에서 이름 규칙 변경). 크기는 `'{숫자}m'`(MiB) 형태만 받고 `'1g'`, `'500k'`, `'50'` 등은 `ValueError` 입니다.
42
54
  - **`modify_loggers_by_prefix` 동작 변경**: 대상 로거들이 파일 핸들러 **하나를 공유**합니다(2.0.x 는 로거마다 핸들러를 따로 붙였습니다). 기록은 전처럼 한 파일에 시간 순서대로 쌓이고, 회전도 한 번만 일어납니다. 2.0.x 에서 첫 로거 다음부터 `backup_count` 를 0 으로 바꾸던 처리는 없어졌습니다 — 준 `backup_count` 가 그대로 적용됩니다.
43
55
 
44
56
  ## 2.0.0 변경 사항
@@ -135,13 +147,13 @@ modify_loggers_by_prefix(
135
147
  | `rotation` | 동작 | 회전 파일 | `backup_count` |
136
148
  |---|---|---|---|
137
149
  | `'midnight'` (기본) | 매일 자정에 회전 | `myapp.log.2026-10-10` | 0 이면 전부 보관, N 이면 N 개 |
138
- | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.1`(가장 최근) … `myapp.log.50m.N` | **1 이상을 주어야 회전합니다.** 0 이면 회전하지 않습니다(Python 표준 크기 회전과 같음) |
150
+ | `'50m'` (`'{숫자}m'`, MiB) | 파일이 그 크기를 넘기 전에 회전 | `myapp.log.50m.2026-11-01.001`, `.002` … (회전한 날짜와 그날의 일련번호) | 0 이면 전부 보관, N 이면 가장 새 N 개 |
139
151
  | `'none'` | 회전하지 않음 — 파일이 끝없이 커집니다 | 없음 | 쓰지 않음 |
140
152
 
141
- - 회전 없이 쌓으려면 `'none'` 을 씁니다(`'50m'` 에 `backup_count=0` 으로 쓰지 않습니다).
153
+ - 크기 회전의 일련번호 자리수는 크기 숫자의 자리수로 정합니다 — `1m`~`9m` 4 자리, `10m`~`99m` 3 자리, `100m`~`999m` 2 자리, `1000m` 이상 1 자리. 하루 약 10 GB 를 넘게 써서 자리수를 넘으면 번호가 그대로 길어집니다. 보관 순서는 숫자로 비교하므로 맞고, 리눅스 `ls` 화면 순서만 어긋납니다(`ls -v` 로 봅니다).
142
154
  - 같은 파일에 쓰는 로거는 **로거 이름도 같게** 합니다. 이름이 다르면 같은 파일을 핸들러 둘이 열게 됩니다. 외부 패키지 로거를 한 파일로 모을 때는 `modify_loggers_by_prefix` 를 씁니다 — 대상 로거들이 핸들러 하나를 공유합니다.
143
- - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 `.1` 로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
144
- - 알려진 한계(크기 회전, Windows 확인 전): 회전할 때 `.50m.1` → `.50m.2` 처럼 번호 파일도 이름을 바꾸거나 지웁니다. 다른 프로그램이 그 번호 파일을 열고 있을 때 Windows 에서 어떻게 되는지는 2.1.0 배포 뒤 확인하고, 문제가 있으면 2.1.1 에서 고칩니다.
155
+ - Windows 에서 다른 프로세스가 로그 파일을 열고 있어 이름 바꾸기가 막히면(`PermissionError`), 회전 파일에 내용을 복사한 뒤 원본을 비우고 계속 씁니다(자정 회전, 그리고 크기 회전에서 현재 파일을 회전 파일로 옮기는 단계). 날짜 파일이 이미 있으면(다른 프로세스가 먼저 회전) 덮어쓰지 않습니다.
156
+ - 회전 파일 이름은 한 번 붙이면 바꾸지 않습니다. 회전할 때 `backup_count` 를 넘는 옛 회전 파일을 지우는데, 다른 프로그램이 그 파일을 열고 있어 막히면(Windows `PermissionError`) 지우기를 건너뛰고 다음 회전에서 다시 지웁니다. 기록은 잃지 않고, 핸들러마다 처음 한 번 `RuntimeWarning` 을 냅니다. 그동안 회전 파일이 `backup_count` 보다 많을 수 있습니다.
145
157
  - 알려진 한계: 복사와 비우기 사이에 **다른 프로세스가 같은 파일에 쓴 줄은 사라질 수 있습니다.** 이 틈은 회전 한 번(보통 자정)뿐입니다. 두 프로세스가 같은 파일에 동시에 쓰는 것 자체도 회전과 무관하게 줄을 잃을 수 있으므로 피합니다.
146
158
  - **정책: 프로세스가 다르면 같은 로그 파일을 쓰지 않습니다.** 워커·컨테이너·인스턴스마다 파일 이름을 다르게 합니다(예: `f"logs/app.{os.getpid()}.log"`). 각 프로세스가 따로 회전하므로, 같은 파일을 쓰면 회전이 겹쳐 기록이 빠지거나 다른 날짜 파일에 들어가고, 드물게 날짜 파일 하나가 통째로 덮어써집니다. `get_app_logger()` 로 같은 로거를 받거나 fork 전에 만든 로거를 물려받아도 같습니다 — 공유는 한 프로세스 안에서만 됩니다. 구현으로 해결되면 이 정책을 풉니다.
147
159
 
@@ -207,6 +219,7 @@ logger.info("payload: %s", JsonLogDict({"user": "kim"}))
207
219
  |------|------|
208
220
  | `dict_load()` | 설정 파일을 `dict`로 읽기 |
209
221
  | `dict_dump()` | `dict`를 설정 파일로 쓰기 |
222
+ | `EnvConfig.load()` · `.dump()` | `${이름}` 자리표시자를 `.env` · 환경변수 값으로 바꿔 읽고, 자리표시자를 되살려 쓰기 (2.3.0, 아래 「비밀값 자리표시자」) |
210
223
 
211
224
  #### 지원 포맷
212
225
 
@@ -257,6 +270,38 @@ dict_dump({'settings': {'@version': '1', 'port': '8080'}}, 'config/settings.xml'
257
270
  dict_dump(settings, 'config/settings.yaml', force_write=False)
258
271
  ```
259
272
 
273
+ #### 비밀값 자리표시자 (`EnvConfig`, 2.3.0)
274
+
275
+ API 키·비밀번호는 설정 파일에 `${이름}` 으로 적고, 실제 값은 `.env` 또는 운영체제 환경변수에 둡니다.
276
+
277
+ ```yaml
278
+ # config.yaml (git 에 커밋)
279
+ api:
280
+ key: ${OPENAI_API_KEY}
281
+ db:
282
+ url: postgres://app:${PG_PASSWORD}@db.local/app
283
+ ```
284
+
285
+ ```
286
+ # .env (git 에 넣지 않음 — .gitignore 에 추가하고, 변수 이름만 적은 .env.example 을 커밋)
287
+ OPENAI_API_KEY=sk-...
288
+ PG_PASSWORD=...
289
+ ```
290
+
291
+ ```python
292
+ from echoss_common import EnvConfig
293
+
294
+ cfg = EnvConfig.load('config.yaml') # .env 는 작업 폴더부터 위로 찾음, env_path='...' 로 지정 가능
295
+ cfg.data['api']['key'] # 'sk-...'
296
+ cfg.data['api']['timeout'] = 60
297
+ cfg.dump('config.yaml') # ${OPENAI_API_KEY} 등 자리표시자는 그대로 저장
298
+ ```
299
+
300
+ - 문자열 값 안의 `${이름}` 을 모두 바꿉니다 — 값 전체와 값 일부(`postgres://app:${PG_PASSWORD}@...`) 모두, 중첩 dict · list 포함. 이름은 영문자 · 숫자 · 밑줄이고 숫자로 시작하지 않습니다. `$HOME`, `pa$$word` 는 바꾸지 않습니다. 이스케이프는 없습니다 — 평문 값에 `${...}` 를 쓰면 치환 대상이 됩니다.
301
+ - 값은 운영체제 환경변수에서 먼저 찾고, 없으면 `.env` 에서 찾습니다. `.env` 의 값은 프로세스 환경변수에도 들어갑니다(이미 있는 값은 바꾸지 않음). 어디에도 없으면 `ValueError` 입니다(어느 키 · 어느 변수인지 메시지에 표시).
302
+ - `dump` 는 자리표시자가 있던 자리를 원본 자리표시자로 씁니다. 그 자리 값을 프로그램이 바꿨으면 바뀐 값은 설정 파일에 쓰지 않고 `UserWarning` 을 냅니다.
303
+ - `dump` 는 기존 `.env` 를 덮어쓰지 않습니다. `.env` 가 없을 때만 자리표시자 변수로 새로 만들고, 값 전체가 자리표시자인 자리를 바꿨으면 바뀐 값을 씁니다. 새로 만든 `.env` 가 git 에 들어가지 않게 `.gitignore` 에 `.env` 를 넣어 둡니다.
304
+
260
305
  ---
261
306
 
262
307
  ## API 참조
@@ -268,7 +313,7 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
268
313
  | `logger_name` | str | `'echoss'` | 로거 이름 |
269
314
  | `logger_format` | str | `LOG_FORMAT` | 로그 포맷 문자열 |
270
315
  | `file_path` | str\|None | `None` | 로그 파일 경로 (`None`이면 파일 미사용) |
271
- | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (자정 회전은 0이면 전부 보관, 크기 회전은 1 이상이어야 회전) |
316
+ | `backup_count` | int | `0` | 보관할 로테이팅 파일 수 (0이면 전부 보관) |
272
317
  | `use_console` | bool | `True` | 콘솔 출력 여부 |
273
318
  | `level` | str\|int | `'DEBUG'` | 로그 레벨 |
274
319
  | `rotation` | str | `'midnight'` | 회전 방식 — `'midnight'` · `'none'` · `'{숫자}m'`(MiB, 예 `'50m'`). 그 밖은 `ValueError` |
@@ -315,6 +360,20 @@ dict_dump(settings, 'config/settings.yaml', force_write=False)
315
360
  | `force_write` | bool | `True` | 기존 파일 덮어쓰기 여부 |
316
361
  | `xml_tag` | str | `None` | XML 루트 태그 (미지정 시 단일 최상위 키 사용) |
317
362
 
363
+ ### `EnvConfig.load(file_path, file_format, env_path, **kwargs)`
364
+
365
+ | 파라미터 | 타입 | 기본값 | 설명 |
366
+ |----------|------|--------|------|
367
+ | `file_path` | str | — | 읽을 설정 파일 경로 (`dict_load` 와 같음) |
368
+ | `file_format` | str | `None` | 포맷 강제 지정 |
369
+ | `env_path` | str | `None` | `.env` 경로 (미지정 시 작업 폴더부터 위로 찾고, 없으면 작업 폴더의 `.env`) |
370
+
371
+ 반환값: `EnvConfig` — `data` 는 치환된 설정, `raw` 는 파일 그대로의 설정, `env_path` 는 쓴 `.env` 경로. 자리표시자 변수가 없으면 `ValueError`.
372
+
373
+ ### `EnvConfig.dump(file_path, file_format, force_write, xml_tag, **kwargs)`
374
+
375
+ 인자는 `dict_dump` 와 같습니다. `data` 를 저장하되 자리표시자가 있던 자리는 원본 자리표시자로 씁니다. `.env` 가 없을 때만 새로 만듭니다.
376
+
318
377
  ---
319
378
 
320
379
  ## 라이선스
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  echoss_common/__init__.py
6
6
  echoss_common/config_fileformat.py
7
7
  echoss_common/echoss_logger.py
8
+ echoss_common/env_config.py
8
9
  echoss_common.egg-info/PKG-INFO
9
10
  echoss_common.egg-info/SOURCES.txt
10
11
  echoss_common.egg-info/dependency_links.txt
@@ -17,6 +18,7 @@ tests/test_echoss_logger_2.py
17
18
  tests/test_echoss_logger_2x.py
18
19
  tests/test_echoss_logger_json.py
19
20
  tests/test_echoss_logger_rotation_modes.py
21
+ tests/test_env_config.py
20
22
  tests/scripts/check_xml_samples.py
21
23
  tests/scripts/hold_file.py
22
24
  tests/scripts/spike_default_log_file.py
@@ -1,4 +1,5 @@
1
1
  pyyaml>6.0.2
2
+ python-dotenv>=1.0.0
2
3
 
3
4
  [dev]
4
5
  pytest
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  # 패키지 메타데이터
7
7
  [project]
8
8
  name="echoss-common"
9
- version="2.1.0"
9
+ version="2.3.0"
10
10
  description="echoss AI Bigdata Center 공통 유틸리티 라이브러리 (Logger, JsonLogDict, config_fileformat)"
11
11
  readme = "README.md"
12
12
  license = "Apache-2.0"
@@ -22,7 +22,8 @@ requires-python='>=3.12'
22
22
 
23
23
  # 런타임 의존성: YAML 파싱을 위한 pyyaml
24
24
  dependencies=[
25
- "pyyaml>6.0.2"
25
+ "pyyaml>6.0.2",
26
+ "python-dotenv>=1.0.0"
26
27
  ]
27
28
 
28
29
  # 개발 의존성: 테스트 실행을 위한 pytest
@@ -207,6 +207,8 @@ def test_two_handlers_lose_and_duplicate_no_record(tmp_path):
207
207
 
208
208
 
209
209
  # T-M3
210
+ @pytest.mark.skipif(sys.platform == "win32", reason="Windows blocks the rename while the second handler keeps "
211
+ "the file open, so copy-truncate keeps A3 and B3 in the current file (T-M1/T-M2 cover no loss)")
210
212
  def test_stale_stream_check_off_keeps_2_0_0_layout(tmp_path, monkeypatch):
211
213
  monkeypatch.setattr(echoss_logger, "_STALE_STREAM_CHECK", False, raising=False)
212
214
  current, rotated = _write_through_two_handlers(tmp_path)
@@ -3,6 +3,8 @@ import logging
3
3
  import os
4
4
  import subprocess
5
5
  import sys
6
+ import time
7
+ import warnings
6
8
  from contextlib import contextmanager
7
9
  from pathlib import Path
8
10
 
@@ -46,6 +48,11 @@ def _numbers(paths):
46
48
  return found
47
49
 
48
50
 
51
+ def _rotated(name, *numbers):
52
+ today = time.strftime("%Y-%m-%d")
53
+ return [f"{name}.1m.{today}.{n:04d}" for n in numbers]
54
+
55
+
49
56
  def _write(logger, count, start=0):
50
57
  for n in range(start, start + count):
51
58
  logger.info(f"{n} {LINE}")
@@ -69,7 +76,7 @@ def test_size_rotation_names_limit_and_backup_count(tmp_path):
69
76
  _write(logger, written)
70
77
 
71
78
  files = sorted(tmp_path.iterdir())
72
- assert [f.name for f in files] == ["s.log", "s.log.1m.1", "s.log.1m.2"]
79
+ assert [f.name for f in files] == ["s.log"] + _rotated("s.log", 2, 3) # 3 rotations, the 2 newest kept
73
80
  assert all(f.stat().st_size <= MIB for f in files)
74
81
  kept = sorted(_numbers(files))
75
82
  assert kept == list(range(written - len(kept), written)) # newest records, no gap
@@ -120,7 +127,7 @@ def test_size_rotation_falls_back_to_copy_truncate_when_rename_is_blocked(tmp_pa
120
127
  _write(logger, written)
121
128
 
122
129
  files = sorted(tmp_path.iterdir())
123
- assert "blk.log.1m.1" in [f.name for f in files]
130
+ assert _rotated("blk.log", 1)[0] in [f.name for f in files]
124
131
  assert sorted(_numbers(files)) == list(range(written))
125
132
 
126
133
 
@@ -179,7 +186,7 @@ def test_modify_loggers_by_prefix_shares_one_file_handler(tmp_path, rotation):
179
186
  files = sorted(tmp_path.iterdir())
180
187
  kept = sorted(_numbers(files))
181
188
  if rotation == "1m":
182
- assert [f.name for f in files] == ["shared.log", "shared.log.1m.1", "shared.log.1m.2"]
189
+ assert [f.name for f in files] == ["shared.log"] + _rotated("shared.log", 2, 3)
183
190
  assert all(f.stat().st_size <= MIB for f in files)
184
191
  assert kept == list(range(count - len(kept), count)) # newest records of all three loggers, no gap
185
192
  else:
@@ -260,16 +267,17 @@ def test_size_rotation_keeps_every_record_while_another_process_holds_the_curren
260
267
  # T-R9
261
268
  def test_size_rotation_keeps_every_record_while_another_process_holds_a_numbered_file(tmp_path):
262
269
  log_file = tmp_path / "h9.log"
263
- logger = _size_logger("rm-h9", log_file, 10)
264
- first = 1200 # one rotation, so h9.log.1m.1 exists
270
+ logger = _size_logger("rm-h9", log_file, 1)
271
+ first = 1200 # one rotation, so the first rotated file exists
265
272
  _write(logger, first)
266
- numbered = tmp_path / "h9.log.1m.1"
273
+ numbered = tmp_path / _rotated("h9.log", 1)[0]
267
274
  assert numbered.exists()
268
- more = 2400 # two more rotations shift .1m.1 while it is held
275
+ more = 2400 # two more rotations try to delete it while it is held
269
276
  with _held(numbered):
270
277
  _write(logger, more, start=first)
271
278
 
272
- assert sorted(_numbers(sorted(tmp_path.iterdir()))) == list(range(first + more))
279
+ kept = sorted(_numbers(sorted(tmp_path.iterdir()))) # Linux deletes, Windows keeps the held file
280
+ assert kept == list(range(first + more - len(kept), first + more)) # newest records, no gap
273
281
 
274
282
 
275
283
  # T-R10
@@ -281,3 +289,113 @@ def test_none_rotation_keeps_every_record_while_another_process_holds_the_file(t
281
289
  _write(logger, 500)
282
290
 
283
291
  assert _numbers([log_file]) == list(range(500))
292
+
293
+
294
+ # T-W1 ~ T-W4: a file to shift or delete is held open (Linux stand-in: PermissionError raised by a patched os call)
295
+ def _held_warnings(caught):
296
+ return [w for w in caught if issubclass(w.category, RuntimeWarning) and "held open" in str(w.message)]
297
+
298
+
299
+ # T-W1
300
+ def test_size_rotation_keeps_rotating_while_an_old_rotated_file_cannot_be_deleted(tmp_path, monkeypatch):
301
+ log_file = tmp_path / "w1.log"
302
+ logger = _size_logger("rm-w1", log_file, 1)
303
+ first = 1200 # one rotation, so the first rotated file exists
304
+ _write(logger, first)
305
+
306
+ def blocked_remove(path):
307
+ raise PermissionError("simulated: another handle keeps the file open")
308
+
309
+ monkeypatch.setattr(os, "remove", blocked_remove)
310
+ more = 2400 # two more rotations, each trying to delete the oldest rotated file
311
+ with pytest.warns(RuntimeWarning) as caught:
312
+ _write(logger, more, start=first)
313
+
314
+ files = sorted(tmp_path.iterdir())
315
+ assert len(_held_warnings(caught)) == 1
316
+ assert [f.name for f in files] == ["w1.log"] + _rotated("w1.log", 1, 2, 3) # kept past backup_count
317
+ assert all(f.stat().st_size <= MIB for f in files)
318
+ assert sorted(_numbers(files)) == list(range(first + more))
319
+
320
+
321
+ # T-W3
322
+ def test_midnight_rotation_keeps_every_record_while_an_old_dated_file_cannot_be_deleted(tmp_path, monkeypatch):
323
+ log_file = tmp_path / "w3.log"
324
+ logger = echoss_logger.get_logger("rm-w3", logger_format="%(message)s", file_path=str(log_file),
325
+ backup_count=1, use_console=False, level="INFO")
326
+ [handler] = _file_handlers(logger)
327
+ for day in ("2020-01-01", "2020-01-02"): # old dated files the rotation will try to delete
328
+ (tmp_path / f"w3.log.{day}").write_text("", encoding="utf-8")
329
+
330
+ def blocked_remove(path):
331
+ raise PermissionError("simulated: another handle keeps the file open")
332
+
333
+ monkeypatch.setattr(os, "remove", blocked_remove)
334
+ _write(logger, 10)
335
+ handler.rolloverAt = int(time.time()) - 1 # one rotation; a second one the same day finds its dated name taken
336
+ with pytest.warns(RuntimeWarning) as caught:
337
+ _write(logger, 10, start=10)
338
+
339
+ assert len(_held_warnings(caught)) == 1
340
+ assert sorted(_numbers(sorted(tmp_path.iterdir()))) == list(range(20))
341
+
342
+
343
+ # T-W5 (Windows: on Linux nothing is blocked)
344
+ def test_midnight_rotation_keeps_every_record_while_another_process_holds_an_old_dated_file(tmp_path):
345
+ log_file = tmp_path / "w5.log"
346
+ logger = echoss_logger.get_logger("rm-w5", logger_format="%(message)s", file_path=str(log_file),
347
+ backup_count=1, use_console=False, level="INFO")
348
+ [handler] = _file_handlers(logger)
349
+ old = tmp_path / "w5.log.2020-01-01"
350
+ old.write_text("", encoding="utf-8")
351
+ _write(logger, 10)
352
+ with _held(old):
353
+ handler.rolloverAt = int(time.time()) - 1
354
+ _write(logger, 10, start=10)
355
+
356
+ assert sorted(_numbers(sorted(tmp_path.iterdir()))) == list(range(20))
357
+
358
+
359
+ # T-W4
360
+ def test_size_rotation_backup_count_zero_keeps_every_rotated_file(tmp_path):
361
+ log_file = tmp_path / "w4.log"
362
+ with warnings.catch_warnings():
363
+ warnings.simplefilter("error")
364
+ logger = _size_logger("rm-w4", log_file, 0)
365
+ _write(logger, 3500)
366
+
367
+ files = sorted(tmp_path.iterdir())
368
+ assert [f.name for f in files] == ["w4.log"] + _rotated("w4.log", 1, 2, 3)
369
+ assert sorted(_numbers(files)) == list(range(3500))
370
+
371
+
372
+ # T-W6
373
+ @pytest.mark.parametrize("size, width", [("1m", 4), ("9m", 4), ("10m", 3), ("100m", 2), ("1000m", 1), ("2048m", 1)])
374
+ def test_size_rotation_number_width_follows_size_digits(tmp_path, size, width):
375
+ handler = echoss_logger.SizeCopyTruncateRotatingHandler(str(tmp_path / "x.log"), size, 5)
376
+ try:
377
+ handler.emit(logging.makeLogRecord({"msg": "0 a"}))
378
+ handler.doRollover()
379
+ finally:
380
+ handler.close()
381
+
382
+ assert [f.name for f in tmp_path.iterdir() if f.name != "x.log"] == \
383
+ [f"x.log.{size}.{time.strftime('%Y-%m-%d')}.{1:0{width}d}"]
384
+
385
+
386
+ # T-W6
387
+ def test_size_rotation_takes_the_days_largest_number_plus_one(tmp_path):
388
+ today = time.strftime("%Y-%m-%d")
389
+ for name in (f"x.log.1m.{today}.0009", f"x.log.1m.{today}.12345", "x.log.1m.2020-01-01.0500",
390
+ "x.log.1m.7", "x.log.50m.2020-01-01.001"): # gap, overflow, another day, 2.1.0 name, other size
391
+ (tmp_path / name).write_text("", encoding="utf-8")
392
+ handler = echoss_logger.SizeCopyTruncateRotatingHandler(str(tmp_path / "x.log"), "1m", 10)
393
+ try:
394
+ handler.emit(logging.makeLogRecord({"msg": "0 a"}))
395
+ handler.doRollover()
396
+ rotated = [os.path.basename(path) for *_, path in handler.rotated_files()]
397
+ finally:
398
+ handler.close()
399
+
400
+ assert rotated == ["x.log.1m.2020-01-01.0500", f"x.log.1m.{today}.0009", f"x.log.1m.{today}.12345",
401
+ f"x.log.1m.{today}.12346"]
@@ -0,0 +1,126 @@
1
+ import json
2
+ import os
3
+
4
+ import pytest
5
+ import yaml
6
+
7
+ from echoss_common import EnvConfig
8
+
9
+ NAMES = ("EC_API_KEY", "EC_PG_PW", "EC_ONLY_IN_ENV_FILE", "EC_MISSING")
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def clean_env(monkeypatch):
14
+ # monkeypatch restores these names afterwards, including values load_dotenv puts in os.environ
15
+ for name in NAMES:
16
+ monkeypatch.delenv(name, raising=False)
17
+
18
+
19
+ def _write_yaml(path, data):
20
+ path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
21
+
22
+
23
+ CONFIG = {
24
+ "api": {"key": "${EC_API_KEY}", "timeout": 30},
25
+ "db": {"url": "postgres://u:${EC_PG_PW}@host/db", "hosts": ["a", "${EC_API_KEY}"]},
26
+ "plain": "pa$$word $HOME",
27
+ }
28
+
29
+
30
+ def _load(tmp_path, env_text=None):
31
+ cfg_file = tmp_path / "config.yaml"
32
+ _write_yaml(cfg_file, CONFIG)
33
+ env_file = tmp_path / ".env"
34
+ if env_text is not None:
35
+ env_file.write_text(env_text, encoding="utf-8")
36
+ return EnvConfig.load(str(cfg_file), env_path=str(env_file)), cfg_file, env_file
37
+
38
+
39
+ # T-E1, T-E8
40
+ def test_load_substitutes_whole_and_partial_placeholders_in_nested_values(tmp_path):
41
+ cfg, _, _ = _load(tmp_path, "EC_API_KEY=key-1\nEC_PG_PW=pw-1\n")
42
+
43
+ assert cfg.data == {
44
+ "api": {"key": "key-1", "timeout": 30},
45
+ "db": {"url": "postgres://u:pw-1@host/db", "hosts": ["a", "key-1"]},
46
+ "plain": "pa$$word $HOME",
47
+ }
48
+ assert cfg.raw == CONFIG
49
+
50
+
51
+ # T-E2
52
+ def test_os_environment_wins_over_env_file_and_env_file_values_reach_os_environ(tmp_path, monkeypatch):
53
+ monkeypatch.setenv("EC_API_KEY", "from-os")
54
+ cfg, _, _ = _load(tmp_path, "EC_API_KEY=from-file\nEC_PG_PW=pw-1\nEC_ONLY_IN_ENV_FILE=x\n")
55
+
56
+ assert cfg.data["api"]["key"] == "from-os"
57
+ assert os.environ["EC_API_KEY"] == "from-os"
58
+ assert os.environ["EC_ONLY_IN_ENV_FILE"] == "x"
59
+
60
+
61
+ # T-E3
62
+ def test_missing_variable_raises_with_key_path_and_name(tmp_path):
63
+ with pytest.raises(ValueError, match=r"\[db\.url\] environment variable 'EC_PG_PW' not found"):
64
+ _load(tmp_path, "EC_API_KEY=key-1\n")
65
+
66
+
67
+ # T-E4
68
+ def test_env_file_in_working_folder_is_used_without_env_path(tmp_path, monkeypatch):
69
+ monkeypatch.chdir(tmp_path)
70
+ (tmp_path / ".env").write_text("EC_API_KEY=key-1\nEC_PG_PW=pw-1\n", encoding="utf-8")
71
+ cfg_file = tmp_path / "config.yaml"
72
+ _write_yaml(cfg_file, CONFIG)
73
+
74
+ cfg = EnvConfig.load(str(cfg_file))
75
+
76
+ assert cfg.data["api"]["key"] == "key-1"
77
+ assert cfg.env_path == str(tmp_path / ".env")
78
+
79
+
80
+ # T-E5
81
+ @pytest.mark.parametrize("ext", ["yaml", "json"])
82
+ def test_dump_keeps_placeholders_and_writes_changed_plain_values(tmp_path, ext, recwarn):
83
+ cfg, _, env_file = _load(tmp_path, "EC_API_KEY=key-1\nEC_PG_PW=pw-1\n")
84
+ cfg.data["api"]["timeout"] = 60
85
+ cfg.data["new"] = "added"
86
+ out = tmp_path / f"out.{ext}"
87
+
88
+ cfg.dump(str(out))
89
+
90
+ text = out.read_text(encoding="utf-8")
91
+ assert "key-1" not in text and "pw-1" not in text
92
+ saved = yaml.safe_load(text) if ext == "yaml" else json.loads(text)
93
+ assert saved == {**CONFIG, "api": {"key": "${EC_API_KEY}", "timeout": 60}, "new": "added"}
94
+ assert not [w for w in recwarn if issubclass(w.category, UserWarning)]
95
+
96
+
97
+ # T-E6
98
+ def test_dump_with_existing_env_file_keeps_placeholder_and_warns_on_changed_secret(tmp_path):
99
+ env_text = "EC_API_KEY=key-1\nEC_PG_PW=pw-1\n"
100
+ cfg, _, env_file = _load(tmp_path, env_text)
101
+ cfg.data["api"]["key"] = "key-2"
102
+ out = tmp_path / "out.yaml"
103
+
104
+ with pytest.warns(UserWarning, match=r"\[api\.key\] changed value not saved"):
105
+ cfg.dump(str(out))
106
+
107
+ assert yaml.safe_load(out.read_text(encoding="utf-8"))["api"]["key"] == "${EC_API_KEY}"
108
+ assert env_file.read_text(encoding="utf-8") == env_text
109
+
110
+
111
+ # T-E7
112
+ def test_dump_without_env_file_creates_it_with_changed_whole_values(tmp_path, monkeypatch):
113
+ monkeypatch.setenv("EC_API_KEY", "key-1")
114
+ monkeypatch.setenv("EC_PG_PW", "pw-1")
115
+ cfg, _, env_file = _load(tmp_path) # no .env
116
+ cfg.data["api"]["key"] = "key-2"
117
+ cfg.data["db"]["url"] = "postgres://u:pw-2@host/db"
118
+ out = tmp_path / "out.yaml"
119
+
120
+ with pytest.warns(UserWarning, match=r"\[db\.url\] changed value not saved") as caught:
121
+ cfg.dump(str(out))
122
+
123
+ assert len([w for w in caught if issubclass(w.category, UserWarning)]) == 1
124
+ assert yaml.safe_load(out.read_text(encoding="utf-8")) == CONFIG
125
+ from dotenv import dotenv_values
126
+ assert dotenv_values(env_file) == {"EC_API_KEY": "key-2", "EC_PG_PW": "pw-1"}
File without changes
File without changes
File without changes