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