rainbow-rb-utils 0.0.9.dev5__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.
- rainbow_rb_utils-0.0.9.dev5/PKG-INFO +139 -0
- rainbow_rb_utils-0.0.9.dev5/README.md +129 -0
- rainbow_rb_utils-0.0.9.dev5/pyproject.toml +29 -0
- rainbow_rb_utils-0.0.9.dev5/setup.cfg +4 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/__init__.py +0 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/asyncio_helper.py +167 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/date.py +221 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/file.py +52 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/flow_manager.py +400 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/helper.py +28 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/manipulate.py +202 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/pagination.py +38 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/parser.py +165 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/py.typed +0 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow/rb_utils/service_exception.py +37 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow_rb_utils.egg-info/PKG-INFO +139 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow_rb_utils.egg-info/SOURCES.txt +18 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow_rb_utils.egg-info/dependency_links.txt +1 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow_rb_utils.egg-info/requires.txt +2 -0
- rainbow_rb_utils-0.0.9.dev5/src/rainbow_rb_utils.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rainbow-rb-utils
|
|
3
|
+
Version: 0.0.9.dev5
|
|
4
|
+
Summary: Rainbow Python Utilities
|
|
5
|
+
Author-email: Derek <dfd1123@rainbow-robotics.com>
|
|
6
|
+
Requires-Python: <3.13,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: psutil<8.0.0,>=7.0.0
|
|
9
|
+
Requires-Dist: rainbow-rb-log==0.0.9.dev5
|
|
10
|
+
|
|
11
|
+
# rb_utils
|
|
12
|
+
|
|
13
|
+
`rb_utils`는 백엔드 전반에서 재사용하는 유틸리티 모음 패키지입니다.
|
|
14
|
+
|
|
15
|
+
## 모듈/함수 상세
|
|
16
|
+
|
|
17
|
+
## 1) `rb_utils.parser`
|
|
18
|
+
|
|
19
|
+
- `snake_to_camel(s: str) -> str`: snake_case 문자열을 CamelCase 형태로 변환
|
|
20
|
+
- `camel_to_snake(s: str) -> str`: CamelCase 문자열을 snake_case로 변환
|
|
21
|
+
- `t_to_dict(obj: Any) -> Any`: 중첩 객체(FlatBuffer T, dict/list/tuple/set, bytes, numpy 배열)를 Python 기본 타입으로 재귀 변환
|
|
22
|
+
- `_normalize_filter(filter_: Any) -> dict`: `None/dict/JSON 문자열`을 공통 dict로 정규화
|
|
23
|
+
- `to_json(obj: Any) -> str`: `t_to_dict` 결과를 JSON 문자열로 직렬화
|
|
24
|
+
- `to_iso(val: str | datetime) -> str`: datetime이면 ISO 문자열, 문자열이면 그대로 반환
|
|
25
|
+
|
|
26
|
+
예시:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from rb_utils.parser import t_to_dict, to_json
|
|
30
|
+
|
|
31
|
+
payload_dict = t_to_dict(obj_payload)
|
|
32
|
+
payload_json = to_json(obj_payload)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 2) `rb_utils.flow_manager`
|
|
36
|
+
|
|
37
|
+
- `safe_eval_expr(expr, variables=None, get_global_variable=None) -> Any`: 제한된 AST 규칙으로 안전 평가
|
|
38
|
+
- `_pick_target_loop(func, provided) -> asyncio.AbstractEventLoop | None`: callable 실행 대상 loop 선택
|
|
39
|
+
- `call_with_matching_args(func, **provided) -> Any`: 함수 시그니처에 맞춰 인자만 골라 호출 (sync/async 공통)
|
|
40
|
+
- `eval_value(value, variables=None, get_global_variable=None) -> Any`: 문자열/리스트/튜플/딕셔너리를 재귀 평가
|
|
41
|
+
- `make_builtins_allow_most() -> dict`: 위험 builtins 제외한 builtins dict 생성
|
|
42
|
+
|
|
43
|
+
예시:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from rb_utils.flow_manager import eval_value
|
|
47
|
+
|
|
48
|
+
value = eval_value("a + 10", variables={"local": {"a": 5}, "global": {}})
|
|
49
|
+
# value == 15
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 3) `rb_utils.asyncio_helper`
|
|
53
|
+
|
|
54
|
+
- `spawn(coro, *, name=None, log_ex=True)`: 현재 loop에 task 생성 후 완료/예외 로깅
|
|
55
|
+
- `_ensure_private_loop() -> asyncio.AbstractEventLoop`: 전용 백그라운드 loop 확보
|
|
56
|
+
- `fire_and_log(coro, *, name=None, loop=None)`: 현재 loop/외부 loop/전용 loop에 안전하게 실행
|
|
57
|
+
- `_attach_done_logging(fut_or_task, *, name=None)`: Future/Task 완료 시 예외 로깅 callback 연결
|
|
58
|
+
|
|
59
|
+
예시:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from rb_utils.asyncio_helper import fire_and_log
|
|
63
|
+
|
|
64
|
+
fire_and_log(socket_client.emit("muscat/program/log", {"msg": "hello"}), name="program-log")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 4) `rb_utils.service_exception`
|
|
68
|
+
|
|
69
|
+
- `class ServiceException(Exception)`:
|
|
70
|
+
- 필드: `message: str`, `status_code: int | None`
|
|
71
|
+
- `__str__()`: `message (status=...)` 형태 출력
|
|
72
|
+
- `to_dict()`: `{message, status_code}` 반환
|
|
73
|
+
|
|
74
|
+
예시:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from rb_utils.service_exception import ServiceException
|
|
78
|
+
|
|
79
|
+
raise ServiceException("invalid request", status_code=400)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 5) `rb_utils.date`
|
|
83
|
+
|
|
84
|
+
- `is_valid_date(d)`: `date/str/int(timestamp)` 유효성 검사
|
|
85
|
+
- `timestamp_ms_to_seconds(timestamp, tz=UTC)`: ms/seconds timestamp를 seconds로 정규화
|
|
86
|
+
- `is_has_time(s: str)`: 문자열에 시간 정보 포함 여부 판단
|
|
87
|
+
- `parse_date_with_time(mode, d, tz=UTC)`: 날짜/문자열/타임스탬프를 timezone-aware datetime으로 변환
|
|
88
|
+
- `ensure_datetime(dt, tz)`: `date | datetime`을 지정 tz의 datetime으로 보정
|
|
89
|
+
- `_tz(tz_name)`: timezone 캐시 조회
|
|
90
|
+
- `convert_dt(local, in_tz, out_tz)`: 입력 tz 기준 datetime/date를 출력 tz로 변환
|
|
91
|
+
- `get_current_dt_yyyymmddhhmmss(tz)`: 현재 시각을 `YYYYMMDDHHMMSS` 문자열 반환
|
|
92
|
+
|
|
93
|
+
예시:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from rb_utils.date import parse_date_with_time
|
|
97
|
+
from datetime import UTC
|
|
98
|
+
|
|
99
|
+
dt = parse_date_with_time("start", "2026-02-25", tz=UTC)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 6) `rb_utils.file`
|
|
103
|
+
|
|
104
|
+
- `content_disposition_header(filename) -> str`: 다운로드용 `Content-Disposition` 헤더 생성
|
|
105
|
+
- `sanitize_filename(name, default="export.csv", max_name_len=100) -> str`: OS 금지문자 제거/길이 제한
|
|
106
|
+
- `get_env_path() -> Path | None`: 실행 위치 기준으로 `.env` 파일 탐색
|
|
107
|
+
|
|
108
|
+
예시:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from rb_utils.file import sanitize_filename
|
|
112
|
+
|
|
113
|
+
safe_name = sanitize_filename("../../report?.csv")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 7) `rb_utils.pagination`
|
|
117
|
+
|
|
118
|
+
- `class PageInfo(TypedDict)`: 페이지 응답 메타 타입 (`mode/page/pages/limit/total/sort/order`)
|
|
119
|
+
- `class LogsResponse(TypedDict)`: 로그 목록 응답 타입 (`items`, `pageInfo`)
|
|
120
|
+
|
|
121
|
+
## 8) `rb_utils.helper`
|
|
122
|
+
|
|
123
|
+
- `ManipulateZenohResHelper(obj)`: Zenoh 응답 객체 보정 유틸 의도 함수
|
|
124
|
+
|
|
125
|
+
주의:
|
|
126
|
+
- 현재 구현은 dict 처리가 아닌 `.hasAttr/.set` 접근과 즉시 `ValueError`를 포함하므로, 사용 전 동작 검증이 필요합니다.
|
|
127
|
+
|
|
128
|
+
## 실무 사용 패턴
|
|
129
|
+
|
|
130
|
+
- FlatBuffer/SDK 응답 평탄화: `t_to_dict`
|
|
131
|
+
- Flow step 동적 인자 평가: `eval_value`, `safe_eval_expr`
|
|
132
|
+
- 비동기 fire-and-forget + 예외 로깅: `fire_and_log`
|
|
133
|
+
- 서비스 레이어 공통 예외: `ServiceException`
|
|
134
|
+
- 파일 다운로드/이름 정리: `content_disposition_header`, `sanitize_filename`
|
|
135
|
+
|
|
136
|
+
## 공통 주의사항
|
|
137
|
+
|
|
138
|
+
- `flow_manager` 평가기는 허용된 연산/함수만 실행합니다.
|
|
139
|
+
- timezone 변환 함수는 `Asia/Seoul` 같은 IANA tz 문자열을 명시해야 합니다.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# rb_utils
|
|
2
|
+
|
|
3
|
+
`rb_utils`는 백엔드 전반에서 재사용하는 유틸리티 모음 패키지입니다.
|
|
4
|
+
|
|
5
|
+
## 모듈/함수 상세
|
|
6
|
+
|
|
7
|
+
## 1) `rb_utils.parser`
|
|
8
|
+
|
|
9
|
+
- `snake_to_camel(s: str) -> str`: snake_case 문자열을 CamelCase 형태로 변환
|
|
10
|
+
- `camel_to_snake(s: str) -> str`: CamelCase 문자열을 snake_case로 변환
|
|
11
|
+
- `t_to_dict(obj: Any) -> Any`: 중첩 객체(FlatBuffer T, dict/list/tuple/set, bytes, numpy 배열)를 Python 기본 타입으로 재귀 변환
|
|
12
|
+
- `_normalize_filter(filter_: Any) -> dict`: `None/dict/JSON 문자열`을 공통 dict로 정규화
|
|
13
|
+
- `to_json(obj: Any) -> str`: `t_to_dict` 결과를 JSON 문자열로 직렬화
|
|
14
|
+
- `to_iso(val: str | datetime) -> str`: datetime이면 ISO 문자열, 문자열이면 그대로 반환
|
|
15
|
+
|
|
16
|
+
예시:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from rb_utils.parser import t_to_dict, to_json
|
|
20
|
+
|
|
21
|
+
payload_dict = t_to_dict(obj_payload)
|
|
22
|
+
payload_json = to_json(obj_payload)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 2) `rb_utils.flow_manager`
|
|
26
|
+
|
|
27
|
+
- `safe_eval_expr(expr, variables=None, get_global_variable=None) -> Any`: 제한된 AST 규칙으로 안전 평가
|
|
28
|
+
- `_pick_target_loop(func, provided) -> asyncio.AbstractEventLoop | None`: callable 실행 대상 loop 선택
|
|
29
|
+
- `call_with_matching_args(func, **provided) -> Any`: 함수 시그니처에 맞춰 인자만 골라 호출 (sync/async 공통)
|
|
30
|
+
- `eval_value(value, variables=None, get_global_variable=None) -> Any`: 문자열/리스트/튜플/딕셔너리를 재귀 평가
|
|
31
|
+
- `make_builtins_allow_most() -> dict`: 위험 builtins 제외한 builtins dict 생성
|
|
32
|
+
|
|
33
|
+
예시:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from rb_utils.flow_manager import eval_value
|
|
37
|
+
|
|
38
|
+
value = eval_value("a + 10", variables={"local": {"a": 5}, "global": {}})
|
|
39
|
+
# value == 15
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 3) `rb_utils.asyncio_helper`
|
|
43
|
+
|
|
44
|
+
- `spawn(coro, *, name=None, log_ex=True)`: 현재 loop에 task 생성 후 완료/예외 로깅
|
|
45
|
+
- `_ensure_private_loop() -> asyncio.AbstractEventLoop`: 전용 백그라운드 loop 확보
|
|
46
|
+
- `fire_and_log(coro, *, name=None, loop=None)`: 현재 loop/외부 loop/전용 loop에 안전하게 실행
|
|
47
|
+
- `_attach_done_logging(fut_or_task, *, name=None)`: Future/Task 완료 시 예외 로깅 callback 연결
|
|
48
|
+
|
|
49
|
+
예시:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from rb_utils.asyncio_helper import fire_and_log
|
|
53
|
+
|
|
54
|
+
fire_and_log(socket_client.emit("muscat/program/log", {"msg": "hello"}), name="program-log")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 4) `rb_utils.service_exception`
|
|
58
|
+
|
|
59
|
+
- `class ServiceException(Exception)`:
|
|
60
|
+
- 필드: `message: str`, `status_code: int | None`
|
|
61
|
+
- `__str__()`: `message (status=...)` 형태 출력
|
|
62
|
+
- `to_dict()`: `{message, status_code}` 반환
|
|
63
|
+
|
|
64
|
+
예시:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from rb_utils.service_exception import ServiceException
|
|
68
|
+
|
|
69
|
+
raise ServiceException("invalid request", status_code=400)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 5) `rb_utils.date`
|
|
73
|
+
|
|
74
|
+
- `is_valid_date(d)`: `date/str/int(timestamp)` 유효성 검사
|
|
75
|
+
- `timestamp_ms_to_seconds(timestamp, tz=UTC)`: ms/seconds timestamp를 seconds로 정규화
|
|
76
|
+
- `is_has_time(s: str)`: 문자열에 시간 정보 포함 여부 판단
|
|
77
|
+
- `parse_date_with_time(mode, d, tz=UTC)`: 날짜/문자열/타임스탬프를 timezone-aware datetime으로 변환
|
|
78
|
+
- `ensure_datetime(dt, tz)`: `date | datetime`을 지정 tz의 datetime으로 보정
|
|
79
|
+
- `_tz(tz_name)`: timezone 캐시 조회
|
|
80
|
+
- `convert_dt(local, in_tz, out_tz)`: 입력 tz 기준 datetime/date를 출력 tz로 변환
|
|
81
|
+
- `get_current_dt_yyyymmddhhmmss(tz)`: 현재 시각을 `YYYYMMDDHHMMSS` 문자열 반환
|
|
82
|
+
|
|
83
|
+
예시:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from rb_utils.date import parse_date_with_time
|
|
87
|
+
from datetime import UTC
|
|
88
|
+
|
|
89
|
+
dt = parse_date_with_time("start", "2026-02-25", tz=UTC)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 6) `rb_utils.file`
|
|
93
|
+
|
|
94
|
+
- `content_disposition_header(filename) -> str`: 다운로드용 `Content-Disposition` 헤더 생성
|
|
95
|
+
- `sanitize_filename(name, default="export.csv", max_name_len=100) -> str`: OS 금지문자 제거/길이 제한
|
|
96
|
+
- `get_env_path() -> Path | None`: 실행 위치 기준으로 `.env` 파일 탐색
|
|
97
|
+
|
|
98
|
+
예시:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from rb_utils.file import sanitize_filename
|
|
102
|
+
|
|
103
|
+
safe_name = sanitize_filename("../../report?.csv")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## 7) `rb_utils.pagination`
|
|
107
|
+
|
|
108
|
+
- `class PageInfo(TypedDict)`: 페이지 응답 메타 타입 (`mode/page/pages/limit/total/sort/order`)
|
|
109
|
+
- `class LogsResponse(TypedDict)`: 로그 목록 응답 타입 (`items`, `pageInfo`)
|
|
110
|
+
|
|
111
|
+
## 8) `rb_utils.helper`
|
|
112
|
+
|
|
113
|
+
- `ManipulateZenohResHelper(obj)`: Zenoh 응답 객체 보정 유틸 의도 함수
|
|
114
|
+
|
|
115
|
+
주의:
|
|
116
|
+
- 현재 구현은 dict 처리가 아닌 `.hasAttr/.set` 접근과 즉시 `ValueError`를 포함하므로, 사용 전 동작 검증이 필요합니다.
|
|
117
|
+
|
|
118
|
+
## 실무 사용 패턴
|
|
119
|
+
|
|
120
|
+
- FlatBuffer/SDK 응답 평탄화: `t_to_dict`
|
|
121
|
+
- Flow step 동적 인자 평가: `eval_value`, `safe_eval_expr`
|
|
122
|
+
- 비동기 fire-and-forget + 예외 로깅: `fire_and_log`
|
|
123
|
+
- 서비스 레이어 공통 예외: `ServiceException`
|
|
124
|
+
- 파일 다운로드/이름 정리: `content_disposition_header`, `sanitize_filename`
|
|
125
|
+
|
|
126
|
+
## 공통 주의사항
|
|
127
|
+
|
|
128
|
+
- `flow_manager` 평가기는 허용된 연산/함수만 실행합니다.
|
|
129
|
+
- timezone 변환 함수는 `Asia/Seoul` 같은 IANA tz 문자열을 명시해야 합니다.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rainbow-rb-utils"
|
|
7
|
+
version = "0.0.9.dev5"
|
|
8
|
+
requires-python = ">=3.12,<3.13"
|
|
9
|
+
description = "Rainbow Python Utilities"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Derek", email = "dfd1123@rainbow-robotics.com" },
|
|
12
|
+
]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
|
|
15
|
+
dependencies = [
|
|
16
|
+
"psutil>=7.0.0,<8.0.0",
|
|
17
|
+
"rainbow-rb-log==0.0.9.dev5",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
package-dir = {"" = "src"}
|
|
22
|
+
include-package-data = true
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
include = ["rainbow.rb_utils*"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
"*" = ["py.typed"]
|
|
File without changes
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import (
|
|
6
|
+
Awaitable,
|
|
7
|
+
Coroutine,
|
|
8
|
+
)
|
|
9
|
+
from concurrent.futures import Future
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rainbow.rb_log.log import (
|
|
13
|
+
rb_log,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_running: set[asyncio.Task] = set()
|
|
17
|
+
|
|
18
|
+
_priv_loop = None
|
|
19
|
+
_priv_thread = None
|
|
20
|
+
_default_loop: asyncio.AbstractEventLoop | None = None
|
|
21
|
+
_error_log_mtx = threading.Lock()
|
|
22
|
+
_last_error_log_ts: dict[str, float] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def spawn(coro, *, name=None, log_ex=True):
|
|
26
|
+
t = asyncio.create_task(coro, name=name)
|
|
27
|
+
_running.add(t)
|
|
28
|
+
|
|
29
|
+
def _done(task: asyncio.Task):
|
|
30
|
+
_running.discard(task)
|
|
31
|
+
if log_ex:
|
|
32
|
+
try:
|
|
33
|
+
task.result()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
rb_log.error(f"[task:{task.get_name() or id(task)}] error: {e}")
|
|
36
|
+
|
|
37
|
+
t.add_done_callback(_done)
|
|
38
|
+
return t
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def cancel_all_tasks():
|
|
42
|
+
for t in list(_running):
|
|
43
|
+
t.cancel()
|
|
44
|
+
await asyncio.gather(*_running, return_exceptions=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ensure_private_loop() -> asyncio.AbstractEventLoop:
|
|
48
|
+
global _priv_loop, _priv_thread
|
|
49
|
+
if _priv_loop and _priv_loop.is_running():
|
|
50
|
+
return _priv_loop
|
|
51
|
+
loop = asyncio.new_event_loop()
|
|
52
|
+
_priv_loop = loop
|
|
53
|
+
|
|
54
|
+
import threading
|
|
55
|
+
|
|
56
|
+
def _runner():
|
|
57
|
+
asyncio.set_event_loop(loop)
|
|
58
|
+
loop.run_forever()
|
|
59
|
+
|
|
60
|
+
_priv_thread = threading.Thread(target=_runner, name="fire-and-log-loop", daemon=True)
|
|
61
|
+
_priv_thread.start()
|
|
62
|
+
return loop
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def set_default_loop(loop: asyncio.AbstractEventLoop | None) -> None:
|
|
66
|
+
global _default_loop
|
|
67
|
+
_default_loop = loop
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_default_loop() -> asyncio.AbstractEventLoop | None:
|
|
71
|
+
return _default_loop
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _await_to_coroutine(awaitable: Awaitable[Any]) -> Any:
|
|
75
|
+
"""Awaitable을 await하여 결과를 반환하는 coroutine으로 변환"""
|
|
76
|
+
return await awaitable
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fire_and_log(
|
|
80
|
+
coro: Awaitable[Any],
|
|
81
|
+
*,
|
|
82
|
+
name: str | None = None,
|
|
83
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
84
|
+
log_exceptions: bool = True,
|
|
85
|
+
sample_error_interval_sec: float | None = None,
|
|
86
|
+
):
|
|
87
|
+
try:
|
|
88
|
+
running = asyncio.get_running_loop()
|
|
89
|
+
|
|
90
|
+
if loop is not None and loop is not running:
|
|
91
|
+
# run_coroutine_threadsafe는 Coroutine만 받을 수 있으므로 변환
|
|
92
|
+
coroutine: Coroutine[Any, Any, Any] = (
|
|
93
|
+
coro if inspect.iscoroutine(coro) else _await_to_coroutine(coro)
|
|
94
|
+
)
|
|
95
|
+
fut: Future[Any] = asyncio.run_coroutine_threadsafe(coroutine, loop)
|
|
96
|
+
_attach_done_logging(
|
|
97
|
+
fut,
|
|
98
|
+
name=name,
|
|
99
|
+
log_exceptions=log_exceptions,
|
|
100
|
+
sample_error_interval_sec=sample_error_interval_sec,
|
|
101
|
+
)
|
|
102
|
+
return fut
|
|
103
|
+
|
|
104
|
+
task: asyncio.Task = running.create_task(coro, name=name) # type: ignore
|
|
105
|
+
_attach_done_logging(
|
|
106
|
+
task,
|
|
107
|
+
name=name,
|
|
108
|
+
log_exceptions=log_exceptions,
|
|
109
|
+
sample_error_interval_sec=sample_error_interval_sec,
|
|
110
|
+
)
|
|
111
|
+
return task
|
|
112
|
+
except RuntimeError:
|
|
113
|
+
target_loop = loop
|
|
114
|
+
if target_loop is None:
|
|
115
|
+
target_loop = get_default_loop()
|
|
116
|
+
if target_loop is None or not target_loop.is_running():
|
|
117
|
+
target_loop = _ensure_private_loop()
|
|
118
|
+
|
|
119
|
+
# run_coroutine_threadsafe는 Coroutine만 받을 수 있으므로 변환
|
|
120
|
+
coroutine = coro if inspect.iscoroutine(coro) else _await_to_coroutine(coro)
|
|
121
|
+
fut = asyncio.run_coroutine_threadsafe(coroutine, target_loop)
|
|
122
|
+
_attach_done_logging(
|
|
123
|
+
fut,
|
|
124
|
+
name=name,
|
|
125
|
+
log_exceptions=log_exceptions,
|
|
126
|
+
sample_error_interval_sec=sample_error_interval_sec,
|
|
127
|
+
)
|
|
128
|
+
return fut
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _should_log_sampled(*, key: str, interval_sec: float | None) -> bool:
|
|
132
|
+
if interval_sec is None or interval_sec <= 0:
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
now = time.monotonic()
|
|
136
|
+
with _error_log_mtx:
|
|
137
|
+
prev = _last_error_log_ts.get(key)
|
|
138
|
+
if prev is not None and (now - prev) < interval_sec:
|
|
139
|
+
return False
|
|
140
|
+
_last_error_log_ts[key] = now
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _attach_done_logging(
|
|
145
|
+
fut_or_task,
|
|
146
|
+
*,
|
|
147
|
+
name: str | None,
|
|
148
|
+
log_exceptions: bool,
|
|
149
|
+
sample_error_interval_sec: float | None,
|
|
150
|
+
):
|
|
151
|
+
def _done(f):
|
|
152
|
+
try:
|
|
153
|
+
_ = f.result()
|
|
154
|
+
except asyncio.CancelledError:
|
|
155
|
+
if log_exceptions:
|
|
156
|
+
rb_log.info(
|
|
157
|
+
f"[task:{name or getattr(f, 'get_name', lambda: '')() or id(f)}] cancelled"
|
|
158
|
+
)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
if not log_exceptions:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
task_name = name or getattr(f, "get_name", lambda: "")() or str(id(f))
|
|
164
|
+
if _should_log_sampled(key=str(task_name), interval_sec=sample_error_interval_sec):
|
|
165
|
+
rb_log.error(f"[task:{task_name}] crashed, {e}")
|
|
166
|
+
|
|
167
|
+
fut_or_task.add_done_callback(_done)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
[날짜 유틸리티]
|
|
3
|
+
"""
|
|
4
|
+
from datetime import (
|
|
5
|
+
UTC,
|
|
6
|
+
date,
|
|
7
|
+
datetime,
|
|
8
|
+
timezone,
|
|
9
|
+
)
|
|
10
|
+
from functools import (
|
|
11
|
+
lru_cache,
|
|
12
|
+
)
|
|
13
|
+
from typing import Literal
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
_STRPTIME_FORMATS = (
|
|
17
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
18
|
+
"%Y-%m-%d %H:%M:%S%z",
|
|
19
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
20
|
+
"%Y-%m-%d %H:%M:%S",
|
|
21
|
+
"%Y/%m/%d %H:%M:%S",
|
|
22
|
+
"%Y-%m-%dT%H:%M",
|
|
23
|
+
"%Y-%m-%d %H:%M",
|
|
24
|
+
"%Y/%m/%d %H:%M",
|
|
25
|
+
"%Y-%m-%d",
|
|
26
|
+
"%Y/%m/%d",
|
|
27
|
+
"%Y%m%d",
|
|
28
|
+
"%Y%m%d%H%M%S",
|
|
29
|
+
"%Y%m%d%H%M",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_valid_date(d: date | str | int):
|
|
34
|
+
"""
|
|
35
|
+
- d: 날짜 문자열 또는 정수 또는 date
|
|
36
|
+
- 날짜 형식 검증
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(d, int):
|
|
39
|
+
try:
|
|
40
|
+
sec = timestamp_ms_to_seconds(int(d))
|
|
41
|
+
if sec <= 0:
|
|
42
|
+
return False
|
|
43
|
+
datetime.fromtimestamp(sec, tz=UTC)
|
|
44
|
+
return True
|
|
45
|
+
except (ValueError, OverflowError, OSError):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
if isinstance(d, str):
|
|
49
|
+
s = d.strip()
|
|
50
|
+
if not s:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
s2 = s[:-1] + "+00:00" if s.endswith("Z") else s
|
|
55
|
+
datetime.fromisoformat(s2)
|
|
56
|
+
return True
|
|
57
|
+
except (ValueError, OverflowError, OSError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
for fmt in _STRPTIME_FORMATS:
|
|
61
|
+
try:
|
|
62
|
+
datetime.strptime(s, fmt)
|
|
63
|
+
return True
|
|
64
|
+
except (ValueError, OverflowError, OSError):
|
|
65
|
+
continue
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
return bool(isinstance(d, date))
|
|
69
|
+
|
|
70
|
+
def timestamp_ms_to_seconds(timestamp: int, *, tz: timezone = UTC):
|
|
71
|
+
"""
|
|
72
|
+
- timestamp: 밀리초 단위 타임스탬프
|
|
73
|
+
- tz: 타임존 (기본: UTC)
|
|
74
|
+
- 밀리초 단위 타임스탬프를 초 단위 타임스탬프로 변환
|
|
75
|
+
"""
|
|
76
|
+
if timestamp > 10**12:
|
|
77
|
+
return datetime.fromtimestamp(timestamp / 1000.0, tz=tz).timestamp()
|
|
78
|
+
return datetime.fromtimestamp(timestamp, tz=tz).timestamp()
|
|
79
|
+
|
|
80
|
+
def is_has_time(s: str) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
- s: 날짜 문자열
|
|
83
|
+
- 날짜 문자열에 시간이 포함되어 있는지 확인
|
|
84
|
+
"""
|
|
85
|
+
s = s.strip()
|
|
86
|
+
if ":" in s or "T" in s:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
digits = "".join(ch for ch in s if ch.isdigit())
|
|
90
|
+
return len(digits) > 8
|
|
91
|
+
|
|
92
|
+
def parse_date_with_time(
|
|
93
|
+
mode: Literal["start", "end"], d: str | int | datetime, *, tz: timezone = UTC
|
|
94
|
+
):
|
|
95
|
+
"""
|
|
96
|
+
- mode: 날짜 타입
|
|
97
|
+
- d: 날짜 문자열 또는 정수 또는 datetime
|
|
98
|
+
- tz: 타임존 (기본: UTC)
|
|
99
|
+
- 날짜 문자열 또는 정수 또는 datetime를 datetime 객체로 변환
|
|
100
|
+
"""
|
|
101
|
+
if not is_valid_date(d):
|
|
102
|
+
raise ValueError("Invalid date")
|
|
103
|
+
|
|
104
|
+
if isinstance(d, datetime):
|
|
105
|
+
dt = d
|
|
106
|
+
|
|
107
|
+
dt = dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
|
108
|
+
|
|
109
|
+
has_time = not (dt.hour == 0 and dt.minute == 0 and dt.second == 0 and dt.microsecond == 0)
|
|
110
|
+
elif isinstance(d, int):
|
|
111
|
+
sec = timestamp_ms_to_seconds(int(d))
|
|
112
|
+
dt = datetime.fromtimestamp(sec, tz=tz)
|
|
113
|
+
has_time = True
|
|
114
|
+
else:
|
|
115
|
+
s = d.strip()
|
|
116
|
+
parsed = None
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
s2 = s[:-1] + "+00:00" if s.endswith("Z") else s
|
|
120
|
+
parsed = datetime.fromisoformat(s2)
|
|
121
|
+
except (ValueError, OverflowError, OSError):
|
|
122
|
+
parsed = None
|
|
123
|
+
|
|
124
|
+
if parsed is None:
|
|
125
|
+
for fmt in _STRPTIME_FORMATS:
|
|
126
|
+
try:
|
|
127
|
+
parsed = datetime.strptime(s, fmt)
|
|
128
|
+
break
|
|
129
|
+
except (ValueError, OverflowError, OSError):
|
|
130
|
+
parsed = None
|
|
131
|
+
|
|
132
|
+
if parsed is None:
|
|
133
|
+
raise ValueError("Unparseable date string")
|
|
134
|
+
|
|
135
|
+
parsed = parsed.replace(tzinfo=tz) if parsed.tzinfo is None else parsed.astimezone(tz)
|
|
136
|
+
dt = parsed
|
|
137
|
+
|
|
138
|
+
if is_has_time(s):
|
|
139
|
+
has_time = True
|
|
140
|
+
else:
|
|
141
|
+
has_time = not (
|
|
142
|
+
dt.hour == 0 and dt.minute == 0 and dt.second == 0 and dt.microsecond == 0
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not has_time:
|
|
146
|
+
if mode == "start":
|
|
147
|
+
dt = datetime(dt.year, dt.month, dt.day, 0, 0, 0, 0, tzinfo=tz)
|
|
148
|
+
else:
|
|
149
|
+
dt = datetime(dt.year, dt.month, dt.day, 23, 59, 59, 999999, tzinfo=tz)
|
|
150
|
+
|
|
151
|
+
return dt
|
|
152
|
+
|
|
153
|
+
# === Yujin Added ===
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# DateTime으로 변환 (날짜만 들어오면 그날 00:00:00 timezone 시간으로 간주)
|
|
157
|
+
def ensure_datetime(dt: date | datetime, tz:str) -> datetime:
|
|
158
|
+
"""
|
|
159
|
+
- dt: 날짜 또는 datetime
|
|
160
|
+
- tz: 타임존
|
|
161
|
+
- 날짜 또는 datetime를 datetime 객체로 변환
|
|
162
|
+
"""
|
|
163
|
+
if not tz or not isinstance(tz, str):
|
|
164
|
+
raise TypeError("timezone must be provided")
|
|
165
|
+
tz = ZoneInfo(tz)
|
|
166
|
+
|
|
167
|
+
if isinstance(dt, datetime):
|
|
168
|
+
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
|
169
|
+
|
|
170
|
+
if isinstance(dt, date):
|
|
171
|
+
return dt.combine(dt, datetime.min.time()).replace(tzinfo=tz)
|
|
172
|
+
|
|
173
|
+
raise TypeError("dt must be date or datetime")
|
|
174
|
+
|
|
175
|
+
@lru_cache(maxsize=128)
|
|
176
|
+
def _tz(tz_name: str) -> ZoneInfo:
|
|
177
|
+
if not tz_name or not isinstance(tz_name, str):
|
|
178
|
+
raise TypeError("timezone must be a non-empty string (e.g., 'Asia/Seoul').")
|
|
179
|
+
try:
|
|
180
|
+
return ZoneInfo(tz_name)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
raise ValueError(f"Unknown timezone: {tz_name}") from e
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# 날짜를 timezone 변환하여 datetime으로 반환
|
|
186
|
+
def convert_dt(
|
|
187
|
+
local: date | datetime,
|
|
188
|
+
in_tz: str,
|
|
189
|
+
out_tz: str,
|
|
190
|
+
) -> datetime:
|
|
191
|
+
"""
|
|
192
|
+
- local: 날짜 또는 datetime
|
|
193
|
+
- in_tz: 입력 타임존
|
|
194
|
+
- out_tz: 출력 타임존
|
|
195
|
+
- 날짜 또는 datetime를 타임존 변환
|
|
196
|
+
"""
|
|
197
|
+
tz_in = _tz(in_tz)
|
|
198
|
+
tz_out = _tz(out_tz)
|
|
199
|
+
print(f"[convert_dt] tz_in: {tz_in}, tz_out: {tz_out}")
|
|
200
|
+
|
|
201
|
+
# datetime 들어온 경우
|
|
202
|
+
if isinstance(local, datetime):
|
|
203
|
+
dt_in = local.replace(tzinfo=tz_in) if local.tzinfo is None else local.astimezone(tz_in)
|
|
204
|
+
dt_out = dt_in.astimezone(tz_out)
|
|
205
|
+
return dt_out
|
|
206
|
+
|
|
207
|
+
# date 들어온 경우 (날짜만 있을 때)
|
|
208
|
+
if isinstance(local, date):
|
|
209
|
+
# 그 날짜의 00:00:00 in_tz 로 만든 후 out_tz 로 변환
|
|
210
|
+
dt_in = datetime.combine(local, datetime.min.time()).replace(tzinfo=tz_in)
|
|
211
|
+
dt_out = dt_in.astimezone(tz_out)
|
|
212
|
+
return dt_out
|
|
213
|
+
raise TypeError("local must be date or datetime")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_current_dt_yyyymmddhhmmss(tz: str) -> str:
|
|
217
|
+
"""
|
|
218
|
+
- tz: 타임존
|
|
219
|
+
- 현재 날짜+시간을 YYYYMMDDHHMMSS로 변환하여 반환
|
|
220
|
+
"""
|
|
221
|
+
return datetime.now(ZoneInfo(tz)).strftime("%Y%m%d%H%M%s")
|