rainbow-rb-utils 0.0.9.dev5__py3-none-any.whl

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.
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")
@@ -0,0 +1,52 @@
1
+ import re
2
+ import sys
3
+ from pathlib import Path
4
+ from urllib.parse import quote
5
+
6
+
7
+ def content_disposition_header(filename: str) -> str:
8
+ safe_fname = filename.replace('"', "'")
9
+ quoted = quote(filename, safe="")
10
+ return f"attachment; filename=\"{safe_fname}\"; filename*=UTF-8''{quoted}"
11
+
12
+
13
+ def sanitize_filename(
14
+ name: str,
15
+ default: str = "export.csv",
16
+ max_name_len: int = 100,
17
+ ) -> str:
18
+ if not name or not isinstance(name, str):
19
+ return default
20
+
21
+ name = name.replace("\x00", "").strip()
22
+ name = re.sub(r'[<>:"/\\|?*\x00-\x1F]', "_", name)
23
+
24
+ if len(name) > max_name_len:
25
+ name = name[:max_name_len]
26
+
27
+ return name
28
+
29
+
30
+ def get_env_path() -> Path | None:
31
+ """현재 파일 기준으로 위로 올라가면서 .env 를 찾는다."""
32
+ # PyInstaller로 빌드된 경우: _MEIPASS 기준
33
+ meipass = getattr(sys, "_MEIPASS", None)
34
+ if meipass:
35
+ base = Path(meipass)
36
+ candidate = base / ".env"
37
+ if candidate.exists():
38
+ return candidate
39
+
40
+ # 개발 환경: __file__ 기준으로 위로 타고 올라가며 .env 검색
41
+ cur = Path(__file__).resolve()
42
+
43
+ for p in [cur, *cur.parents]:
44
+ env_path = p / ".env"
45
+ if "packages" in env_path.as_posix():
46
+ continue
47
+
48
+ if env_path.is_file():
49
+ return env_path
50
+
51
+ # 못 찾으면 None
52
+ return None