garf-core 0.2.2__tar.gz → 0.3.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.2.2 → garf_core-0.3.0}/PKG-INFO +1 -1
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/__init__.py +1 -1
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/api_clients.py +11 -3
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/parsers.py +17 -1
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/query_editor.py +9 -231
- garf_core-0.3.0/garf_core/query_parser.py +299 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/report_fetcher.py +3 -1
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/PKG-INFO +1 -1
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/SOURCES.txt +1 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/README.md +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/base_query.py +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/exceptions.py +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/fetchers/__init__.py +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/fetchers/fake.py +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/fetchers/rest.py +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/report.py +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/dependency_links.txt +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/entry_points.txt +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/requires.txt +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/top_level.txt +0 -0
- {garf_core-0.2.2 → garf_core-0.3.0}/pyproject.toml +0 -0
- {garf_core-0.2.2 → garf_core-0.3.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.3.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
|
@@ -68,10 +68,18 @@ class RestApiClient(BaseClient):
|
|
68
68
|
def get_response(
|
69
69
|
self, request: query_editor.BaseQueryElements, **kwargs: str
|
70
70
|
) -> GarfApiResponse:
|
71
|
-
|
71
|
+
url = f'{self.endpoint}/{request.resource_name}'
|
72
|
+
params = {}
|
73
|
+
for param in request.filters:
|
74
|
+
key, value = param.split('=')
|
75
|
+
params[key.strip()] = value.strip()
|
76
|
+
response = requests.get(url, params=params, headers=kwargs)
|
72
77
|
if response.status_code == self.OK:
|
73
|
-
|
74
|
-
|
78
|
+
results = response.json()
|
79
|
+
if not isinstance(results, list):
|
80
|
+
results = [results]
|
81
|
+
return GarfApiResponse(results=results)
|
82
|
+
raise GarfApiError('Failed to get data from API, reason: ', response.text)
|
75
83
|
|
76
84
|
|
77
85
|
class FakeApiClient(BaseClient):
|
@@ -23,7 +23,7 @@ import operator
|
|
23
23
|
from collections.abc import Mapping, MutableSequence
|
24
24
|
from typing import Any
|
25
25
|
|
26
|
-
from garf_core import api_clients, exceptions, query_editor
|
26
|
+
from garf_core import api_clients, exceptions, query_editor, query_parser
|
27
27
|
|
28
28
|
VALID_VIRTUAL_COLUMN_OPERATORS = (
|
29
29
|
ast.BinOp,
|
@@ -112,6 +112,20 @@ class BaseParser(abc.ABC):
|
|
112
112
|
return virtual_column.value
|
113
113
|
return result
|
114
114
|
|
115
|
+
def process_customizer(
|
116
|
+
self,
|
117
|
+
row: api_clients.ApiResponseRow,
|
118
|
+
customizer: query_parser.Customizer,
|
119
|
+
field: str,
|
120
|
+
) -> api_clients.ApiRowElement:
|
121
|
+
if customizer.type == 'slice':
|
122
|
+
return self._process_customizer_slice(row, customizer, field)
|
123
|
+
return row
|
124
|
+
|
125
|
+
def _process_customizer_slice(self, row, customizer, field):
|
126
|
+
slice_object = customizer.value.slice_literal
|
127
|
+
return [r.get(customizer.value.value) for r in row.get(field)[slice_object]]
|
128
|
+
|
115
129
|
def parse_row(
|
116
130
|
self,
|
117
131
|
row: api_clients.ApiResponseRow,
|
@@ -123,6 +137,8 @@ class BaseParser(abc.ABC):
|
|
123
137
|
for column in self.query_spec.column_names:
|
124
138
|
if virtual_column := self.query_spec.virtual_columns.get(column):
|
125
139
|
result = self.process_virtual_column(row, virtual_column)
|
140
|
+
elif customizer := self.query_spec.customizers.get(column):
|
141
|
+
result = self.process_customizer(row, customizer, fields[index])
|
126
142
|
else:
|
127
143
|
result = self.parse_row_element(row, fields[index])
|
128
144
|
index = index + 1
|
@@ -15,7 +15,6 @@
|
|
15
15
|
|
16
16
|
from __future__ import annotations
|
17
17
|
|
18
|
-
import contextlib
|
19
18
|
import dataclasses
|
20
19
|
import datetime
|
21
20
|
import logging
|
@@ -27,7 +26,7 @@ import pydantic
|
|
27
26
|
from dateutil import relativedelta
|
28
27
|
from typing_extensions import Self, TypeAlias
|
29
28
|
|
30
|
-
from garf_core import
|
29
|
+
from garf_core import query_parser
|
31
30
|
|
32
31
|
QueryParameters: TypeAlias = dict[str, Union[str, float, int, list]]
|
33
32
|
|
@@ -39,219 +38,18 @@ class GarfQueryParameters(pydantic.BaseModel):
|
|
39
38
|
template: QueryParameters = pydantic.Field(default_factory=dict)
|
40
39
|
|
41
40
|
|
42
|
-
class
|
43
|
-
"""Base exception for Garf queries."""
|
44
|
-
|
45
|
-
|
46
|
-
class GarfCustomizerError(GarfQueryError):
|
47
|
-
"""Specifies incorrect customizer."""
|
48
|
-
|
49
|
-
|
50
|
-
class GarfVirtualColumnError(GarfQueryError):
|
51
|
-
"""Specifies incorrect virtual column type."""
|
52
|
-
|
53
|
-
|
54
|
-
class GarfFieldError(GarfQueryError):
|
55
|
-
"""Specifies incorrect fields from API."""
|
56
|
-
|
57
|
-
|
58
|
-
class GarfMacroError(GarfQueryError):
|
41
|
+
class GarfMacroError(query_parser.GarfQueryError):
|
59
42
|
"""Specifies incorrect macro in Garf query."""
|
60
43
|
|
61
44
|
|
62
|
-
class GarfResourceError(GarfQueryError):
|
45
|
+
class GarfResourceError(query_parser.GarfQueryError):
|
63
46
|
"""Specifies incorrect resource name in the query."""
|
64
47
|
|
65
48
|
|
66
|
-
class GarfBuiltInQueryError(GarfQueryError):
|
49
|
+
class GarfBuiltInQueryError(query_parser.GarfQueryError):
|
67
50
|
"""Specifies non-existing builtin query."""
|
68
51
|
|
69
52
|
|
70
|
-
class ProcessedField(pydantic.BaseModel):
|
71
|
-
"""Sore field with its customizers.
|
72
|
-
|
73
|
-
Attributes:
|
74
|
-
field: Extractable field.
|
75
|
-
customizer_type: Type of customizer to be applied to the field.
|
76
|
-
customizer_value: Value to be used in customizer.
|
77
|
-
"""
|
78
|
-
|
79
|
-
field: str
|
80
|
-
customizer_type: str | None = None
|
81
|
-
customizer_value: int | str | None = None
|
82
|
-
|
83
|
-
@classmethod
|
84
|
-
def from_raw(cls, raw_field: str) -> ProcessedField:
|
85
|
-
"""Process field to extract possible customizers.
|
86
|
-
|
87
|
-
Args:
|
88
|
-
raw_field: Unformatted field string value.
|
89
|
-
|
90
|
-
Returns:
|
91
|
-
ProcessedField that contains formatted field with customizers.
|
92
|
-
"""
|
93
|
-
raw_field = raw_field.replace(r'\s+', '').strip()
|
94
|
-
if _is_quoted_string(raw_field):
|
95
|
-
return ProcessedField(field=raw_field)
|
96
|
-
if len(resources := cls._extract_resource_element(raw_field)) > 1:
|
97
|
-
field_name, resource_index = resources
|
98
|
-
return ProcessedField(
|
99
|
-
field=field_name,
|
100
|
-
customizer_type='resource_index',
|
101
|
-
customizer_value=int(resource_index),
|
102
|
-
)
|
103
|
-
|
104
|
-
if len(nested_fields := cls._extract_nested_resource(raw_field)) > 1:
|
105
|
-
field_name, nested_field = nested_fields
|
106
|
-
return ProcessedField(
|
107
|
-
field=field_name,
|
108
|
-
customizer_type='nested_field',
|
109
|
-
customizer_value=nested_field,
|
110
|
-
)
|
111
|
-
if len(pointers := cls._extract_pointer(raw_field)) > 1:
|
112
|
-
field_name, pointer = pointers
|
113
|
-
return ProcessedField(
|
114
|
-
field=field_name, customizer_type='pointer', customizer_value=pointer
|
115
|
-
)
|
116
|
-
return ProcessedField(field=raw_field)
|
117
|
-
|
118
|
-
@classmethod
|
119
|
-
def _extract_resource_element(cls, line_elements: str) -> list[str]:
|
120
|
-
return re.split('~', line_elements)
|
121
|
-
|
122
|
-
@classmethod
|
123
|
-
def _extract_pointer(cls, line_elements: str) -> list[str]:
|
124
|
-
return re.split('->', line_elements)
|
125
|
-
|
126
|
-
@classmethod
|
127
|
-
def _extract_nested_resource(cls, line_elements: str) -> list[str]:
|
128
|
-
if '://' in line_elements:
|
129
|
-
return []
|
130
|
-
return re.split(':', line_elements)
|
131
|
-
|
132
|
-
|
133
|
-
@dataclasses.dataclass(frozen=True)
|
134
|
-
class VirtualColumn:
|
135
|
-
"""Represents element in Garf query that either calculated or plugged-in.
|
136
|
-
|
137
|
-
Virtual columns allow performing basic manipulation with metrics and
|
138
|
-
dimensions (i.e. division or multiplication) as well as adding raw text
|
139
|
-
values directly into report.
|
140
|
-
|
141
|
-
Attributes:
|
142
|
-
type: Type of virtual column, either build-in or expression.
|
143
|
-
value: Value of the field after macro expansion.
|
144
|
-
fields: Possible fields participating in calculations.
|
145
|
-
substitute_expression: Formatted expression.
|
146
|
-
"""
|
147
|
-
|
148
|
-
type: str
|
149
|
-
value: str
|
150
|
-
fields: list[str] | None = None
|
151
|
-
substitute_expression: str | None = None
|
152
|
-
|
153
|
-
@classmethod
|
154
|
-
def from_raw(cls, field: str, macros: QueryParameters) -> VirtualColumn:
|
155
|
-
"""Converts a field to virtual column."""
|
156
|
-
if field.isdigit():
|
157
|
-
field = int(field)
|
158
|
-
else:
|
159
|
-
with contextlib.suppress(ValueError):
|
160
|
-
field = float(field)
|
161
|
-
if isinstance(field, (int, float)):
|
162
|
-
return VirtualColumn(type='built-in', value=field)
|
163
|
-
|
164
|
-
operators = ('/', r'\*', r'\+', ' - ')
|
165
|
-
if '://' in field:
|
166
|
-
expressions = re.split(r'\+', field)
|
167
|
-
else:
|
168
|
-
expressions = re.split('|'.join(operators), field)
|
169
|
-
if len(expressions) > 1:
|
170
|
-
virtual_column_fields = []
|
171
|
-
substitute_expression = field
|
172
|
-
for expression in expressions:
|
173
|
-
element = expression.strip()
|
174
|
-
if not _is_constant(element):
|
175
|
-
virtual_column_fields.append(element)
|
176
|
-
substitute_expression = substitute_expression.replace(
|
177
|
-
element, f'{{{element}}}'
|
178
|
-
)
|
179
|
-
pattern = r'\{([^}]*)\}'
|
180
|
-
substitute_expression = re.sub(
|
181
|
-
pattern, lambda m: m.group(0).replace('.', '_'), substitute_expression
|
182
|
-
)
|
183
|
-
return VirtualColumn(
|
184
|
-
type='expression',
|
185
|
-
value=field.format(**macros) if macros else field,
|
186
|
-
fields=virtual_column_fields,
|
187
|
-
substitute_expression=substitute_expression,
|
188
|
-
)
|
189
|
-
if not _is_quoted_string(field):
|
190
|
-
raise GarfFieldError(f"Incorrect field '{field}'.")
|
191
|
-
field = field.replace("'", '').replace('"', '')
|
192
|
-
field = field.format(**macros) if macros else field
|
193
|
-
return VirtualColumn(type='built-in', value=field)
|
194
|
-
|
195
|
-
|
196
|
-
@dataclasses.dataclass
|
197
|
-
class ExtractedLineElements:
|
198
|
-
"""Helper class for parsing query lines.
|
199
|
-
|
200
|
-
Attributes:
|
201
|
-
fields: All fields extracted from the line.
|
202
|
-
alias: Optional alias assign to a field.
|
203
|
-
virtual_column: Optional virtual column extracted from query line.
|
204
|
-
customizer: Optional values for customizers associated with a field.
|
205
|
-
"""
|
206
|
-
|
207
|
-
field: str | None
|
208
|
-
alias: str | None
|
209
|
-
virtual_column: VirtualColumn | None
|
210
|
-
customizer: dict[str, str | int]
|
211
|
-
|
212
|
-
@classmethod
|
213
|
-
def from_query_line(
|
214
|
-
cls,
|
215
|
-
line: str,
|
216
|
-
macros: QueryParameters | None = None,
|
217
|
-
) -> ExtractedLineElements:
|
218
|
-
if macros is None:
|
219
|
-
macros = {}
|
220
|
-
field, *alias = re.split(' [Aa][Ss] ', line)
|
221
|
-
processed_field = ProcessedField.from_raw(field)
|
222
|
-
field = processed_field.field
|
223
|
-
virtual_column = (
|
224
|
-
VirtualColumn.from_raw(field, macros)
|
225
|
-
if _is_invalid_field(field)
|
226
|
-
else None
|
227
|
-
)
|
228
|
-
if alias and processed_field.customizer_type:
|
229
|
-
customizer = {
|
230
|
-
'type': processed_field.customizer_type,
|
231
|
-
'value': processed_field.customizer_value,
|
232
|
-
}
|
233
|
-
else:
|
234
|
-
customizer = {}
|
235
|
-
if virtual_column and not alias:
|
236
|
-
raise GarfVirtualColumnError('Virtual attributes should be aliased')
|
237
|
-
return ExtractedLineElements(
|
238
|
-
field=_format_type_field_name(field)
|
239
|
-
if not virtual_column and field
|
240
|
-
else None,
|
241
|
-
alias=_normalize_column_name(alias[0] if alias else field),
|
242
|
-
virtual_column=virtual_column,
|
243
|
-
customizer=customizer,
|
244
|
-
)
|
245
|
-
|
246
|
-
|
247
|
-
def _format_type_field_name(field_name: str) -> str:
|
248
|
-
return re.sub(r'\.type', '.type_', field_name)
|
249
|
-
|
250
|
-
|
251
|
-
def _normalize_column_name(column_name: str) -> str:
|
252
|
-
return re.sub(r'\.', '_', column_name)
|
253
|
-
|
254
|
-
|
255
53
|
@dataclasses.dataclass
|
256
54
|
class BaseQueryElements:
|
257
55
|
"""Contains raw query and parsed elements.
|
@@ -278,7 +76,7 @@ class BaseQueryElements:
|
|
278
76
|
customizers: dict[str, dict[str, str]] = dataclasses.field(
|
279
77
|
default_factory=dict
|
280
78
|
)
|
281
|
-
virtual_columns: dict[str, VirtualColumn] = dataclasses.field(
|
79
|
+
virtual_columns: dict[str, query_parser.VirtualColumn] = dataclasses.field(
|
282
80
|
default_factory=dict
|
283
81
|
)
|
284
82
|
is_builtin_query: bool = False
|
@@ -485,7 +283,7 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
|
|
485
283
|
|
486
284
|
def extract_fields(self) -> Self:
|
487
285
|
for line in self._extract_query_lines():
|
488
|
-
line_elements = ExtractedLineElements.from_query_line(line)
|
286
|
+
line_elements = query_parser.ExtractedLineElements.from_query_line(line)
|
489
287
|
if field := line_elements.field:
|
490
288
|
self.query.fields.append(field)
|
491
289
|
return self
|
@@ -514,13 +312,13 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
|
|
514
312
|
|
515
313
|
def extract_column_names(self) -> Self:
|
516
314
|
for line in self._extract_query_lines():
|
517
|
-
line_elements = ExtractedLineElements.from_query_line(line)
|
315
|
+
line_elements = query_parser.ExtractedLineElements.from_query_line(line)
|
518
316
|
self.query.column_names.append(line_elements.alias)
|
519
317
|
return self
|
520
318
|
|
521
319
|
def extract_virtual_columns(self) -> Self:
|
522
320
|
for line in self._extract_query_lines():
|
523
|
-
line_elements = ExtractedLineElements.from_query_line(line)
|
321
|
+
line_elements = query_parser.ExtractedLineElements.from_query_line(line)
|
524
322
|
if virtual_column := line_elements.virtual_column:
|
525
323
|
self.query.virtual_columns[line_elements.alias] = virtual_column
|
526
324
|
if fields := virtual_column.fields:
|
@@ -531,7 +329,7 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
|
|
531
329
|
|
532
330
|
def extract_customizers(self) -> Self:
|
533
331
|
for line in self._extract_query_lines():
|
534
|
-
line_elements = ExtractedLineElements.from_query_line(line)
|
332
|
+
line_elements = query_parser.ExtractedLineElements.from_query_line(line)
|
535
333
|
if customizer := line_elements.customizer:
|
536
334
|
self.query.customizers[line_elements.alias] = customizer
|
537
335
|
return self
|
@@ -550,26 +348,6 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
|
|
550
348
|
yield non_empty_row
|
551
349
|
|
552
350
|
|
553
|
-
def _is_quoted_string(field_name: str) -> bool:
|
554
|
-
return (field_name.startswith("'") and field_name.endswith("'")) or (
|
555
|
-
field_name.startswith('"') and field_name.endswith('"')
|
556
|
-
)
|
557
|
-
|
558
|
-
|
559
|
-
def _is_constant(element) -> bool:
|
560
|
-
with contextlib.suppress(ValueError):
|
561
|
-
float(element)
|
562
|
-
return True
|
563
|
-
return _is_quoted_string(element)
|
564
|
-
|
565
|
-
|
566
|
-
def _is_invalid_field(field) -> bool:
|
567
|
-
operators = ('/', '*', '+', ' - ')
|
568
|
-
is_constant = _is_constant(field)
|
569
|
-
has_operator = any(operator in field for operator in operators)
|
570
|
-
return is_constant or has_operator
|
571
|
-
|
572
|
-
|
573
351
|
def convert_date(date_string: str) -> str:
|
574
352
|
"""Converts specific dates parameters to actual dates.
|
575
353
|
|
@@ -0,0 +1,299 @@
|
|
1
|
+
# Copyright 2024 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
|
+
"""Handles query parsing."""
|
15
|
+
|
16
|
+
from __future__ import annotations
|
17
|
+
|
18
|
+
import contextlib
|
19
|
+
import re
|
20
|
+
from typing import Literal, Union
|
21
|
+
|
22
|
+
import pydantic
|
23
|
+
from typing_extensions import TypeAlias
|
24
|
+
|
25
|
+
from garf_core import exceptions
|
26
|
+
|
27
|
+
QueryParameters: TypeAlias = dict[str, Union[str, float, int, list]]
|
28
|
+
|
29
|
+
CustomizerType: TypeAlias = Literal['resource_index', 'nested_field', 'slice']
|
30
|
+
|
31
|
+
|
32
|
+
class GarfQueryError(exceptions.GarfError):
|
33
|
+
"""Base exception for Garf queries."""
|
34
|
+
|
35
|
+
|
36
|
+
class GarfVirtualColumnError(GarfQueryError):
|
37
|
+
"""Specifies incorrect virtual column type."""
|
38
|
+
|
39
|
+
|
40
|
+
class GarfCustomizerError(GarfQueryError):
|
41
|
+
"""Specifies incorrect customizer."""
|
42
|
+
|
43
|
+
|
44
|
+
class GarfFieldError(GarfQueryError):
|
45
|
+
"""Specifies incorrect fields from API."""
|
46
|
+
|
47
|
+
|
48
|
+
class Customizer(pydantic.BaseModel):
|
49
|
+
"""Specifies extraction operation on a field.
|
50
|
+
|
51
|
+
Attributes:
|
52
|
+
type: Type of customizer.
|
53
|
+
value: Value to be extracted from a field.
|
54
|
+
"""
|
55
|
+
|
56
|
+
type: CustomizerType | None = None
|
57
|
+
value: int | str | SliceField | None = None
|
58
|
+
|
59
|
+
def __bool__(self) -> bool:
|
60
|
+
"""Evaluates whether all fields are not empty."""
|
61
|
+
return bool(self.type and self.value)
|
62
|
+
|
63
|
+
|
64
|
+
class SliceField(pydantic.BaseModel):
|
65
|
+
"""Specifies slice with the content be extracted.
|
66
|
+
|
67
|
+
Attributes:
|
68
|
+
slice_literal: Slice to be extracted from a sequence.
|
69
|
+
value: Value to be extracted from each element of a slice.
|
70
|
+
"""
|
71
|
+
|
72
|
+
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
73
|
+
slice_literal: slice
|
74
|
+
value: str | int
|
75
|
+
|
76
|
+
|
77
|
+
class ProcessedField(pydantic.BaseModel):
|
78
|
+
"""Stores field with its customizers.
|
79
|
+
|
80
|
+
Attributes:
|
81
|
+
field: Extractable field.
|
82
|
+
customizer: Customizer to be applied to the field.
|
83
|
+
"""
|
84
|
+
|
85
|
+
field: str
|
86
|
+
customizer: Customizer = Customizer()
|
87
|
+
|
88
|
+
@classmethod
|
89
|
+
def from_raw(cls, raw_field: str) -> ProcessedField:
|
90
|
+
"""Process field to extract possible customizers.
|
91
|
+
|
92
|
+
Args:
|
93
|
+
raw_field: Unformatted field string value.
|
94
|
+
|
95
|
+
Returns:
|
96
|
+
ProcessedField that contains formatted field with customizers.
|
97
|
+
"""
|
98
|
+
raw_field = raw_field.replace(r'\s+', '').strip()
|
99
|
+
if _is_quoted_string(raw_field):
|
100
|
+
return ProcessedField(field=raw_field)
|
101
|
+
if len(slices := cls._extract_slices(raw_field)) > 1:
|
102
|
+
field_name, op, slice_literal = slices
|
103
|
+
start, *rest = op.split(':')
|
104
|
+
if start == '':
|
105
|
+
if not rest:
|
106
|
+
slice_object = slice(None)
|
107
|
+
else:
|
108
|
+
end = int(rest[0])
|
109
|
+
slice_object = slice(0, end)
|
110
|
+
elif str.isnumeric(start):
|
111
|
+
if not rest:
|
112
|
+
op_ = int(start)
|
113
|
+
slice_object = slice(op_, op_ + 1)
|
114
|
+
elif rest == ['']:
|
115
|
+
op_ = int(start)
|
116
|
+
slice_object = slice(op_, None)
|
117
|
+
else:
|
118
|
+
op_ = int(start)
|
119
|
+
end = int(rest[0])
|
120
|
+
slice_object = slice(op_, end)
|
121
|
+
return ProcessedField(
|
122
|
+
field=field_name,
|
123
|
+
customizer=Customizer(
|
124
|
+
type='slice',
|
125
|
+
value=SliceField(
|
126
|
+
slice_literal=slice_object, value=re.sub(r'^.', '', slice_literal)
|
127
|
+
),
|
128
|
+
),
|
129
|
+
)
|
130
|
+
if len(resources := cls._extract_resource_element(raw_field)) > 1:
|
131
|
+
field_name, resource_index = resources
|
132
|
+
return ProcessedField(
|
133
|
+
field=field_name,
|
134
|
+
customizer=Customizer(type='resource_index', value=int(resource_index)),
|
135
|
+
)
|
136
|
+
|
137
|
+
if len(nested_fields := cls._extract_nested_resource(raw_field)) > 1:
|
138
|
+
field_name, nested_field = nested_fields
|
139
|
+
return ProcessedField(
|
140
|
+
field=field_name,
|
141
|
+
customizer=Customizer(type='nested_field', value=nested_field),
|
142
|
+
)
|
143
|
+
return ProcessedField(field=raw_field)
|
144
|
+
|
145
|
+
@classmethod
|
146
|
+
def _extract_resource_element(cls, line_elements: str) -> list[str]:
|
147
|
+
return re.split('~', line_elements)
|
148
|
+
|
149
|
+
@classmethod
|
150
|
+
def _extract_slices(cls, line_elements: str) -> list[str]:
|
151
|
+
"""Finds all slices in the query line."""
|
152
|
+
pattern = r'\[\d*(:\d*)?\]'
|
153
|
+
slices = re.split(pattern, line_elements)
|
154
|
+
regexp = r'\[(\d*(:\d*)?)\]'
|
155
|
+
op = re.findall(regexp, line_elements)
|
156
|
+
if op:
|
157
|
+
slices[1] = op[0][0]
|
158
|
+
return slices
|
159
|
+
|
160
|
+
@classmethod
|
161
|
+
def _extract_nested_resource(cls, line_elements: str) -> list[str]:
|
162
|
+
if '://' in line_elements:
|
163
|
+
return []
|
164
|
+
return re.split(':', line_elements)
|
165
|
+
|
166
|
+
|
167
|
+
class VirtualColumn(pydantic.BaseModel):
|
168
|
+
"""Represents element in Garf query that either calculated or plugged-in.
|
169
|
+
|
170
|
+
Virtual columns allow performing basic manipulation with metrics and
|
171
|
+
dimensions (i.e. division or multiplication) as well as adding raw text
|
172
|
+
values directly into report.
|
173
|
+
|
174
|
+
Attributes:
|
175
|
+
type: Type of virtual column, either build-in or expression.
|
176
|
+
value: Value of the field after macro expansion.
|
177
|
+
fields: Possible fields participating in calculations.
|
178
|
+
substitute_expression: Formatted expression.
|
179
|
+
"""
|
180
|
+
|
181
|
+
type: str
|
182
|
+
value: str | int | float
|
183
|
+
fields: list[str] | None = None
|
184
|
+
substitute_expression: str | None = None
|
185
|
+
|
186
|
+
@classmethod
|
187
|
+
def from_raw(cls, field: str, macros: QueryParameters) -> VirtualColumn:
|
188
|
+
"""Converts a field to virtual column."""
|
189
|
+
if field.isdigit():
|
190
|
+
field = int(field)
|
191
|
+
else:
|
192
|
+
with contextlib.suppress(ValueError):
|
193
|
+
field = float(field)
|
194
|
+
if isinstance(field, (int, float)):
|
195
|
+
return VirtualColumn(type='built-in', value=field)
|
196
|
+
|
197
|
+
operators = ('/', r'\*', r'\+', ' - ')
|
198
|
+
if '://' in field:
|
199
|
+
expressions = re.split(r'\+', field)
|
200
|
+
else:
|
201
|
+
expressions = re.split('|'.join(operators), field)
|
202
|
+
if len(expressions) > 1:
|
203
|
+
virtual_column_fields = []
|
204
|
+
substitute_expression = field
|
205
|
+
for expression in expressions:
|
206
|
+
element = expression.strip()
|
207
|
+
if not _is_constant(element):
|
208
|
+
virtual_column_fields.append(element)
|
209
|
+
substitute_expression = substitute_expression.replace(
|
210
|
+
element, f'{{{element}}}'
|
211
|
+
)
|
212
|
+
pattern = r'\{([^}]*)\}'
|
213
|
+
substitute_expression = re.sub(
|
214
|
+
pattern, lambda m: m.group(0).replace('.', '_'), substitute_expression
|
215
|
+
)
|
216
|
+
return VirtualColumn(
|
217
|
+
type='expression',
|
218
|
+
value=field.format(**macros) if macros else field,
|
219
|
+
fields=virtual_column_fields,
|
220
|
+
substitute_expression=substitute_expression,
|
221
|
+
)
|
222
|
+
if not _is_quoted_string(field):
|
223
|
+
raise GarfFieldError(f"Incorrect field '{field}'.")
|
224
|
+
field = field.replace("'", '').replace('"', '')
|
225
|
+
field = field.format(**macros) if macros else field
|
226
|
+
return VirtualColumn(type='built-in', value=field)
|
227
|
+
|
228
|
+
|
229
|
+
class ExtractedLineElements(pydantic.BaseModel):
|
230
|
+
"""Helper class for parsing query lines.
|
231
|
+
|
232
|
+
Attributes:
|
233
|
+
fields: All fields extracted from the line.
|
234
|
+
alias: Optional alias assign to a field.
|
235
|
+
virtual_column: Optional virtual column extracted from query line.
|
236
|
+
customizer: Optional values for customizers associated with a field.
|
237
|
+
"""
|
238
|
+
|
239
|
+
field: str | None
|
240
|
+
alias: str | None
|
241
|
+
virtual_column: VirtualColumn | None
|
242
|
+
customizer: Customizer | None
|
243
|
+
|
244
|
+
@classmethod
|
245
|
+
def from_query_line(
|
246
|
+
cls,
|
247
|
+
line: str,
|
248
|
+
macros: QueryParameters | None = None,
|
249
|
+
) -> ExtractedLineElements:
|
250
|
+
if macros is None:
|
251
|
+
macros = {}
|
252
|
+
field, *alias = re.split(' [Aa][Ss] ', line)
|
253
|
+
processed_field = ProcessedField.from_raw(field)
|
254
|
+
field = processed_field.field
|
255
|
+
virtual_column = (
|
256
|
+
VirtualColumn.from_raw(field, macros)
|
257
|
+
if _is_invalid_field(field)
|
258
|
+
else None
|
259
|
+
)
|
260
|
+
if not (customizer := processed_field.customizer):
|
261
|
+
customizer = None
|
262
|
+
if virtual_column and not alias:
|
263
|
+
raise GarfVirtualColumnError('Virtual attributes should be aliased')
|
264
|
+
return ExtractedLineElements(
|
265
|
+
field=_format_type_field_name(field)
|
266
|
+
if not virtual_column and field
|
267
|
+
else None,
|
268
|
+
alias=_normalize_column_name(alias[0] if alias else field),
|
269
|
+
virtual_column=virtual_column,
|
270
|
+
customizer=customizer,
|
271
|
+
)
|
272
|
+
|
273
|
+
|
274
|
+
def _format_type_field_name(field_name: str) -> str:
|
275
|
+
return re.sub(r'\.type', '.type_', field_name)
|
276
|
+
|
277
|
+
|
278
|
+
def _normalize_column_name(column_name: str) -> str:
|
279
|
+
return re.sub(r'\.', '_', column_name)
|
280
|
+
|
281
|
+
|
282
|
+
def _is_quoted_string(field_name: str) -> bool:
|
283
|
+
return (field_name.startswith("'") and field_name.endswith("'")) or (
|
284
|
+
field_name.startswith('"') and field_name.endswith('"')
|
285
|
+
)
|
286
|
+
|
287
|
+
|
288
|
+
def _is_constant(element) -> bool:
|
289
|
+
with contextlib.suppress(ValueError):
|
290
|
+
float(element)
|
291
|
+
return True
|
292
|
+
return _is_quoted_string(element)
|
293
|
+
|
294
|
+
|
295
|
+
def _is_invalid_field(field) -> bool:
|
296
|
+
operators = ('/', '*', '+', ' - ')
|
297
|
+
is_constant = _is_constant(field)
|
298
|
+
has_operator = any(operator in field for operator in operators)
|
299
|
+
return is_constant or has_operator
|
@@ -150,5 +150,7 @@ class ApiReportFetcher:
|
|
150
150
|
response = self.api_client.get_response(query, **kwargs)
|
151
151
|
parsed_response = self.parser(query).parse_response(response)
|
152
152
|
return report.GarfReport(
|
153
|
-
results=parsed_response,
|
153
|
+
results=parsed_response,
|
154
|
+
column_names=query.column_names,
|
155
|
+
query_specification=query,
|
154
156
|
)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: garf-core
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.3.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
|
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
|