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.
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/PKG-INFO +1 -1
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/__init__.py +1 -1
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/api_clients.py +5 -4
- garf_core-0.2.1/garf_core/parsers.py +185 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/query_editor.py +91 -10
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/report_fetcher.py +8 -6
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/PKG-INFO +1 -1
- garf_core-0.1.5.post0/garf_core/parsers.py +0 -119
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/README.md +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/base_query.py +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/exceptions.py +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/fetchers/__init__.py +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/fetchers/fake.py +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/fetchers/rest.py +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core/report.py +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/SOURCES.txt +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/dependency_links.txt +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/entry_points.txt +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/requires.txt +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/garf_core.egg-info/top_level.txt +0 -0
- {garf_core-0.1.5.post0 → garf_core-0.2.1}/pyproject.toml +0 -0
- {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
|
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
|
@@ -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[
|
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
|
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
|
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
|
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
|
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
|
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 =
|
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 =
|
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
|
-
|
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:
|
58
|
-
parser:
|
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.
|
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.
|
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
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|