Repository: jd/daiquiri

Issue:
Rotating logs?
Can this module configure a rotating file log?

logzero does it: http://logzero.readthedocs.io/en/latest/index.html?highlight=rotating#i-logzero-logfile

Discussion:

Patch:
diff --git a/daiquiri/__init__.py b/daiquiri/__init__.py
index 74e5001..0254f57 100644
--- a/daiquiri/__init__.py
+++ b/daiquiri/__init__.py
@@ -79,7 +79,7 @@ def setup(level=logging.WARNING, outputs=[output.STDERR], program_name=None,
     :param level: Root log level.
     :param outputs: Iterable of outputs to log to.
     :param program_name: The name of the program. Auto-detected if not set.
-    :param capture_warnings: Capture warnings from the `warnings' module
+    :param capture_warnings: Capture warnings from the `warnings' module.
     """
     root_logger = logging.getLogger(None)
 
diff --git a/daiquiri/output.py b/daiquiri/output.py
index a9dc088..ca0e858 100644
--- a/daiquiri/output.py
+++ b/daiquiri/output.py
@@ -9,8 +9,11 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
+import datetime
 import inspect
 import logging
+import logging.handlers
+import numbers
 import os
 import sys
 try:
@@ -41,6 +44,26 @@ class Output(object):
         logger.addHandler(self.handler)
 
 
+def _get_log_file_path(logfile=None, logdir=None, program_name=None,
+                       logfile_suffix=".log"):
+    ret_path = None
+
+    if not logdir:
+        ret_path = logfile
+
+    if not ret_path and logfile and logdir:
+        ret_path = os.path.join(logdir, logfile)
+
+    if not ret_path and logdir:
+        program_name = program_name or get_program_name()
+        ret_path = os.path.join(logdir, program_name) + logfile_suffix
+
+    if not ret_path:
+        raise ValueError("Unable to determine log file destination")
+
+    return ret_path
+
+
 class File(Output):
     def __init__(self, filename=None, directory=None, suffix=".log",
                  program_name=None, formatter=formatter.TEXT_FORMATTER,
@@ -56,24 +79,87 @@ class File(Output):
         This will be only used if no filename has been provided.
         :param program_name: Program name. Autodetected by default.
         """
-        logpath = self._get_log_file_path(filename, directory, program_name)
-        if not logpath:
-            raise ValueError("Unable to determine log file destination")
+        logpath = _get_log_file_path(filename, directory,
+                                     program_name, suffix)
         handler = logging.handlers.WatchedFileHandler(logpath)
         super(File, self).__init__(handler, formatter, level)
 
-    @staticmethod
-    def _get_log_file_path(logfile=None, logdir=None, program_name=None,
-                           logfile_suffix=".log"):
-        if not logdir:
-            return logfile
 
-        if logfile and logdir:
-            return os.path.join(logdir, logfile)
+class RotatingFile(Output):
+    def __init__(self, filename=None, directory=None, suffix='.log',
+                 program_name=None, formatter=formatter.TEXT_FORMATTER,
+                 level=None, max_size_bytes=0, backup_count=0):
+        """Rotating log file output.
+
+        :param filename: The log file path to write to.
+        If directory is also specified, both will be combined.
+        :param directory: The log directory to write to.
+        If no filename is specified, the program name and suffix will be used
+        to contruct the full path relative to the directory.
+        :param suffix: The log file name suffix.
+        This will be only used if no filename has been provided.
+        :param program_name: Program name. Autodetected by default.
+        :param max_size_bytes: allow the file to rollover at a
+        predetermined size.
+        :param backup_count: the maximum number of files to rotate
+        logging output between.
+        """
+        logpath = _get_log_file_path(filename, directory,
+                                     program_name, suffix)
+        handler = logging.handlers.RotatingFileHandler(
+            logpath, maxBytes=max_size_bytes, backupCount=backup_count)
+        super(RotatingFile, self).__init__(handler, formatter, level)
+
+    def do_rollover(self):
+        """Manually forces a log file rotation."""
+        return self.handler.doRollover()
+
+
+class TimedRotatingFile(Output):
+    def __init__(self, filename=None, directory=None, suffix='.log',
+                 program_name=None, formatter=formatter.TEXT_FORMATTER,
+                 level=None, interval=datetime.timedelta(hours=24),
+                 backup_count=0):
+        """Rotating log file output, triggered by a fixed interval.
+
+        :param filename: The log file path to write to.
+        If directory is also specified, both will be combined.
+        :param directory: The log directory to write to.
+        If no filename is specified, the program name and suffix will be used
+        to contruct the full path relative to the directory.
+        :param suffix: The log file name suffix.
+        This will be only used if no filename has been provided.
+        :param program_name: Program name. Autodetected by default.
+        :param interval: datetime.timedelta instance representing
+        how often a new log file should be created.
+        :param backup_count: the maximum number of files to rotate
+        logging output between.
+        """
+        logpath = _get_log_file_path(filename, directory,
+                                     program_name, suffix)
+        handler = logging.handlers.TimedRotatingFileHandler(
+            logpath,
+            when='S',
+            interval=self._timedelta_to_seconds(interval),
+            backupCount=backup_count)
+        super(TimedRotatingFile, self).__init__(handler, formatter, level)
+
+    def do_rollover(self):
+        """Manually forces a log file rotation."""
+        return self.handler.doRollover()
+
+    @staticmethod
+    def _timedelta_to_seconds(td):
+        """Convert a datetime.timedelta object into a seconds interval for
+        rotating file ouput.
 
-        if logdir:
-            program_name = program_name or get_program_name()
-            return os.path.join(logdir, program_name) + logfile_suffix
+        :param td: datetime.timedelta
+        :return: time in seconds
+        :rtype: int
+        """
+        if isinstance(td, numbers.Real):
+            td = datetime.timedelta(seconds=td)
+        return td.total_seconds()
 
 
 class Stream(Output):
diff --git a/doc/source/index.rst b/doc/source/index.rst
index 31db38e..bdf8ae0 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -34,7 +34,7 @@ Usage
 =====
 
 The basic usage of daiquiri is to call the `daiquiri.setup` function that will
-setup logging with the options passed as keyword arguments. If no argument are
+setup logging with the options passed as keyword arguments. If no arguments are
 passed, the default will log to `stderr`. If `stderr` is a terminal, the output
 will use colors.
 
@@ -42,7 +42,7 @@ will use colors.
 
 You can specify different outputs with different formatters. The
 `daiquiri.output` module provides a collection of `Output` classes that you can
-use to your liking to configut the logging output. Any number of output can bex
+use to your liking to configure the logging output. Any number of output can be
 configured.
 
 .. literalinclude:: ../../examples/output.py
@@ -51,8 +51,8 @@ configured.
 Picking format
 --------------
 
-You can configure the format of any output by passing a formatter to as the
-`formatter` argument to the contructor. Two default formatter are available:
+You can configure the format of any output by passing a formatter as the
+`formatter` argument to the contructor. Two default formatters are available:
 `daiquiri.formatter.TEXT_FORMATTER` which prints log messages as text, and the
 `daiquiri.formatter.JSON_FORMATTER` which prints log messages as parsable JSON
 (requires `python-json-logger`).
@@ -64,7 +64,7 @@ You can provide any class of type `logging.Formatter` as a formatter.
 Python warning support
 ----------------------
 
-The Python `warnings` module is sometimes used by applications and library to
+The Python `warnings` module is sometimes used by applications and libraries to
 emit warnings. By default, they are printed on `stderr`. Daiquiri overrides
 this by default and log warnings to the `py.warnings` logger.
 
@@ -92,7 +92,7 @@ Systemd journal support
 -----------------------
 
 The `daiquiri.output.Journal` output provides systemd journal support. All the
-extra argument passed to the logger will be shipped as extra keys to the
+extra arguments passed to the logger will be shipped as extra keys to the
 journal.
 
 
@@ -100,3 +100,11 @@ File support
 ------------
 
 The `daiquiri.output.File` output class provides support to log into a file.
+
+`daiquiri.output.RotatingFile` class logs to a file that rotates when a
+maximum file size has been reached.
+
+`daiquiri.output.TimedRotatingFile` will rotate the log file on a fixed
+interval.
+
+.. literalinclude:: ../../examples/files.py
\ No newline at end of file
diff --git a/examples/files.py b/examples/files.py
new file mode 100644
index 0000000..96903d5
--- /dev/null
+++ b/examples/files.py
@@ -0,0 +1,19 @@
+import daiquiri
+import datetime
+import logging
+
+daiquiri.setup(
+    level=logging.DEBUG,
+    outputs=(
+        daiquiri.output.File('errors.log', level=logging.ERROR),
+        daiquiri.output.TimedRotatingFile(
+            'everything.log',
+            level=logging.DEBUG,
+            interval=datetime.timedelta(hours=1))
+    )
+)
+
+logger = daiquiri.getLogger(__name__)
+
+logger.info('only to rotating file logger')
+logger.error('both log files, including errors only')
diff --git a/tox.ini b/tox.ini
index f3ac5a9..6e88a61 100644
--- a/tox.ini
+++ b/tox.ini
@@ -7,6 +7,7 @@ deps = -e.[test,json]
 commands =
   python setup.py test --slowest --testr-args='{posargs}'
   sh -c "for example in examples/*.py; do python $example; done"
+  sh -c "rm errors.log everything.log"
 
 [testenv:pep8]
 deps =


Test patch:
diff --git a/daiquiri/tests/test_output.py b/daiquiri/tests/test_output.py
index 82773b8..dc7fa38 100644
--- a/daiquiri/tests/test_output.py
+++ b/daiquiri/tests/test_output.py
@@ -9,6 +9,7 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
+from datetime import timedelta
 import syslog
 
 import testtools
@@ -31,18 +32,42 @@ class TestOutput(testtools.TestCase):
 
     def test_get_log_file_path(self):
         self.assertEqual("foobar.log",
-                         output.File._get_log_file_path("foobar.log"))
+                         output._get_log_file_path("foobar.log"))
         self.assertEqual("/var/log/foo/foobar.log",
-                         output.File._get_log_file_path("foobar.log",
-                                                        logdir="/var/log/foo"))
+                         output._get_log_file_path("foobar.log",
+                                                   logdir="/var/log/foo"))
         self.assertEqual("/var/log/foobar.log",
-                         output.File._get_log_file_path(logdir="/var/log",
-                                                        program_name="foobar"))
+                         output._get_log_file_path(logdir="/var/log",
+                                                   program_name="foobar"))
         self.assertEqual("/var/log/foobar.log",
-                         output.File._get_log_file_path(logdir="/var/log",
-                                                        program_name="foobar"))
+                         output._get_log_file_path(logdir="/var/log",
+                                                   program_name="foobar"))
         self.assertEqual("/var/log/foobar.journal",
-                         output.File._get_log_file_path(
+                         output._get_log_file_path(
                              logdir="/var/log",
                              logfile_suffix=".journal",
                              program_name="foobar"))
+
+    def test_timedelta_seconds(self):
+        fn = output.TimedRotatingFile._timedelta_to_seconds
+        hour = 60 * 60  # seconds * minutes
+
+        one_hour = [
+            timedelta(hours=1),
+            timedelta(minutes=60),
+            timedelta(seconds=hour),
+            hour,
+            float(hour)
+        ]
+        for t in one_hour:
+            self.assertEqual(hour, fn(t))
+
+        error_cases = [
+            'string',
+            ['some', 'list'],
+            ('some', 'tuple',),
+            ('tuple',),
+            {'dict': 'mapping'}
+        ]
+        for t in error_cases:
+            self.assertRaises(AttributeError, fn, t)