diff --git a/todoist_api_python/_core/http_requests.py b/todoist_api_python/_core/http_requests.py index 9c0f10f..e3bb1b7 100644 --- a/todoist_api_python/_core/http_requests.py +++ b/todoist_api_python/_core/http_requests.py @@ -6,6 +6,7 @@ from requests.status_codes import codes from todoist_api_python._core.http_headers import create_headers +from todoist_api_python._core.utils import log_calls if TYPE_CHECKING: from requests import Session @@ -22,7 +23,7 @@ T = TypeVar("T") - +@log_calls def get( session: Session, url: str, @@ -45,7 +46,7 @@ def get( response.raise_for_status() return cast("T", response.ok) - +@log_calls def post( session: Session, url: str, @@ -73,7 +74,7 @@ def post( response.raise_for_status() return cast("T", response.ok) - +@log_calls def delete( session: Session, url: str, diff --git a/todoist_api_python/_core/utils.py b/todoist_api_python/_core/utils.py index c612f6e..b749274 100644 --- a/todoist_api_python/_core/utils.py +++ b/todoist_api_python/_core/utils.py @@ -3,6 +3,9 @@ import asyncio import sys import uuid +import inspect +import logging +from functools import wraps from datetime import date, datetime, timezone from typing import TYPE_CHECKING, TypeVar, cast @@ -75,3 +78,57 @@ def parse_datetime(datetime_str: str) -> datetime: def default_request_id_fn() -> str: """Generate random UUIDv4s as the default request ID.""" return str(uuid.uuid4()) + + +def log_calls(func: Callable[..., T]) -> Callable[..., T]: + """ + Decorator to log calls to a callable. Arguments and returned values are included in the log + """ + # Create a 'call count' variable to differentiate between multiple calls to the same function + # Useful for recursion + func._call_count = 0 + + # Use inspect to get the module of the caller and the appropriate logger + funcs_module = inspect.getmodule(func) + logger = logging.getLogger(funcs_module.__name__ if funcs_module else "") + logger.debug(f"Wrapping function {func.__name__} with logger {logger}") + + # Create the wrapped function which logs on function entry, with the arguments + # and on exit, with the return value + # Function calls are numbered to make the log file easier to search + @wraps(func) + def wrapper(*args, **kwargs): + func._call_count += 1 + logger.debug(f"Call to function {func.__name__} (#{func._call_count}): Entering with args={args}, kwargs={kwargs}") + result = func(*args, **kwargs) + logger.debug(f"Call to function {func.__name__} (#{func._call_count}): Exiting with result={result}") + return result + + return wrapper + + +def log_method_calls( + exclude_properties: bool = True, exclude_private: bool = True, exclude_dunder: bool = True +) -> Callable[[type], type]: + """ + Class decorator to log calls to all methods of a class. Arguments and returned values are + included in the log. + + Args: + exclude_dunder (bool): If True, exclude dunder methods (methods with names starting and + ending with '__') from logging. Default is True. + """ + def class_decorator(cls: type) -> type: + for attr_name, attr_value in cls.__dict__.items(): + if callable(attr_value): + if exclude_dunder and attr_name.startswith("__") and attr_name.endswith("__"): + continue + if exclude_private and attr_name.startswith("_"): + continue + if exclude_properties and isinstance(getattr(cls, attr_name, None), property): + continue + decorated_attr = log_calls(attr_value) + setattr(cls, attr_name, decorated_attr) + return cls + + return class_decorator diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index a297bf5..1db067f 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -31,6 +31,7 @@ default_request_id_fn, format_date, format_datetime, + log_method_calls ) from todoist_api_python.models import ( Attachment, @@ -84,6 +85,7 @@ ViewStyle = Annotated[str, Predicate(lambda x: x in ("list", "board", "calendar"))] +@log_method_calls() class TodoistAPI: """ Client for the Todoist API.