openrvdas.logger.writers.file_writer

No module-level documentation available.
  1#!/usr/bin/env python3
  2
  3import logging
  4import os.path
  5import sys
  6import re
  7import math
  8
  9from datetime import datetime, timedelta, timezone
 10from typing import Union
 11
 12
 13from logger.utils.timestamp import time_str, DATE_FORMAT  # noqa: E402
 14from logger.writers.writer import Writer  # noqa: E402
 15
 16DEFAULT_DATETIME_STR = '-' + DATE_FORMAT
 17
 18
 19class FileWriter(Writer):
 20    """Write to the specified file. If filename is empty, write to stdout."""
 21
 22    def __init__(self,
 23                 filebase=None,
 24                 filename=None,          # deprecated
 25                 mode='a',
 26                 delimiter='\n',
 27                 flush=True,
 28                 split_by_time=False,    # deprecated
 29                 split_interval=None,
 30                 header=None,
 31                 header_file=None,
 32                 time_format=None,       # deprecated
 33                 date_format=None,
 34                 suffix=None,
 35                 time_zone=timezone.utc,
 36                 create_path=True,
 37                 **kwargs):
 38        """Write text records to a file. If no filename is specified, write to
 39        stdout.
 40        ```
 41
 42        filebase    A filebase string that will be used as for the output
 43                    filename.
 44
 45        filename    DEPRECATED Name of file to write to. If None, write to
 46                    stdout.
 47
 48        mode        Mode with which to open file. 'a' by default to append, but
 49                    can also be 'w' to truncate, 'ab' to append in binary mode,
 50                    or any other valid Python write file mode.
 51
 52        delimiter   By default, append a newline after each record written. Set
 53                    to None to disable appending any record delimiter. Ignored
 54                    if mode is for binary.
 55
 56        flush       If True (default), flush after every write() call
 57
 58        split_by_time   DEPRECATED Create a separate text file for each (by
 59                        default) day, appending a -YYYY-MM-DD string to the
 60                        specified filename. By overridding time_format, other
 61                        split intervals, such as hourly or monthly, may be
 62                        imposed.
 63
 64        split_interval  If set the file will trucate at the specified interval.
 65                        The value must be a string containing an integer
 66                        followed by a 'H' (hours) or 'M' (minutes). Default
 67                        is to not split.
 68
 69        header          A string to add to the beginning of a new file.
 70
 71        header_file     A string containing the path to file containing a
 72                        header string to add to the beginning of a new file.
 73
 74        time_format     DEPRECATED By default ISO 8601-compliant '-%Y-%m-%d'.
 75                        If, e.g. '-%Y-%m' is used, files will be split by
 76                        month; if -%y-%m-%d:%H' is specified, splits will be
 77                        hourly. If '%y+%j' is specified, splits will be daily,
 78                        but named via Julian date.  Putting '-' or '.' on the
 79                        left indicates timestamp suffix, putting it on the
 80                        right indicates timestamp prefix.  If you put '-' or
 81                        '.' on both sides, it's handled as a suffix.
 82
 83        date_fomat      A strftime-compatible string, such as '%Y-%m-%d';
 84                        defaults to '-' plus whatever's defined in
 85                        utils.timestamps.DATE_FORMAT.  If the value starts with
 86                        a '^' character, the string will prepend the file
 87                        name portion of the filebase
 88
 89        suffix          A suffix string to add to the log filename.
 90
 91        time_zone       Timezone to use when constructing the date_format
 92                        portion of the filenames.
 93
 94        create_path     Create directory path to file if it doesn't exist.
 95
 96        quiet           If True, don't complain if a record doesn't match
 97                        any mapped prefix.
 98
 99        encoding        'utf-8' by default. If empty or None, do not attempt
100                        any decoding and return raw bytes. Other possible
101                        encodings are listed in online documentation here:
102                        [https://docs.python.org/3/library/codecs.html]
103                        (https://docs.python.org/3/library/codecs.html)
104
105        encoding_errors 'ignore' by default. Other error strategies are
106                        'strict', 'replace', and 'backslashreplace', described
107                        here: [https://docs.python.org/3/howto/unicode.html]
108                        (https://docs.python.org/3/howto/unicode.html)
109
110        Sample invocations (original vs proposed)
111        - Write to stdout:
112            Original: FileWriter(None)
113            Proposed: FileWriter(None)
114
115        - Write to file, no split:
116            Original: FileWriter(/data/sample_file)
117            Proposed: FileWriter(/data/sample_file)
118
119        - Write to file, no split, with header:
120            Original: FileWriter(/data/sample_file, header='This is a header')
121            Proposed: FileWriter(/data/sample_file, header='This is a header')
122
123        - Write to file, daily split: (filename = /data/sample_file-%Y-%m-%d)
124            Original: FileWriter(/data/sample_file, split_by_time=True)
125            Proposed: FileWriter(/data/sample_file, split_interval='24H')
126
127        - Write to file, daily split, with suffix:
128          (filename = /data/sample_file-%Y-%m-%d.txt)
129            Original: FileWriter(/data/sample_file, split_by_time=True,
130                                 time_format='-%Y-%m-%d.txt')
131            Proposed: FileWriter(/data/sample_file, split_interval='24H',
132                                 suffix='.txt')
133
134        - Write to file, daily split: (filename = /data/sample_file-%Y-%j)
135            Original: FileWriter(/data/sample_file, split_by_time=True,
136                                 time_format='-%Y-%j')
137            Proposed: FileWriter(/data/sample_file, split_interval='24H',
138                                 date_format='-%Y-%j')
139
140        - Write to file, hourly split:
141          (filename = /data/sample_file-%Y-%m-%dT%H00)
142            Original: FileWriter(/data/sample_file, split_by_time=True,
143                                 time_format='-%Y-%m-%d:%H00')
144            Proposed: FileWriter(/data/sample_file, split_interval='1H')
145
146        - Write to file, 15-minute split:
147          (filename = /data/sample_file-%Y-%m-%dT%H%M)
148            Original: FileWriter(/data/sample_file, split_interval='15M',
149                                 time_format='-%Y-%m-%d:%H%M')
150            Proposed: FileWriter(/data/sample_file, split_interval='15M')
151
152        - Write to file, 15-minute split:
153          (filename = /data/%Y-%m-%dT%H%M-sample_file)
154            Original: FileWriter(/data/sample_file, split_interval='15M',
155                                 time_format='%Y-%m-%d:%H%M-')
156            Proposed: FileWriter(/data/sample_file, split_interval='15M',
157                                 date_format='^%Y-%m-%d:%H%M-')
158
159        - Write to file, 15-minute split, with suffix:
160          (filename = /data/%Y-%m-%dT%H%M-sample_file.txt)
161            Original: FileWriter(/data/sample_file.txt, split_interval='15M',
162                                 time_format='%Y-%m-%d:%H%M-')
163            Proposed: FileWriter(/data/sample_file, split_interval='15M',
164                                 date_format='^%Y-%m-%d:%H%M-', suffix='.txt')
165
166        ```
167        """
168        super().__init__(**kwargs)  # processes 'quiet', encoding and hints
169
170        if 'b' in mode and (self.encoding or self.encoding_errors) is not None:
171            logging.warning("Ignoring encoding and encoding_errors because"
172                            " file mode is binary")
173            self.encoding = self.encoding_errors = None
174
175        # --- Deprecated args ---
176        if filename is not None:
177            filebase = filename
178            # warnings.warn(
179            #     "`filename` is deprecated, use `filebase` instead",
180            #     DeprecationWarning,
181            #     stacklevel=2,
182            # )
183        if split_by_time:
184            split_interval = split_interval or '24H'
185            # warnings.warn(
186            #     "`split_by_time` is deprecated, use `split_interval` instead",
187            #     DeprecationWarning,
188            #     stacklevel=2,
189            # )
190        if time_format is not None:
191            # warnings.warn(
192            #     "`time_format` is deprecated, use `date_format` instead",
193            #     DeprecationWarning,
194            #     stacklevel=2,
195            # )
196            date_format = ('^' + time_format[:-1]
197                           if time_format.endswith('-') else time_format)
198
199        # Can't use split_interval or split_by_time if filebase and filename
200        # are None
201        if split_interval and filebase is None:
202            raise ValueError("filebase must be specified")
203
204        # --- Base file name ---
205        self.filebase = filebase
206
207        # --- File settings ---
208        self.mode = mode
209        self.flush = flush
210        self.suffix = suffix or ''
211        self.time_zone = time_zone
212        self.next_file_split = datetime.now(self.time_zone)
213
214        # --- Delimiter ---
215        self.delimiter = self._resolve_delimiter(delimiter)
216
217        # --- Header handling ---
218        self.header = self._load_header(header, header_file)
219
220        # --- Split interval ---
221        self.split_interval = self._validate_split_interval(split_interval)
222        self.split_interval_in_seconds = self._get_split_interval_in_seconds()
223
224        # --- Date/time format ---
225        self.date_format = self._validate_date_format(date_format)
226
227        # --- Ensure path exists ---
228        if self.filebase and create_path:
229            os.makedirs(os.path.dirname(self.filebase), exist_ok=True)
230
231        # --- File state ---
232        self.file = None
233        self.file_date_format = None
234
235        # A hook to aid in debugging; should be None to use system time.
236        self.timestamp = None
237
238    # -----------------------
239    # Validation helpers
240    # -----------------------
241    def _load_header(self, header, header_file):
242        if header and header_file:
243            raise ValueError("Cannot specify both `header` and `header_file`")
244
245        if 'b' in self.mode and header is not None:
246            logging.warning("Ignoring header because file mode is binary")
247            return None
248
249        # Case 1: simple string header
250        if header:
251            return (header.rstrip(self.delimiter) + self.delimiter
252                    if self.delimiter else header)
253
254        # Case 2: header_file is a single path
255        if header_file:
256            try:
257                with open(header_file, "r", encoding="utf-8") as hf:
258                    return (
259                        hf.read().strip().rstrip(self.delimiter)
260                        + self.delimiter
261                        if self.delimiter
262                        else hf.read().strip()
263                    )
264            except OSError as e:
265                raise ValueError(
266                    f"Error reading header_file {header_file}: {e}")
267
268        return None
269
270    def _validate_split_interval(self, split_interval):
271        if split_interval is None:
272            return None
273        if not isinstance(split_interval, str):
274            raise ValueError("split_interval must be a string like '1H' "
275                             "or '30M'")
276        if not split_interval.endswith(("H", "M")):
277            raise ValueError("must be an integer followed by 'H' or 'M'")
278        try:
279            return (int(split_interval[:-1]), split_interval[-1])
280        except ValueError:
281            raise ValueError("must be an integer followed by 'H' or 'M'")
282        return None
283
284    def _resolve_delimiter(self, delimiter):
285        if 'b' in self.mode and delimiter is not None:
286            logging.warning("Ignoring delimiter because file mode is binary")
287            return None
288
289        if delimiter:
290            delimiter = delimiter.encode("utf-8").decode("unicode_escape")
291
292        return delimiter
293
294    def _validate_date_format(self, date_format):
295        if not self.split_interval:
296            return date_format or ""
297
298        unit = self.split_interval[1]
299        value = self.split_interval[0]
300
301        # --- Decide requirements based on interval ---
302        if unit == "H":
303            even_days = value % 24 == 0
304            needs_hour = not even_days
305            needs_minute = False
306        elif unit == "M":
307            even_hours = value % 60 == 0
308            needs_hour = True
309            needs_minute = not even_hours
310        else:
311            return DEFAULT_DATETIME_STR  # fallback
312
313        # --- Default formats if user didn’t supply one ---
314        if not date_format:
315            if unit == "H":
316                return (DEFAULT_DATETIME_STR if even_days
317                        else DEFAULT_DATETIME_STR + "T%H00")
318            if unit == "M":
319                # Concise f-string format
320                return (f"{DEFAULT_DATETIME_STR}T%H"
321                        f"{'%M' if needs_minute else '00'}")
322
323        # --- Extract directives ---
324        found = set(re.findall(r"%[a-zA-Z]", date_format))
325
326        # Must always have year
327        if "%Y" not in found:
328            raise ValueError("date_format must include %Y (year).")
329
330        # Must have either month+day or julian day
331        if not ({"%m", "%d"} <= found or "%j" in found):
332            raise ValueError("date_format must include %m, %d (month, day) "
333                             "or %j (day-of-year).")
334
335        # Hours?
336        if needs_hour and "%H" not in found:
337            raise ValueError("date_format must include %H (hour).")
338
339        # Minutes?
340        if needs_minute and "%M" not in found:
341            raise ValueError("date_format must include %M (minute).")
342
343        return date_format
344
345    ############################
346    def __del__(self):
347        if hasattr(self, 'file') and self.file:
348            self.file.close()
349
350    ############################
351    def _get_split_interval_in_seconds(self):
352
353        if not self.split_interval:
354            return 0
355
356        if self.split_interval[1] == 'H':
357            return self.split_interval[0] * 3600
358
359        if self.split_interval[1] == 'M':
360            return self.split_interval[0] * 60
361
362        return 0
363
364    ############################
365    def _get_file_date_format(self):
366        """Return a string to be used for the file suffix."""
367
368        # Note: the self.timestamp variable exists for debugging, and
369        # should be left as None in actual use, which tells the time_str
370        # method to use current system time.
371
372        # if there is no split interval
373        if self.timestamp:
374            return time_str(timestamp=self.timestamp,
375                            time_zone=self.time_zone,
376                            time_format=self.date_format)
377
378        # if the data is being split by N hours
379        elif self.split_interval[1] == 'H':  # hour
380            timestamp_raw = datetime.now(self.time_zone)
381            # Round down to nearest interval
382            timestamp_hour = (self.split_interval[0] *
383                              math.floor(timestamp_raw.hour /
384                                         self.split_interval[0]))
385            timestamp_proc = timestamp_raw.replace(hour=timestamp_hour,
386                                                   minute=0, second=0)
387            self.next_file_split = (timestamp_proc +
388                                    timedelta(seconds=self
389                                              .split_interval_in_seconds))
390
391            return time_str(timestamp=timestamp_proc.timestamp(),
392                            time_zone=self.time_zone,
393                            time_format=self.date_format)
394
395        # if the data is being split by N minutes
396        elif self.split_interval[1] == 'M':  # minute
397            timestamp_raw = datetime.now(self.time_zone)
398            # Round down to nearest interval
399            timestamp_minute = (self.split_interval[0] *
400                                math.floor(timestamp_raw.minute /
401                                           self.split_interval[0]))
402            timestamp_proc = timestamp_raw.replace(minute=timestamp_minute,
403                                                   second=0)
404            self.next_file_split = (timestamp_proc +
405                                    timedelta(seconds=self
406                                              .split_interval_in_seconds))
407
408            return time_str(timestamp=timestamp_proc.timestamp(),
409                            time_zone=self.time_zone,
410                            time_format=self.date_format)
411
412        return ""
413
414    ############################
415    def _set_file(self, filename):
416        """Set the current file to the specified filename."""
417
418        # If they haven't given us a filename, we'll write to stdout
419        if filename is None:
420            self.file = sys.stdout
421
422            if self.header is not None:
423                self.file.write(self.header)
424
425            return
426
427        # If here, we have a filename. If we already have a file open,
428        # close it, then open the new one.
429        if self.file:
430            self.file.close()
431
432        # Check to see if file already exists
433        file_is_new = not os.path.isfile(filename)
434
435        # Finally, open the specified file with the specified mode and encoding
436        logging.info("opening %s with mode=%s and encoding=%s",
437                     filename, self.mode, self.encoding)
438        self.file = open(filename, self.mode, encoding=self.encoding)
439
440        # Add header record to file if a header was specified and the file was
441        # just created.
442        if file_is_new and self.header:
443            self.file.write(self.header)
444
445    ############################
446    def write(self, record: Union[str, bytes]):
447        """ Write out record, appending a newline at end."""
448
449        # See if it's something we can process, and if not, try digesting
450        if not self.can_process_record(record):  # inherited from BaseModule()
451            self.digest_record(record)  # inherited from BaseModule()
452            return
453
454        if not self.filebase:
455            self._set_file(None)
456
457        # If we're splitting by some time interval, see if it's time to
458        # roll over to a new file.
459        # if self.split_by_time or self.split_interval is not None:
460        elif (self.split_interval and
461              datetime.now(self.time_zone) > self.next_file_split):
462            new_file_date_format = self._get_file_date_format()
463            if new_file_date_format != self.file_date_format:
464                self.file_date_format = new_file_date_format
465                if new_file_date_format.startswith('^'):
466                    self._set_file(
467                        os.path.dirname(self.filebase)
468                        + new_file_date_format[1:]
469                        + os.path.basename(self.filebase)
470                        + self.suffix
471                    )
472                else:
473                    self._set_file(self.filebase + new_file_date_format +
474                                   self.suffix)
475
476        # If we're not splitting by intervals, still check that we've got
477        # a file open we can write to. If not, open it.
478        else:
479            if not self.file:
480                self._set_file(self.filebase + self.suffix)
481
482        # Write the record and flush if requested
483        self.file.write(record)
484        if self.delimiter is not None:
485            self.file.write(self.delimiter)
486        if self.flush:
487            self.file.flush()
DEFAULT_DATETIME_STR = '-%Y-%m-%d'
class FileWriter(logger.writers.writer.Writer):
 20class FileWriter(Writer):
 21    """Write to the specified file. If filename is empty, write to stdout."""
 22
 23    def __init__(self,
 24                 filebase=None,
 25                 filename=None,          # deprecated
 26                 mode='a',
 27                 delimiter='\n',
 28                 flush=True,
 29                 split_by_time=False,    # deprecated
 30                 split_interval=None,
 31                 header=None,
 32                 header_file=None,
 33                 time_format=None,       # deprecated
 34                 date_format=None,
 35                 suffix=None,
 36                 time_zone=timezone.utc,
 37                 create_path=True,
 38                 **kwargs):
 39        """Write text records to a file. If no filename is specified, write to
 40        stdout.
 41        ```
 42
 43        filebase    A filebase string that will be used as for the output
 44                    filename.
 45
 46        filename    DEPRECATED Name of file to write to. If None, write to
 47                    stdout.
 48
 49        mode        Mode with which to open file. 'a' by default to append, but
 50                    can also be 'w' to truncate, 'ab' to append in binary mode,
 51                    or any other valid Python write file mode.
 52
 53        delimiter   By default, append a newline after each record written. Set
 54                    to None to disable appending any record delimiter. Ignored
 55                    if mode is for binary.
 56
 57        flush       If True (default), flush after every write() call
 58
 59        split_by_time   DEPRECATED Create a separate text file for each (by
 60                        default) day, appending a -YYYY-MM-DD string to the
 61                        specified filename. By overridding time_format, other
 62                        split intervals, such as hourly or monthly, may be
 63                        imposed.
 64
 65        split_interval  If set the file will trucate at the specified interval.
 66                        The value must be a string containing an integer
 67                        followed by a 'H' (hours) or 'M' (minutes). Default
 68                        is to not split.
 69
 70        header          A string to add to the beginning of a new file.
 71
 72        header_file     A string containing the path to file containing a
 73                        header string to add to the beginning of a new file.
 74
 75        time_format     DEPRECATED By default ISO 8601-compliant '-%Y-%m-%d'.
 76                        If, e.g. '-%Y-%m' is used, files will be split by
 77                        month; if -%y-%m-%d:%H' is specified, splits will be
 78                        hourly. If '%y+%j' is specified, splits will be daily,
 79                        but named via Julian date.  Putting '-' or '.' on the
 80                        left indicates timestamp suffix, putting it on the
 81                        right indicates timestamp prefix.  If you put '-' or
 82                        '.' on both sides, it's handled as a suffix.
 83
 84        date_fomat      A strftime-compatible string, such as '%Y-%m-%d';
 85                        defaults to '-' plus whatever's defined in
 86                        utils.timestamps.DATE_FORMAT.  If the value starts with
 87                        a '^' character, the string will prepend the file
 88                        name portion of the filebase
 89
 90        suffix          A suffix string to add to the log filename.
 91
 92        time_zone       Timezone to use when constructing the date_format
 93                        portion of the filenames.
 94
 95        create_path     Create directory path to file if it doesn't exist.
 96
 97        quiet           If True, don't complain if a record doesn't match
 98                        any mapped prefix.
 99
100        encoding        'utf-8' by default. If empty or None, do not attempt
101                        any decoding and return raw bytes. Other possible
102                        encodings are listed in online documentation here:
103                        [https://docs.python.org/3/library/codecs.html]
104                        (https://docs.python.org/3/library/codecs.html)
105
106        encoding_errors 'ignore' by default. Other error strategies are
107                        'strict', 'replace', and 'backslashreplace', described
108                        here: [https://docs.python.org/3/howto/unicode.html]
109                        (https://docs.python.org/3/howto/unicode.html)
110
111        Sample invocations (original vs proposed)
112        - Write to stdout:
113            Original: FileWriter(None)
114            Proposed: FileWriter(None)
115
116        - Write to file, no split:
117            Original: FileWriter(/data/sample_file)
118            Proposed: FileWriter(/data/sample_file)
119
120        - Write to file, no split, with header:
121            Original: FileWriter(/data/sample_file, header='This is a header')
122            Proposed: FileWriter(/data/sample_file, header='This is a header')
123
124        - Write to file, daily split: (filename = /data/sample_file-%Y-%m-%d)
125            Original: FileWriter(/data/sample_file, split_by_time=True)
126            Proposed: FileWriter(/data/sample_file, split_interval='24H')
127
128        - Write to file, daily split, with suffix:
129          (filename = /data/sample_file-%Y-%m-%d.txt)
130            Original: FileWriter(/data/sample_file, split_by_time=True,
131                                 time_format='-%Y-%m-%d.txt')
132            Proposed: FileWriter(/data/sample_file, split_interval='24H',
133                                 suffix='.txt')
134
135        - Write to file, daily split: (filename = /data/sample_file-%Y-%j)
136            Original: FileWriter(/data/sample_file, split_by_time=True,
137                                 time_format='-%Y-%j')
138            Proposed: FileWriter(/data/sample_file, split_interval='24H',
139                                 date_format='-%Y-%j')
140
141        - Write to file, hourly split:
142          (filename = /data/sample_file-%Y-%m-%dT%H00)
143            Original: FileWriter(/data/sample_file, split_by_time=True,
144                                 time_format='-%Y-%m-%d:%H00')
145            Proposed: FileWriter(/data/sample_file, split_interval='1H')
146
147        - Write to file, 15-minute split:
148          (filename = /data/sample_file-%Y-%m-%dT%H%M)
149            Original: FileWriter(/data/sample_file, split_interval='15M',
150                                 time_format='-%Y-%m-%d:%H%M')
151            Proposed: FileWriter(/data/sample_file, split_interval='15M')
152
153        - Write to file, 15-minute split:
154          (filename = /data/%Y-%m-%dT%H%M-sample_file)
155            Original: FileWriter(/data/sample_file, split_interval='15M',
156                                 time_format='%Y-%m-%d:%H%M-')
157            Proposed: FileWriter(/data/sample_file, split_interval='15M',
158                                 date_format='^%Y-%m-%d:%H%M-')
159
160        - Write to file, 15-minute split, with suffix:
161          (filename = /data/%Y-%m-%dT%H%M-sample_file.txt)
162            Original: FileWriter(/data/sample_file.txt, split_interval='15M',
163                                 time_format='%Y-%m-%d:%H%M-')
164            Proposed: FileWriter(/data/sample_file, split_interval='15M',
165                                 date_format='^%Y-%m-%d:%H%M-', suffix='.txt')
166
167        ```
168        """
169        super().__init__(**kwargs)  # processes 'quiet', encoding and hints
170
171        if 'b' in mode and (self.encoding or self.encoding_errors) is not None:
172            logging.warning("Ignoring encoding and encoding_errors because"
173                            " file mode is binary")
174            self.encoding = self.encoding_errors = None
175
176        # --- Deprecated args ---
177        if filename is not None:
178            filebase = filename
179            # warnings.warn(
180            #     "`filename` is deprecated, use `filebase` instead",
181            #     DeprecationWarning,
182            #     stacklevel=2,
183            # )
184        if split_by_time:
185            split_interval = split_interval or '24H'
186            # warnings.warn(
187            #     "`split_by_time` is deprecated, use `split_interval` instead",
188            #     DeprecationWarning,
189            #     stacklevel=2,
190            # )
191        if time_format is not None:
192            # warnings.warn(
193            #     "`time_format` is deprecated, use `date_format` instead",
194            #     DeprecationWarning,
195            #     stacklevel=2,
196            # )
197            date_format = ('^' + time_format[:-1]
198                           if time_format.endswith('-') else time_format)
199
200        # Can't use split_interval or split_by_time if filebase and filename
201        # are None
202        if split_interval and filebase is None:
203            raise ValueError("filebase must be specified")
204
205        # --- Base file name ---
206        self.filebase = filebase
207
208        # --- File settings ---
209        self.mode = mode
210        self.flush = flush
211        self.suffix = suffix or ''
212        self.time_zone = time_zone
213        self.next_file_split = datetime.now(self.time_zone)
214
215        # --- Delimiter ---
216        self.delimiter = self._resolve_delimiter(delimiter)
217
218        # --- Header handling ---
219        self.header = self._load_header(header, header_file)
220
221        # --- Split interval ---
222        self.split_interval = self._validate_split_interval(split_interval)
223        self.split_interval_in_seconds = self._get_split_interval_in_seconds()
224
225        # --- Date/time format ---
226        self.date_format = self._validate_date_format(date_format)
227
228        # --- Ensure path exists ---
229        if self.filebase and create_path:
230            os.makedirs(os.path.dirname(self.filebase), exist_ok=True)
231
232        # --- File state ---
233        self.file = None
234        self.file_date_format = None
235
236        # A hook to aid in debugging; should be None to use system time.
237        self.timestamp = None
238
239    # -----------------------
240    # Validation helpers
241    # -----------------------
242    def _load_header(self, header, header_file):
243        if header and header_file:
244            raise ValueError("Cannot specify both `header` and `header_file`")
245
246        if 'b' in self.mode and header is not None:
247            logging.warning("Ignoring header because file mode is binary")
248            return None
249
250        # Case 1: simple string header
251        if header:
252            return (header.rstrip(self.delimiter) + self.delimiter
253                    if self.delimiter else header)
254
255        # Case 2: header_file is a single path
256        if header_file:
257            try:
258                with open(header_file, "r", encoding="utf-8") as hf:
259                    return (
260                        hf.read().strip().rstrip(self.delimiter)
261                        + self.delimiter
262                        if self.delimiter
263                        else hf.read().strip()
264                    )
265            except OSError as e:
266                raise ValueError(
267                    f"Error reading header_file {header_file}: {e}")
268
269        return None
270
271    def _validate_split_interval(self, split_interval):
272        if split_interval is None:
273            return None
274        if not isinstance(split_interval, str):
275            raise ValueError("split_interval must be a string like '1H' "
276                             "or '30M'")
277        if not split_interval.endswith(("H", "M")):
278            raise ValueError("must be an integer followed by 'H' or 'M'")
279        try:
280            return (int(split_interval[:-1]), split_interval[-1])
281        except ValueError:
282            raise ValueError("must be an integer followed by 'H' or 'M'")
283        return None
284
285    def _resolve_delimiter(self, delimiter):
286        if 'b' in self.mode and delimiter is not None:
287            logging.warning("Ignoring delimiter because file mode is binary")
288            return None
289
290        if delimiter:
291            delimiter = delimiter.encode("utf-8").decode("unicode_escape")
292
293        return delimiter
294
295    def _validate_date_format(self, date_format):
296        if not self.split_interval:
297            return date_format or ""
298
299        unit = self.split_interval[1]
300        value = self.split_interval[0]
301
302        # --- Decide requirements based on interval ---
303        if unit == "H":
304            even_days = value % 24 == 0
305            needs_hour = not even_days
306            needs_minute = False
307        elif unit == "M":
308            even_hours = value % 60 == 0
309            needs_hour = True
310            needs_minute = not even_hours
311        else:
312            return DEFAULT_DATETIME_STR  # fallback
313
314        # --- Default formats if user didn’t supply one ---
315        if not date_format:
316            if unit == "H":
317                return (DEFAULT_DATETIME_STR if even_days
318                        else DEFAULT_DATETIME_STR + "T%H00")
319            if unit == "M":
320                # Concise f-string format
321                return (f"{DEFAULT_DATETIME_STR}T%H"
322                        f"{'%M' if needs_minute else '00'}")
323
324        # --- Extract directives ---
325        found = set(re.findall(r"%[a-zA-Z]", date_format))
326
327        # Must always have year
328        if "%Y" not in found:
329            raise ValueError("date_format must include %Y (year).")
330
331        # Must have either month+day or julian day
332        if not ({"%m", "%d"} <= found or "%j" in found):
333            raise ValueError("date_format must include %m, %d (month, day) "
334                             "or %j (day-of-year).")
335
336        # Hours?
337        if needs_hour and "%H" not in found:
338            raise ValueError("date_format must include %H (hour).")
339
340        # Minutes?
341        if needs_minute and "%M" not in found:
342            raise ValueError("date_format must include %M (minute).")
343
344        return date_format
345
346    ############################
347    def __del__(self):
348        if hasattr(self, 'file') and self.file:
349            self.file.close()
350
351    ############################
352    def _get_split_interval_in_seconds(self):
353
354        if not self.split_interval:
355            return 0
356
357        if self.split_interval[1] == 'H':
358            return self.split_interval[0] * 3600
359
360        if self.split_interval[1] == 'M':
361            return self.split_interval[0] * 60
362
363        return 0
364
365    ############################
366    def _get_file_date_format(self):
367        """Return a string to be used for the file suffix."""
368
369        # Note: the self.timestamp variable exists for debugging, and
370        # should be left as None in actual use, which tells the time_str
371        # method to use current system time.
372
373        # if there is no split interval
374        if self.timestamp:
375            return time_str(timestamp=self.timestamp,
376                            time_zone=self.time_zone,
377                            time_format=self.date_format)
378
379        # if the data is being split by N hours
380        elif self.split_interval[1] == 'H':  # hour
381            timestamp_raw = datetime.now(self.time_zone)
382            # Round down to nearest interval
383            timestamp_hour = (self.split_interval[0] *
384                              math.floor(timestamp_raw.hour /
385                                         self.split_interval[0]))
386            timestamp_proc = timestamp_raw.replace(hour=timestamp_hour,
387                                                   minute=0, second=0)
388            self.next_file_split = (timestamp_proc +
389                                    timedelta(seconds=self
390                                              .split_interval_in_seconds))
391
392            return time_str(timestamp=timestamp_proc.timestamp(),
393                            time_zone=self.time_zone,
394                            time_format=self.date_format)
395
396        # if the data is being split by N minutes
397        elif self.split_interval[1] == 'M':  # minute
398            timestamp_raw = datetime.now(self.time_zone)
399            # Round down to nearest interval
400            timestamp_minute = (self.split_interval[0] *
401                                math.floor(timestamp_raw.minute /
402                                           self.split_interval[0]))
403            timestamp_proc = timestamp_raw.replace(minute=timestamp_minute,
404                                                   second=0)
405            self.next_file_split = (timestamp_proc +
406                                    timedelta(seconds=self
407                                              .split_interval_in_seconds))
408
409            return time_str(timestamp=timestamp_proc.timestamp(),
410                            time_zone=self.time_zone,
411                            time_format=self.date_format)
412
413        return ""
414
415    ############################
416    def _set_file(self, filename):
417        """Set the current file to the specified filename."""
418
419        # If they haven't given us a filename, we'll write to stdout
420        if filename is None:
421            self.file = sys.stdout
422
423            if self.header is not None:
424                self.file.write(self.header)
425
426            return
427
428        # If here, we have a filename. If we already have a file open,
429        # close it, then open the new one.
430        if self.file:
431            self.file.close()
432
433        # Check to see if file already exists
434        file_is_new = not os.path.isfile(filename)
435
436        # Finally, open the specified file with the specified mode and encoding
437        logging.info("opening %s with mode=%s and encoding=%s",
438                     filename, self.mode, self.encoding)
439        self.file = open(filename, self.mode, encoding=self.encoding)
440
441        # Add header record to file if a header was specified and the file was
442        # just created.
443        if file_is_new and self.header:
444            self.file.write(self.header)
445
446    ############################
447    def write(self, record: Union[str, bytes]):
448        """ Write out record, appending a newline at end."""
449
450        # See if it's something we can process, and if not, try digesting
451        if not self.can_process_record(record):  # inherited from BaseModule()
452            self.digest_record(record)  # inherited from BaseModule()
453            return
454
455        if not self.filebase:
456            self._set_file(None)
457
458        # If we're splitting by some time interval, see if it's time to
459        # roll over to a new file.
460        # if self.split_by_time or self.split_interval is not None:
461        elif (self.split_interval and
462              datetime.now(self.time_zone) > self.next_file_split):
463            new_file_date_format = self._get_file_date_format()
464            if new_file_date_format != self.file_date_format:
465                self.file_date_format = new_file_date_format
466                if new_file_date_format.startswith('^'):
467                    self._set_file(
468                        os.path.dirname(self.filebase)
469                        + new_file_date_format[1:]
470                        + os.path.basename(self.filebase)
471                        + self.suffix
472                    )
473                else:
474                    self._set_file(self.filebase + new_file_date_format +
475                                   self.suffix)
476
477        # If we're not splitting by intervals, still check that we've got
478        # a file open we can write to. If not, open it.
479        else:
480            if not self.file:
481                self._set_file(self.filebase + self.suffix)
482
483        # Write the record and flush if requested
484        self.file.write(record)
485        if self.delimiter is not None:
486            self.file.write(self.delimiter)
487        if self.flush:
488            self.file.flush()

Write to the specified file. If filename is empty, write to stdout.

FileWriter( filebase=None, filename=None, mode='a', delimiter='\n', flush=True, split_by_time=False, split_interval=None, header=None, header_file=None, time_format=None, date_format=None, suffix=None, time_zone=datetime.timezone.utc, create_path=True, **kwargs)
 23    def __init__(self,
 24                 filebase=None,
 25                 filename=None,          # deprecated
 26                 mode='a',
 27                 delimiter='\n',
 28                 flush=True,
 29                 split_by_time=False,    # deprecated
 30                 split_interval=None,
 31                 header=None,
 32                 header_file=None,
 33                 time_format=None,       # deprecated
 34                 date_format=None,
 35                 suffix=None,
 36                 time_zone=timezone.utc,
 37                 create_path=True,
 38                 **kwargs):
 39        """Write text records to a file. If no filename is specified, write to
 40        stdout.
 41        ```
 42
 43        filebase    A filebase string that will be used as for the output
 44                    filename.
 45
 46        filename    DEPRECATED Name of file to write to. If None, write to
 47                    stdout.
 48
 49        mode        Mode with which to open file. 'a' by default to append, but
 50                    can also be 'w' to truncate, 'ab' to append in binary mode,
 51                    or any other valid Python write file mode.
 52
 53        delimiter   By default, append a newline after each record written. Set
 54                    to None to disable appending any record delimiter. Ignored
 55                    if mode is for binary.
 56
 57        flush       If True (default), flush after every write() call
 58
 59        split_by_time   DEPRECATED Create a separate text file for each (by
 60                        default) day, appending a -YYYY-MM-DD string to the
 61                        specified filename. By overridding time_format, other
 62                        split intervals, such as hourly or monthly, may be
 63                        imposed.
 64
 65        split_interval  If set the file will trucate at the specified interval.
 66                        The value must be a string containing an integer
 67                        followed by a 'H' (hours) or 'M' (minutes). Default
 68                        is to not split.
 69
 70        header          A string to add to the beginning of a new file.
 71
 72        header_file     A string containing the path to file containing a
 73                        header string to add to the beginning of a new file.
 74
 75        time_format     DEPRECATED By default ISO 8601-compliant '-%Y-%m-%d'.
 76                        If, e.g. '-%Y-%m' is used, files will be split by
 77                        month; if -%y-%m-%d:%H' is specified, splits will be
 78                        hourly. If '%y+%j' is specified, splits will be daily,
 79                        but named via Julian date.  Putting '-' or '.' on the
 80                        left indicates timestamp suffix, putting it on the
 81                        right indicates timestamp prefix.  If you put '-' or
 82                        '.' on both sides, it's handled as a suffix.
 83
 84        date_fomat      A strftime-compatible string, such as '%Y-%m-%d';
 85                        defaults to '-' plus whatever's defined in
 86                        utils.timestamps.DATE_FORMAT.  If the value starts with
 87                        a '^' character, the string will prepend the file
 88                        name portion of the filebase
 89
 90        suffix          A suffix string to add to the log filename.
 91
 92        time_zone       Timezone to use when constructing the date_format
 93                        portion of the filenames.
 94
 95        create_path     Create directory path to file if it doesn't exist.
 96
 97        quiet           If True, don't complain if a record doesn't match
 98                        any mapped prefix.
 99
100        encoding        'utf-8' by default. If empty or None, do not attempt
101                        any decoding and return raw bytes. Other possible
102                        encodings are listed in online documentation here:
103                        [https://docs.python.org/3/library/codecs.html]
104                        (https://docs.python.org/3/library/codecs.html)
105
106        encoding_errors 'ignore' by default. Other error strategies are
107                        'strict', 'replace', and 'backslashreplace', described
108                        here: [https://docs.python.org/3/howto/unicode.html]
109                        (https://docs.python.org/3/howto/unicode.html)
110
111        Sample invocations (original vs proposed)
112        - Write to stdout:
113            Original: FileWriter(None)
114            Proposed: FileWriter(None)
115
116        - Write to file, no split:
117            Original: FileWriter(/data/sample_file)
118            Proposed: FileWriter(/data/sample_file)
119
120        - Write to file, no split, with header:
121            Original: FileWriter(/data/sample_file, header='This is a header')
122            Proposed: FileWriter(/data/sample_file, header='This is a header')
123
124        - Write to file, daily split: (filename = /data/sample_file-%Y-%m-%d)
125            Original: FileWriter(/data/sample_file, split_by_time=True)
126            Proposed: FileWriter(/data/sample_file, split_interval='24H')
127
128        - Write to file, daily split, with suffix:
129          (filename = /data/sample_file-%Y-%m-%d.txt)
130            Original: FileWriter(/data/sample_file, split_by_time=True,
131                                 time_format='-%Y-%m-%d.txt')
132            Proposed: FileWriter(/data/sample_file, split_interval='24H',
133                                 suffix='.txt')
134
135        - Write to file, daily split: (filename = /data/sample_file-%Y-%j)
136            Original: FileWriter(/data/sample_file, split_by_time=True,
137                                 time_format='-%Y-%j')
138            Proposed: FileWriter(/data/sample_file, split_interval='24H',
139                                 date_format='-%Y-%j')
140
141        - Write to file, hourly split:
142          (filename = /data/sample_file-%Y-%m-%dT%H00)
143            Original: FileWriter(/data/sample_file, split_by_time=True,
144                                 time_format='-%Y-%m-%d:%H00')
145            Proposed: FileWriter(/data/sample_file, split_interval='1H')
146
147        - Write to file, 15-minute split:
148          (filename = /data/sample_file-%Y-%m-%dT%H%M)
149            Original: FileWriter(/data/sample_file, split_interval='15M',
150                                 time_format='-%Y-%m-%d:%H%M')
151            Proposed: FileWriter(/data/sample_file, split_interval='15M')
152
153        - Write to file, 15-minute split:
154          (filename = /data/%Y-%m-%dT%H%M-sample_file)
155            Original: FileWriter(/data/sample_file, split_interval='15M',
156                                 time_format='%Y-%m-%d:%H%M-')
157            Proposed: FileWriter(/data/sample_file, split_interval='15M',
158                                 date_format='^%Y-%m-%d:%H%M-')
159
160        - Write to file, 15-minute split, with suffix:
161          (filename = /data/%Y-%m-%dT%H%M-sample_file.txt)
162            Original: FileWriter(/data/sample_file.txt, split_interval='15M',
163                                 time_format='%Y-%m-%d:%H%M-')
164            Proposed: FileWriter(/data/sample_file, split_interval='15M',
165                                 date_format='^%Y-%m-%d:%H%M-', suffix='.txt')
166
167        ```
168        """
169        super().__init__(**kwargs)  # processes 'quiet', encoding and hints
170
171        if 'b' in mode and (self.encoding or self.encoding_errors) is not None:
172            logging.warning("Ignoring encoding and encoding_errors because"
173                            " file mode is binary")
174            self.encoding = self.encoding_errors = None
175
176        # --- Deprecated args ---
177        if filename is not None:
178            filebase = filename
179            # warnings.warn(
180            #     "`filename` is deprecated, use `filebase` instead",
181            #     DeprecationWarning,
182            #     stacklevel=2,
183            # )
184        if split_by_time:
185            split_interval = split_interval or '24H'
186            # warnings.warn(
187            #     "`split_by_time` is deprecated, use `split_interval` instead",
188            #     DeprecationWarning,
189            #     stacklevel=2,
190            # )
191        if time_format is not None:
192            # warnings.warn(
193            #     "`time_format` is deprecated, use `date_format` instead",
194            #     DeprecationWarning,
195            #     stacklevel=2,
196            # )
197            date_format = ('^' + time_format[:-1]
198                           if time_format.endswith('-') else time_format)
199
200        # Can't use split_interval or split_by_time if filebase and filename
201        # are None
202        if split_interval and filebase is None:
203            raise ValueError("filebase must be specified")
204
205        # --- Base file name ---
206        self.filebase = filebase
207
208        # --- File settings ---
209        self.mode = mode
210        self.flush = flush
211        self.suffix = suffix or ''
212        self.time_zone = time_zone
213        self.next_file_split = datetime.now(self.time_zone)
214
215        # --- Delimiter ---
216        self.delimiter = self._resolve_delimiter(delimiter)
217
218        # --- Header handling ---
219        self.header = self._load_header(header, header_file)
220
221        # --- Split interval ---
222        self.split_interval = self._validate_split_interval(split_interval)
223        self.split_interval_in_seconds = self._get_split_interval_in_seconds()
224
225        # --- Date/time format ---
226        self.date_format = self._validate_date_format(date_format)
227
228        # --- Ensure path exists ---
229        if self.filebase and create_path:
230            os.makedirs(os.path.dirname(self.filebase), exist_ok=True)
231
232        # --- File state ---
233        self.file = None
234        self.file_date_format = None
235
236        # A hook to aid in debugging; should be None to use system time.
237        self.timestamp = None

Write text records to a file. If no filename is specified, write to stdout.


filebase    A filebase string that will be used as for the output
            filename.

filename    DEPRECATED Name of file to write to. If None, write to
            stdout.

mode        Mode with which to open file. 'a' by default to append, but
            can also be 'w' to truncate, 'ab' to append in binary mode,
            or any other valid Python write file mode.

delimiter   By default, append a newline after each record written. Set
            to None to disable appending any record delimiter. Ignored
            if mode is for binary.

flush       If True (default), flush after every write() call

split_by_time   DEPRECATED Create a separate text file for each (by
                default) day, appending a -YYYY-MM-DD string to the
                specified filename. By overridding time_format, other
                split intervals, such as hourly or monthly, may be
                imposed.

split_interval  If set the file will trucate at the specified interval.
                The value must be a string containing an integer
                followed by a 'H' (hours) or 'M' (minutes). Default
                is to not split.

header          A string to add to the beginning of a new file.

header_file     A string containing the path to file containing a
                header string to add to the beginning of a new file.

time_format     DEPRECATED By default ISO 8601-compliant '-%Y-%m-%d'.
                If, e.g. '-%Y-%m' is used, files will be split by
                month; if -%y-%m-%d:%H' is specified, splits will be
                hourly. If '%y+%j' is specified, splits will be daily,
                but named via Julian date.  Putting '-' or '.' on the
                left indicates timestamp suffix, putting it on the
                right indicates timestamp prefix.  If you put '-' or
                '.' on both sides, it's handled as a suffix.

date_fomat      A strftime-compatible string, such as '%Y-%m-%d';
                defaults to '-' plus whatever's defined in
                utils.timestamps.DATE_FORMAT.  If the value starts with
                a '^' character, the string will prepend the file
                name portion of the filebase

suffix          A suffix string to add to the log filename.

time_zone       Timezone to use when constructing the date_format
                portion of the filenames.

create_path     Create directory path to file if it doesn't exist.

quiet           If True, don't complain if a record doesn't match
                any mapped prefix.

encoding        'utf-8' by default. If empty or None, do not attempt
                any decoding and return raw bytes. Other possible
                encodings are listed in online documentation here:
                [https://docs.python.org/3/library/codecs.html]
                (https://docs.python.org/3/library/codecs.html)

encoding_errors 'ignore' by default. Other error strategies are
                'strict', 'replace', and 'backslashreplace', described
                here: [https://docs.python.org/3/howto/unicode.html]
                (https://docs.python.org/3/howto/unicode.html)

Sample invocations (original vs proposed)
- Write to stdout:
    Original: FileWriter(None)
    Proposed: FileWriter(None)

- Write to file, no split:
    Original: FileWriter(/data/sample_file)
    Proposed: FileWriter(/data/sample_file)

- Write to file, no split, with header:
    Original: FileWriter(/data/sample_file, header='This is a header')
    Proposed: FileWriter(/data/sample_file, header='This is a header')

- Write to file, daily split: (filename = /data/sample_file-%Y-%m-%d)
    Original: FileWriter(/data/sample_file, split_by_time=True)
    Proposed: FileWriter(/data/sample_file, split_interval='24H')

- Write to file, daily split, with suffix:
  (filename = /data/sample_file-%Y-%m-%d.txt)
    Original: FileWriter(/data/sample_file, split_by_time=True,
                         time_format='-%Y-%m-%d.txt')
    Proposed: FileWriter(/data/sample_file, split_interval='24H',
                         suffix='.txt')

- Write to file, daily split: (filename = /data/sample_file-%Y-%j)
    Original: FileWriter(/data/sample_file, split_by_time=True,
                         time_format='-%Y-%j')
    Proposed: FileWriter(/data/sample_file, split_interval='24H',
                         date_format='-%Y-%j')

- Write to file, hourly split:
  (filename = /data/sample_file-%Y-%m-%dT%H00)
    Original: FileWriter(/data/sample_file, split_by_time=True,
                         time_format='-%Y-%m-%d:%H00')
    Proposed: FileWriter(/data/sample_file, split_interval='1H')

- Write to file, 15-minute split:
  (filename = /data/sample_file-%Y-%m-%dT%H%M)
    Original: FileWriter(/data/sample_file, split_interval='15M',
                         time_format='-%Y-%m-%d:%H%M')
    Proposed: FileWriter(/data/sample_file, split_interval='15M')

- Write to file, 15-minute split:
  (filename = /data/%Y-%m-%dT%H%M-sample_file)
    Original: FileWriter(/data/sample_file, split_interval='15M',
                         time_format='%Y-%m-%d:%H%M-')
    Proposed: FileWriter(/data/sample_file, split_interval='15M',
                         date_format='^%Y-%m-%d:%H%M-')

- Write to file, 15-minute split, with suffix:
  (filename = /data/%Y-%m-%dT%H%M-sample_file.txt)
    Original: FileWriter(/data/sample_file.txt, split_interval='15M',
                         time_format='%Y-%m-%d:%H%M-')
    Proposed: FileWriter(/data/sample_file, split_interval='15M',
                         date_format='^%Y-%m-%d:%H%M-', suffix='.txt')

filebase
mode
flush
suffix
time_zone
next_file_split
delimiter
header
split_interval
split_interval_in_seconds
date_format
file
file_date_format
timestamp
def write(self, record: Union[str, bytes]):
447    def write(self, record: Union[str, bytes]):
448        """ Write out record, appending a newline at end."""
449
450        # See if it's something we can process, and if not, try digesting
451        if not self.can_process_record(record):  # inherited from BaseModule()
452            self.digest_record(record)  # inherited from BaseModule()
453            return
454
455        if not self.filebase:
456            self._set_file(None)
457
458        # If we're splitting by some time interval, see if it's time to
459        # roll over to a new file.
460        # if self.split_by_time or self.split_interval is not None:
461        elif (self.split_interval and
462              datetime.now(self.time_zone) > self.next_file_split):
463            new_file_date_format = self._get_file_date_format()
464            if new_file_date_format != self.file_date_format:
465                self.file_date_format = new_file_date_format
466                if new_file_date_format.startswith('^'):
467                    self._set_file(
468                        os.path.dirname(self.filebase)
469                        + new_file_date_format[1:]
470                        + os.path.basename(self.filebase)
471                        + self.suffix
472                    )
473                else:
474                    self._set_file(self.filebase + new_file_date_format +
475                                   self.suffix)
476
477        # If we're not splitting by intervals, still check that we've got
478        # a file open we can write to. If not, open it.
479        else:
480            if not self.file:
481                self._set_file(self.filebase + self.suffix)
482
483        # Write the record and flush if requested
484        self.file.write(record)
485        if self.delimiter is not None:
486            self.file.write(self.delimiter)
487        if self.flush:
488            self.file.flush()

Write out record, appending a newline at end.