Repository: Delgan/loguru (python)

Issue:
Use extra keys for TypedDict
https://github.com/Delgan/loguru/blob/fa3746fe8ce354e8e32e444b2bc7e4221a88ac86/loguru/__init__.pyi#L215

It seems mypy 1.9.0 implemented a fix https://github.com/python/mypy/pull/14225, can `_HandlerConfig` be made actually usable now? 😅

Pull request description:
Improve type hints of handlers (in "add()" and "configure()")
Fix #1208.

Instead of using `**kwargs` in type hints of `logger.add()`, we can explicitly list the supported additional arguments which correspond to the ones accepted by the built-in [`open()`](https://docs.python.org/3/library/functions.html#open) method.

Same reasoning applies to type hints of `logger.configure()` handlers: we can define the three `TypedDict` configurations accepted, without requiring additional keys to be allowed.

Patch:
diff --git a/loguru/__init__.pyi b/loguru/__init__.pyi
index bdecac2..85f6fc7 100644
--- a/loguru/__init__.pyi
+++ b/loguru/__init__.pyi
@@ -194,7 +194,7 @@ class Record(TypedDict):
     line: int
     message: str
     module: str
-    name: Union[str, None]
+    name: Optional[str]
     process: RecordProcess
     thread: RecordThread
     time: datetime
@@ -205,7 +205,7 @@ class Message(str):
 class Writable(Protocol):
     def write(self, message: Message) -> None: ...
 
-FilterDict = Dict[Union[str, None], Union[str, int, bool]]
+FilterDict = Dict[Optional[str], Union[str, int, bool]]
 FilterFunction = Callable[[Record], bool]
 FormatFunction = Callable[[Record], str]
 PatcherFunction = Callable[[Record], None]
@@ -213,9 +213,10 @@ RotationFunction = Callable[[Message, TextIO], bool]
 RetentionFunction = Callable[[List[str]], None]
 CompressionFunction = Callable[[str], None]
 
-# Actually unusable because TypedDict can't allow extra keys: python/mypy#4617
-class _HandlerConfig(TypedDict, total=False):
-    sink: Union[str, PathLikeStr, TextIO, Writable, Callable[[Message], None], Handler]
+StandardOpener = Callable[[str, int], int]
+
+class BasicHandlerConfig(TypedDict, total=False):
+    sink: Union[TextIO, Writable, Callable[[Message], None], Handler]
     level: Union[str, int]
     format: Union[str, FormatFunction]
     filter: Optional[Union[str, FilterFunction, FilterDict]]
@@ -226,13 +227,53 @@ class _HandlerConfig(TypedDict, total=False):
     enqueue: bool
     catch: bool
 
+class FileHandlerConfig(TypedDict, total=False):
+    sink: Union[str, PathLikeStr]
+    level: Union[str, int]
+    format: Union[str, FormatFunction]
+    filter: Optional[Union[str, FilterFunction, FilterDict]]
+    colorize: Optional[bool]
+    serialize: bool
+    backtrace: bool
+    diagnose: bool
+    enqueue: bool
+    catch: bool
+    rotation: Optional[Union[str, int, time, timedelta, RotationFunction]]
+    retention: Optional[Union[str, int, timedelta, RetentionFunction]]
+    compression: Optional[Union[str, CompressionFunction]]
+    delay: bool
+    watch: bool
+    mode: str
+    buffering: int
+    encoding: str
+    errors: Optional[str]
+    newline: Optional[str]
+    closefd: bool
+    opener: Optional[StandardOpener]
+
+class AsyncHandlerConfig(TypedDict, total=False):
+    sink: Callable[[Message], Awaitable[None]]
+    level: Union[str, int]
+    format: Union[str, FormatFunction]
+    filter: Optional[Union[str, FilterFunction, FilterDict]]
+    colorize: Optional[bool]
+    serialize: bool
+    backtrace: bool
+    diagnose: bool
+    enqueue: bool
+    catch: bool
+    context: Optional[Union[str, BaseContext]]
+    loop: Optional[AbstractEventLoop]
+
+HandlerConfig = Union[BasicHandlerConfig, FileHandlerConfig, AsyncHandlerConfig]
+
 class LevelConfig(TypedDict, total=False):
     name: str
     no: int
     color: str
     icon: str
 
-ActivationConfig = Tuple[Union[str, None], bool]
+ActivationConfig = Tuple[Optional[str], bool]
 
 class Logger:
     @overload
@@ -264,8 +305,8 @@ class Logger:
         backtrace: bool = ...,
         diagnose: bool = ...,
         enqueue: bool = ...,
-        context: Optional[Union[str, BaseContext]] = ...,
         catch: bool = ...,
+        context: Optional[Union[str, BaseContext]] = ...,
         loop: Optional[AbstractEventLoop] = ...
     ) -> int: ...
     @overload
@@ -291,7 +332,10 @@ class Logger:
         mode: str = ...,
         buffering: int = ...,
         encoding: str = ...,
-        **kwargs: Any
+        errors: Optional[str] = ...,
+        newline: Optional[str] = ...,
+        closefd: bool = ...,
+        opener: Optional[StandardOpener] = ...,
     ) -> int: ...
     def remove(self, handler_id: Optional[int] = ...) -> None: ...
     def complete(self) -> AwaitableCompleter: ...
@@ -338,12 +382,12 @@ class Logger:
         color: Optional[str] = ...,
         icon: Optional[str] = ...,
     ) -> Level: ...
-    def disable(self, name: Union[str, None]) -> None: ...
-    def enable(self, name: Union[str, None]) -> None: ...
+    def disable(self, name: Optional[str]) -> None: ...
+    def enable(self, name: Optional[str]) -> None: ...
     def configure(
         self,
         *,
-        handlers: Sequence[Dict[str, Any]] = ...,
+        handlers: Optional[Sequence[HandlerConfig]] = ...,
         levels: Optional[Sequence[LevelConfig]] = ...,
         extra: Optional[Dict[Any, Any]] = ...,
         patcher: Optional[PatcherFunction] = ...,


Test patch:
diff --git a/tests/typesafety/test_logger.yml b/tests/typesafety/test_logger.yml
index 8e4ae13..5d02229 100644
--- a/tests/typesafety/test_logger.yml
+++ b/tests/typesafety/test_logger.yml
@@ -106,6 +106,7 @@
       pass
     logger.add(
       sink,
+      context="fork",
       loop=asyncio.get_event_loop(),
     )
 
@@ -266,6 +267,7 @@
     logger.disable("foo")
 
 - case: configure
+  skip: sys.version_info < (3, 7)  # Old Mypy bug: Union of TypedDict not fully supported.
   main: |
     from loguru import logger
     import sys
@@ -279,6 +281,28 @@
   out: |
     main:9: note: Revealed type is "builtins.list[builtins.int]"
 
+- case: configure_stream_handler
+  skip: sys.version_info < (3, 7)  # Old Mypy bug: Union of TypedDict not fully supported.
+  main: |
+    from loguru import logger
+    import sys
+    logger.configure(handlers=[{"sink": sys.stderr, "colorize": False}])
+
+
+- case: configure_file_handler
+  main: |
+    from loguru import logger
+    logger.configure(handlers=[{"sink": "file.log", "mode": "w", "closefd": False}])
+
+
+- case: configure_coroutine_handler
+  main: |
+    import loguru
+    from loguru import logger
+    async def sink(m: loguru.Message) -> None:
+      pass
+    logger.configure(handlers=[{"sink": sink, "context": "spawn", "loop": None}])
+
 - case: parse
   main: |
     from loguru import logger
@@ -300,8 +324,8 @@
     main:2: error: No overload variant of "add" of "Logger" matches argument types "Callable[[Any], None]", "int"
     main:2: note: Possible overload variants:
     main:2: note:     def add(self, sink: Union[TextIO, Writable, Callable[[Message], None], Handler], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ...) -> int
-    main:2: note:     def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., loop: Optional[AbstractEventLoop] = ...) -> int
-    main:2: note:     def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., **kwargs: Any) -> int
+    main:2: note:     def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., catch: bool = ..., context: Union[str, BaseContext, None] = ..., loop: Optional[AbstractEventLoop] = ...) -> int
+    main:2: note:     def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., errors: Optional[str] = ..., newline: Optional[str] = ..., closefd: bool = ..., opener: Optional[Callable[[str, int], int]] = ...) -> int
   mypy_config: |
     show_error_codes = false
     force_uppercase_builtins = true
@@ -316,8 +340,8 @@
     main:2: error: No overload variant of "add" of "Logger" matches argument types "Callable[[Any], None]", "int"
     main:2: note: Possible overload variants:
     main:2: note:     def add(self, sink: Union[TextIO, Writable, Callable[[Message], None], Handler], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ...) -> int
-    main:2: note:     def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., loop: Optional[AbstractEventLoop] = ...) -> int
-    main:2: note:     def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., **kwargs: Any) -> int
+    main:2: note:     def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., catch: bool = ..., context: Union[str, BaseContext, None] = ..., loop: Optional[AbstractEventLoop] = ...) -> int
+    main:2: note:     def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., errors: Optional[str] = ..., newline: Optional[str] = ..., closefd: bool = ..., opener: Optional[Callable[[str, int], int]] = ...) -> int
   mypy_config: |
     show_error_codes = false
 
@@ -359,6 +383,7 @@
       extra=[1],
     )
   out: |
+    main:3: error: List item 0 has incompatible type "Dict[str, str]"; expected "Union[BasicHandlerConfig, FileHandlerConfig, AsyncHandlerConfig]"
     main:4: error: Extra key "baz" for TypedDict "LevelConfig"
     main:5: error: Argument "patcher" to "configure" of "Logger" has incompatible type "int"; expected "Optional[Callable[[Record], None]]"
     main:6: error: List item 0 has incompatible type "Dict[str, str]"; expected "Tuple[Optional[str], bool]"
@@ -380,6 +405,7 @@
       extra=[1],
     )
   out: |
+    main:3: error: List item 0 has incompatible type "Dict[str, str]"; expected "Union[BasicHandlerConfig, FileHandlerConfig, AsyncHandlerConfig]"
     main:4: error: Extra key "baz" for TypedDict "LevelConfig"
     main:5: error: Argument "patcher" to "configure" of "Logger" has incompatible type "int"; expected "Optional[Callable[[Record], None]]"
     main:6: error: List item 0 has incompatible type "Dict[str, str]"; expected "Tuple[Optional[str], bool]"