Skip to content

Commit 5f7b697

Browse files
committed
feat: CallbackManager 별도의 모듈로 분리
1 parent 7a3d1fa commit 5f7b697

3 files changed

Lines changed: 279 additions & 267 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import logging
5+
import typing
6+
from collections import defaultdict
7+
from collections.abc import Callable, Coroutine
8+
from contextlib import asynccontextmanager
9+
from typing import (
10+
Any,
11+
NamedTuple,
12+
TypeVar,
13+
overload,
14+
)
15+
16+
from ..base import logger
17+
18+
CallableT = TypeVar("CallableT", bound=Callable)
19+
LogLevel = typing.Literal["debug", "info", "warning", "error", "critical"] | int
20+
21+
22+
class Callback(NamedTuple):
23+
function: Callable
24+
is_async: bool
25+
replace_default: bool
26+
use_task: bool | None = None
27+
28+
29+
class CallbackManager:
30+
"""콜백을 관리합니다."""
31+
32+
def __init__(self, default_context: dict | None = None):
33+
self.callbacks: defaultdict[str, list[Callback]] = defaultdict(list)
34+
self.default_context = default_context or {}
35+
36+
def default(
37+
self,
38+
message: str | Callable | None = None,
39+
extra_context: dict | None = None,
40+
*,
41+
func: Callable | None = None,
42+
level: LogLevel = "info",
43+
progress_update: str | Callable | None = None,
44+
log_with_progress: bool = False,
45+
is_async: bool = False,
46+
use_task: bool = False,
47+
) -> Callback:
48+
"""기본 콜백을 생성합니다."""
49+
50+
if func is not None:
51+
return Callback(
52+
func,
53+
is_async=is_async,
54+
use_task=use_task,
55+
replace_default=False, # no-op
56+
)
57+
58+
if isinstance(level, str):
59+
level = logging._nameToLevel[level.upper()]
60+
61+
if is_async:
62+
async def log(**context): # type: ignore
63+
nonlocal extra_context
64+
extra_context = extra_context or {}
65+
66+
self = context["scraper"]
67+
if self.use_progress_bar and progress_update is not None:
68+
if isinstance(progress_update, str):
69+
log = progress_update.format(**context, **extra_context)
70+
else:
71+
log = await progress_update(**context, **extra_context)
72+
self.progress.update(self.progress_task_id, description=log)
73+
updated = True
74+
else:
75+
updated = False
76+
77+
if message is not None and (not updated or updated and log_with_progress):
78+
if isinstance(message, str):
79+
log = message.format(**context, **extra_context)
80+
else:
81+
log = await message(**context, **extra_context)
82+
logger.log(level, log)
83+
else:
84+
def log(**context):
85+
nonlocal extra_context
86+
extra_context = extra_context or {}
87+
88+
self = context["scraper"]
89+
if self.use_progress_bar and progress_update is not None:
90+
if isinstance(progress_update, str):
91+
log = progress_update.format(**context, **extra_context)
92+
else:
93+
log = progress_update(**context, **extra_context)
94+
self.progress.update(self.progress_task_id, description=log)
95+
updated = True
96+
else:
97+
updated = False
98+
99+
if message is not None and (not updated or updated and log_with_progress):
100+
if isinstance(message, str):
101+
log = message.format(**context, **extra_context)
102+
else:
103+
log = message(**context, **extra_context)
104+
logger.log(level, log)
105+
106+
return Callback(
107+
log,
108+
is_async=is_async,
109+
use_task=use_task,
110+
replace_default=False, # no-op
111+
)
112+
113+
@overload
114+
def register_async(self, trigger: str, func: CallableT, *, replace_default: bool = False, blocking: bool = True) -> CallableT: ...
115+
116+
@overload
117+
def register_async(self, trigger: str, *, replace_default: bool = False, blocking: bool = True) -> Callable[[CallableT], CallableT]: ...
118+
119+
def register_async(self, trigger: str, func: Callable[..., Coroutine] | None = None, *, replace_default: bool = False, blocking: bool = True) -> Any:
120+
"""특정 callback 트리거가 발생했을 때 실행할 비동기 콜백을 등록합니다."""
121+
if func is None:
122+
return lambda func: self.register_async(trigger, func, replace_default=replace_default, blocking=blocking)
123+
124+
# blocking으로 할지 말지를 callback을 등록할 때 해야 할까, 아님 부를 때 결정해야 할까?
125+
# 실례를 한번 봐야 할 것 같은데 아직은 잘 모르겠다.
126+
# 일단 지금은 callback을 등록할 때 결정하는 것으로 한다.
127+
self.callbacks[trigger].append(Callback(func, is_async=True, replace_default=replace_default, use_task=not blocking))
128+
return func
129+
130+
def remove(self, trigger: str, func_or_callback: Callable | Callback) -> None:
131+
if isinstance(func_or_callback, Callback):
132+
self.callbacks[trigger].remove(func_or_callback)
133+
else:
134+
self.callbacks[trigger][:] = (callback for callback in self.callbacks[trigger] if callback.function is not func_or_callback)
135+
136+
@overload
137+
def register(self, trigger: str, func: CallableT, *, replace_default: bool = False) -> CallableT: ...
138+
139+
@overload
140+
def register(self, trigger: str, *, log_format: str, log_level: typing.Literal["info", "warning", "error", "critical"] | int = "info", replace_default: bool = False) -> None: ...
141+
142+
@overload
143+
def register(self, trigger: str, *, replace_default: bool = False) -> Callable[[CallableT], CallableT]: ...
144+
145+
def register(
146+
self,
147+
trigger: str,
148+
func: Callable | None = None,
149+
*,
150+
log_format: str | None = None,
151+
log_level: LogLevel = "info",
152+
replace_default: bool = False,
153+
):
154+
"""특정 callback 트리거가 발생했을 때 실행할 콜백을 등록합니다.
155+
156+
Example:
157+
```python
158+
scraper = Scraper.from_url(...)
159+
@scraper.register_callback("setup"):
160+
def startup_message(scraper: Scraper, finishing: bool, **context):
161+
if not finishing:
162+
print("Download has been started!")
163+
scraper.download_webtoon()
164+
165+
# output:
166+
# ...
167+
# Download has been started!
168+
# ...
169+
```
170+
171+
Note:
172+
이 메서드는 메서드로도 데코레이터로도 사용될 수 있습니다.
173+
callback과 마찬가지로 등록된 함수들도 진행을 멈추고 호출되니 지연되지 않도록 주의해야 합니다.
174+
175+
Args:
176+
trigger (str): callback을 실행할 명령어를 결정합니다.
177+
func (Callable, optional): 이 인자는 설정되지 않을 수 있으며, 설정되지 않을 경우 데코레이터로서 사용할 수 있습니다.
178+
replace_default (bool, optional): 기본으로 설정되어 있는 callback을 대체할 것인지 설정합니다. True로 설정할 경우 기존 callback은 실행되지 않습니다.
179+
"""
180+
if func is None and log_format is None:
181+
return lambda func: self.register(trigger, func, replace_default=replace_default)
182+
183+
if log_format is not None:
184+
if isinstance(log_level, str):
185+
log_level = logging._nameToLevel[log_level.upper()]
186+
func = lambda scraper, **context: logger.log(log_level, log_format.format(context)) # noqa: E731
187+
188+
self.callbacks[trigger].append(Callback(func, is_async=False, replace_default=replace_default)) # type: ignore
189+
return func
190+
191+
async def async_callback(
192+
self,
193+
situation: str,
194+
default_callback: Callback | None = None,
195+
**context,
196+
) -> list[asyncio.Task] | None:
197+
# async_callback이 callback을 부르지 않으니 둘 다 수정하도록 할 것
198+
# async_callback이 더 상위 개념이고 async_callback이
199+
# callback도 부를 수 있으니 async_callback을 사용할 수 있는 순간에는
200+
# 무조건 async_callback을 사용할 것.
201+
skip_default = False
202+
tasks = []
203+
if callbacks := self.callbacks.get(situation):
204+
for callback in callbacks:
205+
if callback.is_async:
206+
if callback.use_task:
207+
# task가 제대로 종료되는지 확인하는 것은 caller의 몫
208+
task = asyncio.create_task(callback.function(**self.default_context, **context))
209+
tasks.append(task)
210+
else:
211+
await callback.function(**self.default_context, **context)
212+
else:
213+
callback.function(**self.default_context, **context)
214+
if callback.replace_default:
215+
skip_default = True
216+
217+
if not skip_default and default_callback is not None:
218+
if default_callback.is_async:
219+
await default_callback.function(**self.default_context, **context)
220+
else:
221+
default_callback.function(**self.default_context, **context)
222+
223+
if context:
224+
logger.debug(f"{situation}: {context}")
225+
else:
226+
logger.debug(f"{situation}:")
227+
228+
return tasks or None
229+
230+
def callback(
231+
self,
232+
situation: str,
233+
default_callback: Callback | None = None,
234+
**context,
235+
) -> None:
236+
# async_callback이 callback을 부르지 않으니 둘 다 수정하도록 할 것
237+
skip_default = False
238+
if callbacks := self.callbacks.get(situation):
239+
for callback in callbacks:
240+
if callback.is_async:
241+
logger.error("An registered async callback is ignored. This callback does not support async callbacks.")
242+
continue # callback이 실행되지 않을 경우 skip_callback을 enable하지 않음
243+
else:
244+
callback.function(**self.default_context, **context)
245+
if callback.replace_default:
246+
skip_default = True
247+
248+
if not skip_default and default_callback is not None:
249+
if default_callback.is_async:
250+
logger.error("A default async callback is ignored. This callback does not support async callbacks.")
251+
else:
252+
default_callback.function(**self.default_context, **context)
253+
254+
if context:
255+
logger.debug(f"{situation}: {context}")
256+
else:
257+
logger.debug(f"{situation}:")
258+
259+
# TODO: 실제로 유용하게 사용될 수 있는지 분석하기
260+
@asynccontextmanager
261+
async def with_context(self, context: dict | None = None):
262+
context = context or {}
263+
yield lambda *args, **kwargs: self.async_callback(*args, **context, **kwargs)
264+
265+
@asynccontextmanager
266+
async def context(self, context_name: str, *, start_default: Callback | None = None, end_default: Callback | None = None, **contexts):
267+
await self.async_callback(context_name, start_default, finishing=False, **contexts)
268+
end_contexts: dict = dict(finishing=True, is_successful=True)
269+
yield end_contexts
270+
await self.async_callback(context_name, end_default, **end_contexts)

WebtoonScraper/scrapers/_helpers.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@
33
import asyncio
44
import functools
55
import json
6-
import logging
76
from collections.abc import Iterable
87
from pathlib import Path
9-
from typing import TYPE_CHECKING, NamedTuple, Self
10-
from collections.abc import Callable
11-
import typing
8+
from typing import TYPE_CHECKING, Self
129

1310
from WebtoonScraper.exceptions import AuthenticationError
1411
import filetype
@@ -17,9 +14,7 @@
1714
if TYPE_CHECKING:
1815
from WebtoonScraper.scrapers._scraper import Scraper
1916

20-
from ..base import __version__ as version, logger
21-
22-
LogLevel = typing.Literal["debug", "info", "warning", "error", "critical"] | int
17+
from ..base import __version__ as version
2318

2419

2520
class ExtraInfoScraper:
@@ -204,13 +199,6 @@ def from_string(cls, episode_range: str, inclusive: bool = True) -> Self:
204199
return self
205200

206201

207-
class Callback(NamedTuple):
208-
function: Callable
209-
is_async: bool
210-
replace_default: bool
211-
use_task: bool | None = None
212-
213-
214202
class BearerMixin:
215203
@property
216204
def bearer(self) -> str | None:

0 commit comments

Comments
 (0)