openrvdas.logger.utils.regex_parser

Tools for parsing NMEA and other text records using regex.

  1#!/usr/bin/env python3
  2
  3"""Tools for parsing NMEA and other text records using regex.
  4"""
  5import datetime
  6import logging
  7import re
  8import pprint
  9import time
 10
 11
 12
 13# Append openrvdas root to syspath prior to importing openrvdas modules
 14
 15from logger.utils.das_record import DASRecord  # noqa: E402
 16from logger.utils.read_config import load_definitions  # noqa: E402
 17from logger.utils.das_record import collect_metadata_for_fields  # noqa: E402
 18
 19# Import ConvertFieldsTransform, but handle gracefully if unavailable
 20try:
 21    from logger.transforms.convert_fields_transform import ConvertFieldsTransform
 22except ImportError:
 23    ConvertFieldsTransform = None
 24
 25# Note: this is a "permissive" regex. It looks for data_id and timestamp prior to field_string,
 26# But still parses field_string if they are absent
 27# Works for both "data_id timestamp fields" but also parses "fields" if
 28# data_id or timestamp are missing
 29
 30DEFAULT_RECORD_FORMAT = r"^(?:(?P<data_id>\w+)\s+(?P<timestamp>[0-9TZ:\-\.]*)\s+)?(?P<field_string>(.|\r|\n)*)"  # noqa: E501
 31
 32DEFAULT_DEFINITION_PATH = 'local/devices/*.yaml,contrib/devices/*.yaml'
 33
 34
 35################################################################################
 36class RegexParser:
 37    ############################
 38    def __init__(self,
 39                 record_format=None,
 40                 field_patterns=None,
 41                 data_id=None,
 42                 definition_path=None,
 43                 metadata=None,
 44                 metadata_interval=None,
 45                 quiet=False):
 46        r"""Create a parser that will parse field values out of a text record
 47        and return a DASRecord object.
 48        ```
 49        record_format - string for re.match() to use to break out data_id
 50            and timestamp from the rest of the message. By default this will
 51            look for 'data_id timestamp field_string', where 'field_string'
 52            is a str containing the fields to be parsed.
 53
 54        field_patterns
 55            If not None, either
 56            - a list of regex patterns to be tried
 57            - a dict of message_type:regex patterns to be tried. When one
 58              matches, the record's message_type is set accordingly.
 59            If None and definition_path is provided, patterns are loaded from
 60            device definition files.
 61
 62        data_id
 63            If specified, this string is used as the data_id for all records,
 64            overriding any data_id extracted from the source record.
 65
 66        definition_path
 67            Wildcarded path matching YAML definitions for devices. Used only
 68            if 'field_patterns' is None. Defaults to DEFAULT_DEFINITION_PATH.
 69            Comma-separated globs are supported.
 70
 71        metadata
 72            If provided, a dict mapping field names to their metadata dicts.
 73            If None and definition_path is used, metadata is compiled from
 74            device definitions.
 75
 76        metadata_interval
 77            If not None, include the description, units and other metadata
 78            pertaining to each field in the returned record if those data
 79            haven't been returned in the last metadata_interval seconds.
 80
 81        quiet - if not False, don't complain when unable to parse a record.
 82        ```
 83        """
 84        self.quiet = quiet
 85        self.record_format = record_format or DEFAULT_RECORD_FORMAT
 86        self.compiled_record_format = re.compile(self.record_format)
 87        self.data_id = data_id  # Store the data_id override
 88
 89        # Check for conflict
 90        if field_patterns and definition_path:
 91            raise ValueError('RegexParser: Both field_patterns and definition_path '
 92                             'specified. Please specify only one.')
 93
 94        # Device-aware parsing state
 95        self.devices = {}
 96        self.device_types = {}
 97        self.type_converters = {}
 98
 99        # Metadata state
100        self.metadata = metadata or {}
101        self.metadata_interval = metadata_interval
102        self.metadata_last_sent = {}
103
104        # If field patterns not provided, look them up in definitions
105        if field_patterns is None and definition_path is not None:
106            field_patterns = self._load_definitions(definition_path)
107
108            # If metadata not explicitly provided, compile it from definitions
109            if not metadata and metadata_interval:
110                self._compile_metadata()
111
112        self.field_patterns = field_patterns
113
114        # If we've been explicitly given the field_patterns we're to use for
115        # parsing, compile them now.
116        if field_patterns:
117            if isinstance(field_patterns, list):
118                self.compiled_field_patterns = [
119                    re.compile(pattern)
120                    for pattern in field_patterns
121                ]
122            elif isinstance(field_patterns, dict):
123                self.compiled_field_patterns = {
124                    message_type: re.compile(pattern)
125                    for (message_type, pattern) in field_patterns.items()
126                }
127            else:
128                raise ValueError('field_patterns must either be a list of patterns or '
129                                 'dict of message_type:pattern pairs. Found type '
130                                 f'{type(field_patterns)}')
131        else:
132            self.compiled_field_patterns = None
133
134    ############################
135    def _load_definitions(self, definition_path):
136        """Load device definitions and return aggregated field patterns.
137        Populates self.devices and self.device_types.
138        """
139        field_patterns = {}
140
141        # Use shared utility to load definitions
142        definitions = load_definitions(definition_path)
143
144        # Store devices
145        self.devices = definitions.get('devices', {})
146
147        # Process device_types: store them, extract patterns, create converters
148        for dt_name, dt_def in definitions.get('device_types', {}).items():
149            self.device_types[dt_name] = dt_def
150
151            # Aggregate formats (regexes)
152            dt_formats = dt_def.get('format', {})
153            if isinstance(dt_formats, dict):
154                field_patterns.update(dt_formats)
155
156            # Create cached component converter for this type
157            dt_fields = dt_def.get('fields', {})
158            if dt_fields and ConvertFieldsTransform:
159                self.type_converters[dt_name] = ConvertFieldsTransform(
160                    fields=dt_fields,
161                    quiet=self.quiet
162                )
163
164        return field_patterns
165
166    ############################
167    def _compile_metadata(self):
168        """
169        Compile metadata from device definitions if available.
170        Logic adapted from RecordParser.
171        """
172        # It's a map from variable name to the device and device type it
173        # came from, along with device type variable and its units and
174        # description, if provided in the device type definition.
175        for device, device_def in self.devices.items():
176            device_type_name = device_def.get('device_type')
177            if not device_type_name:
178                continue
179
180            device_type_def = self.device_types.get(device_type_name)
181            if not device_type_def:
182                continue
183
184            device_type_fields = device_type_def.get('fields')
185            if not device_type_fields:
186                continue
187
188            fields = device_def.get('fields')
189            if not fields:
190                continue
191
192            # e.g. device_type_field = GPSTime, device_field = S330GPSTime
193            for device_type_field, device_field in fields.items():
194                # e.g. GPSTime: {'units':..., 'description':...}
195                field_desc = device_type_fields.get(device_type_field)
196
197                # field_desc might be a string (type) or dict (metadata) or None
198                if not field_desc or not isinstance(field_desc, dict):
199                    continue
200
201                self.metadata[device_field] = {
202                    'device': device,
203                    'device_type': device_type_name,
204                    'device_type_field': device_type_field,
205                }
206                # Copy relevant keys like units, description
207                for k in ['units', 'description']:
208                    if k in field_desc:
209                        self.metadata[device_field][k] = field_desc[k]
210
211    ############################
212    def parse_record(self, record):
213        """Parse an id-prefixed text record into a DASRecord.
214        """
215        if not record:
216            return None
217        if not isinstance(record, str):
218            logging.info('Record is not a string: "%s"', record)
219            return None
220        try:
221            parsed_record = self.compiled_record_format.match(record).groupdict()
222        except (ValueError, AttributeError):
223            if not self.quiet:
224                logging.warning('Unable to parse record into "%s"', self.record_format)
225                logging.warning('Record: %s', record)
226            return None
227
228        if parsed_record is None:
229            return None
230
231        # Logic to determine data_id:
232        # 1. If self.data_id is set (in __init__), use it (Override).
233        # 2. Else, look for 'data_id' extracted from the record via regex.
234        # 3. If that fails, default to 'unknown'.
235        if self.data_id:
236            data_id = self.data_id
237        else:
238            data_id = parsed_record.get('data_id', None)
239            if not data_id:
240                if not self.quiet:
241                    logging.warning('No data_id found in record and none specified. '
242                                    'Defaulting to "unknown".')
243                data_id = 'unknown'
244
245        # Convert timestamp to numeric, if it's there.
246        # Initialize to None first to avoid UnboundLocalError if 'timestamp'
247        # is not in the regex groups.
248        timestamp = None
249        timestamp_text = parsed_record.get('timestamp', None)
250
251        if timestamp_text is not None:
252            timestamp = self.convert_timestamp(timestamp_text)
253
254        # If no timestamp found, DASRecord will default to time.time()
255        # if passed None.
256        if timestamp is None:
257            timestamp = time.time()
258
259        # Extract the field string we're going to parse;
260        # remove trailing whitespace.
261        field_string = parsed_record.get('field_string', None)
262        if field_string is not None:
263            field_string = field_string.rstrip()
264
265        message_type = None
266        fields = {}
267        if field_string:
268            # If we've been given a set of field_patterns to apply,
269            # use the first that matches.
270            # Shortcut that lets us iterate through a list or a dict with the same
271            # invocation. With a list, it returns (None, value); with a dict it
272            # returns (key, value).
273            def iterate_patterns(obj):
274                return (obj.items() if isinstance(obj, dict) else ((None, v) for v in obj))
275
276            if self.field_patterns:
277                for message_type, pattern in iterate_patterns(self.compiled_field_patterns):
278                    try:
279                        try_parse = pattern.match(field_string)
280                        # Did we find a parse that matched?
281                        # If so, return its fields
282                        if try_parse:
283                            fields = try_parse.groupdict()
284                            break
285                    except Exception as e:
286                        logging.error(e)
287
288        logging.debug('Created parsed fields: %s', pprint.pformat(fields))
289
290        # Create the initial DASRecord
291        try:
292            das_record = DASRecord(data_id=data_id, timestamp=timestamp,
293                                   message_type=message_type,
294                                   fields=fields)
295        except KeyError:
296            return None
297
298        # Device-Specific Processing
299        # Try to match data_id to a known device
300        if data_id in self.devices:
301            device_def = self.devices[data_id]
302            device_type = device_def.get('device_type')
303
304            # A. Type Conversion (delegated to cached ConvertFieldsTransform)
305            if device_type in self.type_converters:
306                converter = self.type_converters[device_type]
307                das_record = converter.transform(das_record)
308                if not das_record:
309                    return None
310
311            # B. Field Renaming / Filtering
312            # Only retain fields that are in the device's 'fields' map
313            device_fields_map = device_def.get('fields', {})
314            if device_fields_map:
315                new_fields = {}
316                for original_name, mapped_name in device_fields_map.items():
317                    if original_name in das_record.fields:
318                        # Use the mapped name (value)
319                        new_fields[mapped_name] = das_record.fields[original_name]
320
321                das_record.fields = new_fields
322
323        # Metadata Injection
324        # If we have parsed fields, see if we also have metadata. Are we
325        # supposed to occasionally send it for our variables? Is it time
326        # to send it again?
327        metadata_to_inject = collect_metadata_for_fields(
328            das_record.fields,
329            das_record.timestamp or 0,
330            self.metadata,
331            self.metadata_interval,
332            self.metadata_last_sent
333        )
334        if metadata_to_inject:
335            if das_record.metadata is None:
336                das_record.metadata = {}
337            das_record.metadata['fields'] = metadata_to_inject['fields']
338
339        return das_record
340
341    ############################
342    def convert_timestamp(self, datetime_text):
343        """Validates a datetime string and converts to numeric.
344        """
345
346        DEFAULT_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
347
348        try:
349            datetime_ti = datetime.datetime.strptime(
350                datetime_text, DEFAULT_FORMAT)
351        except ValueError:
352            logging.debug("Incorrect datetime format.")
353            return None
354
355        if datetime_ti:
356            # Explicitly set UTC timezone because the format expects 'Z'
357            # .replace(tzinfo=...) ensures .timestamp() treats it as UTC
358            # regardless of the local system clock.
359            timestamp = datetime_ti.replace(tzinfo=datetime.timezone.utc).timestamp()
360            return timestamp
DEFAULT_RECORD_FORMAT = '^(?:(?P<data_id>\\w+)\\s+(?P<timestamp>[0-9TZ:\\-\\.]*)\\s+)?(?P<field_string>(.|\\r|\\n)*)'
DEFAULT_DEFINITION_PATH = 'local/devices/*.yaml,contrib/devices/*.yaml'
class RegexParser:
 37class RegexParser:
 38    ############################
 39    def __init__(self,
 40                 record_format=None,
 41                 field_patterns=None,
 42                 data_id=None,
 43                 definition_path=None,
 44                 metadata=None,
 45                 metadata_interval=None,
 46                 quiet=False):
 47        r"""Create a parser that will parse field values out of a text record
 48        and return a DASRecord object.
 49        ```
 50        record_format - string for re.match() to use to break out data_id
 51            and timestamp from the rest of the message. By default this will
 52            look for 'data_id timestamp field_string', where 'field_string'
 53            is a str containing the fields to be parsed.
 54
 55        field_patterns
 56            If not None, either
 57            - a list of regex patterns to be tried
 58            - a dict of message_type:regex patterns to be tried. When one
 59              matches, the record's message_type is set accordingly.
 60            If None and definition_path is provided, patterns are loaded from
 61            device definition files.
 62
 63        data_id
 64            If specified, this string is used as the data_id for all records,
 65            overriding any data_id extracted from the source record.
 66
 67        definition_path
 68            Wildcarded path matching YAML definitions for devices. Used only
 69            if 'field_patterns' is None. Defaults to DEFAULT_DEFINITION_PATH.
 70            Comma-separated globs are supported.
 71
 72        metadata
 73            If provided, a dict mapping field names to their metadata dicts.
 74            If None and definition_path is used, metadata is compiled from
 75            device definitions.
 76
 77        metadata_interval
 78            If not None, include the description, units and other metadata
 79            pertaining to each field in the returned record if those data
 80            haven't been returned in the last metadata_interval seconds.
 81
 82        quiet - if not False, don't complain when unable to parse a record.
 83        ```
 84        """
 85        self.quiet = quiet
 86        self.record_format = record_format or DEFAULT_RECORD_FORMAT
 87        self.compiled_record_format = re.compile(self.record_format)
 88        self.data_id = data_id  # Store the data_id override
 89
 90        # Check for conflict
 91        if field_patterns and definition_path:
 92            raise ValueError('RegexParser: Both field_patterns and definition_path '
 93                             'specified. Please specify only one.')
 94
 95        # Device-aware parsing state
 96        self.devices = {}
 97        self.device_types = {}
 98        self.type_converters = {}
 99
100        # Metadata state
101        self.metadata = metadata or {}
102        self.metadata_interval = metadata_interval
103        self.metadata_last_sent = {}
104
105        # If field patterns not provided, look them up in definitions
106        if field_patterns is None and definition_path is not None:
107            field_patterns = self._load_definitions(definition_path)
108
109            # If metadata not explicitly provided, compile it from definitions
110            if not metadata and metadata_interval:
111                self._compile_metadata()
112
113        self.field_patterns = field_patterns
114
115        # If we've been explicitly given the field_patterns we're to use for
116        # parsing, compile them now.
117        if field_patterns:
118            if isinstance(field_patterns, list):
119                self.compiled_field_patterns = [
120                    re.compile(pattern)
121                    for pattern in field_patterns
122                ]
123            elif isinstance(field_patterns, dict):
124                self.compiled_field_patterns = {
125                    message_type: re.compile(pattern)
126                    for (message_type, pattern) in field_patterns.items()
127                }
128            else:
129                raise ValueError('field_patterns must either be a list of patterns or '
130                                 'dict of message_type:pattern pairs. Found type '
131                                 f'{type(field_patterns)}')
132        else:
133            self.compiled_field_patterns = None
134
135    ############################
136    def _load_definitions(self, definition_path):
137        """Load device definitions and return aggregated field patterns.
138        Populates self.devices and self.device_types.
139        """
140        field_patterns = {}
141
142        # Use shared utility to load definitions
143        definitions = load_definitions(definition_path)
144
145        # Store devices
146        self.devices = definitions.get('devices', {})
147
148        # Process device_types: store them, extract patterns, create converters
149        for dt_name, dt_def in definitions.get('device_types', {}).items():
150            self.device_types[dt_name] = dt_def
151
152            # Aggregate formats (regexes)
153            dt_formats = dt_def.get('format', {})
154            if isinstance(dt_formats, dict):
155                field_patterns.update(dt_formats)
156
157            # Create cached component converter for this type
158            dt_fields = dt_def.get('fields', {})
159            if dt_fields and ConvertFieldsTransform:
160                self.type_converters[dt_name] = ConvertFieldsTransform(
161                    fields=dt_fields,
162                    quiet=self.quiet
163                )
164
165        return field_patterns
166
167    ############################
168    def _compile_metadata(self):
169        """
170        Compile metadata from device definitions if available.
171        Logic adapted from RecordParser.
172        """
173        # It's a map from variable name to the device and device type it
174        # came from, along with device type variable and its units and
175        # description, if provided in the device type definition.
176        for device, device_def in self.devices.items():
177            device_type_name = device_def.get('device_type')
178            if not device_type_name:
179                continue
180
181            device_type_def = self.device_types.get(device_type_name)
182            if not device_type_def:
183                continue
184
185            device_type_fields = device_type_def.get('fields')
186            if not device_type_fields:
187                continue
188
189            fields = device_def.get('fields')
190            if not fields:
191                continue
192
193            # e.g. device_type_field = GPSTime, device_field = S330GPSTime
194            for device_type_field, device_field in fields.items():
195                # e.g. GPSTime: {'units':..., 'description':...}
196                field_desc = device_type_fields.get(device_type_field)
197
198                # field_desc might be a string (type) or dict (metadata) or None
199                if not field_desc or not isinstance(field_desc, dict):
200                    continue
201
202                self.metadata[device_field] = {
203                    'device': device,
204                    'device_type': device_type_name,
205                    'device_type_field': device_type_field,
206                }
207                # Copy relevant keys like units, description
208                for k in ['units', 'description']:
209                    if k in field_desc:
210                        self.metadata[device_field][k] = field_desc[k]
211
212    ############################
213    def parse_record(self, record):
214        """Parse an id-prefixed text record into a DASRecord.
215        """
216        if not record:
217            return None
218        if not isinstance(record, str):
219            logging.info('Record is not a string: "%s"', record)
220            return None
221        try:
222            parsed_record = self.compiled_record_format.match(record).groupdict()
223        except (ValueError, AttributeError):
224            if not self.quiet:
225                logging.warning('Unable to parse record into "%s"', self.record_format)
226                logging.warning('Record: %s', record)
227            return None
228
229        if parsed_record is None:
230            return None
231
232        # Logic to determine data_id:
233        # 1. If self.data_id is set (in __init__), use it (Override).
234        # 2. Else, look for 'data_id' extracted from the record via regex.
235        # 3. If that fails, default to 'unknown'.
236        if self.data_id:
237            data_id = self.data_id
238        else:
239            data_id = parsed_record.get('data_id', None)
240            if not data_id:
241                if not self.quiet:
242                    logging.warning('No data_id found in record and none specified. '
243                                    'Defaulting to "unknown".')
244                data_id = 'unknown'
245
246        # Convert timestamp to numeric, if it's there.
247        # Initialize to None first to avoid UnboundLocalError if 'timestamp'
248        # is not in the regex groups.
249        timestamp = None
250        timestamp_text = parsed_record.get('timestamp', None)
251
252        if timestamp_text is not None:
253            timestamp = self.convert_timestamp(timestamp_text)
254
255        # If no timestamp found, DASRecord will default to time.time()
256        # if passed None.
257        if timestamp is None:
258            timestamp = time.time()
259
260        # Extract the field string we're going to parse;
261        # remove trailing whitespace.
262        field_string = parsed_record.get('field_string', None)
263        if field_string is not None:
264            field_string = field_string.rstrip()
265
266        message_type = None
267        fields = {}
268        if field_string:
269            # If we've been given a set of field_patterns to apply,
270            # use the first that matches.
271            # Shortcut that lets us iterate through a list or a dict with the same
272            # invocation. With a list, it returns (None, value); with a dict it
273            # returns (key, value).
274            def iterate_patterns(obj):
275                return (obj.items() if isinstance(obj, dict) else ((None, v) for v in obj))
276
277            if self.field_patterns:
278                for message_type, pattern in iterate_patterns(self.compiled_field_patterns):
279                    try:
280                        try_parse = pattern.match(field_string)
281                        # Did we find a parse that matched?
282                        # If so, return its fields
283                        if try_parse:
284                            fields = try_parse.groupdict()
285                            break
286                    except Exception as e:
287                        logging.error(e)
288
289        logging.debug('Created parsed fields: %s', pprint.pformat(fields))
290
291        # Create the initial DASRecord
292        try:
293            das_record = DASRecord(data_id=data_id, timestamp=timestamp,
294                                   message_type=message_type,
295                                   fields=fields)
296        except KeyError:
297            return None
298
299        # Device-Specific Processing
300        # Try to match data_id to a known device
301        if data_id in self.devices:
302            device_def = self.devices[data_id]
303            device_type = device_def.get('device_type')
304
305            # A. Type Conversion (delegated to cached ConvertFieldsTransform)
306            if device_type in self.type_converters:
307                converter = self.type_converters[device_type]
308                das_record = converter.transform(das_record)
309                if not das_record:
310                    return None
311
312            # B. Field Renaming / Filtering
313            # Only retain fields that are in the device's 'fields' map
314            device_fields_map = device_def.get('fields', {})
315            if device_fields_map:
316                new_fields = {}
317                for original_name, mapped_name in device_fields_map.items():
318                    if original_name in das_record.fields:
319                        # Use the mapped name (value)
320                        new_fields[mapped_name] = das_record.fields[original_name]
321
322                das_record.fields = new_fields
323
324        # Metadata Injection
325        # If we have parsed fields, see if we also have metadata. Are we
326        # supposed to occasionally send it for our variables? Is it time
327        # to send it again?
328        metadata_to_inject = collect_metadata_for_fields(
329            das_record.fields,
330            das_record.timestamp or 0,
331            self.metadata,
332            self.metadata_interval,
333            self.metadata_last_sent
334        )
335        if metadata_to_inject:
336            if das_record.metadata is None:
337                das_record.metadata = {}
338            das_record.metadata['fields'] = metadata_to_inject['fields']
339
340        return das_record
341
342    ############################
343    def convert_timestamp(self, datetime_text):
344        """Validates a datetime string and converts to numeric.
345        """
346
347        DEFAULT_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
348
349        try:
350            datetime_ti = datetime.datetime.strptime(
351                datetime_text, DEFAULT_FORMAT)
352        except ValueError:
353            logging.debug("Incorrect datetime format.")
354            return None
355
356        if datetime_ti:
357            # Explicitly set UTC timezone because the format expects 'Z'
358            # .replace(tzinfo=...) ensures .timestamp() treats it as UTC
359            # regardless of the local system clock.
360            timestamp = datetime_ti.replace(tzinfo=datetime.timezone.utc).timestamp()
361            return timestamp
RegexParser( record_format=None, field_patterns=None, data_id=None, definition_path=None, metadata=None, metadata_interval=None, quiet=False)
 39    def __init__(self,
 40                 record_format=None,
 41                 field_patterns=None,
 42                 data_id=None,
 43                 definition_path=None,
 44                 metadata=None,
 45                 metadata_interval=None,
 46                 quiet=False):
 47        r"""Create a parser that will parse field values out of a text record
 48        and return a DASRecord object.
 49        ```
 50        record_format - string for re.match() to use to break out data_id
 51            and timestamp from the rest of the message. By default this will
 52            look for 'data_id timestamp field_string', where 'field_string'
 53            is a str containing the fields to be parsed.
 54
 55        field_patterns
 56            If not None, either
 57            - a list of regex patterns to be tried
 58            - a dict of message_type:regex patterns to be tried. When one
 59              matches, the record's message_type is set accordingly.
 60            If None and definition_path is provided, patterns are loaded from
 61            device definition files.
 62
 63        data_id
 64            If specified, this string is used as the data_id for all records,
 65            overriding any data_id extracted from the source record.
 66
 67        definition_path
 68            Wildcarded path matching YAML definitions for devices. Used only
 69            if 'field_patterns' is None. Defaults to DEFAULT_DEFINITION_PATH.
 70            Comma-separated globs are supported.
 71
 72        metadata
 73            If provided, a dict mapping field names to their metadata dicts.
 74            If None and definition_path is used, metadata is compiled from
 75            device definitions.
 76
 77        metadata_interval
 78            If not None, include the description, units and other metadata
 79            pertaining to each field in the returned record if those data
 80            haven't been returned in the last metadata_interval seconds.
 81
 82        quiet - if not False, don't complain when unable to parse a record.
 83        ```
 84        """
 85        self.quiet = quiet
 86        self.record_format = record_format or DEFAULT_RECORD_FORMAT
 87        self.compiled_record_format = re.compile(self.record_format)
 88        self.data_id = data_id  # Store the data_id override
 89
 90        # Check for conflict
 91        if field_patterns and definition_path:
 92            raise ValueError('RegexParser: Both field_patterns and definition_path '
 93                             'specified. Please specify only one.')
 94
 95        # Device-aware parsing state
 96        self.devices = {}
 97        self.device_types = {}
 98        self.type_converters = {}
 99
100        # Metadata state
101        self.metadata = metadata or {}
102        self.metadata_interval = metadata_interval
103        self.metadata_last_sent = {}
104
105        # If field patterns not provided, look them up in definitions
106        if field_patterns is None and definition_path is not None:
107            field_patterns = self._load_definitions(definition_path)
108
109            # If metadata not explicitly provided, compile it from definitions
110            if not metadata and metadata_interval:
111                self._compile_metadata()
112
113        self.field_patterns = field_patterns
114
115        # If we've been explicitly given the field_patterns we're to use for
116        # parsing, compile them now.
117        if field_patterns:
118            if isinstance(field_patterns, list):
119                self.compiled_field_patterns = [
120                    re.compile(pattern)
121                    for pattern in field_patterns
122                ]
123            elif isinstance(field_patterns, dict):
124                self.compiled_field_patterns = {
125                    message_type: re.compile(pattern)
126                    for (message_type, pattern) in field_patterns.items()
127                }
128            else:
129                raise ValueError('field_patterns must either be a list of patterns or '
130                                 'dict of message_type:pattern pairs. Found type '
131                                 f'{type(field_patterns)}')
132        else:
133            self.compiled_field_patterns = None

Create a parser that will parse field values out of a text record and return a DASRecord object.

record_format - string for re.match() 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, either
    - a list of regex patterns to be tried
    - a dict of message_type:regex patterns to be tried. When one
      matches, the record's message_type is set accordingly.
    If None and definition_path is provided, patterns are loaded from
    device definition files.

data_id
    If specified, this string is used as the data_id for all records,
    overriding any data_id extracted from the source record.

definition_path
    Wildcarded path matching YAML definitions for devices. Used only
    if 'field_patterns' is None. Defaults to DEFAULT_DEFINITION_PATH.
    Comma-separated globs are supported.

metadata
    If provided, a dict mapping field names to their metadata dicts.
    If None and definition_path is used, metadata is compiled from
    device definitions.

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.

quiet - if not False, don't complain when unable to parse a record.
quiet
record_format
compiled_record_format
data_id
devices
device_types
type_converters
metadata
metadata_interval
metadata_last_sent
field_patterns
def parse_record(self, record):
213    def parse_record(self, record):
214        """Parse an id-prefixed text record into a DASRecord.
215        """
216        if not record:
217            return None
218        if not isinstance(record, str):
219            logging.info('Record is not a string: "%s"', record)
220            return None
221        try:
222            parsed_record = self.compiled_record_format.match(record).groupdict()
223        except (ValueError, AttributeError):
224            if not self.quiet:
225                logging.warning('Unable to parse record into "%s"', self.record_format)
226                logging.warning('Record: %s', record)
227            return None
228
229        if parsed_record is None:
230            return None
231
232        # Logic to determine data_id:
233        # 1. If self.data_id is set (in __init__), use it (Override).
234        # 2. Else, look for 'data_id' extracted from the record via regex.
235        # 3. If that fails, default to 'unknown'.
236        if self.data_id:
237            data_id = self.data_id
238        else:
239            data_id = parsed_record.get('data_id', None)
240            if not data_id:
241                if not self.quiet:
242                    logging.warning('No data_id found in record and none specified. '
243                                    'Defaulting to "unknown".')
244                data_id = 'unknown'
245
246        # Convert timestamp to numeric, if it's there.
247        # Initialize to None first to avoid UnboundLocalError if 'timestamp'
248        # is not in the regex groups.
249        timestamp = None
250        timestamp_text = parsed_record.get('timestamp', None)
251
252        if timestamp_text is not None:
253            timestamp = self.convert_timestamp(timestamp_text)
254
255        # If no timestamp found, DASRecord will default to time.time()
256        # if passed None.
257        if timestamp is None:
258            timestamp = time.time()
259
260        # Extract the field string we're going to parse;
261        # remove trailing whitespace.
262        field_string = parsed_record.get('field_string', None)
263        if field_string is not None:
264            field_string = field_string.rstrip()
265
266        message_type = None
267        fields = {}
268        if field_string:
269            # If we've been given a set of field_patterns to apply,
270            # use the first that matches.
271            # Shortcut that lets us iterate through a list or a dict with the same
272            # invocation. With a list, it returns (None, value); with a dict it
273            # returns (key, value).
274            def iterate_patterns(obj):
275                return (obj.items() if isinstance(obj, dict) else ((None, v) for v in obj))
276
277            if self.field_patterns:
278                for message_type, pattern in iterate_patterns(self.compiled_field_patterns):
279                    try:
280                        try_parse = pattern.match(field_string)
281                        # Did we find a parse that matched?
282                        # If so, return its fields
283                        if try_parse:
284                            fields = try_parse.groupdict()
285                            break
286                    except Exception as e:
287                        logging.error(e)
288
289        logging.debug('Created parsed fields: %s', pprint.pformat(fields))
290
291        # Create the initial DASRecord
292        try:
293            das_record = DASRecord(data_id=data_id, timestamp=timestamp,
294                                   message_type=message_type,
295                                   fields=fields)
296        except KeyError:
297            return None
298
299        # Device-Specific Processing
300        # Try to match data_id to a known device
301        if data_id in self.devices:
302            device_def = self.devices[data_id]
303            device_type = device_def.get('device_type')
304
305            # A. Type Conversion (delegated to cached ConvertFieldsTransform)
306            if device_type in self.type_converters:
307                converter = self.type_converters[device_type]
308                das_record = converter.transform(das_record)
309                if not das_record:
310                    return None
311
312            # B. Field Renaming / Filtering
313            # Only retain fields that are in the device's 'fields' map
314            device_fields_map = device_def.get('fields', {})
315            if device_fields_map:
316                new_fields = {}
317                for original_name, mapped_name in device_fields_map.items():
318                    if original_name in das_record.fields:
319                        # Use the mapped name (value)
320                        new_fields[mapped_name] = das_record.fields[original_name]
321
322                das_record.fields = new_fields
323
324        # Metadata Injection
325        # If we have parsed fields, see if we also have metadata. Are we
326        # supposed to occasionally send it for our variables? Is it time
327        # to send it again?
328        metadata_to_inject = collect_metadata_for_fields(
329            das_record.fields,
330            das_record.timestamp or 0,
331            self.metadata,
332            self.metadata_interval,
333            self.metadata_last_sent
334        )
335        if metadata_to_inject:
336            if das_record.metadata is None:
337                das_record.metadata = {}
338            das_record.metadata['fields'] = metadata_to_inject['fields']
339
340        return das_record

Parse an id-prefixed text record into a DASRecord.

def convert_timestamp(self, datetime_text):
343    def convert_timestamp(self, datetime_text):
344        """Validates a datetime string and converts to numeric.
345        """
346
347        DEFAULT_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
348
349        try:
350            datetime_ti = datetime.datetime.strptime(
351                datetime_text, DEFAULT_FORMAT)
352        except ValueError:
353            logging.debug("Incorrect datetime format.")
354            return None
355
356        if datetime_ti:
357            # Explicitly set UTC timezone because the format expects 'Z'
358            # .replace(tzinfo=...) ensures .timestamp() treats it as UTC
359            # regardless of the local system clock.
360            timestamp = datetime_ti.replace(tzinfo=datetime.timezone.utc).timestamp()
361            return timestamp

Validates a datetime string and converts to numeric.