openrvdas.logger.writers.logfile_writer

No module-level documentation available.
  1#!/usr/bin/env python3
  2
  3import os
  4import json
  5import logging
  6import re
  7import math
  8from datetime import datetime, timedelta, timezone
  9
 10from typing import Union
 11from logger.utils.das_record import DASRecord  # noqa: E402
 12from logger.utils import timestamp  # noqa: E402
 13from logger.writers.writer import Writer  # noqa: E402
 14from logger.writers.file_writer import FileWriter  # noqa: E402
 15
 16DEFAULT_DATETIME_STR = '-' + timestamp.DATE_FORMAT
 17
 18
 19class LogfileWriter(Writer):
 20    """Write to the specified filebase, with datestamp appended. If filebase
 21    is a <regex>:<filebase> dict, write records to every filebase whose
 22    regex appears in the record.
 23    """
 24    def __init__(self,
 25                 filebase=None,
 26                 delimiter='\n',
 27                 flush=True,
 28                 split_interval='24H',
 29                 header=None,
 30                 header_file=None,
 31                 time_format=timestamp.TIME_FORMAT,
 32                 date_format=DEFAULT_DATETIME_STR,
 33                 time_zone=timezone.utc,
 34                 suffix=None,
 35                 split_char=' ',
 36                 **kwargs):
 37        """Write timestamped records to a filebase. The filebase will
 38        have the current date appended, in keeping with R2R format
 39        recommendations (http://www.rvdata.us/operators/directory). When the
 40        timestamped date on records rolls over to next day, create a new file
 41        with the new date suffix.
 42
 43        If filebase is a dict of <string>:<filebase> pairs, The writer will
 44        attempt to match a <string> in the dict to each record it receives.
 45        It will write the record to the filebase corresponding to the first
 46        string it matches (Note that the order of comparison is not
 47        guaranteed!). If no strings match, the record will be written to the
 48        standalone filebase provided.
 49
 50        Four formats of records can be written by a LogfileWriter:
 51            1. A string prefixed by a timestamp
 52            2. A DASRecord
 53            3. A dict that has a 'timestamp' key
 54            4. A list of any of the above
 55
 56        ```
 57        filebase        A filebase string to write to or a dict mapping
 58                        <string>:<filebase>.
 59
 60        delimiter       A character to trucate each incoming record.
 61
 62        flush           If True (default), flush after every write() call
 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                        value is '24H' (daily).
 68
 69        header          A string to add to the beginning of a new log file
 70                        or a dict mapping <string>:<header> to select the
 71                        header string based on the record contents.
 72
 73        header_file     A string containing the path to file containing a
 74                        header string to add to the beginning of a new log
 75                        file or a dict mapping <string>:<header_file> to select
 76                        the header filepath based on the record contents.
 77
 78        time_format     The format of the record's timestamp. Defaults to
 79                        whatever's defined in utils.timestamp.TIME_FORMAT.
 80
 81        date_fomat      A strftime-compatible string, such as '%Y-%m-%d';
 82                        defaults to '-' plus whatever's defined in
 83                        utils.timestamps.DATE_FORMAT.  If the value starts with
 84                        a '^' character, the string will prepend the file
 85                        name portion of the filebase
 86
 87        time_zone       Timezone to use when constructing the date_format
 88                        portion of the filenames.
 89
 90        suffix          A suffix string to add to the log filename or a dict
 91                        mapping <string>:<suffix> to select the suffix to
 92                        add to a filename based on the record contents.
 93
 94        split_char      Delimiter between timestamp and rest of message
 95
 96        quiet           If True, don't complain if a record doesn't match
 97                        any mapped prefix
 98        ```
 99        """
100        super().__init__(**kwargs)  # processes 'quiet' and type hints
101
102        self.filebase = filebase
103        self.flush = flush
104        self.delimiter = delimiter
105        self.split_interval = self._validate_split_interval(split_interval)
106        self.split_interval_in_seconds = self._get_split_interval_in_seconds()
107        self.time_format = time_format
108        self.date_format = self._validate_date_format(date_format)
109        self.time_zone = time_zone
110        self.split_char = split_char
111        self.suffix = suffix or ''
112
113        self.header = self._load_header(header, header_file)
114
115        # If our filebase is a dict, we're going to be doing our
116        # fancy pattern->filebase mapping.
117        self.do_filebase_mapping = isinstance(self.filebase, dict)
118
119        if self.do_filebase_mapping:
120            # Do our matches faster by precompiling
121            self.compiled_filebase_map = {
122                pattern: re.compile(pattern) for pattern in self.filebase
123            }
124
125        # If our suffix is a dict, we're going to be doing our
126        # fancy pattern->suffix mapping.
127        self.do_suffix_mapping = isinstance(self.suffix, dict)
128
129        if self.do_suffix_mapping:
130            # Do our matches faster by precompiling
131            self.compiled_suffix_map = {
132                pattern: re.compile(pattern) for pattern in self.suffix
133            }
134
135        # If our header is a dict, we're going to be doing our
136        # fancy pattern->header mapping.
137        self.do_header_mapping = isinstance(self.header, dict)
138
139        if self.do_header_mapping:
140            # Do our matches faster by precompiling
141            self.compiled_header_map = {
142                pattern: re.compile(pattern) for pattern in self.header
143            }
144
145        self.current_filename = {}
146        self.writer = {}
147
148    ############################
149    def _validate_split_interval(self, split_interval):
150        """
151        Helper function to validate split_interval
152        """
153        if split_interval is None:
154            return None
155        if not isinstance(split_interval, str):
156            raise ValueError("split_interval must be a string like '1H' or '30M'")
157        if not split_interval.endswith(("H", "M")):
158            raise ValueError("must be an integer followed by 'H' or 'M'")
159        try:
160            return (int(split_interval[:-1]), split_interval[-1])
161        except ValueError:
162            raise ValueError("must be an integer followed by 'H' or 'M'")
163        return None
164
165    ############################
166    def _validate_date_format(self, date_format):
167        if not self.split_interval:
168            return date_format or ""
169
170        unit = self.split_interval[1]
171        value = self.split_interval[0]
172
173        # --- Decide requirements based on interval ---
174        if unit == "H":
175            even_days = value % 24 == 0
176            needs_hour = not even_days
177            needs_minute = False
178        elif unit == "M":
179            even_hours = value % 60 == 0
180            needs_hour = True
181            needs_minute = not even_hours
182        else:
183            return DEFAULT_DATETIME_STR  # fallback
184
185        # --- Default formats if user didn’t supply one ---
186        if not date_format:
187            if unit == "H":
188                return DEFAULT_DATETIME_STR if even_days else DEFAULT_DATETIME_STR + "T%H00"
189            if unit == "M":
190                return f"{DEFAULT_DATETIME_STR}T%H{'%M' if needs_minute else '00'}"
191        # --- Extract directives ---
192        found = set(re.findall(r"%[a-zA-Z]", date_format))
193
194        # Must always have year
195        if "%Y" not in found:
196            raise ValueError("date_format must include %Y (year).")
197
198        # Must have either month+day or julian day
199        if not ({"%m", "%d"} <= found or "%j" in found):
200            raise ValueError("date_format must include %m, %d (month, day) or %j (day-of-year).")
201
202        # Hours?
203        if needs_hour and "%H" not in found:
204            raise ValueError("date_format must include %H (hour).")
205
206        # Minutes?
207        if needs_minute and "%M" not in found:
208            raise ValueError("date_format must include %M (minute).")
209
210        return date_format
211
212    ############################
213    def _load_header(self, header, header_file):
214        """
215        Helper function to verify the header. If a header_file is specified the
216        files are read into a local str or dict depending on the data type of
217        the header_file argument
218        """
219
220        if header and header_file:
221            raise ValueError("Cannot specify both `header` and `header_file`")
222
223        # Case 1: simple string header
224        if header:
225            return header
226
227        # Case 2: header is a dict {key: filepath}
228        if isinstance(header, dict):
229            result = {}
230            for key, header_str in header.items():
231                if not isinstance(header_str, str):
232                    raise ValueError(f"Invalid string for header key {key}: {header_str!r}")
233                result[key] = header_str
234
235            return result
236
237        # Case 3: header_file is a single path
238        if isinstance(header_file, str):
239            try:
240                with open(header_file, "r", encoding="utf-8") as hf:
241                    return hf.read().strip()
242            except OSError as e:
243                raise ValueError(f"Error reading header_file {header_file}: {e}")
244
245        # Case 4: header_file is a dict {key: filepath}
246        if isinstance(header_file, dict):
247            result = {}
248            for key, path in header_file.items():
249                if not isinstance(path, str):
250                    raise ValueError(f"Invalid path for header key {key}: {path!r}")
251                try:
252                    with open(path, "r", encoding="utf-8") as hf:
253                        result[key] = hf.read().strip()
254                except OSError as e:
255                    raise ValueError(f"Error reading header_file {path} for key {key}: {e}")
256            return result
257
258        return None
259
260    ############################
261    def _get_split_interval_in_seconds(self):
262        """
263        Helper function to calculate the value of the split_interval in seconds
264        """
265
266        if not self.split_interval:
267            return 0
268
269        if self.split_interval[1] == 'H':
270            return self.split_interval[0] * 3600
271
272        if self.split_interval[1] == 'M':
273            return self.split_interval[0] * 60
274
275        return 0
276
277    ############################
278    def _get_file_date_format(self, ts):
279        """
280        Helper function to return build the date_format portion of the
281        filename.
282        """
283
284        # if the data is being split by N hours
285        if self.split_interval[1] == 'H':  # hour
286            timestamp_raw = datetime.fromtimestamp(ts, tz=self.time_zone)
287            timestamp_hour = (self.split_interval[0] *
288                              math.floor(timestamp_raw.hour/self.split_interval[0]))
289            timestamp_proc = timestamp_raw.replace(hour=timestamp_hour, minute=0, second=0)
290            self.next_file_split = (timestamp_proc +
291                                    timedelta(seconds=self.split_interval_in_seconds))
292
293            return timestamp.time_str(timestamp=timestamp_proc.timestamp(),
294                                      time_zone=self.time_zone,
295                                      time_format=self.date_format)
296
297        # if the data is being split by N minutes
298        elif self.split_interval[1] == 'M':  # minute
299            timestamp_raw = datetime.fromtimestamp(ts, tz=self.time_zone)
300            timestamp_minute = (self.split_interval[0] *
301                                math.floor(timestamp_raw.minute/self.split_interval[0]))
302            timestamp_proc = timestamp_raw.replace(minute=timestamp_minute, second=0)
303            self.next_file_split = (timestamp_proc +
304                                    timedelta(seconds=self.split_interval_in_seconds))
305
306            return timestamp.time_str(timestamp=timestamp_proc.timestamp(),
307                                      time_zone=self.time_zone,
308                                      time_format=self.date_format)
309
310        return ""
311
312    ############################
313    def fetch_suffix(self, record: str, filename_pattern: str = 'fixed'):
314        """
315        Return the suffix for the given record.  If filename_pattern os defined
316        (because filebase has already matched to a pattern) then that pattern
317        is used over what pattern would normally match.
318        """
319
320        if not self.do_suffix_mapping:
321            return self.suffix
322
323        if filename_pattern != "fixed":
324            return_suffix = self.suffix.get(filename_pattern)
325
326            if return_suffix:
327                return return_suffix
328
329            if not self.quiet:
330                logging.warning('LogfileWriter.fetch_suffix() - no suffix match: "%s"!', record)
331            return
332
333        for pattern, regex in self.compiled_suffix_map.items():
334            if regex and regex.search(record):
335                return self.suffix.get(pattern)
336
337        logging.warning('LogfileWriter.fetch_suffix() - no suffix match: "%s"!', record)
338
339    ############################
340    def fetch_header(self, record: str, filename_pattern: str = 'fixed'):
341        """
342        Return the header for the given record.  If filename_pattern os defined
343        (because filebase has already matched to a pattern) then that pattern
344        is used over what pattern would normally match.
345        """
346
347        if not self.do_header_mapping:
348            return self.header
349
350        if filename_pattern != "fixed":
351            return_header = self.header.get(filename_pattern)
352
353            if return_header:
354                return return_header
355
356            if not self.quiet:
357                logging.warning('LogfileWriter.fetch_header() - no header match: "%s"', record)
358            return ''
359
360        for pattern, regex in self.compiled_header_map.items():
361            if regex and regex.search(record):
362                return self.header.get(pattern, '')
363
364        logging.warning('LogfileWriter.fetch_header() - no header match: "%s"', record)
365        return ''
366
367    ############################
368    def write(self, record: Union[str, DASRecord, dict]):
369        if record == '':
370            return
371
372        # See if it's something we can process, and if not, try digesting
373        if not self.can_process_record(record):  # inherited from BaseModule()
374            self.digest_record(record)  # inherited from BaseModule()
375            return
376
377        # Look for the timestamp
378        if isinstance(record, DASRecord):  # If DASRecord or structured dict,
379            ts = record.timestamp          # convert to JSON before writing
380            record = record.as_json()
381
382        elif isinstance(record, dict):
383            ts = record.get('timestamp')
384            if ts is None:
385                if not self.quiet:
386                    logging.error('LogfileWriter.write() - bad timestamp: "%s"', record)
387                return
388            record = json.dumps(record)
389
390        elif isinstance(record, str):  # If str, it better begin with time string
391            try:  # Try to extract timestamp from record
392                time_str = record.split(self.split_char)[0]
393                ts = timestamp.timestamp(time_str, time_format=self.time_format)
394            except ValueError:
395                if not self.quiet:
396                    logging.error('LogfileWriter.write() - bad timestamp: "%s"', record)
397                    return
398        else:
399            if not self.quiet:
400                logging.error(f'LogfileWriter received badly formatted record. Must be DASRecord, '
401                              f'dict, or timestamp-prefixed string. Received: "{record}"')
402            return
403
404        # Now parse ts into hour and date strings
405        datetime_str = self._get_file_date_format(ts)
406
407        # Figure out where we're going to write
408        if self.do_filebase_mapping:
409            matched_patterns = [self.write_if_match(record, pattern, datetime_str)
410                                for pattern in self.filebase]
411            if True not in matched_patterns:
412                if not self.quiet:
413                    logging.warning(f'No patterns matched in LogfileWriter '
414                                    f'options for record "{record}"')
415        else:
416            pattern = 'fixed'  # just an arbitrary fixed pattern
417
418            suffix = self.fetch_suffix(record, pattern)
419            if suffix is None:
420                logging.error(f'System error: found no suffix matching record: "{record}"!')
421                return None
422
423            if datetime_str.startswith('^'):
424                filename = (
425                    os.path.dirname(self.filebase)
426                    + datetime_str[1:]
427                    + os.path.basename(self.filebase)
428                    + suffix
429                )
430            else:
431                filename = self.filebase + datetime_str + suffix
432
433            self.write_filename(record, pattern, filename)
434
435    ############################
436    def write_if_match(self, record, pattern, datetime_str):
437        """
438        If the record matches the pattern, write to the matching filebase.
439        """
440
441        # Find the compiled regex matching the pattern
442        regex = self.compiled_filebase_map.get(pattern)
443        if not regex:
444            logging.error(f'System error: found no regex matching pattern: "{pattern}"!')
445            return None
446
447        # If the pattern isn't in this record, go home quietly
448        if regex.search(record) is None:
449            return None
450
451        # Otherwise, we write.
452        filebase = self.filebase.get(pattern)
453        if filebase is None:
454            logging.error(f'System error: found no filebase matching pattern "{pattern}"!')
455            return None
456
457        suffix = self.fetch_suffix(record, pattern)
458        if suffix is None:
459            logging.error(f'System error: found no suffix matching pattern: "{pattern}"!')
460            return None
461
462        if datetime_str.startswith('^'):
463            filename = (
464                os.path.dirname(filebase)
465                + datetime_str[1:]
466                + os.path.basename(filebase)
467                + suffix
468            )
469        else:
470            filename = filebase + datetime_str + suffix
471
472        self.write_filename(record, pattern, filename)
473        return True
474
475    ############################
476    def write_filename(self, record, pattern, filename):
477        """
478        Write record to filename. If it's the first time we're writing to
479        this filename, create the appropriate FileWriter and insert it into
480        the map for the relevant pattern.
481        """
482
483        # Are we currently writing to this file? If not, open/create it.
484        if not filename == self.current_filename.get(pattern):
485
486            # calculate header/header_file and suffix
487            header = self.fetch_header(record, pattern) if self.do_header_mapping else self.header
488
489            self.current_filename[pattern] = filename
490            self.writer[pattern] = FileWriter(filename=filename,
491                                              delimiter=self.delimiter,
492                                              header=header,
493                                              flush=self.flush)
494        # Now, if our logic is correct, should *always* have a matching_writer
495        matching_writer = self.writer.get(pattern)
496        matching_writer.write(record)
DEFAULT_DATETIME_STR = '-%Y-%m-%d'
class LogfileWriter(logger.writers.writer.Writer):
 20class LogfileWriter(Writer):
 21    """Write to the specified filebase, with datestamp appended. If filebase
 22    is a <regex>:<filebase> dict, write records to every filebase whose
 23    regex appears in the record.
 24    """
 25    def __init__(self,
 26                 filebase=None,
 27                 delimiter='\n',
 28                 flush=True,
 29                 split_interval='24H',
 30                 header=None,
 31                 header_file=None,
 32                 time_format=timestamp.TIME_FORMAT,
 33                 date_format=DEFAULT_DATETIME_STR,
 34                 time_zone=timezone.utc,
 35                 suffix=None,
 36                 split_char=' ',
 37                 **kwargs):
 38        """Write timestamped records to a filebase. The filebase will
 39        have the current date appended, in keeping with R2R format
 40        recommendations (http://www.rvdata.us/operators/directory). When the
 41        timestamped date on records rolls over to next day, create a new file
 42        with the new date suffix.
 43
 44        If filebase is a dict of <string>:<filebase> pairs, The writer will
 45        attempt to match a <string> in the dict to each record it receives.
 46        It will write the record to the filebase corresponding to the first
 47        string it matches (Note that the order of comparison is not
 48        guaranteed!). If no strings match, the record will be written to the
 49        standalone filebase provided.
 50
 51        Four formats of records can be written by a LogfileWriter:
 52            1. A string prefixed by a timestamp
 53            2. A DASRecord
 54            3. A dict that has a 'timestamp' key
 55            4. A list of any of the above
 56
 57        ```
 58        filebase        A filebase string to write to or a dict mapping
 59                        <string>:<filebase>.
 60
 61        delimiter       A character to trucate each incoming record.
 62
 63        flush           If True (default), flush after every write() call
 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                        value is '24H' (daily).
 69
 70        header          A string to add to the beginning of a new log file
 71                        or a dict mapping <string>:<header> to select the
 72                        header string based on the record contents.
 73
 74        header_file     A string containing the path to file containing a
 75                        header string to add to the beginning of a new log
 76                        file or a dict mapping <string>:<header_file> to select
 77                        the header filepath based on the record contents.
 78
 79        time_format     The format of the record's timestamp. Defaults to
 80                        whatever's defined in utils.timestamp.TIME_FORMAT.
 81
 82        date_fomat      A strftime-compatible string, such as '%Y-%m-%d';
 83                        defaults to '-' plus whatever's defined in
 84                        utils.timestamps.DATE_FORMAT.  If the value starts with
 85                        a '^' character, the string will prepend the file
 86                        name portion of the filebase
 87
 88        time_zone       Timezone to use when constructing the date_format
 89                        portion of the filenames.
 90
 91        suffix          A suffix string to add to the log filename or a dict
 92                        mapping <string>:<suffix> to select the suffix to
 93                        add to a filename based on the record contents.
 94
 95        split_char      Delimiter between timestamp and rest of message
 96
 97        quiet           If True, don't complain if a record doesn't match
 98                        any mapped prefix
 99        ```
100        """
101        super().__init__(**kwargs)  # processes 'quiet' and type hints
102
103        self.filebase = filebase
104        self.flush = flush
105        self.delimiter = delimiter
106        self.split_interval = self._validate_split_interval(split_interval)
107        self.split_interval_in_seconds = self._get_split_interval_in_seconds()
108        self.time_format = time_format
109        self.date_format = self._validate_date_format(date_format)
110        self.time_zone = time_zone
111        self.split_char = split_char
112        self.suffix = suffix or ''
113
114        self.header = self._load_header(header, header_file)
115
116        # If our filebase is a dict, we're going to be doing our
117        # fancy pattern->filebase mapping.
118        self.do_filebase_mapping = isinstance(self.filebase, dict)
119
120        if self.do_filebase_mapping:
121            # Do our matches faster by precompiling
122            self.compiled_filebase_map = {
123                pattern: re.compile(pattern) for pattern in self.filebase
124            }
125
126        # If our suffix is a dict, we're going to be doing our
127        # fancy pattern->suffix mapping.
128        self.do_suffix_mapping = isinstance(self.suffix, dict)
129
130        if self.do_suffix_mapping:
131            # Do our matches faster by precompiling
132            self.compiled_suffix_map = {
133                pattern: re.compile(pattern) for pattern in self.suffix
134            }
135
136        # If our header is a dict, we're going to be doing our
137        # fancy pattern->header mapping.
138        self.do_header_mapping = isinstance(self.header, dict)
139
140        if self.do_header_mapping:
141            # Do our matches faster by precompiling
142            self.compiled_header_map = {
143                pattern: re.compile(pattern) for pattern in self.header
144            }
145
146        self.current_filename = {}
147        self.writer = {}
148
149    ############################
150    def _validate_split_interval(self, split_interval):
151        """
152        Helper function to validate split_interval
153        """
154        if split_interval is None:
155            return None
156        if not isinstance(split_interval, str):
157            raise ValueError("split_interval must be a string like '1H' or '30M'")
158        if not split_interval.endswith(("H", "M")):
159            raise ValueError("must be an integer followed by 'H' or 'M'")
160        try:
161            return (int(split_interval[:-1]), split_interval[-1])
162        except ValueError:
163            raise ValueError("must be an integer followed by 'H' or 'M'")
164        return None
165
166    ############################
167    def _validate_date_format(self, date_format):
168        if not self.split_interval:
169            return date_format or ""
170
171        unit = self.split_interval[1]
172        value = self.split_interval[0]
173
174        # --- Decide requirements based on interval ---
175        if unit == "H":
176            even_days = value % 24 == 0
177            needs_hour = not even_days
178            needs_minute = False
179        elif unit == "M":
180            even_hours = value % 60 == 0
181            needs_hour = True
182            needs_minute = not even_hours
183        else:
184            return DEFAULT_DATETIME_STR  # fallback
185
186        # --- Default formats if user didn’t supply one ---
187        if not date_format:
188            if unit == "H":
189                return DEFAULT_DATETIME_STR if even_days else DEFAULT_DATETIME_STR + "T%H00"
190            if unit == "M":
191                return f"{DEFAULT_DATETIME_STR}T%H{'%M' if needs_minute else '00'}"
192        # --- Extract directives ---
193        found = set(re.findall(r"%[a-zA-Z]", date_format))
194
195        # Must always have year
196        if "%Y" not in found:
197            raise ValueError("date_format must include %Y (year).")
198
199        # Must have either month+day or julian day
200        if not ({"%m", "%d"} <= found or "%j" in found):
201            raise ValueError("date_format must include %m, %d (month, day) or %j (day-of-year).")
202
203        # Hours?
204        if needs_hour and "%H" not in found:
205            raise ValueError("date_format must include %H (hour).")
206
207        # Minutes?
208        if needs_minute and "%M" not in found:
209            raise ValueError("date_format must include %M (minute).")
210
211        return date_format
212
213    ############################
214    def _load_header(self, header, header_file):
215        """
216        Helper function to verify the header. If a header_file is specified the
217        files are read into a local str or dict depending on the data type of
218        the header_file argument
219        """
220
221        if header and header_file:
222            raise ValueError("Cannot specify both `header` and `header_file`")
223
224        # Case 1: simple string header
225        if header:
226            return header
227
228        # Case 2: header is a dict {key: filepath}
229        if isinstance(header, dict):
230            result = {}
231            for key, header_str in header.items():
232                if not isinstance(header_str, str):
233                    raise ValueError(f"Invalid string for header key {key}: {header_str!r}")
234                result[key] = header_str
235
236            return result
237
238        # Case 3: header_file is a single path
239        if isinstance(header_file, str):
240            try:
241                with open(header_file, "r", encoding="utf-8") as hf:
242                    return hf.read().strip()
243            except OSError as e:
244                raise ValueError(f"Error reading header_file {header_file}: {e}")
245
246        # Case 4: header_file is a dict {key: filepath}
247        if isinstance(header_file, dict):
248            result = {}
249            for key, path in header_file.items():
250                if not isinstance(path, str):
251                    raise ValueError(f"Invalid path for header key {key}: {path!r}")
252                try:
253                    with open(path, "r", encoding="utf-8") as hf:
254                        result[key] = hf.read().strip()
255                except OSError as e:
256                    raise ValueError(f"Error reading header_file {path} for key {key}: {e}")
257            return result
258
259        return None
260
261    ############################
262    def _get_split_interval_in_seconds(self):
263        """
264        Helper function to calculate the value of the split_interval in seconds
265        """
266
267        if not self.split_interval:
268            return 0
269
270        if self.split_interval[1] == 'H':
271            return self.split_interval[0] * 3600
272
273        if self.split_interval[1] == 'M':
274            return self.split_interval[0] * 60
275
276        return 0
277
278    ############################
279    def _get_file_date_format(self, ts):
280        """
281        Helper function to return build the date_format portion of the
282        filename.
283        """
284
285        # if the data is being split by N hours
286        if self.split_interval[1] == 'H':  # hour
287            timestamp_raw = datetime.fromtimestamp(ts, tz=self.time_zone)
288            timestamp_hour = (self.split_interval[0] *
289                              math.floor(timestamp_raw.hour/self.split_interval[0]))
290            timestamp_proc = timestamp_raw.replace(hour=timestamp_hour, minute=0, second=0)
291            self.next_file_split = (timestamp_proc +
292                                    timedelta(seconds=self.split_interval_in_seconds))
293
294            return timestamp.time_str(timestamp=timestamp_proc.timestamp(),
295                                      time_zone=self.time_zone,
296                                      time_format=self.date_format)
297
298        # if the data is being split by N minutes
299        elif self.split_interval[1] == 'M':  # minute
300            timestamp_raw = datetime.fromtimestamp(ts, tz=self.time_zone)
301            timestamp_minute = (self.split_interval[0] *
302                                math.floor(timestamp_raw.minute/self.split_interval[0]))
303            timestamp_proc = timestamp_raw.replace(minute=timestamp_minute, second=0)
304            self.next_file_split = (timestamp_proc +
305                                    timedelta(seconds=self.split_interval_in_seconds))
306
307            return timestamp.time_str(timestamp=timestamp_proc.timestamp(),
308                                      time_zone=self.time_zone,
309                                      time_format=self.date_format)
310
311        return ""
312
313    ############################
314    def fetch_suffix(self, record: str, filename_pattern: str = 'fixed'):
315        """
316        Return the suffix for the given record.  If filename_pattern os defined
317        (because filebase has already matched to a pattern) then that pattern
318        is used over what pattern would normally match.
319        """
320
321        if not self.do_suffix_mapping:
322            return self.suffix
323
324        if filename_pattern != "fixed":
325            return_suffix = self.suffix.get(filename_pattern)
326
327            if return_suffix:
328                return return_suffix
329
330            if not self.quiet:
331                logging.warning('LogfileWriter.fetch_suffix() - no suffix match: "%s"!', record)
332            return
333
334        for pattern, regex in self.compiled_suffix_map.items():
335            if regex and regex.search(record):
336                return self.suffix.get(pattern)
337
338        logging.warning('LogfileWriter.fetch_suffix() - no suffix match: "%s"!', record)
339
340    ############################
341    def fetch_header(self, record: str, filename_pattern: str = 'fixed'):
342        """
343        Return the header for the given record.  If filename_pattern os defined
344        (because filebase has already matched to a pattern) then that pattern
345        is used over what pattern would normally match.
346        """
347
348        if not self.do_header_mapping:
349            return self.header
350
351        if filename_pattern != "fixed":
352            return_header = self.header.get(filename_pattern)
353
354            if return_header:
355                return return_header
356
357            if not self.quiet:
358                logging.warning('LogfileWriter.fetch_header() - no header match: "%s"', record)
359            return ''
360
361        for pattern, regex in self.compiled_header_map.items():
362            if regex and regex.search(record):
363                return self.header.get(pattern, '')
364
365        logging.warning('LogfileWriter.fetch_header() - no header match: "%s"', record)
366        return ''
367
368    ############################
369    def write(self, record: Union[str, DASRecord, dict]):
370        if record == '':
371            return
372
373        # See if it's something we can process, and if not, try digesting
374        if not self.can_process_record(record):  # inherited from BaseModule()
375            self.digest_record(record)  # inherited from BaseModule()
376            return
377
378        # Look for the timestamp
379        if isinstance(record, DASRecord):  # If DASRecord or structured dict,
380            ts = record.timestamp          # convert to JSON before writing
381            record = record.as_json()
382
383        elif isinstance(record, dict):
384            ts = record.get('timestamp')
385            if ts is None:
386                if not self.quiet:
387                    logging.error('LogfileWriter.write() - bad timestamp: "%s"', record)
388                return
389            record = json.dumps(record)
390
391        elif isinstance(record, str):  # If str, it better begin with time string
392            try:  # Try to extract timestamp from record
393                time_str = record.split(self.split_char)[0]
394                ts = timestamp.timestamp(time_str, time_format=self.time_format)
395            except ValueError:
396                if not self.quiet:
397                    logging.error('LogfileWriter.write() - bad timestamp: "%s"', record)
398                    return
399        else:
400            if not self.quiet:
401                logging.error(f'LogfileWriter received badly formatted record. Must be DASRecord, '
402                              f'dict, or timestamp-prefixed string. Received: "{record}"')
403            return
404
405        # Now parse ts into hour and date strings
406        datetime_str = self._get_file_date_format(ts)
407
408        # Figure out where we're going to write
409        if self.do_filebase_mapping:
410            matched_patterns = [self.write_if_match(record, pattern, datetime_str)
411                                for pattern in self.filebase]
412            if True not in matched_patterns:
413                if not self.quiet:
414                    logging.warning(f'No patterns matched in LogfileWriter '
415                                    f'options for record "{record}"')
416        else:
417            pattern = 'fixed'  # just an arbitrary fixed pattern
418
419            suffix = self.fetch_suffix(record, pattern)
420            if suffix is None:
421                logging.error(f'System error: found no suffix matching record: "{record}"!')
422                return None
423
424            if datetime_str.startswith('^'):
425                filename = (
426                    os.path.dirname(self.filebase)
427                    + datetime_str[1:]
428                    + os.path.basename(self.filebase)
429                    + suffix
430                )
431            else:
432                filename = self.filebase + datetime_str + suffix
433
434            self.write_filename(record, pattern, filename)
435
436    ############################
437    def write_if_match(self, record, pattern, datetime_str):
438        """
439        If the record matches the pattern, write to the matching filebase.
440        """
441
442        # Find the compiled regex matching the pattern
443        regex = self.compiled_filebase_map.get(pattern)
444        if not regex:
445            logging.error(f'System error: found no regex matching pattern: "{pattern}"!')
446            return None
447
448        # If the pattern isn't in this record, go home quietly
449        if regex.search(record) is None:
450            return None
451
452        # Otherwise, we write.
453        filebase = self.filebase.get(pattern)
454        if filebase is None:
455            logging.error(f'System error: found no filebase matching pattern "{pattern}"!')
456            return None
457
458        suffix = self.fetch_suffix(record, pattern)
459        if suffix is None:
460            logging.error(f'System error: found no suffix matching pattern: "{pattern}"!')
461            return None
462
463        if datetime_str.startswith('^'):
464            filename = (
465                os.path.dirname(filebase)
466                + datetime_str[1:]
467                + os.path.basename(filebase)
468                + suffix
469            )
470        else:
471            filename = filebase + datetime_str + suffix
472
473        self.write_filename(record, pattern, filename)
474        return True
475
476    ############################
477    def write_filename(self, record, pattern, filename):
478        """
479        Write record to filename. If it's the first time we're writing to
480        this filename, create the appropriate FileWriter and insert it into
481        the map for the relevant pattern.
482        """
483
484        # Are we currently writing to this file? If not, open/create it.
485        if not filename == self.current_filename.get(pattern):
486
487            # calculate header/header_file and suffix
488            header = self.fetch_header(record, pattern) if self.do_header_mapping else self.header
489
490            self.current_filename[pattern] = filename
491            self.writer[pattern] = FileWriter(filename=filename,
492                                              delimiter=self.delimiter,
493                                              header=header,
494                                              flush=self.flush)
495        # Now, if our logic is correct, should *always* have a matching_writer
496        matching_writer = self.writer.get(pattern)
497        matching_writer.write(record)

Write to the specified filebase, with datestamp appended. If filebase is a : dict, write records to every filebase whose regex appears in the record.

LogfileWriter( filebase=None, delimiter='\n', flush=True, split_interval='24H', header=None, header_file=None, time_format='%Y-%m-%dT%H:%M:%S.%fZ', date_format='-%Y-%m-%d', time_zone=datetime.timezone.utc, suffix=None, split_char=' ', **kwargs)
 25    def __init__(self,
 26                 filebase=None,
 27                 delimiter='\n',
 28                 flush=True,
 29                 split_interval='24H',
 30                 header=None,
 31                 header_file=None,
 32                 time_format=timestamp.TIME_FORMAT,
 33                 date_format=DEFAULT_DATETIME_STR,
 34                 time_zone=timezone.utc,
 35                 suffix=None,
 36                 split_char=' ',
 37                 **kwargs):
 38        """Write timestamped records to a filebase. The filebase will
 39        have the current date appended, in keeping with R2R format
 40        recommendations (http://www.rvdata.us/operators/directory). When the
 41        timestamped date on records rolls over to next day, create a new file
 42        with the new date suffix.
 43
 44        If filebase is a dict of <string>:<filebase> pairs, The writer will
 45        attempt to match a <string> in the dict to each record it receives.
 46        It will write the record to the filebase corresponding to the first
 47        string it matches (Note that the order of comparison is not
 48        guaranteed!). If no strings match, the record will be written to the
 49        standalone filebase provided.
 50
 51        Four formats of records can be written by a LogfileWriter:
 52            1. A string prefixed by a timestamp
 53            2. A DASRecord
 54            3. A dict that has a 'timestamp' key
 55            4. A list of any of the above
 56
 57        ```
 58        filebase        A filebase string to write to or a dict mapping
 59                        <string>:<filebase>.
 60
 61        delimiter       A character to trucate each incoming record.
 62
 63        flush           If True (default), flush after every write() call
 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                        value is '24H' (daily).
 69
 70        header          A string to add to the beginning of a new log file
 71                        or a dict mapping <string>:<header> to select the
 72                        header string based on the record contents.
 73
 74        header_file     A string containing the path to file containing a
 75                        header string to add to the beginning of a new log
 76                        file or a dict mapping <string>:<header_file> to select
 77                        the header filepath based on the record contents.
 78
 79        time_format     The format of the record's timestamp. Defaults to
 80                        whatever's defined in utils.timestamp.TIME_FORMAT.
 81
 82        date_fomat      A strftime-compatible string, such as '%Y-%m-%d';
 83                        defaults to '-' plus whatever's defined in
 84                        utils.timestamps.DATE_FORMAT.  If the value starts with
 85                        a '^' character, the string will prepend the file
 86                        name portion of the filebase
 87
 88        time_zone       Timezone to use when constructing the date_format
 89                        portion of the filenames.
 90
 91        suffix          A suffix string to add to the log filename or a dict
 92                        mapping <string>:<suffix> to select the suffix to
 93                        add to a filename based on the record contents.
 94
 95        split_char      Delimiter between timestamp and rest of message
 96
 97        quiet           If True, don't complain if a record doesn't match
 98                        any mapped prefix
 99        ```
100        """
101        super().__init__(**kwargs)  # processes 'quiet' and type hints
102
103        self.filebase = filebase
104        self.flush = flush
105        self.delimiter = delimiter
106        self.split_interval = self._validate_split_interval(split_interval)
107        self.split_interval_in_seconds = self._get_split_interval_in_seconds()
108        self.time_format = time_format
109        self.date_format = self._validate_date_format(date_format)
110        self.time_zone = time_zone
111        self.split_char = split_char
112        self.suffix = suffix or ''
113
114        self.header = self._load_header(header, header_file)
115
116        # If our filebase is a dict, we're going to be doing our
117        # fancy pattern->filebase mapping.
118        self.do_filebase_mapping = isinstance(self.filebase, dict)
119
120        if self.do_filebase_mapping:
121            # Do our matches faster by precompiling
122            self.compiled_filebase_map = {
123                pattern: re.compile(pattern) for pattern in self.filebase
124            }
125
126        # If our suffix is a dict, we're going to be doing our
127        # fancy pattern->suffix mapping.
128        self.do_suffix_mapping = isinstance(self.suffix, dict)
129
130        if self.do_suffix_mapping:
131            # Do our matches faster by precompiling
132            self.compiled_suffix_map = {
133                pattern: re.compile(pattern) for pattern in self.suffix
134            }
135
136        # If our header is a dict, we're going to be doing our
137        # fancy pattern->header mapping.
138        self.do_header_mapping = isinstance(self.header, dict)
139
140        if self.do_header_mapping:
141            # Do our matches faster by precompiling
142            self.compiled_header_map = {
143                pattern: re.compile(pattern) for pattern in self.header
144            }
145
146        self.current_filename = {}
147        self.writer = {}

Write timestamped records to a filebase. The filebase will have the current date appended, in keeping with R2R format recommendations (http://www.rvdata.us/operators/directory). When the timestamped date on records rolls over to next day, create a new file with the new date suffix.

If filebase is a dict of : pairs, The writer will attempt to match a in the dict to each record it receives. It will write the record to the filebase corresponding to the first string it matches (Note that the order of comparison is not guaranteed!). If no strings match, the record will be written to the standalone filebase provided.

Four formats of records can be written by a LogfileWriter: 1. A string prefixed by a timestamp 2. A DASRecord 3. A dict that has a 'timestamp' key 4. A list of any of the above

filebase        A filebase string to write to or a dict mapping
                <string>:<filebase>.

delimiter       A character to trucate each incoming record.

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

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
                value is '24H' (daily).

header          A string to add to the beginning of a new log file
                or a dict mapping <string>:<header> to select the
                header string based on the record contents.

header_file     A string containing the path to file containing a
                header string to add to the beginning of a new log
                file or a dict mapping <string>:<header_file> to select
                the header filepath based on the record contents.

time_format     The format of the record's timestamp. Defaults to
                whatever's defined in utils.timestamp.TIME_FORMAT.

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

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

suffix          A suffix string to add to the log filename or a dict
                mapping <string>:<suffix> to select the suffix to
                add to a filename based on the record contents.

split_char      Delimiter between timestamp and rest of message

quiet           If True, don't complain if a record doesn't match
                any mapped prefix
filebase
flush
delimiter
split_interval
split_interval_in_seconds
time_format
date_format
time_zone
split_char
suffix
header
do_filebase_mapping
do_suffix_mapping
do_header_mapping
current_filename
writer
def fetch_suffix(self, record: str, filename_pattern: str = 'fixed'):
314    def fetch_suffix(self, record: str, filename_pattern: str = 'fixed'):
315        """
316        Return the suffix for the given record.  If filename_pattern os defined
317        (because filebase has already matched to a pattern) then that pattern
318        is used over what pattern would normally match.
319        """
320
321        if not self.do_suffix_mapping:
322            return self.suffix
323
324        if filename_pattern != "fixed":
325            return_suffix = self.suffix.get(filename_pattern)
326
327            if return_suffix:
328                return return_suffix
329
330            if not self.quiet:
331                logging.warning('LogfileWriter.fetch_suffix() - no suffix match: "%s"!', record)
332            return
333
334        for pattern, regex in self.compiled_suffix_map.items():
335            if regex and regex.search(record):
336                return self.suffix.get(pattern)
337
338        logging.warning('LogfileWriter.fetch_suffix() - no suffix match: "%s"!', record)

Return the suffix for the given record. If filename_pattern os defined (because filebase has already matched to a pattern) then that pattern is used over what pattern would normally match.

def fetch_header(self, record: str, filename_pattern: str = 'fixed'):
341    def fetch_header(self, record: str, filename_pattern: str = 'fixed'):
342        """
343        Return the header for the given record.  If filename_pattern os defined
344        (because filebase has already matched to a pattern) then that pattern
345        is used over what pattern would normally match.
346        """
347
348        if not self.do_header_mapping:
349            return self.header
350
351        if filename_pattern != "fixed":
352            return_header = self.header.get(filename_pattern)
353
354            if return_header:
355                return return_header
356
357            if not self.quiet:
358                logging.warning('LogfileWriter.fetch_header() - no header match: "%s"', record)
359            return ''
360
361        for pattern, regex in self.compiled_header_map.items():
362            if regex and regex.search(record):
363                return self.header.get(pattern, '')
364
365        logging.warning('LogfileWriter.fetch_header() - no header match: "%s"', record)
366        return ''

Return the header for the given record. If filename_pattern os defined (because filebase has already matched to a pattern) then that pattern is used over what pattern would normally match.

def write(self, record: Union[str, logger.utils.das_record.DASRecord, dict]):
369    def write(self, record: Union[str, DASRecord, dict]):
370        if record == '':
371            return
372
373        # See if it's something we can process, and if not, try digesting
374        if not self.can_process_record(record):  # inherited from BaseModule()
375            self.digest_record(record)  # inherited from BaseModule()
376            return
377
378        # Look for the timestamp
379        if isinstance(record, DASRecord):  # If DASRecord or structured dict,
380            ts = record.timestamp          # convert to JSON before writing
381            record = record.as_json()
382
383        elif isinstance(record, dict):
384            ts = record.get('timestamp')
385            if ts is None:
386                if not self.quiet:
387                    logging.error('LogfileWriter.write() - bad timestamp: "%s"', record)
388                return
389            record = json.dumps(record)
390
391        elif isinstance(record, str):  # If str, it better begin with time string
392            try:  # Try to extract timestamp from record
393                time_str = record.split(self.split_char)[0]
394                ts = timestamp.timestamp(time_str, time_format=self.time_format)
395            except ValueError:
396                if not self.quiet:
397                    logging.error('LogfileWriter.write() - bad timestamp: "%s"', record)
398                    return
399        else:
400            if not self.quiet:
401                logging.error(f'LogfileWriter received badly formatted record. Must be DASRecord, '
402                              f'dict, or timestamp-prefixed string. Received: "{record}"')
403            return
404
405        # Now parse ts into hour and date strings
406        datetime_str = self._get_file_date_format(ts)
407
408        # Figure out where we're going to write
409        if self.do_filebase_mapping:
410            matched_patterns = [self.write_if_match(record, pattern, datetime_str)
411                                for pattern in self.filebase]
412            if True not in matched_patterns:
413                if not self.quiet:
414                    logging.warning(f'No patterns matched in LogfileWriter '
415                                    f'options for record "{record}"')
416        else:
417            pattern = 'fixed'  # just an arbitrary fixed pattern
418
419            suffix = self.fetch_suffix(record, pattern)
420            if suffix is None:
421                logging.error(f'System error: found no suffix matching record: "{record}"!')
422                return None
423
424            if datetime_str.startswith('^'):
425                filename = (
426                    os.path.dirname(self.filebase)
427                    + datetime_str[1:]
428                    + os.path.basename(self.filebase)
429                    + suffix
430                )
431            else:
432                filename = self.filebase + datetime_str + suffix
433
434            self.write_filename(record, pattern, filename)

Core method - write a record that we've been passed.

def write_if_match(self, record, pattern, datetime_str):
437    def write_if_match(self, record, pattern, datetime_str):
438        """
439        If the record matches the pattern, write to the matching filebase.
440        """
441
442        # Find the compiled regex matching the pattern
443        regex = self.compiled_filebase_map.get(pattern)
444        if not regex:
445            logging.error(f'System error: found no regex matching pattern: "{pattern}"!')
446            return None
447
448        # If the pattern isn't in this record, go home quietly
449        if regex.search(record) is None:
450            return None
451
452        # Otherwise, we write.
453        filebase = self.filebase.get(pattern)
454        if filebase is None:
455            logging.error(f'System error: found no filebase matching pattern "{pattern}"!')
456            return None
457
458        suffix = self.fetch_suffix(record, pattern)
459        if suffix is None:
460            logging.error(f'System error: found no suffix matching pattern: "{pattern}"!')
461            return None
462
463        if datetime_str.startswith('^'):
464            filename = (
465                os.path.dirname(filebase)
466                + datetime_str[1:]
467                + os.path.basename(filebase)
468                + suffix
469            )
470        else:
471            filename = filebase + datetime_str + suffix
472
473        self.write_filename(record, pattern, filename)
474        return True

If the record matches the pattern, write to the matching filebase.

def write_filename(self, record, pattern, filename):
477    def write_filename(self, record, pattern, filename):
478        """
479        Write record to filename. If it's the first time we're writing to
480        this filename, create the appropriate FileWriter and insert it into
481        the map for the relevant pattern.
482        """
483
484        # Are we currently writing to this file? If not, open/create it.
485        if not filename == self.current_filename.get(pattern):
486
487            # calculate header/header_file and suffix
488            header = self.fetch_header(record, pattern) if self.do_header_mapping else self.header
489
490            self.current_filename[pattern] = filename
491            self.writer[pattern] = FileWriter(filename=filename,
492                                              delimiter=self.delimiter,
493                                              header=header,
494                                              flush=self.flush)
495        # Now, if our logic is correct, should *always* have a matching_writer
496        matching_writer = self.writer.get(pattern)
497        matching_writer.write(record)

Write record to filename. If it's the first time we're writing to this filename, create the appropriate FileWriter and insert it into the map for the relevant pattern.