garf-core 0.1.5.post0__py3-none-any.whl → 0.2.1__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.1.5.post0'
29
+ __version__ = '0.2.1'
garf_core/api_clients.py CHANGED
@@ -20,23 +20,24 @@ import contextlib
20
20
  import csv
21
21
  import json
22
22
  import os
23
- import pathlib
24
23
  from collections.abc import Sequence
25
24
  from typing import Any, Union
26
25
 
27
26
  import pydantic
28
27
  import requests
28
+ import smart_open
29
29
  from typing_extensions import TypeAlias, override
30
30
 
31
31
  from garf_core import exceptions, query_editor
32
32
 
33
33
  ApiRowElement: TypeAlias = Union[int, float, str, bool, list, dict, None]
34
+ ApiResponseRow: TypeAlias = dict[str, ApiRowElement]
34
35
 
35
36
 
36
37
  class GarfApiResponse(pydantic.BaseModel):
37
38
  """Base class for specifying response."""
38
39
 
39
- results: list[ApiRowElement]
40
+ results: list[ApiResponseRow]
40
41
 
41
42
 
42
43
  class GarfApiError(exceptions.GarfError):
@@ -123,7 +124,7 @@ class FakeApiClient(BaseClient):
123
124
  GarfApiError: When file with data not found.
124
125
  """
125
126
  try:
126
- with pathlib.Path.open(file_location, 'r', encoding='utf-8') as f:
127
+ with smart_open.open(file_location, 'r', encoding='utf-8') as f:
127
128
  data = json.load(f)
128
129
  return FakeApiClient(data)
129
130
  except FileNotFoundError as e:
@@ -143,7 +144,7 @@ class FakeApiClient(BaseClient):
143
144
  GarfApiError: When file with data not found.
144
145
  """
145
146
  try:
146
- with pathlib.Path.open(file_location, 'r', encoding='utf-8') as f:
147
+ with smart_open.open(file_location, 'r', encoding='utf-8') as f:
147
148
  reader = csv.DictReader(f)
148
149
  data = []
149
150
  for row in reader:
garf_core/parsers.py CHANGED
@@ -16,16 +16,23 @@
16
16
  from __future__ import annotations
17
17
 
18
18
  import abc
19
+ import ast
19
20
  import contextlib
20
21
  import functools
21
22
  import operator
22
23
  from collections.abc import Mapping, MutableSequence
23
24
  from typing import Any
24
25
 
25
- from typing_extensions import override
26
-
27
26
  from garf_core import api_clients, exceptions, query_editor
28
27
 
28
+ VALID_VIRTUAL_COLUMN_OPERATORS = (
29
+ ast.BinOp,
30
+ ast.UnaryOp,
31
+ ast.operator,
32
+ ast.Constant,
33
+ ast.Expression,
34
+ )
35
+
29
36
 
30
37
  class BaseParser(abc.ABC):
31
38
  """An interface for all parsers to implement."""
@@ -48,44 +55,101 @@ class BaseParser(abc.ABC):
48
55
  results.append(self.parse_row(result))
49
56
  return results
50
57
 
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
+ 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
58
114
 
59
- @override
60
115
  def parse_row(
61
116
  self,
62
- row: list,
63
- ) -> list[list[api_clients.ApiRowElement]]:
64
- return row
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."""
65
137
 
66
138
 
67
139
  class DictParser(BaseParser):
68
140
  """Extracts nested dict elements."""
69
141
 
70
- @override
71
- def parse_row(
72
- self,
73
- row: list,
74
- ) -> list[list[api_clients.ApiRowElement]]:
142
+ def parse_row_element(
143
+ self, row: api_clients.ApiResponseRow, key: str
144
+ ) -> api_clients.ApiRowElement:
145
+ """Returns nested fields from a dictionary."""
75
146
  if not isinstance(row, Mapping):
76
147
  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):
148
+ if result := row.get(key):
85
149
  return result
86
150
  key = key.split('.')
87
151
  try:
88
- return functools.reduce(operator.getitem, key, dictionary)
152
+ return functools.reduce(operator.getitem, key, row)
89
153
  except (TypeError, KeyError):
90
154
  return None
91
155
 
@@ -93,7 +157,9 @@ class DictParser(BaseParser):
93
157
  class NumericConverterDictParser(DictParser):
94
158
  """Extracts nested dict elements with numerical conversions."""
95
159
 
96
- def get_nested_field(self, dictionary: dict[str, Any], key: str):
160
+ def parse_row_element(
161
+ self, row: api_clients.ApiResponseRow, key: str
162
+ ) -> api_clients.ApiRowElement:
97
163
  """Extract nested field with int/float conversion."""
98
164
 
99
165
  def convert_field(value):
@@ -102,12 +168,12 @@ class NumericConverterDictParser(DictParser):
102
168
  return type_(value)
103
169
  return value
104
170
 
105
- if result := dictionary.get(key):
171
+ if result := row.get(key):
106
172
  return convert_field(result)
107
173
 
108
174
  key = key.split('.')
109
175
  try:
110
- field = functools.reduce(operator.getitem, key, dictionary)
176
+ field = functools.reduce(operator.getitem, key, row)
111
177
  if isinstance(field, MutableSequence) or field in (True, False):
112
178
  return field
113
179
  return convert_field(field)
garf_core/query_editor.py CHANGED
@@ -29,7 +29,7 @@ from typing_extensions import Self, TypeAlias
29
29
 
30
30
  from garf_core import exceptions
31
31
 
32
- QueryParameters: TypeAlias = dict[str, Union[str, float, int]]
32
+ QueryParameters: TypeAlias = dict[str, Union[str, float, int, list]]
33
33
 
34
34
 
35
35
  class GarfQueryParameters(pydantic.BaseModel):
@@ -125,6 +125,8 @@ class ProcessedField(pydantic.BaseModel):
125
125
 
126
126
  @classmethod
127
127
  def _extract_nested_resource(cls, line_elements: str) -> list[str]:
128
+ if '://' in line_elements:
129
+ return []
128
130
  return re.split(':', line_elements)
129
131
 
130
132
 
@@ -160,22 +162,29 @@ class VirtualColumn:
160
162
  return VirtualColumn(type='built-in', value=field)
161
163
 
162
164
  operators = ('/', r'\*', r'\+', ' - ')
163
- if len(expressions := re.split('|'.join(operators), field)) > 1:
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 True:
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.replace('.', '_'),
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 = None if field else VirtualColumn.from_raw(field, macros)
223
+ virtual_column = (
224
+ VirtualColumn.from_raw(field, macros)
225
+ if _is_invalid_field(field)
226
+ else None
227
+ )
215
228
  if alias and processed_field.customizer_type:
216
229
  customizer = {
217
230
  'type': processed_field.customizer_type,
@@ -373,7 +386,7 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
373
386
  self,
374
387
  text: str,
375
388
  title: str | None = None,
376
- args: GarfQueryParameters | None = GarfQueryParameters(),
389
+ args: GarfQueryParameters | None = None,
377
390
  **kwargs: str,
378
391
  ) -> None:
379
392
  """Instantiates QuerySpecification based on text, title and optional args.
@@ -392,7 +405,10 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
392
405
  """Returns macros with injected common parameters."""
393
406
  common_params = dict(self.common_params)
394
407
  if macros := self.args.macro:
395
- common_params.update(macros)
408
+ converted_macros = {
409
+ key: convert_date(value) for key, value in macros.items()
410
+ }
411
+ common_params.update(converted_macros)
396
412
  return common_params
397
413
 
398
414
  def generate(self) -> BaseQueryElements:
@@ -435,10 +451,10 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
435
451
  if re.match('/\\*', line) or multiline_comment:
436
452
  multiline_comment = True
437
453
  continue
438
- if re.match('^(#|--|//)', line):
454
+ if re.match('^(#|--|//) ', line):
439
455
  continue
440
456
  cleaned_query_line = re.sub(
441
- ';$', '', re.sub('(--|//).*$', '', line).strip()
457
+ ';$', '', re.sub('(--|//) .*$', '', line).strip()
442
458
  )
443
459
  result.append(cleaned_query_line)
444
460
  self.query.text = ' '.join(result)
@@ -506,6 +522,10 @@ class QuerySpecification(CommonParametersMixin, TemplateProcessorMixin):
506
522
  line_elements = ExtractedLineElements.from_query_line(line)
507
523
  if virtual_column := line_elements.virtual_column:
508
524
  self.query.virtual_columns[line_elements.alias] = virtual_column
525
+ if fields := virtual_column.fields:
526
+ for field in fields:
527
+ if field not in self.query.fields:
528
+ self.query.fields.append(field)
509
529
  return self
510
530
 
511
531
  def extract_customizers(self) -> Self:
@@ -533,3 +553,64 @@ def _is_quoted_string(field_name: str) -> bool:
533
553
  return (field_name.startswith("'") and field_name.endswith("'")) or (
534
554
  field_name.startswith('"') and field_name.endswith('"')
535
555
  )
556
+
557
+
558
+ def _is_constant(element) -> bool:
559
+ with contextlib.suppress(ValueError):
560
+ float(element)
561
+ return True
562
+ return _is_quoted_string(element)
563
+
564
+
565
+ def _is_invalid_field(field) -> bool:
566
+ operators = ('/', '*', '+', ' - ')
567
+ is_constant = _is_constant(field)
568
+ has_operator = any(operator in field for operator in operators)
569
+ return is_constant or has_operator
570
+
571
+
572
+ def convert_date(date_string: str) -> str:
573
+ """Converts specific dates parameters to actual dates.
574
+
575
+ Returns:
576
+ Date string in YYYY-MM-DD format.
577
+
578
+ Raises:
579
+ GarfMacroError:
580
+ If dynamic lookback value (:YYYYMMDD-N) is incorrect.
581
+ """
582
+ if isinstance(date_string, list) or date_string.find(':Y') == -1:
583
+ return date_string
584
+ current_date = datetime.date.today()
585
+ base_date, *date_customizer = re.split('\\+|-', date_string)
586
+ if len(date_customizer) > 1:
587
+ raise GarfMacroError(
588
+ 'Invalid format for date macro, should be in :YYYYMMDD-N format'
589
+ )
590
+ if not date_customizer:
591
+ days_lookback = 0
592
+ else:
593
+ try:
594
+ days_lookback = int(date_customizer[0])
595
+ except ValueError as e:
596
+ raise GarfMacroError(
597
+ 'Must provide numeric value for a number lookback period, '
598
+ 'i.e. :YYYYMMDD-1'
599
+ ) from e
600
+ if base_date == ':YYYY':
601
+ new_date = datetime.datetime(current_date.year, 1, 1)
602
+ delta = relativedelta.relativedelta(years=days_lookback)
603
+ elif base_date == ':YYYYMM':
604
+ new_date = datetime.datetime(current_date.year, current_date.month, 1)
605
+ delta = relativedelta.relativedelta(months=days_lookback)
606
+ elif base_date == ':YYYYMMDD':
607
+ new_date = current_date
608
+ delta = relativedelta.relativedelta(days=days_lookback)
609
+ else:
610
+ raise GarfMacroError(
611
+ 'Invalid format for date macro, should be in :YYYYMMDD-N format'
612
+ )
613
+
614
+ if '-' in date_string:
615
+ return (new_date - delta).strftime('%Y-%m-%d')
616
+ return (new_date + delta).strftime('%Y-%m-%d')
@@ -54,16 +54,18 @@ class ApiReportFetcher:
54
54
  """Class responsible for getting data from report API.
55
55
 
56
56
  Attributes:
57
- api_client: a client used for connecting to API.
58
- parser: Type of parser to convert API response.
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.BaseApiClient,
65
- parser: parsers.BaseParser = parsers.DictParser,
66
- query_specification_builder: query_editor.QuerySpecification = (
66
+ api_client: api_clients.BaseClient,
67
+ parser: type[parsers.BaseParser] = parsers.DictParser,
68
+ query_specification_builder: type[query_editor.QuerySpecification] = (
67
69
  query_editor.QuerySpecification
68
70
  ),
69
71
  builtin_queries: dict[str, Callable[[ApiReportFetcher], report.GarfReport]]
@@ -94,7 +96,7 @@ class ApiReportFetcher:
94
96
 
95
97
  async def afetch(
96
98
  self,
97
- query_specification: str | query_editor.QueryElements,
99
+ query_specification: str | query_editor.QuerySpecification,
98
100
  args: query_editor.GarfQueryParameters | None = None,
99
101
  **kwargs: str,
100
102
  ) -> report.GarfReport:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: garf-core
3
- Version: 0.1.5.post0
3
+ Version: 0.2.1
4
4
  Summary: Abstracts fetching data from API based on provided SQL-like query.
5
5
  Author-email: "Google Inc. (gTech gPS CSE team)" <no-reply@google.com>, Andrei Markin <andrey.markin.ppc@gmail.com>
6
6
  License: Apache 2.0
@@ -0,0 +1,16 @@
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,,
@@ -1,16 +0,0 @@
1
- garf_core/__init__.py,sha256=ZKDzKwZl1HtqUnBH6ijUMZgu6dRZJsbgMGuL8s9gzNU,960
2
- garf_core/api_clients.py,sha256=P9fSy0xUXqmQoupWGWP1-SVBz_yZ80Jh58fvkh2dY8s,4694
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=A4Yb614ydN_psKvyUfWZjnTG5-aooT0pz2Cm6CHUnC4,3283
6
- garf_core/query_editor.py,sha256=QPbUz8jHloTcGm_Nz2Nog_sdYIPiDshtm_t45-R76pk,17147
7
- garf_core/report.py,sha256=J2-oNV3Nh7ToYhm2fsdovkiE__-UhT0K5-wP0jBD0oQ,20330
8
- garf_core/report_fetcher.py,sha256=dgErrkRv1bdPN0npX3atZbJrOpzy9OmoPC38e2FpBSQ,4883
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.1.5.post0.dist-info/METADATA,sha256=-YyPa7wDx_3dzKaWEPlEupNyg2QtKblNLvem6pWhew0,2433
13
- garf_core-0.1.5.post0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- garf_core-0.1.5.post0.dist-info/entry_points.txt,sha256=u4h-ujHO1hbxVXRQzwcC4ftju9_KBYtq5mCLKEBHMj0,69
15
- garf_core-0.1.5.post0.dist-info/top_level.txt,sha256=Gj-Zp7fM2turGut5vTJuo5xEcKfow7cTLX3y3WFfNgA,10
16
- garf_core-0.1.5.post0.dist-info/RECORD,,