diff --git a/webtoon_downloader/cmd/cli.py b/webtoon_downloader/cmd/cli.py index b111190..63a5b0e 100644 --- a/webtoon_downloader/cmd/cli.py +++ b/webtoon_downloader/cmd/cli.py @@ -10,17 +10,24 @@ from webtoon_downloader import logger from webtoon_downloader.cmd.exceptions import ( - LatestWithStartOrEndError, - SeparateOptionWithNonImageSaveAsError, + CLIInvalidConcurrentCountError, + CLIInvalidStartAndEndRangeError, + CLILatestWithStartOrEndError, + CLISeparateOptionWithNonImageSaveAsError, handle_deprecated_options, ) from webtoon_downloader.cmd.progress import ChapterProgressManager, init_progress +from webtoon_downloader.core.exceptions import WebtoonDownloadError from webtoon_downloader.core.webtoon.downloaders import comic -from webtoon_downloader.core.webtoon.downloaders.options import StorageType, WebtoonDownloadOptions +from webtoon_downloader.core.webtoon.downloaders.options import ( + DEFAULT_CONCURENT_CHAPTER_DOWNLOADS, + DEFAULT_CONCURENT_IMAGE_DOWNLOADS, + StorageType, + WebtoonDownloadOptions, +) from webtoon_downloader.core.webtoon.exporter import DataExporterFormat from webtoon_downloader.transformers.image import ImageFormat -log, console = logger.setup() help_config = click.RichHelpConfiguration( show_metavars_column=False, append_metavars_help=True, @@ -33,6 +40,13 @@ class GracefulExit(SystemExit): code = 1 +def validate_concurrent_count(ctx: Any, param: Any, value: int | None) -> int | None: + if value is not None and value <= 0: + raise CLIInvalidConcurrentCountError(value) + + return value + + @click.command() @click.version_option() @click.pass_context @@ -44,12 +58,7 @@ class GracefulExit(SystemExit): type=int, help="Start chapter", ) -@click.option( - "--end", - "-e", - type=int, - help="End chapter", -) +@click.option("--end", "-e", type=int, help="End chapter") @click.option( "--latest", "-l", @@ -113,6 +122,21 @@ class GracefulExit(SystemExit): hidden=True, help="[Deprecated] Use --export-metadata instead", ) +@click.option( + "--concurrent-chapters", + type=int, + default=DEFAULT_CONCURENT_CHAPTER_DOWNLOADS, + callback=validate_concurrent_count, + help="Number of workers for concurrent chapter downloads", +) +@click.option( + "--concurrent-pages", + type=int, + default=DEFAULT_CONCURENT_IMAGE_DOWNLOADS, + callback=validate_concurrent_count, + help="Number of workers for concurrent image downloads. This value is shared between all concurrent chapter downloads.", +) +@click.option("--debug", type=bool, is_flag=True, help="Enable debug mode") def cli( ctx: click.Context, url: str, @@ -125,7 +149,16 @@ def cli( export_metadata: bool, export_format: DataExporterFormat, save_as: StorageType, + concurrent_chapters: int, + concurrent_pages: int, + debug: bool, ) -> None: + log, console = logger.setup( + log_filename="webtoon_downloader.log" if debug else None, + enable_traceback=debug, + enable_console_logging=debug, + ) + loop = asyncio.get_event_loop() if not url: console.print( @@ -133,9 +166,11 @@ def cli( ) ctx.exit(1) if latest and (start or end): - raise LatestWithStartOrEndError(ctx) + raise CLILatestWithStartOrEndError(ctx) if separate and (save_as != "images"): - raise SeparateOptionWithNonImageSaveAsError(ctx) + raise CLISeparateOptionWithNonImageSaveAsError(ctx) + if start is not None and end is not None and start > end: + raise CLIInvalidStartAndEndRangeError(ctx) progress = init_progress(console) series_download_task = progress.add_task( @@ -160,6 +195,8 @@ def cli( save_as=save_as, chapter_progress_callback=progress_manager.advance_progress, on_webtoon_fetched=progress_manager.on_webtoon_fetched, + concurrent_chapters=concurrent_chapters, + concurrent_pages=concurrent_pages, ) loop = asyncio.get_event_loop() @@ -181,11 +218,16 @@ def _raise_graceful_exit(*_: Any) -> None: signal.signal(signal.SIGINT, _raise_graceful_exit) signal.signal(signal.SIGTERM, _raise_graceful_exit) with contextlib.suppress(GracefulExit): - loop.run_until_complete(main_task) + try: + loop.run_until_complete(main_task) + except WebtoonDownloadError as exc: + console.print(f"[red][bold]Download error:[/bold] {exc}[/]") + log.exception("Download error") def run() -> None: """CLI entrypoint""" if len(sys.argv) <= 1: sys.argv.append("--help") + cli() # pylint: disable=no-value-for-parameter diff --git a/webtoon_downloader/cmd/exceptions.py b/webtoon_downloader/cmd/exceptions.py index 508a6b2..dfde8d0 100644 --- a/webtoon_downloader/cmd/exceptions.py +++ b/webtoon_downloader/cmd/exceptions.py @@ -5,7 +5,20 @@ import rich_click as click -class LatestWithStartOrEndError(click.UsageError): +class CLIInvalidStartAndEndRangeError(click.UsageError): + """ + This error is raised when the user provides a start that is greater than the end. + + Args: + ctx: The Click context associated with the error, if any. + """ + + def __init__(self, ctx: click.Context | None = None) -> None: + message = "Start chapter cannot be greater than end chapter." + super().__init__(message, ctx) + + +class CLILatestWithStartOrEndError(click.UsageError): """ This error is raised when the user attempts to use --latest in conjunction with either --start or --end options, which is not allowed due to their @@ -20,7 +33,7 @@ def __init__(self, ctx: click.Context | None = None) -> None: super().__init__(message, ctx) -class SeparateOptionWithNonImageSaveAsError(click.UsageError): +class CLISeparateOptionWithNonImageSaveAsError(click.UsageError): """ This error is raised when the user attempts to use --separate with a save-as option other than 'images'. The --separate option is only compatible with @@ -35,7 +48,7 @@ def __init__(self, ctx: click.Context | None = None) -> None: super().__init__(message, ctx) -class DeprecatedOptionError(click.UsageError): +class CLIDeprecatedOptionError(click.UsageError): """ Custom error for handling deprecated options in the CLI. """ @@ -45,9 +58,19 @@ def __init__(self, deprecated_option: str, use_instead_option: str): super().__init__(message) +class CLIInvalidConcurrentCountError(click.BadParameter): + """ + Custom error for handling invalid value for concurrent workers in the CLI. + """ + + def __init__(self, value: Any): + message = f"Invalid value for concurrent workers {value}." + super().__init__(message) + + def handle_deprecated_options(_: click.Context, param: click.Parameter, value: Any) -> None: """Handler for deprecated options""" if param.name == "export_texts" and value: - raise DeprecatedOptionError(deprecated_option="--export-texts", use_instead_option="--export-metadata") + raise CLIDeprecatedOptionError(deprecated_option="--export-texts", use_instead_option="--export-metadata") elif param.name == "dest" and value is not None: - raise DeprecatedOptionError(deprecated_option="--dest", use_instead_option="--out") + raise CLIDeprecatedOptionError(deprecated_option="--dest", use_instead_option="--out") diff --git a/webtoon_downloader/core/downloaders/image.py b/webtoon_downloader/core/downloaders/image.py index adb93d8..8e71ac6 100644 --- a/webtoon_downloader/core/downloaders/image.py +++ b/webtoon_downloader/core/downloaders/image.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import logging from dataclasses import dataclass, field from typing import Awaitable, Callable @@ -9,6 +11,8 @@ from webtoon_downloader.storage import AioWriter from webtoon_downloader.transformers.base import AioImageTransformer +log = logging.getLogger(__name__) + ImageProgressCallback = Callable[[int], Awaitable[None]] """ Progress callback called for each image download. @@ -32,9 +36,15 @@ class ImageDownloadResult: @dataclass class ImageDownloader: client: httpx.AsyncClient + concurent_downloads_limit: int transformers: list[AioImageTransformer] = field(default_factory=list) progress_callback: ImageProgressCallback | None = None + _semaphore: asyncio.Semaphore = field(init=False) + + def __post_init__(self) -> None: + self._semaphore = asyncio.Semaphore(self.concurent_downloads_limit) + async def run(self, url: str, target: str, storage: AioWriter) -> ImageDownloadResult: """ Initiates the downloading of an image from a specified URL. @@ -50,7 +60,8 @@ async def run(self, url: str, target: str, storage: AioWriter) -> ImageDownloadR ImageDownloadError: If an error occurs during the download process. """ try: - return await self._download_image(self.client, url, target, storage) + async with self._semaphore: + return await self._download_image(self.client, url, target, storage) except Exception as exc: raise ImageDownloadError(url=url, cause=exc) from exc diff --git a/webtoon_downloader/core/exceptions.py b/webtoon_downloader/core/exceptions.py index 0bc5237..5d1dd79 100644 --- a/webtoon_downloader/core/exceptions.py +++ b/webtoon_downloader/core/exceptions.py @@ -16,7 +16,20 @@ class DownloadError(Exception): def __str__(self) -> str: if self.message: return self.message - return f'Failed to download from "{self.url}" due to: {self.cause}' + + if self.cause: + cause_msg = str(self.cause) + if cause_msg: + return f"Failed to download from {self.url} => {cause_msg}" + + return f"Failed to download from {self.url} due to: {self.cause.__class__.__name__}" + + return f"Failed to download from {self.url}" + + +@dataclass +class WebtoonDownloadError(DownloadError): + """Exception raised for Webtoon download errors""" @dataclass @@ -31,21 +44,63 @@ class ChapterDownloadError(DownloadError): chapter_info: ChapterInfo | None = None +@dataclass +class WebtoonGetError(Exception): + """Exception raised due to a fetch error when retreiving Webtoon information""" + + series_url: str + status_code: int + + def __str__(self) -> str: + return f"Failed to fetch Webtoon information from {self.series_url}. Status code: {self.status_code}" + + +@dataclass class FetchError(Exception): """Exception raised due to a fetch error""" + msg: str | None = None + +@dataclass class ChapterURLFetchError(FetchError): """Exception raised due to a fetch error when retreiving the chapter URL""" + def __str__(self) -> str: + if self.msg: + return self.msg + + return "Failed to fetch chapter URL" + +@dataclass class ChapterTitleFetchError(FetchError): """Exception raised due to a fetch error when retreiving the chapter title""" + def __str__(self) -> str: + if self.msg: + return self.msg + + return "Failed to fetch chapter title" + +@dataclass class ChapterDataEpisodeNumberFetchError(FetchError): """Exception raised due to a fetch error when retreiving data chapter number""" + def __str__(self) -> str: + if self.msg: + return self.msg + + return "Failed to fetch data episode number" + +@dataclass class SeriesTitleFetchError(FetchError): """Exception raised due to a fetch error when retreiving the series title""" + + def __str__(self) -> str: + if self.msg: + return self.msg + + return "Failed to fetch series title" diff --git a/webtoon_downloader/core/webtoon/downloaders/chapter.py b/webtoon_downloader/core/webtoon/downloaders/chapter.py index 990846d..5e824cf 100644 --- a/webtoon_downloader/core/webtoon/downloaders/chapter.py +++ b/webtoon_downloader/core/webtoon/downloaders/chapter.py @@ -2,7 +2,7 @@ import asyncio import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from os import PathLike from pathlib import Path @@ -27,20 +27,27 @@ class ChapterDownloader: Downloads chapters from a Webtoon. Attributes: - client : HTTP client for making web requests. - image_downloader : Downloader for Webtoon images. - file_name_generator : Generator for file names based on chapter and page details. - exporter : Optional data exporter for exporting chapter details. - progress_callback : Optional callback for reporting chapter download progress. + client : HTTP client for making web requests. + image_downloader : Downloader for Webtoon images. + file_name_generator : Generator for file names based on chapter and page details. + concurrent_downloads_limit : The number of chapters to download concurrently. + exporter : Optional data exporter for exporting chapter details. + progress_callback : Optional callback for reporting chapter download progress. """ client: httpx.AsyncClient image_downloader: ImageDownloader file_name_generator: FileNameGenerator + concurrent_downloads_limit: int exporter: DataExporter | None = None progress_callback: ChapterProgressCallback | None = None + _semaphore: asyncio.Semaphore = field(init=False) + + def __post_init__(self) -> None: + self._semaphore = asyncio.Semaphore(self.concurrent_downloads_limit) + async def run( self, chapter_info: ChapterInfo, directory: str | PathLike[str], storage: AioWriter ) -> list[DownloadResult]: @@ -59,7 +66,8 @@ async def run( ChapterDownloadError in case of error downloading the chapter. """ try: - return await self._run(chapter_info, directory, storage) + async with self._semaphore: + return await self._run(chapter_info, directory, storage) except Exception as exc: raise ChapterDownloadError(chapter_info.viewer_url, exc, chapter_info=chapter_info) from exc @@ -104,7 +112,9 @@ def _create_task(self, chapter_info: ChapterInfo, url: str, name: str, storage: """ async def _task() -> ImageDownloadResult: + log.debug('Downloading: "%s" from "%s" from chapter "%s"', name, url, chapter_info.viewer_url) res = await self.image_downloader.run(url, name, storage) + log.debug('Finished downloading: "%s" from "%s" from chapter "%s"', name, url, chapter_info.viewer_url) await self._report_progress(chapter_info, "PageCompleted") return res diff --git a/webtoon_downloader/core/webtoon/downloaders/comic.py b/webtoon_downloader/core/webtoon/downloaders/comic.py index 0def442..27c1c4f 100644 --- a/webtoon_downloader/core/webtoon/downloaders/comic.py +++ b/webtoon_downloader/core/webtoon/downloaders/comic.py @@ -2,6 +2,7 @@ import asyncio import logging +import re from dataclasses import dataclass, field from os import PathLike from pathlib import Path @@ -12,6 +13,7 @@ from webtoon_downloader.core import file as fileutil from webtoon_downloader.core import webtoon from webtoon_downloader.core.downloaders.image import ImageDownloader +from webtoon_downloader.core.exceptions import WebtoonDownloadError from webtoon_downloader.core.webtoon.downloaders.callbacks import OnWebtoonFetchCallback from webtoon_downloader.core.webtoon.downloaders.chapter import ChapterDownloader from webtoon_downloader.core.webtoon.downloaders.options import StorageType, WebtoonDownloadOptions @@ -26,9 +28,6 @@ log = logging.getLogger(__name__) -DEFAULT_CHAPTER_LIMIT = 8 -"""Default number of asynchronous workers. More == More likely to get server rate limited""" - @dataclass class WebtoonDownloader: @@ -38,15 +37,14 @@ class WebtoonDownloader: Manages the entire process of downloading multiple chapters from a Webtoon series, including fetching chapter details, setting up storage, and handling concurrency. Attributes: - url : URL of the Webtoon series to download. - chapter_downloader : The downloader responsible for individual chapters. - storage_type : The type of storage to use for the downloaded chapters. - start_chapter : The first chapter to download. - end_chapter : The last chapter to download. - concurrent_chapters : The number of chapters to download concurrently. - directory : The directory where the downloaded chapters will be stored. - exporter : Optional data exporter for exporting series details. - on_webtoon_fetched : Optional callback executed after fetching Webtoon information. + url : URL of the Webtoon series to download. + chapter_downloader : The downloader responsible for individual chapters. + storage_type : The type of storage to use for the downloaded chapters. + start_chapter : The first chapter to download. + end_chapter : The last chapter to download. + directory : The directory where the downloaded chapters will be stored. + exporter : Optional data exporter for exporting series details. + on_webtoon_fetched : Optional callback executed after fetching Webtoon information. """ url: str @@ -55,13 +53,16 @@ class WebtoonDownloader: start_chapter: int | None = None end_chapter: int | None | Literal["latest"] = None - concurrent_chapters: int = DEFAULT_CHAPTER_LIMIT directory: str | PathLike[str] | None = None exporter: DataExporter | None = None on_webtoon_fetched: OnWebtoonFetchCallback | None = None _directory: Path = field(init=False) + def __post_init__(self) -> None: + # sanitize url + self.url = re.sub(r"\\(?=[?=&])", "", self.url) + async def run(self) -> list[DownloadResult]: """ Asynchronously downloads chapters from a Webtoon series. @@ -83,17 +84,16 @@ async def run(self) -> list[DownloadResult]: await self._export_data(extractor) - # Semaphore to limit the number of concurrent chapter downloads - semaphore = asyncio.Semaphore(self.concurrent_chapters) tasks = [] for chapter_info in chapter_list: - task = self._create_task(chapter_info, semaphore) + task = self._create_task(chapter_info) tasks.append(task) results = await asyncio.gather(*tasks, return_exceptions=False) if self.exporter: await self.exporter.write_data(self._directory) + return results async def _get_chapters(self, client: httpx.AsyncClient) -> list[ChapterInfo]: @@ -106,14 +106,14 @@ async def _get_chapters(self, client: httpx.AsyncClient) -> list[ChapterInfo]: Returns: A list of `ChapterInfo` objects containing information about each chapter. """ - fetcher = WebtoonFetcher(client) + fetcher = WebtoonFetcher(client, self.url) chapters = await fetcher.get_chapters_details(self.url, self.start_chapter, self.end_chapter) if self.on_webtoon_fetched: await self.on_webtoon_fetched(chapters) return chapters - def _create_task(self, chapter_info: ChapterInfo, semaphore: asyncio.Semaphore) -> asyncio.Task: + def _create_task(self, chapter_info: ChapterInfo) -> asyncio.Task: """ Creates an asynchronous task for downloading a Webtoon chapter. @@ -126,9 +126,8 @@ def _create_task(self, chapter_info: ChapterInfo, semaphore: asyncio.Semaphore) """ async def task() -> list[DownloadResult]: - async with semaphore: - storage = await self._get_storage(chapter_info) - return await self.chapter_downloader.run(chapter_info, self._directory, storage) + storage = await self._get_storage(chapter_info) + return await self.chapter_downloader.run(chapter_info, self._directory, storage) return asyncio.create_task(task()) @@ -180,6 +179,7 @@ async def download_webtoon(opts: WebtoonDownloadOptions) -> list[DownloadResult] image_downloader = ImageDownloader( client=webtoon.client.new_image_client(), transformers=[AioImageFormatTransformer(opts.image_format)], + concurent_downloads_limit=opts.concurrent_pages, ) exporter = DataExporter(opts.exporter_format) if opts.export_metadata else None @@ -189,6 +189,7 @@ async def download_webtoon(opts: WebtoonDownloadOptions) -> list[DownloadResult] progress_callback=opts.chapter_progress_callback, image_downloader=image_downloader, file_name_generator=file_name_generator, + concurrent_downloads_limit=opts.concurrent_chapters, ) end: int | None | Literal["latest"] @@ -207,5 +208,7 @@ async def download_webtoon(opts: WebtoonDownloadOptions) -> list[DownloadResult] exporter=exporter, on_webtoon_fetched=opts.on_webtoon_fetched, ) - - return await downloader.run() + try: + return await downloader.run() + except Exception as exc: + raise WebtoonDownloadError(downloader.url, exc) from exc diff --git a/webtoon_downloader/core/webtoon/downloaders/options.py b/webtoon_downloader/core/webtoon/downloaders/options.py index 5217380..6b1c5bf 100644 --- a/webtoon_downloader/core/webtoon/downloaders/options.py +++ b/webtoon_downloader/core/webtoon/downloaders/options.py @@ -13,6 +13,13 @@ """Valid option for storing the downloaded images.""" +DEFAULT_CONCURENT_IMAGE_DOWNLOADS = 120 +"""Default number of asynchronous image download workers. More == More likely to get server rate limited""" + +DEFAULT_CONCURENT_CHAPTER_DOWNLOADS = 6 +"""Default number of asynchronous chapter download workers. This does not affect rate limiting or download speed since that is all controlled by the number of concurrent image downloads.""" + + @dataclass class WebtoonDownloadOptions: """ @@ -31,6 +38,8 @@ class WebtoonDownloadOptions: image_format : Format to save chapter images. chapter_progress_callback : Callback function for chapter download progress. on_webtoon_fetched : function invoked after fetching Webtoon information. + concurrent_chapters : The number of chapters to download concurrently. + concurrent_pages : The number of images to download concurrently. """ url: str @@ -49,3 +58,6 @@ class WebtoonDownloadOptions: chapter_progress_callback: ChapterProgressCallback | None = None on_webtoon_fetched: OnWebtoonFetchCallback | None = None + + concurrent_chapters: int = DEFAULT_CONCURENT_CHAPTER_DOWNLOADS + concurrent_pages: int = DEFAULT_CONCURENT_IMAGE_DOWNLOADS diff --git a/webtoon_downloader/core/webtoon/fetchers.py b/webtoon_downloader/core/webtoon/fetchers.py index 5792a6f..0839cf2 100644 --- a/webtoon_downloader/core/webtoon/fetchers.py +++ b/webtoon_downloader/core/webtoon/fetchers.py @@ -14,6 +14,7 @@ ChapterTitleFetchError, ChapterURLFetchError, SeriesTitleFetchError, + WebtoonGetError, ) from webtoon_downloader.core.webtoon import client from webtoon_downloader.core.webtoon.models import ChapterInfo @@ -37,13 +38,17 @@ class WebtoonFetcher: Attributes: client: The HTTP client used for making requests to Webtoon. + series_url: The URL of the Webtoon series from which to fetch details. """ client: httpx.AsyncClient + series_url: str - def _convert_url_domain(self, series_url: str, target_subdomain: WebtoonDomain) -> str: + def _convert_url_domain(self, viewer_url: str, target_subdomain: WebtoonDomain) -> str: """Converts the provided Webtoon URL to the specified subdomain (default 'm').""" - f = furl(series_url) + viewer_url = viewer_url.replace("\\", "/") + + f = furl(viewer_url) domain_parts = f.host.split(".") domain_parts = [part for part in domain_parts if part not in [WebtoonDomain.MOBILE, WebtoonDomain.STANDARD]] domain_parts.insert(0, target_subdomain) @@ -55,6 +60,7 @@ def _get_viewer_url(self, tag: Tag) -> str: viewer_url_tag = tag.find("a") if not isinstance(viewer_url_tag, Tag): raise ChapterURLFetchError + return self._convert_url_domain(str(viewer_url_tag["href"]), target_subdomain=WebtoonDomain.STANDARD) def _get_chapter_title(self, tag: Tag) -> str: @@ -62,9 +68,11 @@ def _get_chapter_title(self, tag: Tag) -> str: chapter_details_tag = tag.find("p", class_="sub_title") if not isinstance(chapter_details_tag, Tag): raise ChapterTitleFetchError + chapter_details_tag = chapter_details_tag.find("span", class_="ellipsis") if not isinstance(chapter_details_tag, Tag): raise ChapterTitleFetchError + return chapter_details_tag.text def _get_data_episode_num(self, tag: Tag) -> int: @@ -72,6 +80,7 @@ def _get_data_episode_num(self, tag: Tag) -> int: data_episode_no_tag = tag["data-episode-no"] if not isinstance(data_episode_no_tag, str): raise ChapterDataEpisodeNumberFetchError + return int(data_episode_no_tag) def _get_series_title(self, soup: BeautifulSoup) -> str: @@ -79,6 +88,7 @@ def _get_series_title(self, soup: BeautifulSoup) -> str: series_title_tag = soup.find("p", class_="subj") if not isinstance(series_title_tag, Tag): raise SeriesTitleFetchError + return series_title_tag.text async def get_chapters_details( @@ -104,6 +114,9 @@ async def get_chapters_details( response = await self.client.get( mobile_url, headers={**self.client.headers, "user-agent": client.get_mobile_ua()} ) + if response.status_code != 200: + raise WebtoonGetError(series_url, response.status_code) + soup = BeautifulSoup(response.text, "html.parser") chapter_items: Sequence[Tag] = soup.findAll("li", class_="_episodeItem") series_title = self._get_series_title(soup) diff --git a/webtoon_downloader/logger.py b/webtoon_downloader/logger.py index 338deaa..ac30b81 100644 --- a/webtoon_downloader/logger.py +++ b/webtoon_downloader/logger.py @@ -1,9 +1,8 @@ import asyncio import logging -import tempfile +import sys from logging import FileHandler -from pathlib import Path -from typing import Tuple +from typing import Optional, Tuple import aiofiles import httpx @@ -13,7 +12,12 @@ from rich.logging import RichHandler -def setup() -> Tuple[logging.Logger, Console]: +def setup( + log_level: int = logging.DEBUG, + log_filename: Optional[str] = None, + enable_console_logging: bool = False, + enable_traceback: bool = False, +) -> Tuple[logging.Logger, Console]: """ Sets up the logging system and a rich console for the application. @@ -24,30 +28,38 @@ def setup() -> Tuple[logging.Logger, Console]: The configured logger and the rich console object. """ console = Console() - traceback.install( - console=console, - show_locals=False, - suppress=[ - click, - httpx, - aiofiles, - asyncio, - ], - ) - log = logging.getLogger(__name__) - log.setLevel(logging.DEBUG) - - file_format = "%(asctime)s - %(levelname)-8s - %(message)s - %(filename)s - %(lineno)d - %(name)s" - log_filename = Path(tempfile.gettempdir()) / "webtoon_downloader.log" - - # Create file handler for logging - file_handler = FileHandler(log_filename, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter(file_format)) - - # Create console handler with a higher log level - console_handler = RichHandler(console=console, level=logging.WARNING, rich_tracebacks=True, markup=True) - log.addHandler(file_handler) - log.addHandler(console_handler) + if not enable_traceback: + sys.tracebacklimit = 0 + else: + traceback.install( + console=console, + show_locals=False, + suppress=[click, httpx, aiofiles, asyncio], + ) + + log = logging.getLogger() + log.setLevel(log_level) + + # Create the console handler + if enable_console_logging: + console_handler = RichHandler(console=console, level=log_level, rich_tracebacks=enable_traceback, markup=True) + log.addHandler(console_handler) + else: + log.addHandler(logging.NullHandler()) + + # Create the file handler for logging if a filename is provided + if log_filename: + file_log_format = "%(asctime)s - %(levelname)-6s - [%(name)s] - %(message)s - %(filename)s - %(lineno)d" + file_handler = FileHandler(log_filename, encoding="utf-8") + file_handler.setLevel(log_level) + file_handler.setFormatter(logging.Formatter(file_log_format)) + log.addHandler(file_handler) + + logging.getLogger("httpx").setLevel(logging.INFO) + logging.getLogger("hpack").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.INFO) + logging.getLogger("click").setLevel(logging.WARNING) + logging.getLogger("asyncio").setLevel(logging.INFO) + logging.getLogger("aiofiles").setLevel(logging.INFO) return log, console diff --git a/webtoon_downloader/transformers/image.py b/webtoon_downloader/transformers/image.py index fd8935d..e22a3d3 100644 --- a/webtoon_downloader/transformers/image.py +++ b/webtoon_downloader/transformers/image.py @@ -83,7 +83,7 @@ async def transform(self, image_stream: AsyncIterator[bytes], target_name: str) log.debug("Running image convertion to %s", self.target_format) transformed_stream = await self._run_in_executor(self._sync_transform, bytes_io_stream) else: - log.debug("No transformation needed") + log.debug('No transformation needed to convert to %s the target "%s"', self.target_format, target_name) transformed_stream = bytes_io_stream transformed_stream.seek(0)