garf-core 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -26,4 +26,4 @@ __all__ = [
26
26
  'ApiReportFetcher',
27
27
  ]
28
28
 
29
- __version__ = '0.2.1'
29
+ __version__ = '0.3.0'
garf_core/api_clients.py CHANGED
@@ -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):
garf_core/parsers.py CHANGED
@@ -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
garf_core/query_editor.py CHANGED
@@ -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
@@ -444,14 +242,15 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
444
242
  """Removes comments and converts text to lines."""
445
243
  result: list[str] = []
446
244
  multiline_comment = False
447
- for line in self.query.text.split('\n'):
245
+ for raw_line in self.query.text.split('\n'):
246
+ line = raw_line.strip()
448
247
  if re.match('\\*/', line):
449
248
  multiline_comment = False
450
249
  continue
451
250
  if re.match('/\\*', line) or multiline_comment:
452
251
  multiline_comment = True
453
252
  continue
454
- if re.match('^(#|--|//) ', line):
253
+ if re.match('^(#|--|//) ', line) or line in ('--', '#', '//'):
455
254
  continue
456
255
  cleaned_query_line = re.sub(
457
256
  ';$', '', re.sub('(--|//) .*$', '', line).strip()
@@ -484,7 +283,7 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
484
283
 
485
284
  def extract_fields(self) -> Self:
486
285
  for line in self._extract_query_lines():
487
- line_elements = ExtractedLineElements.from_query_line(line)
286
+ line_elements = query_parser.ExtractedLineElements.from_query_line(line)
488
287
  if field := line_elements.field:
489
288
  self.query.fields.append(field)
490
289
  return self
@@ -513,13 +312,13 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
513
312
 
514
313
  def extract_column_names(self) -> Self:
515
314
  for line in self._extract_query_lines():
516
- line_elements = ExtractedLineElements.from_query_line(line)
315
+ line_elements = query_parser.ExtractedLineElements.from_query_line(line)
517
316
  self.query.column_names.append(line_elements.alias)
518
317
  return self
519
318
 
520
319
  def extract_virtual_columns(self) -> Self:
521
320
  for line in self._extract_query_lines():
522
- line_elements = ExtractedLineElements.from_query_line(line)
321
+ line_elements = query_parser.ExtractedLineElements.from_query_line(line)
523
322
  if virtual_column := line_elements.virtual_column:
524
323
  self.query.virtual_columns[line_elements.alias] = virtual_column
525
324
  if fields := virtual_column.fields:
@@ -530,7 +329,7 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
530
329
 
531
330
  def extract_customizers(self) -> Self:
532
331
  for line in self._extract_query_lines():
533
- line_elements = ExtractedLineElements.from_query_line(line)
332
+ line_elements = query_parser.ExtractedLineElements.from_query_line(line)
534
333
  if customizer := line_elements.customizer:
535
334
  self.query.customizers[line_elements.alias] = customizer
536
335
  return self
@@ -549,26 +348,6 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
549
348
  yield non_empty_row
550
349
 
551
350
 
552
- def _is_quoted_string(field_name: str) -> bool:
553
- return (field_name.startswith("'") and field_name.endswith("'")) or (
554
- field_name.startswith('"') and field_name.endswith('"')
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
351
  def convert_date(date_string: str) -> str:
573
352
  """Converts specific dates parameters to actual dates.
574
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.1
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
@@ -0,0 +1,17 @@
1
+ garf_core/__init__.py,sha256=BhxCRYG-ocz7Fcc6f32V_8se-SKSktceAQtIBXQWbM8,954
2
+ garf_core/api_clients.py,sha256=-M8wwro8N-jKpcMpWXg6v4B3veFhACGPOjpfKlTlCOU,5037
3
+ garf_core/base_query.py,sha256=ZDAw2ojmismXRO0HXEvKDukpS7OAc7390LnM8kvCSCY,1201
4
+ garf_core/exceptions.py,sha256=Gzvkl2M-rA_XQRAMd3CC62KHeFQE_b6uby0fD0pouw4,1269
5
+ garf_core/parsers.py,sha256=i2Jwul12X1o16Rz1yO027ocbjJ6ry3rhLU0-wofnilE,5984
6
+ garf_core/query_editor.py,sha256=RcG28bh17QDvBaO1om_JBY_9Eh8bp5EVph73lP6fcMo,12974
7
+ garf_core/query_parser.py,sha256=2orZTVr5DO6NiU8hf6kFGSb6eLJEHzTccU3-8IHurQE,9084
8
+ garf_core/report.py,sha256=J2-oNV3Nh7ToYhm2fsdovkiE__-UhT0K5-wP0jBD0oQ,20330
9
+ garf_core/report_fetcher.py,sha256=N5APydV3wUg4li88WSFIj7BT9zemRMXjyabb69NH1CU,5034
10
+ garf_core/fetchers/__init__.py,sha256=_cSjg1D5RhUKxaVeVbaDdb8AAoI9glKJXgN5H4qXFkw,783
11
+ garf_core/fetchers/fake.py,sha256=fgJjxuHyd6EIUflUtj8r_HfaMS1YTDrOqDlaj6Kvbjs,2584
12
+ garf_core/fetchers/rest.py,sha256=-5B2-Ck_t3hG99ym59AKwlzctiDxRFI2Nnc8STBxRDo,2201
13
+ garf_core-0.3.0.dist-info/METADATA,sha256=6pg81nCPvHIbSLbBdokb3Qo-RcpRhrl_lqibIHMdbuk,2427
14
+ garf_core-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
+ garf_core-0.3.0.dist-info/entry_points.txt,sha256=u4h-ujHO1hbxVXRQzwcC4ftju9_KBYtq5mCLKEBHMj0,69
16
+ garf_core-0.3.0.dist-info/top_level.txt,sha256=Gj-Zp7fM2turGut5vTJuo5xEcKfow7cTLX3y3WFfNgA,10
17
+ garf_core-0.3.0.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- garf_core/__init__.py,sha256=ml6Xq1xyIO0Mx2FJsW0lTQ7z51UhpmMWfANHqedkpQg,954
2
- garf_core/api_clients.py,sha256=PTuNz-nsnAwAhh1eyqt6fx0O3kbnL_1W5lxU_3YmhF4,4747
3
- garf_core/base_query.py,sha256=ZDAw2ojmismXRO0HXEvKDukpS7OAc7390LnM8kvCSCY,1201
4
- garf_core/exceptions.py,sha256=Gzvkl2M-rA_XQRAMd3CC62KHeFQE_b6uby0fD0pouw4,1269
5
- garf_core/parsers.py,sha256=Y1hJFvvtRCiiR0A-Vh8fIVjBVLdmKZEnxRnGJ5I7hZw,5353
6
- garf_core/query_editor.py,sha256=0bOX_Tz3D3KCcUrNddp_do4aoWAd0IEBtM-1u7JcJVU,19603
7
- garf_core/report.py,sha256=J2-oNV3Nh7ToYhm2fsdovkiE__-UhT0K5-wP0jBD0oQ,20330
8
- garf_core/report_fetcher.py,sha256=478HrA31-mCEP2E6cPlxonvBrddBBnSUxFp5D3yE-yU,4994
9
- garf_core/fetchers/__init__.py,sha256=_cSjg1D5RhUKxaVeVbaDdb8AAoI9glKJXgN5H4qXFkw,783
10
- garf_core/fetchers/fake.py,sha256=fgJjxuHyd6EIUflUtj8r_HfaMS1YTDrOqDlaj6Kvbjs,2584
11
- garf_core/fetchers/rest.py,sha256=-5B2-Ck_t3hG99ym59AKwlzctiDxRFI2Nnc8STBxRDo,2201
12
- garf_core-0.2.1.dist-info/METADATA,sha256=0wuvUWUnnoqwNI9Veg2BNeOAncAcB4BOwTKmPf9CmeM,2427
13
- garf_core-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- garf_core-0.2.1.dist-info/entry_points.txt,sha256=u4h-ujHO1hbxVXRQzwcC4ftju9_KBYtq5mCLKEBHMj0,69
15
- garf_core-0.2.1.dist-info/top_level.txt,sha256=Gj-Zp7fM2turGut5vTJuo5xEcKfow7cTLX3y3WFfNgA,10
16
- garf_core-0.2.1.dist-info/RECORD,,