openrvdas.logger.utils.record_parser
Tools for parsing NMEA and other text records.
By default, will load device and device_type definitions from files in logger/devices/.yaml and contrib/devices/.yaml. Please see documentation in contrib/devices/README.md for a description of the format these definitions should take.
1#!/usr/bin/env python3 2 3"""Tools for parsing NMEA and other text records. 4 5By default, will load device and device_type definitions from files in 6logger/devices/*.yaml and contrib/devices/*.yaml. Please see documentation 7in contrib/devices/README.md for a description of the format these 8definitions should take. 9""" 10import datetime 11import json 12import logging 13import pprint 14 15try: 16 import parse 17 PARSE_INSTALLED = True 18except ImportError: 19 PARSE_INSTALLED = False 20 21# Append openrvdas root to syspath prior to importing openrvdas modules 22from logger.utils.das_record import DASRecord # noqa: E402 23from logger.utils.read_config import load_definitions # noqa: E402 24from logger.utils.das_record import collect_metadata_for_fields # noqa: E402 25 26# Dict of format types that extend the default formats recognized by the 27# parse module. 28from logger.utils.record_parser_formats import extra_format_types # noqa: E402 29 30DEFAULT_DEFINITION_PATH = 'logger/devices/*.yaml,contrib/devices/*.yaml' 31DEFAULT_RECORD_FORMAT = '{data_id:w} {timestamp:ti} {field_string}' 32 33 34class RecordParser: 35 ############################ 36 def __init__(self, record_format=None, 37 field_patterns=None, metadata=None, 38 definition_path=DEFAULT_DEFINITION_PATH, 39 return_das_record=False, return_json=False, 40 metadata_interval=None, strip_unprintable=False, 41 quiet=False, prepend_data_id=False, delimiter=':'): 42 """Create a parser that will parse field values out of a text record 43 and return either a Python dict of data_id, timestamp and fields, 44 a JSON encoding of that dict, or a binary DASRecord. 45 ``` 46 record_format - string for parse.parse() to use to break out data_id 47 and timestamp from the rest of the message. By default this will 48 look for 'data_id timestamp field_string', where 'field_string' 49 is a str containing the fields to be parsed. 50 51 field_patterns 52 If not None, a list of parse patterns to be tried instead 53 of looking for device definitions along the definition path, 54 or a dict of message_type:[parse_pattern, parse_pattern]. 55 56 metadata 57 If field_patterns is not None, the metadata to send along with 58 data records. 59 60 definition_path - a comma-separated set of file globs in which to look 61 for device and device_type definitions with which to parse message. 62 63 return_json - return the parsed fields as a JSON encoded dict 64 65 return_das_record - return the parsed fields as a DASRecord object 66 67 metadata_interval - if not None, include the description, units 68 and other metadata pertaining to each field in the returned 69 record if those data haven't been returned in the last 70 metadata_interval seconds. 71 72 strip_unprintable 73 Strip out and ignore any leading or trailing non-printable binary 74 characters in the string to be parsed. 75 76 quiet - if not False, don't complain when unable to parse a record. 77 78 prepend_data_id - If true prepend the instrument data_id to field_names 79 in the record. 80 81 delimiter 82 The string to insert between data_id and field_name when prepend_data_id is true. 83 Defaults to ':'. 84 Not used if prepend_data_id is false. 85 ``` 86 """ 87 if not PARSE_INSTALLED: 88 raise ImportError('RecordParser requires Python "parse" module; ' 89 'please run "pip install parse"') 90 91 self.strip_unprintable = strip_unprintable 92 self.quiet = quiet 93 self.field_patterns = field_patterns 94 self.metadata = metadata or {} 95 self.record_format = record_format or DEFAULT_RECORD_FORMAT 96 self.compiled_record_format = parse.compile(format=self.record_format, 97 extra_types=extra_format_types) 98 self.return_das_record = return_das_record 99 self.return_json = return_json 100 if return_das_record and return_json: 101 raise ValueError('Only one of return_json and return_das_record ' 102 'may be true.') 103 104 self.metadata_interval = metadata_interval 105 self.metadata_last_sent = {} 106 self.prepend_data_id = prepend_data_id 107 self.delimiter = delimiter 108 109 # If we've been explicitly given the field_patterns we're to use for 110 # parsing, compile them now. Patterns may either be a list of strings, 111 # a dict of strings or a dict of lists of strings. 112 if field_patterns: 113 self.compiled_field_patterns = self._compile_formats_from_patterns(field_patterns) 114 self.metadata = metadata 115 116 # If we've not been given field_patterns to use for parsing, read in all 117 # the devices and device types to compile them. 118 else: 119 # Fill in the devices and device_types - NOTE: we won't be using 120 # these if 'field_patterns' is provided as an argument. 121 definitions = load_definitions(definition_path) 122 self.devices = definitions.get('devices', {}) 123 self.device_types = definitions.get('device_types', {}) 124 125 # Some limited error checking: make sure that all devices have a 126 # defined device_type. 127 for device, device_def in self.devices.items(): 128 device_type = device_def.get('device_type') 129 if not device_type: 130 raise ValueError('Device definition for "%s" has no declaration of ' 131 'its device_type.' % device) 132 if device_type not in self.device_types: 133 raise ValueError('Device type "%s" (declared in definition of "%s") ' 134 'is undefined.' % (device_type, device)) 135 136 # Compile format definitions so that we can run them more 137 # quickly. If format is a single string, normalize it into a list 138 # to simplify later code. 139 for device_type, device_type_def in self.device_types.items(): 140 format = device_type_def.get('format') 141 if format is None: 142 raise ValueError('Device type %s has no format definition' 143 % device_type) 144 compiled_format = self._compile_formats_from_patterns(format) 145 self.device_types[device_type]['compiled_format'] = compiled_format 146 147 # Metadata: If we haven't been handed a dict of metadata, compile it from 148 # the devices we've read. 149 # 150 # It's a map from variable name to the device and device type it 151 # came from, along with device type variable and its units and 152 # description, if provided in the device type 153 # definition. Compiling this information is kind of excruciating 154 # and voluminous. 155 if not metadata and metadata_interval is not None: 156 for device, device_def in self.devices.items(): # e.g. s330 157 device_type_name = device_def.get('device_type') # Seapath330 158 if not device_type_name: 159 raise ValueError('Device definition for "%s" has no declaration of ' 160 'its device_type.' % device) 161 device_type_def = self.device_types.get(device_type_name) 162 if not device_type_def: 163 raise ValueError('Device type "%s" (declared in definition of "%s")' 164 ' is undefined.' % (device_type_name, device)) 165 device_type_fields = device_type_def.get('fields') 166 if not device_type_fields: 167 raise ValueError('Device type "%s" has no fields?' 168 % device_type_name) 169 170 fields = device_def.get('fields') 171 if not fields: 172 raise ValueError('Device "%s" has no fields?!?' % device) 173 174 # e.g. device_type_field = GPSTime, device_field = S330GPSTime 175 for device_type_field, device_field in fields.items(): 176 # e.g. GPSTime: {'units':..., 'description':...} 177 field_desc = device_type_fields.get(device_type_field) 178 if not field_desc: 179 logging.warning('Device type "%s" has no field corresponding to ' 180 'device field "%s"' % (device_type_name, 181 device_type_field)) 182 continue 183 self.metadata[device_field] = { 184 'device': device, 185 'device_type': device_type_name, 186 'device_type_field': device_type_field, 187 } 188 self.metadata[device_field].update(field_desc) 189 190 ############################ 191 def parse_record(self, record): 192 """Parse an id-prefixed text record into a Python dict of data_id, 193 timestamp and fields. 194 """ 195 if not record: 196 return None 197 if not isinstance(record, str): 198 if not self.quiet: 199 logging.info('Record is not a string: "%s"', record) 200 return None 201 try: 202 # Break record into (by default) data_id, timestamp and field_string 203 parsed_record = self.compiled_record_format.parse(record).named 204 except (ValueError, AttributeError): 205 if not self.quiet: 206 logging.warning('Unable to parse record into "%s"', self.record_format) 207 logging.warning('Record: %s', record) 208 return None 209 210 data_id = parsed_record.get('data_id', 'no_data_id') 211 212 # Convert timestamp to numeric, if it's there 213 timestamp = parsed_record.get('timestamp') 214 if timestamp is not None and isinstance(timestamp, datetime.datetime): 215 timestamp = timestamp.timestamp() 216 parsed_record['timestamp'] = timestamp 217 218 # Extract the field string we're going to parse; remove trailing 219 # whitespace. 220 field_string = parsed_record.get('field_string') 221 222 # If we don't have fields, there's nothing to parse 223 if field_string is None: 224 if not self.quiet: 225 logging.warning('No field_string found in record "%s"', record) 226 return None 227 228 if self.strip_unprintable: 229 field_string = ''.join([c for c in field_string if c.isprintable()]) 230 field_string = field_string.strip() 231 if not field_string: 232 if not self.quiet: 233 logging.warning('No field_string found in record "%s"', record) 234 return None 235 236 # If we've been given a set of field_patterns: If they're a dict, see if there's 237 # a key that matches our data_id. If so, only look at those patterns. Otherwise 238 # try them all and use the first that matches. 239 if self.field_patterns: 240 # If field_patterns is a dict, see if our data_id matches any of the keys 241 if isinstance(self.field_patterns, dict): 242 patterns = self.compiled_field_patterns.get(data_id) 243 else: 244 patterns = self.compiled_field_patterns 245 246 # If no patterns to try, go home emptyhanded 247 if not patterns: 248 if not self.quiet: 249 logging.warning(f'No parse field patterns matched data_id "{data_id}"') 250 return None 251 fields, message_type = self._parse_field_string(field_string, patterns) 252 253 # If we were given no explicit field_patterns to use, we need to 254 # count on the record having a data_id that lets us figure out 255 # which device, and therefore which field_patterns to try. 256 else: 257 fields, message_type = self.parse_for_data_id(data_id, field_string) 258 259 # We should now have a dictionary of fields. If not, go home 260 if fields is None: 261 if not self.quiet: 262 logging.warning('No formats matched field_string "%s"', field_string) 263 return None 264 265 # Some folks want the data_id prepended 266 if self.prepend_data_id: 267 # This conditional dictates whether fields are stored with just the 268 # field_name key, or <data_id><delimiter><field_name> 269 # Doing some work directly on the fields dict, so we'll take a copy 270 # to loop over. 271 fields_copy = fields.copy() 272 # Reset the fields dict 273 fields = {} 274 for field in fields_copy: 275 # Determine the new "field_name" 276 key = '' + data_id + self.delimiter + field 277 # Set the value 278 fields[key] = fields_copy[field] 279 280 # Remove raw 'field_string' and add parsed 'fields' to parsed_record 281 del parsed_record['field_string'] 282 parsed_record['fields'] = fields 283 if message_type: 284 parsed_record['message_type'] = message_type 285 286 # Metadata Injection - use shared utility 287 metadata = collect_metadata_for_fields( 288 fields, timestamp, self.metadata, 289 self.metadata_interval, self.metadata_last_sent 290 ) 291 if metadata: 292 parsed_record['metadata'] = metadata 293 294 logging.debug('Created parsed record: %s', pprint.pformat(parsed_record)) 295 296 # What are we going to do with the result we've created? 297 if self.return_das_record: 298 try: 299 return DASRecord(data_id=data_id, timestamp=timestamp, 300 message_type=message_type, fields=fields, 301 metadata=metadata) 302 except KeyError: 303 return None 304 305 elif self.return_json: 306 return json.dumps(parsed_record) 307 else: 308 return parsed_record 309 310 ############################ 311 def _parse_field_string(self, field_string, compiled_field_patterns): 312 # Default if we don't match anything 313 fields = message_type = None 314 315 # If our pattern(s) are just a single compiled parser, try parsing and 316 # return with no message type. 317 if isinstance(compiled_field_patterns, parse.Parser): 318 result = compiled_field_patterns.parse(field_string) 319 if result: 320 fields = result.named 321 322 # Else, if it's a list, try it out on all the elements. 323 elif isinstance(compiled_field_patterns, list): 324 for pattern in compiled_field_patterns: 325 fields, message_type = self._parse_field_string(field_string, pattern) 326 if fields is not None: 327 break 328 329 # If it's a dict, try out on all values, using the key as message type. 330 # It's syntactically possible for the internal set of patterns to have 331 # their own message types. Not sure why someone would ever create patterns 332 # that did this, but if they do, let that override our base one. 333 elif isinstance(compiled_field_patterns, dict): 334 for message_type, pattern in compiled_field_patterns.items(): 335 fields, int_message_type = self._parse_field_string(field_string, pattern) 336 message_type = int_message_type or message_type 337 if fields is not None: 338 break 339 else: 340 raise ValueError('Unexpected pattern type in parser: %s' 341 % type(compiled_field_patterns)) 342 343 return fields, message_type 344 345 ############################ 346 def parse_for_data_id(self, data_id, field_string): 347 """Look up the device and device type for a data_id. Parse the field_string 348 according to those formats. If successful, return a tuple of 349 (field_dict, message_type), where field_dict is a dict of 350 {field_name: field_value}. Return ({}, None) if unable to match a format pattern. 351 """ 352 failure_values = (None, None) 353 if not self.devices: 354 logging.warning('RecordParser has no device definitions; unable to parse!') 355 return failure_values 356 357 # Get device and device_type definitions for data_id 358 device = self.devices.get(data_id) 359 if not device: 360 if not self.quiet: 361 logging.warning('Unrecognized data id "%s", field string: %s', 362 data_id, field_string) 363 logging.warning('Known data ids are: "%s"', 364 ', '.join(self.devices.keys())) 365 return failure_values 366 367 device_type = device.get('device_type') 368 if not device_type: 369 if not self.quiet: 370 logging.error('Internal error: No "device_type" for device %s!', device) 371 return failure_values 372 373 device_definition = self.device_types.get(device_type) 374 if not device_definition: 375 if not self.quiet: 376 logging.error('No definition found for device_type "%s"', device_type) 377 return failure_values 378 379 compiled_format_patterns = device_definition.get('compiled_format') 380 parsed_fields, message_type = \ 381 self._parse_field_string(field_string, compiled_format_patterns) 382 383 # Did we get anything? 384 if parsed_fields is None: 385 if not self.quiet: 386 logging.warning('No formats matched field_string "%s"', field_string) 387 return failure_values 388 389 logging.debug('Got fields: %s', pprint.pformat(parsed_fields)) 390 391 # Finally, convert field values to variable names specific to device 392 device_fields = device.get('fields') 393 if not device_fields: 394 if not self.quiet: 395 logging.error('No "fields" definition found for device %s', data_id) 396 return failure_values 397 398 # Assign field values to the appropriate named variable. 399 fields = {} 400 for field_name, value in parsed_fields.items(): 401 variable_name = device_fields.get(field_name) 402 # None means we're not supposed to report it. 403 if variable_name is None: 404 continue 405 # None means we didn't have a value for this field; omit it. 406 if value is None: 407 continue 408 # If it's a datetime, convert to numeric timestamp 409 if isinstance(value, datetime.datetime): 410 value = value.timestamp() 411 fields[variable_name] = value 412 413 logging.debug('Got fields: %s', pprint.pformat(fields)) 414 return fields, message_type 415 416 ############################ 417 def _compile_formats_from_patterns(self, field_patterns): 418 """Return a list/dict of patterns compiled from the 419 str/list/dict of passed field_patterns. 420 """ 421 if isinstance(field_patterns, str): 422 return [parse.compile(format=field_patterns, 423 extra_types=extra_format_types)] 424 elif isinstance(field_patterns, list): 425 return [parse.compile(format=p, extra_types=extra_format_types) 426 for p in field_patterns] 427 elif isinstance(field_patterns, dict): 428 compiled_field_patterns = {} 429 for message_type, message_pattern in field_patterns.items(): 430 compiled_patterns = self._compile_formats_from_patterns(message_pattern) 431 compiled_field_patterns[message_type] = compiled_patterns 432 return compiled_field_patterns 433 434 else: 435 raise ValueError('Passed field_patterns must be str, list or dict. Found %s: %s' 436 % (type(field_patterns), str(field_patterns)))
35class RecordParser: 36 ############################ 37 def __init__(self, record_format=None, 38 field_patterns=None, metadata=None, 39 definition_path=DEFAULT_DEFINITION_PATH, 40 return_das_record=False, return_json=False, 41 metadata_interval=None, strip_unprintable=False, 42 quiet=False, prepend_data_id=False, delimiter=':'): 43 """Create a parser that will parse field values out of a text record 44 and return either a Python dict of data_id, timestamp and fields, 45 a JSON encoding of that dict, or a binary DASRecord. 46 ``` 47 record_format - string for parse.parse() to use to break out data_id 48 and timestamp from the rest of the message. By default this will 49 look for 'data_id timestamp field_string', where 'field_string' 50 is a str containing the fields to be parsed. 51 52 field_patterns 53 If not None, a list of parse patterns to be tried instead 54 of looking for device definitions along the definition path, 55 or a dict of message_type:[parse_pattern, parse_pattern]. 56 57 metadata 58 If field_patterns is not None, the metadata to send along with 59 data records. 60 61 definition_path - a comma-separated set of file globs in which to look 62 for device and device_type definitions with which to parse message. 63 64 return_json - return the parsed fields as a JSON encoded dict 65 66 return_das_record - return the parsed fields as a DASRecord object 67 68 metadata_interval - if not None, include the description, units 69 and other metadata pertaining to each field in the returned 70 record if those data haven't been returned in the last 71 metadata_interval seconds. 72 73 strip_unprintable 74 Strip out and ignore any leading or trailing non-printable binary 75 characters in the string to be parsed. 76 77 quiet - if not False, don't complain when unable to parse a record. 78 79 prepend_data_id - If true prepend the instrument data_id to field_names 80 in the record. 81 82 delimiter 83 The string to insert between data_id and field_name when prepend_data_id is true. 84 Defaults to ':'. 85 Not used if prepend_data_id is false. 86 ``` 87 """ 88 if not PARSE_INSTALLED: 89 raise ImportError('RecordParser requires Python "parse" module; ' 90 'please run "pip install parse"') 91 92 self.strip_unprintable = strip_unprintable 93 self.quiet = quiet 94 self.field_patterns = field_patterns 95 self.metadata = metadata or {} 96 self.record_format = record_format or DEFAULT_RECORD_FORMAT 97 self.compiled_record_format = parse.compile(format=self.record_format, 98 extra_types=extra_format_types) 99 self.return_das_record = return_das_record 100 self.return_json = return_json 101 if return_das_record and return_json: 102 raise ValueError('Only one of return_json and return_das_record ' 103 'may be true.') 104 105 self.metadata_interval = metadata_interval 106 self.metadata_last_sent = {} 107 self.prepend_data_id = prepend_data_id 108 self.delimiter = delimiter 109 110 # If we've been explicitly given the field_patterns we're to use for 111 # parsing, compile them now. Patterns may either be a list of strings, 112 # a dict of strings or a dict of lists of strings. 113 if field_patterns: 114 self.compiled_field_patterns = self._compile_formats_from_patterns(field_patterns) 115 self.metadata = metadata 116 117 # If we've not been given field_patterns to use for parsing, read in all 118 # the devices and device types to compile them. 119 else: 120 # Fill in the devices and device_types - NOTE: we won't be using 121 # these if 'field_patterns' is provided as an argument. 122 definitions = load_definitions(definition_path) 123 self.devices = definitions.get('devices', {}) 124 self.device_types = definitions.get('device_types', {}) 125 126 # Some limited error checking: make sure that all devices have a 127 # defined device_type. 128 for device, device_def in self.devices.items(): 129 device_type = device_def.get('device_type') 130 if not device_type: 131 raise ValueError('Device definition for "%s" has no declaration of ' 132 'its device_type.' % device) 133 if device_type not in self.device_types: 134 raise ValueError('Device type "%s" (declared in definition of "%s") ' 135 'is undefined.' % (device_type, device)) 136 137 # Compile format definitions so that we can run them more 138 # quickly. If format is a single string, normalize it into a list 139 # to simplify later code. 140 for device_type, device_type_def in self.device_types.items(): 141 format = device_type_def.get('format') 142 if format is None: 143 raise ValueError('Device type %s has no format definition' 144 % device_type) 145 compiled_format = self._compile_formats_from_patterns(format) 146 self.device_types[device_type]['compiled_format'] = compiled_format 147 148 # Metadata: If we haven't been handed a dict of metadata, compile it from 149 # the devices we've read. 150 # 151 # It's a map from variable name to the device and device type it 152 # came from, along with device type variable and its units and 153 # description, if provided in the device type 154 # definition. Compiling this information is kind of excruciating 155 # and voluminous. 156 if not metadata and metadata_interval is not None: 157 for device, device_def in self.devices.items(): # e.g. s330 158 device_type_name = device_def.get('device_type') # Seapath330 159 if not device_type_name: 160 raise ValueError('Device definition for "%s" has no declaration of ' 161 'its device_type.' % device) 162 device_type_def = self.device_types.get(device_type_name) 163 if not device_type_def: 164 raise ValueError('Device type "%s" (declared in definition of "%s")' 165 ' is undefined.' % (device_type_name, device)) 166 device_type_fields = device_type_def.get('fields') 167 if not device_type_fields: 168 raise ValueError('Device type "%s" has no fields?' 169 % device_type_name) 170 171 fields = device_def.get('fields') 172 if not fields: 173 raise ValueError('Device "%s" has no fields?!?' % device) 174 175 # e.g. device_type_field = GPSTime, device_field = S330GPSTime 176 for device_type_field, device_field in fields.items(): 177 # e.g. GPSTime: {'units':..., 'description':...} 178 field_desc = device_type_fields.get(device_type_field) 179 if not field_desc: 180 logging.warning('Device type "%s" has no field corresponding to ' 181 'device field "%s"' % (device_type_name, 182 device_type_field)) 183 continue 184 self.metadata[device_field] = { 185 'device': device, 186 'device_type': device_type_name, 187 'device_type_field': device_type_field, 188 } 189 self.metadata[device_field].update(field_desc) 190 191 ############################ 192 def parse_record(self, record): 193 """Parse an id-prefixed text record into a Python dict of data_id, 194 timestamp and fields. 195 """ 196 if not record: 197 return None 198 if not isinstance(record, str): 199 if not self.quiet: 200 logging.info('Record is not a string: "%s"', record) 201 return None 202 try: 203 # Break record into (by default) data_id, timestamp and field_string 204 parsed_record = self.compiled_record_format.parse(record).named 205 except (ValueError, AttributeError): 206 if not self.quiet: 207 logging.warning('Unable to parse record into "%s"', self.record_format) 208 logging.warning('Record: %s', record) 209 return None 210 211 data_id = parsed_record.get('data_id', 'no_data_id') 212 213 # Convert timestamp to numeric, if it's there 214 timestamp = parsed_record.get('timestamp') 215 if timestamp is not None and isinstance(timestamp, datetime.datetime): 216 timestamp = timestamp.timestamp() 217 parsed_record['timestamp'] = timestamp 218 219 # Extract the field string we're going to parse; remove trailing 220 # whitespace. 221 field_string = parsed_record.get('field_string') 222 223 # If we don't have fields, there's nothing to parse 224 if field_string is None: 225 if not self.quiet: 226 logging.warning('No field_string found in record "%s"', record) 227 return None 228 229 if self.strip_unprintable: 230 field_string = ''.join([c for c in field_string if c.isprintable()]) 231 field_string = field_string.strip() 232 if not field_string: 233 if not self.quiet: 234 logging.warning('No field_string found in record "%s"', record) 235 return None 236 237 # If we've been given a set of field_patterns: If they're a dict, see if there's 238 # a key that matches our data_id. If so, only look at those patterns. Otherwise 239 # try them all and use the first that matches. 240 if self.field_patterns: 241 # If field_patterns is a dict, see if our data_id matches any of the keys 242 if isinstance(self.field_patterns, dict): 243 patterns = self.compiled_field_patterns.get(data_id) 244 else: 245 patterns = self.compiled_field_patterns 246 247 # If no patterns to try, go home emptyhanded 248 if not patterns: 249 if not self.quiet: 250 logging.warning(f'No parse field patterns matched data_id "{data_id}"') 251 return None 252 fields, message_type = self._parse_field_string(field_string, patterns) 253 254 # If we were given no explicit field_patterns to use, we need to 255 # count on the record having a data_id that lets us figure out 256 # which device, and therefore which field_patterns to try. 257 else: 258 fields, message_type = self.parse_for_data_id(data_id, field_string) 259 260 # We should now have a dictionary of fields. If not, go home 261 if fields is None: 262 if not self.quiet: 263 logging.warning('No formats matched field_string "%s"', field_string) 264 return None 265 266 # Some folks want the data_id prepended 267 if self.prepend_data_id: 268 # This conditional dictates whether fields are stored with just the 269 # field_name key, or <data_id><delimiter><field_name> 270 # Doing some work directly on the fields dict, so we'll take a copy 271 # to loop over. 272 fields_copy = fields.copy() 273 # Reset the fields dict 274 fields = {} 275 for field in fields_copy: 276 # Determine the new "field_name" 277 key = '' + data_id + self.delimiter + field 278 # Set the value 279 fields[key] = fields_copy[field] 280 281 # Remove raw 'field_string' and add parsed 'fields' to parsed_record 282 del parsed_record['field_string'] 283 parsed_record['fields'] = fields 284 if message_type: 285 parsed_record['message_type'] = message_type 286 287 # Metadata Injection - use shared utility 288 metadata = collect_metadata_for_fields( 289 fields, timestamp, self.metadata, 290 self.metadata_interval, self.metadata_last_sent 291 ) 292 if metadata: 293 parsed_record['metadata'] = metadata 294 295 logging.debug('Created parsed record: %s', pprint.pformat(parsed_record)) 296 297 # What are we going to do with the result we've created? 298 if self.return_das_record: 299 try: 300 return DASRecord(data_id=data_id, timestamp=timestamp, 301 message_type=message_type, fields=fields, 302 metadata=metadata) 303 except KeyError: 304 return None 305 306 elif self.return_json: 307 return json.dumps(parsed_record) 308 else: 309 return parsed_record 310 311 ############################ 312 def _parse_field_string(self, field_string, compiled_field_patterns): 313 # Default if we don't match anything 314 fields = message_type = None 315 316 # If our pattern(s) are just a single compiled parser, try parsing and 317 # return with no message type. 318 if isinstance(compiled_field_patterns, parse.Parser): 319 result = compiled_field_patterns.parse(field_string) 320 if result: 321 fields = result.named 322 323 # Else, if it's a list, try it out on all the elements. 324 elif isinstance(compiled_field_patterns, list): 325 for pattern in compiled_field_patterns: 326 fields, message_type = self._parse_field_string(field_string, pattern) 327 if fields is not None: 328 break 329 330 # If it's a dict, try out on all values, using the key as message type. 331 # It's syntactically possible for the internal set of patterns to have 332 # their own message types. Not sure why someone would ever create patterns 333 # that did this, but if they do, let that override our base one. 334 elif isinstance(compiled_field_patterns, dict): 335 for message_type, pattern in compiled_field_patterns.items(): 336 fields, int_message_type = self._parse_field_string(field_string, pattern) 337 message_type = int_message_type or message_type 338 if fields is not None: 339 break 340 else: 341 raise ValueError('Unexpected pattern type in parser: %s' 342 % type(compiled_field_patterns)) 343 344 return fields, message_type 345 346 ############################ 347 def parse_for_data_id(self, data_id, field_string): 348 """Look up the device and device type for a data_id. Parse the field_string 349 according to those formats. If successful, return a tuple of 350 (field_dict, message_type), where field_dict is a dict of 351 {field_name: field_value}. Return ({}, None) if unable to match a format pattern. 352 """ 353 failure_values = (None, None) 354 if not self.devices: 355 logging.warning('RecordParser has no device definitions; unable to parse!') 356 return failure_values 357 358 # Get device and device_type definitions for data_id 359 device = self.devices.get(data_id) 360 if not device: 361 if not self.quiet: 362 logging.warning('Unrecognized data id "%s", field string: %s', 363 data_id, field_string) 364 logging.warning('Known data ids are: "%s"', 365 ', '.join(self.devices.keys())) 366 return failure_values 367 368 device_type = device.get('device_type') 369 if not device_type: 370 if not self.quiet: 371 logging.error('Internal error: No "device_type" for device %s!', device) 372 return failure_values 373 374 device_definition = self.device_types.get(device_type) 375 if not device_definition: 376 if not self.quiet: 377 logging.error('No definition found for device_type "%s"', device_type) 378 return failure_values 379 380 compiled_format_patterns = device_definition.get('compiled_format') 381 parsed_fields, message_type = \ 382 self._parse_field_string(field_string, compiled_format_patterns) 383 384 # Did we get anything? 385 if parsed_fields is None: 386 if not self.quiet: 387 logging.warning('No formats matched field_string "%s"', field_string) 388 return failure_values 389 390 logging.debug('Got fields: %s', pprint.pformat(parsed_fields)) 391 392 # Finally, convert field values to variable names specific to device 393 device_fields = device.get('fields') 394 if not device_fields: 395 if not self.quiet: 396 logging.error('No "fields" definition found for device %s', data_id) 397 return failure_values 398 399 # Assign field values to the appropriate named variable. 400 fields = {} 401 for field_name, value in parsed_fields.items(): 402 variable_name = device_fields.get(field_name) 403 # None means we're not supposed to report it. 404 if variable_name is None: 405 continue 406 # None means we didn't have a value for this field; omit it. 407 if value is None: 408 continue 409 # If it's a datetime, convert to numeric timestamp 410 if isinstance(value, datetime.datetime): 411 value = value.timestamp() 412 fields[variable_name] = value 413 414 logging.debug('Got fields: %s', pprint.pformat(fields)) 415 return fields, message_type 416 417 ############################ 418 def _compile_formats_from_patterns(self, field_patterns): 419 """Return a list/dict of patterns compiled from the 420 str/list/dict of passed field_patterns. 421 """ 422 if isinstance(field_patterns, str): 423 return [parse.compile(format=field_patterns, 424 extra_types=extra_format_types)] 425 elif isinstance(field_patterns, list): 426 return [parse.compile(format=p, extra_types=extra_format_types) 427 for p in field_patterns] 428 elif isinstance(field_patterns, dict): 429 compiled_field_patterns = {} 430 for message_type, message_pattern in field_patterns.items(): 431 compiled_patterns = self._compile_formats_from_patterns(message_pattern) 432 compiled_field_patterns[message_type] = compiled_patterns 433 return compiled_field_patterns 434 435 else: 436 raise ValueError('Passed field_patterns must be str, list or dict. Found %s: %s' 437 % (type(field_patterns), str(field_patterns)))
37 def __init__(self, record_format=None, 38 field_patterns=None, metadata=None, 39 definition_path=DEFAULT_DEFINITION_PATH, 40 return_das_record=False, return_json=False, 41 metadata_interval=None, strip_unprintable=False, 42 quiet=False, prepend_data_id=False, delimiter=':'): 43 """Create a parser that will parse field values out of a text record 44 and return either a Python dict of data_id, timestamp and fields, 45 a JSON encoding of that dict, or a binary DASRecord. 46 ``` 47 record_format - string for parse.parse() to use to break out data_id 48 and timestamp from the rest of the message. By default this will 49 look for 'data_id timestamp field_string', where 'field_string' 50 is a str containing the fields to be parsed. 51 52 field_patterns 53 If not None, a list of parse patterns to be tried instead 54 of looking for device definitions along the definition path, 55 or a dict of message_type:[parse_pattern, parse_pattern]. 56 57 metadata 58 If field_patterns is not None, the metadata to send along with 59 data records. 60 61 definition_path - a comma-separated set of file globs in which to look 62 for device and device_type definitions with which to parse message. 63 64 return_json - return the parsed fields as a JSON encoded dict 65 66 return_das_record - return the parsed fields as a DASRecord object 67 68 metadata_interval - if not None, include the description, units 69 and other metadata pertaining to each field in the returned 70 record if those data haven't been returned in the last 71 metadata_interval seconds. 72 73 strip_unprintable 74 Strip out and ignore any leading or trailing non-printable binary 75 characters in the string to be parsed. 76 77 quiet - if not False, don't complain when unable to parse a record. 78 79 prepend_data_id - If true prepend the instrument data_id to field_names 80 in the record. 81 82 delimiter 83 The string to insert between data_id and field_name when prepend_data_id is true. 84 Defaults to ':'. 85 Not used if prepend_data_id is false. 86 ``` 87 """ 88 if not PARSE_INSTALLED: 89 raise ImportError('RecordParser requires Python "parse" module; ' 90 'please run "pip install parse"') 91 92 self.strip_unprintable = strip_unprintable 93 self.quiet = quiet 94 self.field_patterns = field_patterns 95 self.metadata = metadata or {} 96 self.record_format = record_format or DEFAULT_RECORD_FORMAT 97 self.compiled_record_format = parse.compile(format=self.record_format, 98 extra_types=extra_format_types) 99 self.return_das_record = return_das_record 100 self.return_json = return_json 101 if return_das_record and return_json: 102 raise ValueError('Only one of return_json and return_das_record ' 103 'may be true.') 104 105 self.metadata_interval = metadata_interval 106 self.metadata_last_sent = {} 107 self.prepend_data_id = prepend_data_id 108 self.delimiter = delimiter 109 110 # If we've been explicitly given the field_patterns we're to use for 111 # parsing, compile them now. Patterns may either be a list of strings, 112 # a dict of strings or a dict of lists of strings. 113 if field_patterns: 114 self.compiled_field_patterns = self._compile_formats_from_patterns(field_patterns) 115 self.metadata = metadata 116 117 # If we've not been given field_patterns to use for parsing, read in all 118 # the devices and device types to compile them. 119 else: 120 # Fill in the devices and device_types - NOTE: we won't be using 121 # these if 'field_patterns' is provided as an argument. 122 definitions = load_definitions(definition_path) 123 self.devices = definitions.get('devices', {}) 124 self.device_types = definitions.get('device_types', {}) 125 126 # Some limited error checking: make sure that all devices have a 127 # defined device_type. 128 for device, device_def in self.devices.items(): 129 device_type = device_def.get('device_type') 130 if not device_type: 131 raise ValueError('Device definition for "%s" has no declaration of ' 132 'its device_type.' % device) 133 if device_type not in self.device_types: 134 raise ValueError('Device type "%s" (declared in definition of "%s") ' 135 'is undefined.' % (device_type, device)) 136 137 # Compile format definitions so that we can run them more 138 # quickly. If format is a single string, normalize it into a list 139 # to simplify later code. 140 for device_type, device_type_def in self.device_types.items(): 141 format = device_type_def.get('format') 142 if format is None: 143 raise ValueError('Device type %s has no format definition' 144 % device_type) 145 compiled_format = self._compile_formats_from_patterns(format) 146 self.device_types[device_type]['compiled_format'] = compiled_format 147 148 # Metadata: If we haven't been handed a dict of metadata, compile it from 149 # the devices we've read. 150 # 151 # It's a map from variable name to the device and device type it 152 # came from, along with device type variable and its units and 153 # description, if provided in the device type 154 # definition. Compiling this information is kind of excruciating 155 # and voluminous. 156 if not metadata and metadata_interval is not None: 157 for device, device_def in self.devices.items(): # e.g. s330 158 device_type_name = device_def.get('device_type') # Seapath330 159 if not device_type_name: 160 raise ValueError('Device definition for "%s" has no declaration of ' 161 'its device_type.' % device) 162 device_type_def = self.device_types.get(device_type_name) 163 if not device_type_def: 164 raise ValueError('Device type "%s" (declared in definition of "%s")' 165 ' is undefined.' % (device_type_name, device)) 166 device_type_fields = device_type_def.get('fields') 167 if not device_type_fields: 168 raise ValueError('Device type "%s" has no fields?' 169 % device_type_name) 170 171 fields = device_def.get('fields') 172 if not fields: 173 raise ValueError('Device "%s" has no fields?!?' % device) 174 175 # e.g. device_type_field = GPSTime, device_field = S330GPSTime 176 for device_type_field, device_field in fields.items(): 177 # e.g. GPSTime: {'units':..., 'description':...} 178 field_desc = device_type_fields.get(device_type_field) 179 if not field_desc: 180 logging.warning('Device type "%s" has no field corresponding to ' 181 'device field "%s"' % (device_type_name, 182 device_type_field)) 183 continue 184 self.metadata[device_field] = { 185 'device': device, 186 'device_type': device_type_name, 187 'device_type_field': device_type_field, 188 } 189 self.metadata[device_field].update(field_desc)
Create a parser that will parse field values out of a text record and return either a Python dict of data_id, timestamp and fields, a JSON encoding of that dict, or a binary DASRecord.
record_format - string for parse.parse() to use to break out data_id
and timestamp from the rest of the message. By default this will
look for 'data_id timestamp field_string', where 'field_string'
is a str containing the fields to be parsed.
field_patterns
If not None, a list of parse patterns to be tried instead
of looking for device definitions along the definition path,
or a dict of message_type:[parse_pattern, parse_pattern].
metadata
If field_patterns is not None, the metadata to send along with
data records.
definition_path - a comma-separated set of file globs in which to look
for device and device_type definitions with which to parse message.
return_json - return the parsed fields as a JSON encoded dict
return_das_record - return the parsed fields as a DASRecord object
metadata_interval - if not None, include the description, units
and other metadata pertaining to each field in the returned
record if those data haven't been returned in the last
metadata_interval seconds.
strip_unprintable
Strip out and ignore any leading or trailing non-printable binary
characters in the string to be parsed.
quiet - if not False, don't complain when unable to parse a record.
prepend_data_id - If true prepend the instrument data_id to field_names
in the record.
delimiter
The string to insert between data_id and field_name when prepend_data_id is true.
Defaults to ':'.
Not used if prepend_data_id is false.
192 def parse_record(self, record): 193 """Parse an id-prefixed text record into a Python dict of data_id, 194 timestamp and fields. 195 """ 196 if not record: 197 return None 198 if not isinstance(record, str): 199 if not self.quiet: 200 logging.info('Record is not a string: "%s"', record) 201 return None 202 try: 203 # Break record into (by default) data_id, timestamp and field_string 204 parsed_record = self.compiled_record_format.parse(record).named 205 except (ValueError, AttributeError): 206 if not self.quiet: 207 logging.warning('Unable to parse record into "%s"', self.record_format) 208 logging.warning('Record: %s', record) 209 return None 210 211 data_id = parsed_record.get('data_id', 'no_data_id') 212 213 # Convert timestamp to numeric, if it's there 214 timestamp = parsed_record.get('timestamp') 215 if timestamp is not None and isinstance(timestamp, datetime.datetime): 216 timestamp = timestamp.timestamp() 217 parsed_record['timestamp'] = timestamp 218 219 # Extract the field string we're going to parse; remove trailing 220 # whitespace. 221 field_string = parsed_record.get('field_string') 222 223 # If we don't have fields, there's nothing to parse 224 if field_string is None: 225 if not self.quiet: 226 logging.warning('No field_string found in record "%s"', record) 227 return None 228 229 if self.strip_unprintable: 230 field_string = ''.join([c for c in field_string if c.isprintable()]) 231 field_string = field_string.strip() 232 if not field_string: 233 if not self.quiet: 234 logging.warning('No field_string found in record "%s"', record) 235 return None 236 237 # If we've been given a set of field_patterns: If they're a dict, see if there's 238 # a key that matches our data_id. If so, only look at those patterns. Otherwise 239 # try them all and use the first that matches. 240 if self.field_patterns: 241 # If field_patterns is a dict, see if our data_id matches any of the keys 242 if isinstance(self.field_patterns, dict): 243 patterns = self.compiled_field_patterns.get(data_id) 244 else: 245 patterns = self.compiled_field_patterns 246 247 # If no patterns to try, go home emptyhanded 248 if not patterns: 249 if not self.quiet: 250 logging.warning(f'No parse field patterns matched data_id "{data_id}"') 251 return None 252 fields, message_type = self._parse_field_string(field_string, patterns) 253 254 # If we were given no explicit field_patterns to use, we need to 255 # count on the record having a data_id that lets us figure out 256 # which device, and therefore which field_patterns to try. 257 else: 258 fields, message_type = self.parse_for_data_id(data_id, field_string) 259 260 # We should now have a dictionary of fields. If not, go home 261 if fields is None: 262 if not self.quiet: 263 logging.warning('No formats matched field_string "%s"', field_string) 264 return None 265 266 # Some folks want the data_id prepended 267 if self.prepend_data_id: 268 # This conditional dictates whether fields are stored with just the 269 # field_name key, or <data_id><delimiter><field_name> 270 # Doing some work directly on the fields dict, so we'll take a copy 271 # to loop over. 272 fields_copy = fields.copy() 273 # Reset the fields dict 274 fields = {} 275 for field in fields_copy: 276 # Determine the new "field_name" 277 key = '' + data_id + self.delimiter + field 278 # Set the value 279 fields[key] = fields_copy[field] 280 281 # Remove raw 'field_string' and add parsed 'fields' to parsed_record 282 del parsed_record['field_string'] 283 parsed_record['fields'] = fields 284 if message_type: 285 parsed_record['message_type'] = message_type 286 287 # Metadata Injection - use shared utility 288 metadata = collect_metadata_for_fields( 289 fields, timestamp, self.metadata, 290 self.metadata_interval, self.metadata_last_sent 291 ) 292 if metadata: 293 parsed_record['metadata'] = metadata 294 295 logging.debug('Created parsed record: %s', pprint.pformat(parsed_record)) 296 297 # What are we going to do with the result we've created? 298 if self.return_das_record: 299 try: 300 return DASRecord(data_id=data_id, timestamp=timestamp, 301 message_type=message_type, fields=fields, 302 metadata=metadata) 303 except KeyError: 304 return None 305 306 elif self.return_json: 307 return json.dumps(parsed_record) 308 else: 309 return parsed_record
Parse an id-prefixed text record into a Python dict of data_id, timestamp and fields.
347 def parse_for_data_id(self, data_id, field_string): 348 """Look up the device and device type for a data_id. Parse the field_string 349 according to those formats. If successful, return a tuple of 350 (field_dict, message_type), where field_dict is a dict of 351 {field_name: field_value}. Return ({}, None) if unable to match a format pattern. 352 """ 353 failure_values = (None, None) 354 if not self.devices: 355 logging.warning('RecordParser has no device definitions; unable to parse!') 356 return failure_values 357 358 # Get device and device_type definitions for data_id 359 device = self.devices.get(data_id) 360 if not device: 361 if not self.quiet: 362 logging.warning('Unrecognized data id "%s", field string: %s', 363 data_id, field_string) 364 logging.warning('Known data ids are: "%s"', 365 ', '.join(self.devices.keys())) 366 return failure_values 367 368 device_type = device.get('device_type') 369 if not device_type: 370 if not self.quiet: 371 logging.error('Internal error: No "device_type" for device %s!', device) 372 return failure_values 373 374 device_definition = self.device_types.get(device_type) 375 if not device_definition: 376 if not self.quiet: 377 logging.error('No definition found for device_type "%s"', device_type) 378 return failure_values 379 380 compiled_format_patterns = device_definition.get('compiled_format') 381 parsed_fields, message_type = \ 382 self._parse_field_string(field_string, compiled_format_patterns) 383 384 # Did we get anything? 385 if parsed_fields is None: 386 if not self.quiet: 387 logging.warning('No formats matched field_string "%s"', field_string) 388 return failure_values 389 390 logging.debug('Got fields: %s', pprint.pformat(parsed_fields)) 391 392 # Finally, convert field values to variable names specific to device 393 device_fields = device.get('fields') 394 if not device_fields: 395 if not self.quiet: 396 logging.error('No "fields" definition found for device %s', data_id) 397 return failure_values 398 399 # Assign field values to the appropriate named variable. 400 fields = {} 401 for field_name, value in parsed_fields.items(): 402 variable_name = device_fields.get(field_name) 403 # None means we're not supposed to report it. 404 if variable_name is None: 405 continue 406 # None means we didn't have a value for this field; omit it. 407 if value is None: 408 continue 409 # If it's a datetime, convert to numeric timestamp 410 if isinstance(value, datetime.datetime): 411 value = value.timestamp() 412 fields[variable_name] = value 413 414 logging.debug('Got fields: %s', pprint.pformat(fields)) 415 return fields, message_type
Look up the device and device type for a data_id. Parse the field_string according to those formats. If successful, return a tuple of (field_dict, message_type), where field_dict is a dict of {field_name: field_value}. Return ({}, None) if unable to match a format pattern.