Skip to content

Commit 4a7bdc4

Browse files
[executors] feat: add Config to store parameters for query execution
Config allows to capture all execution context parameters related to a particular source (writer, fetcher, macros). * Add Config class with save and load methods; add tests for them * Add methods to ExecutionContext to save and load particular execution context from a file * Add support to garf cli to work with Config. * Remove obsoleted BaseConfig class and its subclasses as well as methods operating on it; remove all related tests * Move initialize_runtime_parameters helper function into `garf_core.query_editor` - now dynamic dates are converted to normal date strings during macro initialization. * Move convert_date to `garf_core.query_editor` and add support for look_forward dynamic dates.
1 parent 41a1c11 commit 4a7bdc4

File tree

12 files changed

+386
-938
lines changed

12 files changed

+386
-938
lines changed

libs/core/garf_core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@
2626
'ApiReportFetcher',
2727
]
2828

29-
__version__ = '0.2.0'
29+
__version__ = '0.2.1'

libs/core/garf_core/query_editor.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,10 @@ def macros(self) -> QueryParameters:
405405
"""Returns macros with injected common parameters."""
406406
common_params = dict(self.common_params)
407407
if macros := self.args.macro:
408-
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)
409412
return common_params
410413

411414
def generate(self) -> BaseQueryElements:
@@ -564,3 +567,50 @@ def _is_invalid_field(field) -> bool:
564567
is_constant = _is_constant(field)
565568
has_operator = any(operator in field for operator in operators)
566569
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')

libs/core/tests/unit/test_query_editor.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import datetime
1818

1919
import pytest
20+
from dateutil.relativedelta import relativedelta
2021
from garf_core import query_editor
2122

2223

@@ -129,3 +130,67 @@ def test_generate_returns_builtin_query(self):
129130
)
130131

131132
assert test_query_spec.query == expected_query_elements
133+
134+
def test_generate_returns_macros(self):
135+
query = 'SELECT test FROM resource WHERE start_date = {start_date}'
136+
test_query_spec = query_editor.QuerySpecification(
137+
text=query,
138+
title='test',
139+
args=query_editor.GarfQueryParameters(
140+
macro={'start_date': ':YYYYMMDD'},
141+
),
142+
)
143+
assert test_query_spec.macros.get(
144+
'start_date'
145+
) == datetime.date.today().strftime('%Y-%m-%d')
146+
147+
148+
def test_convert_date_returns_correct_datestring():
149+
current_date = datetime.datetime.today()
150+
current_year = datetime.datetime(current_date.year, 1, 1)
151+
current_month = datetime.datetime(current_date.year, current_date.month, 1)
152+
last_year = current_year - relativedelta(years=1)
153+
last_month = current_month - relativedelta(months=1)
154+
yesterday = current_date - relativedelta(days=1)
155+
tomorrow = current_date + relativedelta(days=1)
156+
next_month = current_month + relativedelta(months=1)
157+
next_year = current_year + relativedelta(years=1)
158+
159+
non_macro_date = '2022-01-01'
160+
date_year = ':YYYY'
161+
date_month = ':YYYYMM'
162+
date_day = ':YYYYMMDD'
163+
date_year_minus_one = ':YYYY-1'
164+
date_month_minus_one = ':YYYYMM-1'
165+
date_day_minus_one = ':YYYYMMDD-1'
166+
date_day_plus_one = ':YYYYMMDD+1'
167+
date_month_plus_one = ':YYYYMM+1'
168+
date_year_plus_one = ':YYYY+1'
169+
170+
non_macro_date_converted = query_editor.convert_date(non_macro_date)
171+
new_date_year = query_editor.convert_date(date_year)
172+
new_date_month = query_editor.convert_date(date_month)
173+
new_date_day = query_editor.convert_date(date_day)
174+
new_date_year_minus_one = query_editor.convert_date(date_year_minus_one)
175+
new_date_month_minus_one = query_editor.convert_date(date_month_minus_one)
176+
new_date_day_minus_one = query_editor.convert_date(date_day_minus_one)
177+
new_date_day_plus_one = query_editor.convert_date(date_day_plus_one)
178+
new_date_month_plus_one = query_editor.convert_date(date_month_plus_one)
179+
new_date_year_plus_one = query_editor.convert_date(date_year_plus_one)
180+
181+
assert non_macro_date_converted == non_macro_date
182+
assert new_date_year == current_year.strftime('%Y-%m-%d')
183+
assert new_date_month == current_month.strftime('%Y-%m-%d')
184+
assert new_date_day == current_date.strftime('%Y-%m-%d')
185+
assert new_date_year_minus_one == last_year.strftime('%Y-%m-%d')
186+
assert new_date_month_minus_one == last_month.strftime('%Y-%m-%d')
187+
assert new_date_day_minus_one == yesterday.strftime('%Y-%m-%d')
188+
assert new_date_day_plus_one == tomorrow.strftime('%Y-%m-%d')
189+
assert new_date_month_plus_one == next_month.strftime('%Y-%m-%d')
190+
assert new_date_year_plus_one == next_year.strftime('%Y-%m-%d')
191+
192+
193+
@pytest.mark.parametrize('date', [':YYYYMMDD-N', ':YYYYMMDD-2-3', ':YYYMMDD'])
194+
def test_convert_date_raise_garf_macro_error(date):
195+
with pytest.raises(query_editor.GarfMacroError):
196+
query_editor.convert_date(date)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# pylint: disable=C0330, g-bad-import-order, g-multiple-import
16+
17+
"""Stores mapping between API aliases and their execution context."""
18+
19+
from __future__ import annotations
20+
21+
import os
22+
import pathlib
23+
24+
import pydantic
25+
import smart_open
26+
import yaml
27+
28+
from garf_executors.execution_context import ExecutionContext
29+
30+
31+
class Config(pydantic.BaseModel):
32+
"""Stores necessary parameters for one or multiple API sources.
33+
34+
Attributes:
35+
source: Mapping between API source alias and execution parameters.
36+
"""
37+
38+
sources: dict[str, ExecutionContext]
39+
40+
@classmethod
41+
def from_file(cls, path: str | pathlib.Path | os.PathLike[str]) -> Config:
42+
"""Builds config from local or remote yaml file."""
43+
with smart_open.open(path, 'r', encoding='utf-8') as f:
44+
data = yaml.safe_load(f)
45+
return Config(sources=data)
46+
47+
def save(self, path: str | pathlib.Path | os.PathLike[str]) -> str:
48+
"""Saves config to local or remote yaml file."""
49+
with smart_open.open(path, 'w', encoding='utf-8') as f:
50+
yaml.dump(self.model_dump().get('sources'), f, encoding='utf-8')
51+
return f'Config is saved to {str(path)}'

libs/executors/garf_executors/entrypoints/cli.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@
2525
from garf_io import reader
2626

2727
import garf_executors
28-
from garf_executors import exceptions
28+
from garf_executors import config, exceptions
2929
from garf_executors.entrypoints import utils
3030

3131

3232
def main():
3333
parser = argparse.ArgumentParser()
3434
parser.add_argument('query', nargs='*')
35-
parser.add_argument('-c', '--config', dest='garf_config', default=None)
35+
parser.add_argument('-c', '--config', dest='config', default=None)
3636
parser.add_argument('--source', dest='source', default=None)
3737
parser.add_argument('--output', dest='output', default='console')
3838
parser.add_argument('--input', dest='input', default='file')
@@ -64,34 +64,44 @@ def main():
6464
raise exceptions.GarfExecutorError(
6565
'Please provide one or more queries to run'
6666
)
67-
config = utils.ConfigBuilder('garf').build(vars(args), kwargs)
68-
logger.debug('config: %s', config)
69-
70-
if config.params:
71-
config = utils.initialize_runtime_parameters(config)
72-
logger.debug('initialized config: %s', config)
73-
74-
extra_parameters = utils.ParamsParser(['source']).parse(kwargs)
75-
source_parameters = extra_parameters.get('source', {})
7667
reader_client = reader.create_reader(args.input)
77-
78-
context = garf_executors.api_executor.ApiExecutionContext(
79-
query_parameters=config.params,
80-
writer=args.output,
81-
writer_parameters=config.writer_params,
82-
fetcher_parameters=source_parameters,
83-
)
84-
query_executor = garf_executors.setup_executor(
85-
args.source, context.fetcher_parameters
86-
)
87-
if args.parallel_queries:
88-
logger.info('Running queries in parallel')
68+
if config_file := args.config:
69+
execution_config = config.Config.from_file(config_file)
70+
if not (context := execution_config.sources.get(args.source)):
71+
raise exceptions.GarfExecutorError(
72+
f'No execution context found for source {args.source} in {config_file}'
73+
)
74+
query_executor = garf_executors.setup_executor(
75+
args.source, context.fetcher_parameters
76+
)
8977
batch = {query: reader_client.read(query) for query in args.query}
9078
query_executor.execute_batch(batch, context, args.parallel_queries)
9179
else:
92-
logger.info('Running queries sequentially')
93-
for query in args.query:
94-
query_executor.execute(reader_client.read(query), query, context)
80+
extra_parameters = utils.ParamsParser(
81+
['source', args.output, 'macro', 'template']
82+
).parse(kwargs)
83+
source_parameters = extra_parameters.get('source', {})
84+
85+
context = garf_executors.api_executor.ApiExecutionContext(
86+
query_parameters={
87+
'macro': extra_parameters.get('macro'),
88+
'template': extra_parameters.get('template'),
89+
},
90+
writer=args.output,
91+
writer_parameters=extra_parameters.get(args.output),
92+
fetcher_parameters=source_parameters,
93+
)
94+
query_executor = garf_executors.setup_executor(
95+
args.source, context.fetcher_parameters
96+
)
97+
if args.parallel_queries:
98+
logger.info('Running queries in parallel')
99+
batch = {query: reader_client.read(query) for query in args.query}
100+
query_executor.execute_batch(batch, context, args.parallel_queries)
101+
else:
102+
logger.info('Running queries sequentially')
103+
for query in args.query:
104+
query_executor.execute(reader_client.read(query), query, context)
95105

96106

97107
if __name__ == '__main__':

0 commit comments

Comments
 (0)