Repository: Delgan/loguru
Pull request #1175 (2024-07-15 00:11:51)

Allow list of rotation conditions in file rotation logic
This implements the behavior described in #1174 , and allows for specification of a list (or tuple/set) of conditions for file rotation, like so:

```py
from loguru import logger

logger.configure(
  handlers=[
    dict(
      sink='file.log',
      rotation=['12h', '10 MB'],
    )
  ]
)
```

Delgan: Thanks for your PR @CollinHeist. Apologies for the delay.

Admittedly, I was initially rather reluctant to add such a feature, for the sake of simplicity. Though, I agree that this is not actually very cumbersome to add, and indeed, and it can visibly be useful to several users.

Just to let you know that I haven't forgotten the PR and plan to merge it after releasing a new debug release. Thank you again for your work.

Patch:
diff --git a/loguru/__init__.pyi b/loguru/__init__.pyi
index 3a13ad7..4fd698e 100644
--- a/loguru/__init__.pyi
+++ b/loguru/__init__.pyi
@@ -233,7 +233,16 @@ class Logger:
         enqueue: bool = ...,
         context: Optional[Union[str, BaseContext]] = ...,
         catch: bool = ...,
-        rotation: Optional[Union[str, int, time, timedelta, RotationFunction]] = ...,
+        rotation: Optional[
+            Union[
+                str,
+                int,
+                time,
+                timedelta,
+                RotationFunction,
+                list[Union[str, int, time, timedelta, RotationFunction]],
+            ]
+        ] = ...,
         retention: Optional[Union[str, int, timedelta, RetentionFunction]] = ...,
         compression: Optional[Union[str, CompressionFunction]] = ...,
         delay: bool = ...,
diff --git a/loguru/_file_sink.py b/loguru/_file_sink.py
index 4b09b06..25d63f9 100644
--- a/loguru/_file_sink.py
+++ b/loguru/_file_sink.py
@@ -153,6 +153,13 @@ class Rotation:
                 return True
             return False
 
+    class RotationGroup:
+        def __init__(self, rotations) -> None:
+            self._rotations = rotations
+
+        def __call__(self, message, file) -> bool:
+            return any(rotation(message, file) for rotation in self._rotations)
+
 
 class FileSink:
     def __init__(
@@ -310,6 +317,12 @@ class FileSink:
     def _make_rotation_function(rotation):
         if rotation is None:
             return None
+        if isinstance(rotation, (list, tuple, set)):
+            if len(rotation) == 0:
+                raise ValueError("Must provide at least one rotation condition")
+            return Rotation.RotationGroup(
+                [FileSink._make_rotation_function(rot) for rot in rotation]
+            )
         if isinstance(rotation, str):
             size = string_parsers.parse_size(rotation)
             if size is not None:
diff --git a/loguru/_logger.py b/loguru/_logger.py
index 0167a1e..867b004 100644
--- a/loguru/_logger.py
+++ b/loguru/_logger.py
@@ -314,9 +314,11 @@ class Logger:
 
         Parameters
         ----------
-        rotation : |str|, |int|, |time|, |timedelta| or |callable|_, optional
-            A condition indicating whenever the current logged file should be closed and a new one
-            started.
+        rotation : |str|, |int|, |time|, |timedelta|, |callable|_, or |list| of any of those
+            types, optional
+            Condition(s) indicating whenever the current logged file should be closed and a
+            new one started. If a list of conditions are provided, the current file is rotated
+            if any condition is true.
         retention : |str|, |int|, |timedelta| or |callable|_, optional
             A directive filtering old files that should be removed during rotation or end of
             program.


Test patch:
diff --git a/tests/test_filesink_rotation.py b/tests/test_filesink_rotation.py
index 7e56c0f..33d24fd 100644
--- a/tests/test_filesink_rotation.py
+++ b/tests/test_filesink_rotation.py
@@ -1058,6 +1058,36 @@ def test_recipe_rotation_both_size_and_time(freeze_time, tmp_path):
     )
 
 
+def test_multiple_rotation_conditions(freeze_time, tmp_path):
+    with freeze_time("2020-01-01 20:00:00") as frozen:
+        logger.add(tmp_path / "file.log", rotation=["8 B", "1 min"], format="{message}")
+        logger.info("abcde")
+        frozen.tick()
+
+        logger.info("fghij")
+        frozen.tick()
+
+        logger.info("klm")
+        frozen.move_to("2020-01-01 20:01:01")
+
+        logger.info("no")
+
+    check_dir(
+        tmp_path,
+        files=[
+            ("file.2020-01-01_20-00-00_000000.log", "abcde\n"),
+            ("file.2020-01-01_20-00-01_000000.log", "fghij\n"),
+            ("file.2020-01-01_20-00-02_000000.log", "klm\n"),
+            ("file.log", "no\n"),
+        ],
+    )
+
+
+def test_empty_rotation_condition_list():
+    with pytest.raises(ValueError, match="^Must provide at least one rotation condition$"):
+        logger.add("test.log", rotation=[])
+
+
 @pytest.mark.parametrize(
     "rotation", [object(), os, datetime.date(2017, 11, 11), datetime.datetime.now(), 1j]
 )
diff --git a/tests/typesafety/test_logger.yml b/tests/typesafety/test_logger.yml
index 319845f..b5908ee 100644
--- a/tests/typesafety/test_logger.yml
+++ b/tests/typesafety/test_logger.yml
@@ -287,7 +287,7 @@
     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 = ..., 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
+    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], List[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
 
 - case: invalid_logged_object_formatting
   main: |