openrvdas.logger.writers.logfile_writer
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)
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
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
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
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.
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.
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.
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.
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.