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.
Files changed (22) hide show
  1. {garf_core-0.2.2 → garf_core-0.3.0}/PKG-INFO +1 -1
  2. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/__init__.py +1 -1
  3. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/api_clients.py +11 -3
  4. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/parsers.py +17 -1
  5. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/query_editor.py +9 -231
  6. garf_core-0.3.0/garf_core/query_parser.py +299 -0
  7. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/report_fetcher.py +3 -1
  8. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/PKG-INFO +1 -1
  9. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/SOURCES.txt +1 -0
  10. {garf_core-0.2.2 → garf_core-0.3.0}/README.md +0 -0
  11. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/base_query.py +0 -0
  12. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/exceptions.py +0 -0
  13. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/fetchers/__init__.py +0 -0
  14. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/fetchers/fake.py +0 -0
  15. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/fetchers/rest.py +0 -0
  16. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core/report.py +0 -0
  17. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/dependency_links.txt +0 -0
  18. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/entry_points.txt +0 -0
  19. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/requires.txt +0 -0
  20. {garf_core-0.2.2 → garf_core-0.3.0}/garf_core.egg-info/top_level.txt +0 -0
  21. {garf_core-0.2.2 → garf_core-0.3.0}/pyproject.toml +0 -0
  22. {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.2.2
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
@@ -26,4 +26,4 @@ __all__ = [
26
26
  'ApiReportFetcher',
27
27
  ]
28
28
 
29
- __version__ = '0.2.2'
29
+ __version__ = '0.3.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
- response = requests.get(f'{self.endpoint}/{request.resource_name}')
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
- return GarfApiResponse(results=response.json())
74
- raise GarfApiError('Failed to get data from API')
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 exceptions
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 GarfQueryError(exceptions.GarfError):
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, column_names=query.column_names
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.2.2
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
@@ -6,6 +6,7 @@ garf_core/base_query.py
6
6
  garf_core/exceptions.py
7
7
  garf_core/parsers.py
8
8
  garf_core/query_editor.py
9
+ garf_core/query_parser.py
9
10
  garf_core/report.py
10
11
  garf_core/report_fetcher.py
11
12
  garf_core.egg-info/PKG-INFO
File without changes
File without changes
File without changes
File without changes