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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")