garf-executors 0.0.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.

Potentially problematic release.


This version of garf-executors might be problematic. Click here for more details.

@@ -0,0 +1,470 @@
1
+ # Copyright 2022 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
+ """Module for various helpers for executing Garf as CLI tool."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import datetime
20
+ import logging
21
+ import os
22
+ import sys
23
+ import traceback
24
+ from collections.abc import MutableSequence, Sequence
25
+ from typing import Any, Callable, TypedDict
26
+
27
+ import smart_open
28
+ import yaml
29
+ from dateutil import relativedelta
30
+ from rich import logging as rich_logging
31
+
32
+ from garf_core import query_editor
33
+
34
+
35
+ class GarfQueryParameters(TypedDict):
36
+ """Annotation for dictionary of query specific parameters passed via CLI.
37
+
38
+ Attributes:
39
+ macros: Mapping for elements that will be replaced in the queries.
40
+ template: Mapping for elements that will rendered via Jinja templates.
41
+ """
42
+
43
+ macros: dict[str, str]
44
+ template: dict[str, str]
45
+
46
+
47
+ @dataclasses.dataclass
48
+ class BaseConfig:
49
+ """Base config to inherit other configs from."""
50
+
51
+ def __add__(self, other: BaseConfig) -> BaseConfig:
52
+ """Creates new config of the same type from two configs.
53
+
54
+ Parameters from added config overwrite already present parameters.
55
+
56
+ Args:
57
+ other: Config that could be merged with the original one.
58
+
59
+ Returns:
60
+ New config with values from both configs.
61
+ """
62
+ right_dict = _remove_empty_values(self.__dict__)
63
+ left_dict = _remove_empty_values(other.__dict__)
64
+ new_dict = {**right_dict, **left_dict}
65
+ return self.__class__(**new_dict)
66
+
67
+ @classmethod
68
+ def from_dict(
69
+ cls, config_parameters: dict[str, str | GarfQueryParameters]
70
+ ) -> BaseConfig:
71
+ """Builds config from provided parameters ignoring empty ones."""
72
+ return cls(**_remove_empty_values(config_parameters))
73
+
74
+
75
+ @dataclasses.dataclass
76
+ class GarfConfig(BaseConfig):
77
+ """Stores values to run garf from command line.
78
+
79
+ Attributes:
80
+ account:
81
+ Account(s) to get data from.
82
+ output:
83
+ Specifies where to store fetched data (console, csv, BQ.)
84
+ api_version:
85
+ Google Ads API version.
86
+ params:
87
+ Any parameters passed to Garf query for substitution.
88
+ writer_params:
89
+ Any parameters that can be passed to writer for data saving.
90
+ customer_ids_query:
91
+ Query text to limit accounts fetched from Ads API.
92
+ customer_ids_query_file:
93
+ Path to query to limit accounts fetched from Ads API.
94
+ """
95
+
96
+ account: str | list[str] | None = None
97
+ output: str = 'console'
98
+ params: GarfQueryParameters = dataclasses.field(default_factory=dict)
99
+ writer_params: dict[str, str | int] = dataclasses.field(default_factory=dict)
100
+ customer_ids_query: str | None = None
101
+ customer_ids_query_file: str | None = None
102
+
103
+ def __post_init__(self) -> None:
104
+ """Ensures that values passed during __init__ correctly formatted."""
105
+ if isinstance(self.account, MutableSequence):
106
+ self.account = [
107
+ str(account).replace('-', '').strip() for account in self.account
108
+ ]
109
+ else:
110
+ self.account = (
111
+ str(self.account).replace('-', '').strip() if self.account else None
112
+ )
113
+ self.writer_params = {
114
+ key.replace('-', '_'): value for key, value in self.writer_params.items()
115
+ }
116
+
117
+
118
+ class GarfConfigException(Exception):
119
+ """Exception for invalid GarfConfig."""
120
+
121
+
122
+ @dataclasses.dataclass
123
+ class GarfBqConfig(BaseConfig):
124
+ """Stores values to run garf-bq from command line.
125
+
126
+ Attributes:
127
+ project:
128
+ Google Cloud project name.
129
+ dataset_location:
130
+ Location of BigQuery dataset.
131
+ params:
132
+ Any parameters passed to BigQuery query for substitution.
133
+ """
134
+
135
+ project: str | None = None
136
+ dataset_location: str | None = None
137
+ params: GarfQueryParameters = dataclasses.field(default_factory=dict)
138
+
139
+
140
+ @dataclasses.dataclass
141
+ class GarfSqlConfig(BaseConfig):
142
+ """Stores values to run garf-sql from command line.
143
+
144
+ Attributes:
145
+ connection_string:
146
+ Connection string to SqlAlchemy database engine.
147
+ params:
148
+ Any parameters passed to SQL query for substitution.
149
+ """
150
+
151
+ connection_string: str | None = None
152
+ params: GarfQueryParameters = dataclasses.field(default_factory=dict)
153
+
154
+
155
+ class ConfigBuilder:
156
+ """Builds config of provided type.
157
+
158
+ Config can be created from file, build from arguments or both.
159
+
160
+ Attributes:
161
+ config: Concrete config class that needs to be built.
162
+ """
163
+
164
+ _config_mapping: dict[str, BaseConfig] = {
165
+ 'garf': GarfConfig,
166
+ 'garf-bq': GarfBqConfig,
167
+ 'garf-sql': GarfSqlConfig,
168
+ }
169
+
170
+ def __init__(self, config_type: str) -> None:
171
+ """Sets concrete config type.
172
+
173
+ Args:
174
+ config_type: Type of config that should be built.
175
+
176
+ Raises:
177
+ GarfConfigException: When incorrect config_type is supplied.
178
+ """
179
+ if config_type not in self._config_mapping:
180
+ raise GarfConfigException(f'Invalid config_type: {config_type}')
181
+ self._config_type = config_type
182
+ self.config = self._config_mapping.get(config_type)
183
+
184
+ def build(
185
+ self, parameters: dict[str, str], cli_named_args: Sequence[str]
186
+ ) -> BaseConfig | None:
187
+ """Builds config from file, build from arguments or both ways.
188
+
189
+ When there are both config_file and CLI arguments the latter have more
190
+ priority.
191
+
192
+ Args:
193
+ parameters: Parsed CLI arguments.
194
+ cli_named_args: Unparsed CLI args in a form `--key.subkey=value`.
195
+
196
+ Returns:
197
+ Concrete config with injected values.
198
+ """
199
+ if not (garf_config_path := parameters.get('garf_config')):
200
+ return self._build_config(parameters, cli_named_args)
201
+ config_file = self._load_config(garf_config_path)
202
+ config_cli = self._build_config(
203
+ parameters, cli_named_args, init_defaults=False
204
+ )
205
+ if config_file and config_cli:
206
+ config_file = config_file + config_cli
207
+ return config_file
208
+
209
+ def _build_config(
210
+ self,
211
+ parameters: dict[str, str],
212
+ cli_named_args: Sequence[str],
213
+ init_defaults: bool = True,
214
+ ) -> BaseConfig | None:
215
+ """Builds config from named and unnamed CLI parameters.
216
+
217
+ Args:
218
+ parameters: Parsed CLI arguments.
219
+ cli_named_args: Unparsed CLI args in a form `--key.subkey=value`.
220
+ init_defaults: Whether to provided default config values if
221
+ expected parameter is missing
222
+
223
+ Returns:
224
+ Concrete config with injected values.
225
+ """
226
+ output = parameters.get('output')
227
+ config_parameters = {
228
+ k: v for k, v in parameters.items() if k in self.config.__annotations__
229
+ }
230
+ cli_params = ParamsParser(['macro', 'template', output]).parse(
231
+ cli_named_args
232
+ )
233
+ cli_params = _remove_empty_values(cli_params)
234
+ if output and (writer_params := cli_params.get(output)):
235
+ _ = cli_params.pop(output)
236
+ config_parameters.update({'writer_params': writer_params})
237
+ if cli_params:
238
+ config_parameters.update({'params': cli_params})
239
+ if not config_parameters:
240
+ return None
241
+ if init_defaults:
242
+ return self.config.from_dict(config_parameters)
243
+ return self.config(**config_parameters)
244
+
245
+ def _load_config(self, garf_config_path: str) -> BaseConfig:
246
+ """Loads config from provided path.
247
+
248
+ Args:
249
+ garf_config_path: Path to local or remote storage.
250
+
251
+ Returns:
252
+ Concreate config with values taken from config file.
253
+
254
+ Raises:
255
+ GarfConfigException:
256
+ If config file missing `garf` section.
257
+ """
258
+ with smart_open.open(garf_config_path, encoding='utf-8') as f:
259
+ config = yaml.safe_load(f)
260
+ garf_section = config.get(self._config_type)
261
+ if not garf_section:
262
+ raise GarfConfigException(
263
+ f'Invalid config, must have `{self._config_type}` section!'
264
+ )
265
+ config_parameters = {
266
+ k: v for k, v in garf_section.items() if k in self.config.__annotations__
267
+ }
268
+ if params := garf_section.get('params', {}):
269
+ config_parameters.update({'params': params})
270
+ if writer_params := garf_section.get(garf_section.get('output', '')):
271
+ config_parameters.update({'writer_params': writer_params})
272
+ return self.config(**config_parameters)
273
+
274
+
275
+ class ParamsParser:
276
+ def __init__(self, identifiers: Sequence[str]) -> None:
277
+ self.identifiers = identifiers
278
+
279
+ def parse(self, params: Sequence) -> dict[str, dict | None]:
280
+ return {
281
+ identifier: self._parse_params(identifier, params)
282
+ for identifier in self.identifiers
283
+ }
284
+
285
+ def _parse_params(self, identifier: str, params: Sequence[Any]) -> dict:
286
+ parsed_params = {}
287
+ if params:
288
+ raw_params = [param.split('=', maxsplit=1) for param in params]
289
+ for param in raw_params:
290
+ param_pair = self._identify_param_pair(identifier, param)
291
+ if param_pair:
292
+ parsed_params.update(param_pair)
293
+ return parsed_params
294
+
295
+ def _identify_param_pair(
296
+ self, identifier: str, param: Sequence[str]
297
+ ) -> dict[str, Any] | None:
298
+ key = param[0]
299
+ if not identifier or identifier not in key:
300
+ return None
301
+ provided_identifier, key = key.split('.')
302
+ if provided_identifier.replace('--', '') not in self.identifiers:
303
+ raise GarfParamsException(
304
+ f'CLI argument {provided_identifier} is not supported'
305
+ f", supported arguments {', '.join(self.identifiers)}"
306
+ )
307
+ key = key.replace('-', '_')
308
+ if len(param) == 2:
309
+ # TODO: b/337860595 - Ensure that writer params are converted to int
310
+ return {key: param[1]}
311
+ raise GarfParamsException(
312
+ f'{identifier} {key} is invalid,'
313
+ f'--{identifier}.key=value is the correct format'
314
+ )
315
+
316
+
317
+ class GarfParamsException(Exception):
318
+ """Defines exception for incorrect parameters."""
319
+
320
+
321
+ def convert_date(date_string: str) -> str:
322
+ """Converts specific dates parameters to actual dates.
323
+
324
+ Returns:
325
+ Date string in YYYY-MM-DD format.
326
+
327
+ Raises:
328
+ ValueError:
329
+ If dynamic lookback value (:YYYYMMDD-N) is incorrect.
330
+ """
331
+ if isinstance(date_string, list) or date_string.find(':YYYY') == -1:
332
+ return date_string
333
+ current_date = datetime.date.today()
334
+ date_object = date_string.split('-')
335
+ base_date = date_object[0]
336
+ if len(date_object) == 2:
337
+ try:
338
+ days_ago = int(date_object[1])
339
+ except ValueError as e:
340
+ raise ValueError(
341
+ 'Must provide numeric value for a number lookback period, '
342
+ 'i.e. :YYYYMMDD-1'
343
+ ) from e
344
+ else:
345
+ days_ago = 0
346
+ if base_date == ':YYYY':
347
+ new_date = datetime.datetime(current_date.year, 1, 1)
348
+ delta = relativedelta.relativedelta(years=days_ago)
349
+ elif base_date == ':YYYYMM':
350
+ new_date = datetime.datetime(current_date.year, current_date.month, 1)
351
+ delta = relativedelta.relativedelta(months=days_ago)
352
+ elif base_date == ':YYYYMMDD':
353
+ new_date = current_date
354
+ delta = relativedelta.relativedelta(days=days_ago)
355
+ return (new_date - delta).strftime('%Y-%m-%d')
356
+
357
+
358
+ class ConfigSaver:
359
+ def __init__(self, path: str) -> None:
360
+ self.path = path
361
+
362
+ def save(self, garf_config: BaseConfig):
363
+ if os.path.exists(self.path):
364
+ with smart_open.open(self.path, 'r', encoding='utf-8') as f:
365
+ config = yaml.safe_load(f)
366
+ else:
367
+ config = {}
368
+ config = self.prepare_config(config, garf_config)
369
+ with smart_open.open(self.path, 'w', encoding='utf-8') as f:
370
+ yaml.dump(
371
+ config, f, default_flow_style=False, sort_keys=False, encoding='utf-8'
372
+ )
373
+
374
+ def prepare_config(self, config: dict, garf_config: BaseConfig) -> dict:
375
+ garf = dataclasses.asdict(garf_config)
376
+ if isinstance(garf_config, GarfConfig):
377
+ garf[garf_config.output] = garf_config.writer_params
378
+ if not isinstance(garf_config.account, MutableSequence):
379
+ garf['account'] = garf_config.account.split(',')
380
+ del garf['writer_params']
381
+ garf = _remove_empty_values(garf)
382
+ config.update({'garf': garf})
383
+ if isinstance(garf_config, GarfBqConfig):
384
+ garf = _remove_empty_values(garf)
385
+ config.update({'garf-bq': garf})
386
+ if isinstance(garf_config, GarfSqlConfig):
387
+ garf = _remove_empty_values(garf)
388
+ config.update({'garf-sql': garf})
389
+ return config
390
+
391
+
392
+ def initialize_runtime_parameters(config: BaseConfig) -> BaseConfig:
393
+ """Formats parameters and add common parameter in config.
394
+
395
+ Initialization identifies whether there are `date` parameters and performs
396
+ necessary date conversions.
397
+ Set of parameters that need to be generally available are injected into
398
+ every parameter section of the config.
399
+
400
+ Args:
401
+ config: Instantiated config.
402
+
403
+ Returns:
404
+ Config with formatted parameters.
405
+ """
406
+ common_params = query_editor.CommonParametersMixin().common_params
407
+ for key, param in config.params.items():
408
+ for key_param, value_param in param.items():
409
+ config.params[key][key_param] = convert_date(value_param)
410
+ for common_param_key, common_param_value in common_params.items():
411
+ if common_param_key not in config.params[key]:
412
+ config.params[key][common_param_key] = common_param_value
413
+ return config
414
+
415
+
416
+ def _remove_empty_values(dict_object: dict[str, Any]) -> dict[str, Any]:
417
+ """Remove all empty elements: strings, dictionaries from a dictionary."""
418
+ if isinstance(dict_object, dict):
419
+ return {
420
+ key: value
421
+ for key, value in (
422
+ (key, _remove_empty_values(value)) for key, value in dict_object.items()
423
+ )
424
+ if value
425
+ }
426
+ if isinstance(dict_object, (int, str, MutableSequence)):
427
+ return dict_object
428
+
429
+
430
+ def garf_runner(query: str, callback: Callable, logger) -> None:
431
+ try:
432
+ logger.debug('starting query %s', query)
433
+ callback()
434
+ logger.info('%s executed successfully', query)
435
+ except Exception as e:
436
+ traceback.print_tb(e.__traceback__)
437
+ logger.error('%s generated an exception: %s', query, str(e))
438
+
439
+
440
+ def postprocessor_runner(query: str, callback: Callable, logger) -> None:
441
+ try:
442
+ logger.debug('starting query %s', query)
443
+ callback()
444
+ logger.info('%s executed successfully', query)
445
+ except Exception as e:
446
+ logger.error('%s generated an exception: %s', query, str(e))
447
+
448
+
449
+ def init_logging(
450
+ loglevel: str = 'INFO', logger_type: str = 'local', name: str = __name__
451
+ ) -> logging.Logger:
452
+ if logger_type == 'rich':
453
+ logging.basicConfig(
454
+ format='%(message)s',
455
+ level=loglevel,
456
+ datefmt='%Y-%m-%d %H:%M:%S',
457
+ handlers=[
458
+ rich_logging.RichHandler(rich_tracebacks=True),
459
+ ],
460
+ )
461
+ else:
462
+ logging.basicConfig(
463
+ format='[%(asctime)s][%(name)s][%(levelname)s] %(message)s',
464
+ stream=sys.stdout,
465
+ level=loglevel,
466
+ datefmt='%Y-%m-%d %H:%M:%S',
467
+ )
468
+ logging.getLogger('smart_open.smart_open_lib').setLevel(logging.WARNING)
469
+ logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING)
470
+ return logging.getLogger(name)
@@ -0,0 +1,79 @@
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
+ """Defines mechanism for executing queries via SqlAlchemy."""
15
+
16
+ from __future__ import annotations
17
+
18
+ try:
19
+ import sqlalchemy
20
+ except ImportError as e:
21
+ raise ImportError(
22
+ 'Please install garf-executors with sqlalchemy support '
23
+ '- `pip install garf-executors[sqlalchemy]`'
24
+ ) from e
25
+
26
+ import logging
27
+ import re
28
+ from typing import Any
29
+
30
+ import pandas as pd
31
+
32
+ from garf_core import query_editor
33
+
34
+
35
+ class SqlAlchemyQueryExecutor(query_editor.TemplateProcessorMixin):
36
+ """Handles query execution via SqlAlchemy.
37
+
38
+ Attributes:
39
+ engine: Initialized Engine object to operated on a given database.
40
+ """
41
+
42
+ def __init__(self, engine: sqlalchemy.engine.base.Engine) -> None:
43
+ """Initializes executor with a given engine.
44
+
45
+ Args:
46
+ engine: Initialized Engine object to operated on a given database.
47
+ """
48
+ self.engine = engine
49
+
50
+ def execute(
51
+ self,
52
+ script_name: str | None,
53
+ query_text: str,
54
+ params: dict[str, Any] | None = None,
55
+ ) -> pd.DataFrame:
56
+ """Executes query in a given database via SqlAlchemy.
57
+
58
+ Args:
59
+ script_name: Script identifier.
60
+ query_text: Query to be executed.
61
+ params: Optional parameters to be replaced in query text.
62
+
63
+ Returns:
64
+ DataFrame if query returns some data otherwise empty DataFrame.
65
+ """
66
+ logging.info('Executing script: %s', script_name)
67
+ query_text = self.replace_params_template(query_text, params)
68
+ with self.engine.begin() as conn:
69
+ if re.findall(r'(create|update) ', query_text.lower()):
70
+ conn.connection.executescript(query_text)
71
+ return pd.DataFrame()
72
+ temp_table_name = f'temp_{script_name}'.replace('.', '_')
73
+ query_text = f'CREATE TABLE {temp_table_name} AS {query_text}'
74
+ conn.connection.executescript(query_text)
75
+ try:
76
+ result = pd.read_sql(f'SELECT * FROM {temp_table_name}', conn)
77
+ finally:
78
+ conn.connection.execute(f'DROP TABLE {temp_table_name}')
79
+ return result
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.1
2
+ Name: garf-executors
3
+ Version: 0.0.1
4
+ Summary: Executes queries against API and writes data to local/remote storage.
5
+ Author-email: "Google Inc. (gTech gPS CSE team)" <no-reply@google.com>
6
+ License: Apache 2.0
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Classifier: Programming Language :: Python :: 3.8
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: garf-core
20
+ Requires-Dist: garf-io
21
+ Provides-Extra: all
22
+ Requires-Dist: garf-executors[bq,sql] ; extra == 'all'
23
+ Provides-Extra: bq
24
+ Requires-Dist: garf-io[bq] ; extra == 'bq'
25
+ Requires-Dist: pandas ; extra == 'bq'
26
+ Provides-Extra: sql
27
+ Requires-Dist: garf-io[sqlalchemy] ; extra == 'sql'
28
+ Requires-Dist: pandas ; extra == 'sql'
29
+
30
+ # Gaarf Executors
@@ -0,0 +1,16 @@
1
+ garf_executors/__init__.py,sha256=EWaHzmbB0XnTnYoR0bNsknBBbCet7tpv7gUwahtBIC0,873
2
+ garf_executors/api_executor.py,sha256=axB7msuZ7LXFk_f3MjIyGM2AhDILyu7g70zqNW0hG6Q,3149
3
+ garf_executors/bq_executor.py,sha256=JBPxbDRYgUgpJv6SqYiFPypTFjZGIZ-SOOb6dS2sZQY,3822
4
+ garf_executors/sql_executor.py,sha256=vBNQ4HZZYxP_EYAh8Z4BerzLESfsNpXdhENzXIw-OIo,2532
5
+ garf_executors/entrypoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ garf_executors/entrypoints/utils.py,sha256=07SUDnnjznO8o3r2qHsVFAz8TDbvv6T8MhtLdWxv8rc,15085
7
+ garf_executors/entrypoints/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ garf_executors/entrypoints/cli/api.py,sha256=0QzB7KdXyZnHhaMEGsFhj2B8hyNxV-F5JW6zC90rTcQ,7118
9
+ garf_executors/entrypoints/cli/bq.py,sha256=Rk3nTkcGhyp1hnSSFsLFIFPXo33l_B_O1MiCK2fdZM8,3824
10
+ garf_executors/entrypoints/cli/gaarf.py,sha256=0QzB7KdXyZnHhaMEGsFhj2B8hyNxV-F5JW6zC90rTcQ,7118
11
+ garf_executors/entrypoints/cli/sql.py,sha256=tXQwhrLNUvfORxVdaZHnIawAR06oWZGro2vcreJ22Kc,3753
12
+ garf_executors-0.0.1.dist-info/METADATA,sha256=ksb0r59ozF1UFEOfQ5zS_-oh3Wus8ByjpVxrN8KmV8M,1180
13
+ garf_executors-0.0.1.dist-info/WHEEL,sha256=a7TGlA-5DaHMRrarXjVbQagU3Man_dCnGIWMJr5kRWo,91
14
+ garf_executors-0.0.1.dist-info/entry_points.txt,sha256=ksbFBDblKlOYqNyYoL3uaZVYWEYM_KWb0sWrvUamhd4,136
15
+ garf_executors-0.0.1.dist-info/top_level.txt,sha256=sP4dCXOENPn1hDFAunjMV8Js4NND_KGeO_gQWuaT0EY,15
16
+ garf_executors-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.4.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ garf-bq-executor = garf_executors.entrypoints.cli.bq:main
3
+ garf-sql-executor = garf_executors.entrypoints.cli.sql:main
@@ -0,0 +1 @@
1
+ garf_executors