garf-core 0.1.5__tar.gz → 0.2.0__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 → garf_core-0.2.0}/PKG-INFO +1 -1
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/__init__.py +1 -1
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/api_clients.py +3 -2
- garf_core-0.2.0/garf_core/parsers.py +185 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/query_editor.py +38 -7
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/report_fetcher.py +7 -5
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core.egg-info/PKG-INFO +1 -1
- garf_core-0.1.5/garf_core/parsers.py +0 -119
- {garf_core-0.1.5 → garf_core-0.2.0}/README.md +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/base_query.py +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/exceptions.py +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/fetchers/__init__.py +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/fetchers/fake.py +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/fetchers/rest.py +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core/report.py +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core.egg-info/SOURCES.txt +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core.egg-info/dependency_links.txt +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core.egg-info/entry_points.txt +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core.egg-info/requires.txt +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/garf_core.egg-info/top_level.txt +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/pyproject.toml +0 -0
- {garf_core-0.1.5 → garf_core-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: garf-core
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.0
|
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
|
@@ -31,12 +31,13 @@ from typing_extensions import TypeAlias, override
|
|
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):
|
@@ -69,7 +70,7 @@ class RestApiClient(BaseClient):
|
|
69
70
|
) -> GarfApiResponse:
|
70
71
|
response = requests.get(f'{self.endpoint}/{request.resource_name}')
|
71
72
|
if response.status_code == self.OK:
|
72
|
-
return GarfApiResponse(response.json())
|
73
|
+
return GarfApiResponse(results=response.json())
|
73
74
|
raise GarfApiError('Failed to get data from API')
|
74
75
|
|
75
76
|
|
@@ -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."""
|
@@ -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,
|
@@ -435,10 +448,10 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
|
|
435
448
|
if re.match('/\\*', line) or multiline_comment:
|
436
449
|
multiline_comment = True
|
437
450
|
continue
|
438
|
-
if re.match('^(#|--|//)', line):
|
451
|
+
if re.match('^(#|--|//) ', line):
|
439
452
|
continue
|
440
453
|
cleaned_query_line = re.sub(
|
441
|
-
';$', '', re.sub('(--|//).*$', '', line).strip()
|
454
|
+
';$', '', re.sub('(--|//) .*$', '', line).strip()
|
442
455
|
)
|
443
456
|
result.append(cleaned_query_line)
|
444
457
|
self.query.text = ' '.join(result)
|
@@ -506,6 +519,10 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
|
|
506
519
|
line_elements = ExtractedLineElements.from_query_line(line)
|
507
520
|
if virtual_column := line_elements.virtual_column:
|
508
521
|
self.query.virtual_columns[line_elements.alias] = virtual_column
|
522
|
+
if fields := virtual_column.fields:
|
523
|
+
for field in fields:
|
524
|
+
if field not in self.query.fields:
|
525
|
+
self.query.fields.append(field)
|
509
526
|
return self
|
510
527
|
|
511
528
|
def extract_customizers(self) -> Self:
|
@@ -533,3 +550,17 @@ def _is_quoted_string(field_name: str) -> bool:
|
|
533
550
|
return (field_name.startswith("'") and field_name.endswith("'")) or (
|
534
551
|
field_name.startswith('"') and field_name.endswith('"')
|
535
552
|
)
|
553
|
+
|
554
|
+
|
555
|
+
def _is_constant(element) -> bool:
|
556
|
+
with contextlib.suppress(ValueError):
|
557
|
+
float(element)
|
558
|
+
return True
|
559
|
+
return _is_quoted_string(element)
|
560
|
+
|
561
|
+
|
562
|
+
def _is_invalid_field(field) -> bool:
|
563
|
+
operators = ('/', '*', '+', ' - ')
|
564
|
+
is_constant = _is_constant(field)
|
565
|
+
has_operator = any(operator in field for operator in operators)
|
566
|
+
return is_constant or has_operator
|
@@ -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]]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: garf-core
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.0
|
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
|