garf-core 0.1.5.post0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {garf_core-0.1.5.post0 → garf_core-0.2.1}/PKG-INFO +1 -1
  2. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/__init__.py +1 -1
  3. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/api_clients.py +5 -4
  4. garf_core-0.2.1/garf_core/parsers.py +185 -0
  5. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/query_editor.py +91 -10
  6. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/report_fetcher.py +8 -6
  7. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/PKG-INFO +1 -1
  8. garf_core-0.1.5.post0/garf_core/parsers.py +0 -119
  9. {garf_core-0.1.5.post0 → garf_core-0.2.1}/README.md +0 -0
  10. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/base_query.py +0 -0
  11. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/exceptions.py +0 -0
  12. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/fetchers/__init__.py +0 -0
  13. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/fetchers/fake.py +0 -0
  14. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/fetchers/rest.py +0 -0
  15. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/report.py +0 -0
  16. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/SOURCES.txt +0 -0
  17. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/dependency_links.txt +0 -0
  18. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/entry_points.txt +0 -0
  19. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/requires.txt +0 -0
  20. {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/top_level.txt +0 -0
  21. {garf_core-0.1.5.post0 → garf_core-0.2.1}/pyproject.toml +0 -0
  22. {garf_core-0.1.5.post0 → garf_core-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: garf-core
3
- Version: 0.1.5.post0
3
+ Version: 0.2.1
4
4
  Summary: Abstracts fetching data from API based on provided SQL-like query.
5
5
  Author-email: "Google Inc. (gTech gPS CSE team)" <no-reply@google.com>, Andrei Markin <andrey.markin.ppc@gmail.com>
6
6
  License: Apache 2.0
@@ -26,4 +26,4 @@ __all__ = [
26
26
  'ApiReportFetcher',
27
27
  ]
28
28
 
29
- __version__ = '0.1.5.post0'
29
+ __version__ = '0.2.1'
@@ -20,23 +20,24 @@ import contextlib
20
20
  import csv
21
21
  import json
22
22
  import os
23
- import pathlib
24
23
  from collections.abc import Sequence
25
24
  from typing import Any, Union
26
25
 
27
26
  import pydantic
28
27
  import requests
28
+ import smart_open
29
29
  from typing_extensions import TypeAlias, override
30
30
 
31
31
  from garf_core import exceptions, query_editor
32
32
 
33
33
  ApiRowElement: TypeAlias = Union[int, float, str, bool, list, dict, None]
34
+ ApiResponseRow: TypeAlias = dict[str, ApiRowElement]
34
35
 
35
36
 
36
37
  class GarfApiResponse(pydantic.BaseModel):
37
38
  """Base class for specifying response."""
38
39
 
39
- results: list[ApiRowElement]
40
+ results: list[ApiResponseRow]
40
41
 
41
42
 
42
43
  class GarfApiError(exceptions.GarfError):
@@ -123,7 +124,7 @@ class FakeApiClient(BaseClient):
123
124
  GarfApiError: When file with data not found.
124
125
  """
125
126
  try:
126
- with pathlib.Path.open(file_location, 'r', encoding='utf-8') as f:
127
+ with smart_open.open(file_location, 'r', encoding='utf-8') as f:
127
128
  data = json.load(f)
128
129
  return FakeApiClient(data)
129
130
  except FileNotFoundError as e:
@@ -143,7 +144,7 @@ class FakeApiClient(BaseClient):
143
144
  GarfApiError: When file with data not found.
144
145
  """
145
146
  try:
146
- with pathlib.Path.open(file_location, 'r', encoding='utf-8') as f:
147
+ with smart_open.open(file_location, 'r', encoding='utf-8') as f:
147
148
  reader = csv.DictReader(f)
148
149
  data = []
149
150
  for row in reader:
@@ -0,0 +1,185 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Module for defining various parsing strategy for API response."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import abc
19
+ import ast
20
+ import contextlib
21
+ import functools
22
+ import operator
23
+ from collections.abc import Mapping, MutableSequence
24
+ from typing import Any
25
+
26
+ from garf_core import api_clients, exceptions, query_editor
27
+
28
+ VALID_VIRTUAL_COLUMN_OPERATORS = (
29
+ ast.BinOp,
30
+ ast.UnaryOp,
31
+ ast.operator,
32
+ ast.Constant,
33
+ ast.Expression,
34
+ )
35
+
36
+
37
+ class BaseParser(abc.ABC):
38
+ """An interface for all parsers to implement."""
39
+
40
+ def __init__(
41
+ self, query_specification: query_editor.BaseQueryElements
42
+ ) -> None:
43
+ """Initializes BaseParser."""
44
+ self.query_spec = query_specification
45
+
46
+ def parse_response(
47
+ self,
48
+ response: api_clients.GarfApiResponse,
49
+ ) -> list[list[api_clients.ApiRowElement]]:
50
+ """Parses response."""
51
+ if not response.results:
52
+ return [[]]
53
+ results = []
54
+ for result in response.results:
55
+ results.append(self.parse_row(result))
56
+ return results
57
+
58
+ def _evalute_virtual_column(
59
+ self,
60
+ fields: list[str],
61
+ virtual_column_values: dict[str, Any],
62
+ substitute_expression: str,
63
+ ) -> api_clients.ApiRowElement:
64
+ virtual_column_replacements = {
65
+ field.replace('.', '_'): value
66
+ for field, value in zip(fields, virtual_column_values)
67
+ }
68
+ virtual_column_expression = substitute_expression.format(
69
+ **virtual_column_replacements
70
+ )
71
+ try:
72
+ tree = ast.parse(virtual_column_expression, mode='eval')
73
+ valid = all(
74
+ isinstance(node, VALID_VIRTUAL_COLUMN_OPERATORS)
75
+ for node in ast.walk(tree)
76
+ )
77
+ if valid:
78
+ return eval(
79
+ compile(tree, filename='', mode='eval'), {'__builtins__': None}
80
+ )
81
+ except ZeroDivisionError:
82
+ return 0
83
+ return None
84
+
85
+ def process_virtual_column(
86
+ self,
87
+ row: api_clients.ApiResponseRow,
88
+ virtual_column: query_editor.VirtualColumn,
89
+ ) -> api_clients.ApiRowElement:
90
+ if virtual_column.type == 'built-in':
91
+ return virtual_column.value
92
+ virtual_column_values = [
93
+ self.parse_row_element(row, field) for field in virtual_column.fields
94
+ ]
95
+ try:
96
+ result = self._evalute_virtual_column(
97
+ virtual_column.fields,
98
+ virtual_column_values,
99
+ virtual_column.substitute_expression,
100
+ )
101
+ except TypeError:
102
+ virtual_column_values = [
103
+ f"'{self.parse_row_element(row, field)}'"
104
+ for field in virtual_column.fields
105
+ ]
106
+ result = self._evalute_virtual_column(
107
+ virtual_column.fields,
108
+ virtual_column_values,
109
+ virtual_column.substitute_expression,
110
+ )
111
+ except SyntaxError:
112
+ return virtual_column.value
113
+ return result
114
+
115
+ def parse_row(
116
+ self,
117
+ row: api_clients.ApiResponseRow,
118
+ ) -> list[api_clients.ApiRowElement]:
119
+ """Parses single row from response."""
120
+ results = []
121
+ fields = self.query_spec.fields
122
+ index = 0
123
+ for column in self.query_spec.column_names:
124
+ if virtual_column := self.query_spec.virtual_columns.get(column):
125
+ result = self.process_virtual_column(row, virtual_column)
126
+ else:
127
+ result = self.parse_row_element(row, fields[index])
128
+ index = index + 1
129
+ results.append(result)
130
+ return results
131
+
132
+ @abc.abstractmethod
133
+ def parse_row_element(
134
+ self, row: api_clients.ApiResponseRow, key: str
135
+ ) -> api_clients.ApiRowElement:
136
+ """Returns nested fields from a dictionary."""
137
+
138
+
139
+ class DictParser(BaseParser):
140
+ """Extracts nested dict elements."""
141
+
142
+ def parse_row_element(
143
+ self, row: api_clients.ApiResponseRow, key: str
144
+ ) -> api_clients.ApiRowElement:
145
+ """Returns nested fields from a dictionary."""
146
+ if not isinstance(row, Mapping):
147
+ raise GarfParserError
148
+ if result := row.get(key):
149
+ return result
150
+ key = key.split('.')
151
+ try:
152
+ return functools.reduce(operator.getitem, key, row)
153
+ except (TypeError, KeyError):
154
+ return None
155
+
156
+
157
+ class NumericConverterDictParser(DictParser):
158
+ """Extracts nested dict elements with numerical conversions."""
159
+
160
+ def parse_row_element(
161
+ self, row: api_clients.ApiResponseRow, key: str
162
+ ) -> api_clients.ApiRowElement:
163
+ """Extract nested field with int/float conversion."""
164
+
165
+ def convert_field(value):
166
+ for type_ in (int, float):
167
+ with contextlib.suppress(ValueError):
168
+ return type_(value)
169
+ return value
170
+
171
+ if result := row.get(key):
172
+ return convert_field(result)
173
+
174
+ key = key.split('.')
175
+ try:
176
+ field = functools.reduce(operator.getitem, key, row)
177
+ if isinstance(field, MutableSequence) or field in (True, False):
178
+ return field
179
+ return convert_field(field)
180
+ except KeyError:
181
+ return None
182
+
183
+
184
+ class GarfParserError(exceptions.GarfError):
185
+ """Incorrect data format for parser."""
@@ -29,7 +29,7 @@ from typing_extensions import Self, TypeAlias
29
29
 
30
30
  from garf_core import exceptions
31
31
 
32
- QueryParameters: TypeAlias = dict[str, Union[str, float, int]]
32
+ QueryParameters: TypeAlias = dict[str, Union[str, float, int, list]]
33
33
 
34
34
 
35
35
  class GarfQueryParameters(pydantic.BaseModel):
@@ -125,6 +125,8 @@ class ProcessedField(pydantic.BaseModel):
125
125
 
126
126
  @classmethod
127
127
  def _extract_nested_resource(cls, line_elements: str) -> list[str]:
128
+ if '://' in line_elements:
129
+ return []
128
130
  return re.split(':', line_elements)
129
131
 
130
132
 
@@ -160,22 +162,29 @@ class VirtualColumn:
160
162
  return VirtualColumn(type='built-in', value=field)
161
163
 
162
164
  operators = ('/', r'\*', r'\+', ' - ')
163
- if len(expressions := re.split('|'.join(operators), field)) > 1:
165
+ if '://' in field:
166
+ expressions = re.split(r'\+', field)
167
+ else:
168
+ expressions = re.split('|'.join(operators), field)
169
+ if len(expressions) > 1:
164
170
  virtual_column_fields = []
165
171
  substitute_expression = field
166
172
  for expression in expressions:
167
173
  element = expression.strip()
168
- if True:
169
- # if self._is_valid_field(element):
174
+ if not _is_constant(element):
170
175
  virtual_column_fields.append(element)
171
176
  substitute_expression = substitute_expression.replace(
172
177
  element, f'{{{element}}}'
173
178
  )
179
+ pattern = r'\{([^}]*)\}'
180
+ substitute_expression = re.sub(
181
+ pattern, lambda m: m.group(0).replace('.', '_'), substitute_expression
182
+ )
174
183
  return VirtualColumn(
175
184
  type='expression',
176
185
  value=field.format(**macros) if macros else field,
177
186
  fields=virtual_column_fields,
178
- substitute_expression=substitute_expression.replace('.', '_'),
187
+ substitute_expression=substitute_expression,
179
188
  )
180
189
  if not _is_quoted_string(field):
181
190
  raise GarfFieldError(f"Incorrect field '{field}'.")
@@ -211,7 +220,11 @@ class ExtractedLineElements:
211
220
  field, *alias = re.split(' [Aa][Ss] ', line)
212
221
  processed_field = ProcessedField.from_raw(field)
213
222
  field = processed_field.field
214
- virtual_column = None if field else VirtualColumn.from_raw(field, macros)
223
+ virtual_column = (
224
+ VirtualColumn.from_raw(field, macros)
225
+ if _is_invalid_field(field)
226
+ else None
227
+ )
215
228
  if alias and processed_field.customizer_type:
216
229
  customizer = {
217
230
  'type': processed_field.customizer_type,
@@ -373,7 +386,7 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
373
386
  self,
374
387
  text: str,
375
388
  title: str | None = None,
376
- args: GarfQueryParameters | None = GarfQueryParameters(),
389
+ args: GarfQueryParameters | None = None,
377
390
  **kwargs: str,
378
391
  ) -> None:
379
392
  """Instantiates QuerySpecification based on text, title and optional args.
@@ -392,7 +405,10 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
392
405
  """Returns macros with injected common parameters."""
393
406
  common_params = dict(self.common_params)
394
407
  if macros := self.args.macro:
395
- common_params.update(macros)
408
+ converted_macros = {
409
+ key: convert_date(value) for key, value in macros.items()
410
+ }
411
+ common_params.update(converted_macros)
396
412
  return common_params
397
413
 
398
414
  def generate(self) -> BaseQueryElements:
@@ -435,10 +451,10 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
435
451
  if re.match('/\\*', line) or multiline_comment:
436
452
  multiline_comment = True
437
453
  continue
438
- if re.match('^(#|--|//)', line):
454
+ if re.match('^(#|--|//) ', line):
439
455
  continue
440
456
  cleaned_query_line = re.sub(
441
- ';$', '', re.sub('(--|//).*$', '', line).strip()
457
+ ';$', '', re.sub('(--|//) .*$', '', line).strip()
442
458
  )
443
459
  result.append(cleaned_query_line)
444
460
  self.query.text = ' '.join(result)
@@ -506,6 +522,10 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
506
522
  line_elements = ExtractedLineElements.from_query_line(line)
507
523
  if virtual_column := line_elements.virtual_column:
508
524
  self.query.virtual_columns[line_elements.alias] = virtual_column
525
+ if fields := virtual_column.fields:
526
+ for field in fields:
527
+ if field not in self.query.fields:
528
+ self.query.fields.append(field)
509
529
  return self
510
530
 
511
531
  def extract_customizers(self) -> Self:
@@ -533,3 +553,64 @@ def _is_quoted_string(field_name: str) -> bool:
533
553
  return (field_name.startswith("'") and field_name.endswith("'")) or (
534
554
  field_name.startswith('"') and field_name.endswith('"')
535
555
  )
556
+
557
+
558
+ def _is_constant(element) -> bool:
559
+ with contextlib.suppress(ValueError):
560
+ float(element)
561
+ return True
562
+ return _is_quoted_string(element)
563
+
564
+
565
+ def _is_invalid_field(field) -> bool:
566
+ operators = ('/', '*', '+', ' - ')
567
+ is_constant = _is_constant(field)
568
+ has_operator = any(operator in field for operator in operators)
569
+ return is_constant or has_operator
570
+
571
+
572
+ def convert_date(date_string: str) -> str:
573
+ """Converts specific dates parameters to actual dates.
574
+
575
+ Returns:
576
+ Date string in YYYY-MM-DD format.
577
+
578
+ Raises:
579
+ GarfMacroError:
580
+ If dynamic lookback value (:YYYYMMDD-N) is incorrect.
581
+ """
582
+ if isinstance(date_string, list) or date_string.find(':Y') == -1:
583
+ return date_string
584
+ current_date = datetime.date.today()
585
+ base_date, *date_customizer = re.split('\\+|-', date_string)
586
+ if len(date_customizer) > 1:
587
+ raise GarfMacroError(
588
+ 'Invalid format for date macro, should be in :YYYYMMDD-N format'
589
+ )
590
+ if not date_customizer:
591
+ days_lookback = 0
592
+ else:
593
+ try:
594
+ days_lookback = int(date_customizer[0])
595
+ except ValueError as e:
596
+ raise GarfMacroError(
597
+ 'Must provide numeric value for a number lookback period, '
598
+ 'i.e. :YYYYMMDD-1'
599
+ ) from e
600
+ if base_date == ':YYYY':
601
+ new_date = datetime.datetime(current_date.year, 1, 1)
602
+ delta = relativedelta.relativedelta(years=days_lookback)
603
+ elif base_date == ':YYYYMM':
604
+ new_date = datetime.datetime(current_date.year, current_date.month, 1)
605
+ delta = relativedelta.relativedelta(months=days_lookback)
606
+ elif base_date == ':YYYYMMDD':
607
+ new_date = current_date
608
+ delta = relativedelta.relativedelta(days=days_lookback)
609
+ else:
610
+ raise GarfMacroError(
611
+ 'Invalid format for date macro, should be in :YYYYMMDD-N format'
612
+ )
613
+
614
+ if '-' in date_string:
615
+ return (new_date - delta).strftime('%Y-%m-%d')
616
+ return (new_date + delta).strftime('%Y-%m-%d')
@@ -54,16 +54,18 @@ class ApiReportFetcher:
54
54
  """Class responsible for getting data from report API.
55
55
 
56
56
  Attributes:
57
- api_client: a client used for connecting to API.
58
- parser: Type of parser to convert API response.
57
+ api_client: Client used for connecting to API.
58
+ parser: Class of parser to convert API response.
59
59
  query_specification_builder: Class to perform query parsing.
60
+ builtin_queries:
61
+ Mapping between query name and function for generating GarfReport.
60
62
  """
61
63
 
62
64
  def __init__(
63
65
  self,
64
- api_client: api_clients.BaseApiClient,
65
- parser: parsers.BaseParser = parsers.DictParser,
66
- query_specification_builder: query_editor.QuerySpecification = (
66
+ api_client: api_clients.BaseClient,
67
+ parser: type[parsers.BaseParser] = parsers.DictParser,
68
+ query_specification_builder: type[query_editor.QuerySpecification] = (
67
69
  query_editor.QuerySpecification
68
70
  ),
69
71
  builtin_queries: dict[str, Callable[[ApiReportFetcher], report.GarfReport]]
@@ -94,7 +96,7 @@ class ApiReportFetcher:
94
96
 
95
97
  async def afetch(
96
98
  self,
97
- query_specification: str | query_editor.QueryElements,
99
+ query_specification: str | query_editor.QuerySpecification,
98
100
  args: query_editor.GarfQueryParameters | None = None,
99
101
  **kwargs: str,
100
102
  ) -> report.GarfReport:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: garf-core
3
- Version: 0.1.5.post0
3
+ Version: 0.2.1
4
4
  Summary: Abstracts fetching data from API based on provided SQL-like query.
5
5
  Author-email: "Google Inc. (gTech gPS CSE team)" <no-reply@google.com>, Andrei Markin <andrey.markin.ppc@gmail.com>
6
6
  License: Apache 2.0
@@ -1,119 +0,0 @@
1
- # Copyright 2025 Google LLC
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # https://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- """Module for defining various parsing strategy for API response."""
15
-
16
- from __future__ import annotations
17
-
18
- import abc
19
- import contextlib
20
- import functools
21
- import operator
22
- from collections.abc import Mapping, MutableSequence
23
- from typing import Any
24
-
25
- from typing_extensions import override
26
-
27
- from garf_core import api_clients, exceptions, query_editor
28
-
29
-
30
- class BaseParser(abc.ABC):
31
- """An interface for all parsers to implement."""
32
-
33
- def __init__(
34
- self, query_specification: query_editor.BaseQueryElements
35
- ) -> None:
36
- """Initializes BaseParser."""
37
- self.query_spec = query_specification
38
-
39
- def parse_response(
40
- self,
41
- response: api_clients.GarfApiResponse,
42
- ) -> list[list[api_clients.ApiRowElement]]:
43
- """Parses response."""
44
- if not response.results:
45
- return [[]]
46
- results = []
47
- for result in response.results:
48
- results.append(self.parse_row(result))
49
- return results
50
-
51
- @abc.abstractmethod
52
- def parse_row(self, row):
53
- """Parses single row from response."""
54
-
55
-
56
- class ListParser(BaseParser):
57
- """Returns API results as is."""
58
-
59
- @override
60
- def parse_row(
61
- self,
62
- row: list,
63
- ) -> list[list[api_clients.ApiRowElement]]:
64
- return row
65
-
66
-
67
- class DictParser(BaseParser):
68
- """Extracts nested dict elements."""
69
-
70
- @override
71
- def parse_row(
72
- self,
73
- row: list,
74
- ) -> list[list[api_clients.ApiRowElement]]:
75
- if not isinstance(row, Mapping):
76
- raise GarfParserError
77
- result = []
78
- for field in self.query_spec.fields:
79
- result.append(self.get_nested_field(row, field))
80
- return result
81
-
82
- def get_nested_field(self, dictionary: dict[str, Any], key: str):
83
- """Returns nested fields from a dictionary."""
84
- if result := dictionary.get(key):
85
- return result
86
- key = key.split('.')
87
- try:
88
- return functools.reduce(operator.getitem, key, dictionary)
89
- except (TypeError, KeyError):
90
- return None
91
-
92
-
93
- class NumericConverterDictParser(DictParser):
94
- """Extracts nested dict elements with numerical conversions."""
95
-
96
- def get_nested_field(self, dictionary: dict[str, Any], key: str):
97
- """Extract nested field with int/float conversion."""
98
-
99
- def convert_field(value):
100
- for type_ in (int, float):
101
- with contextlib.suppress(ValueError):
102
- return type_(value)
103
- return value
104
-
105
- if result := dictionary.get(key):
106
- return convert_field(result)
107
-
108
- key = key.split('.')
109
- try:
110
- field = functools.reduce(operator.getitem, key, dictionary)
111
- if isinstance(field, MutableSequence) or field in (True, False):
112
- return field
113
- return convert_field(field)
114
- except KeyError:
115
- return None
116
-
117
-
118
- class GarfParserError(exceptions.GarfError):
119
- """Incorrect data format for parser."""
File without changes
File without changes