|
21 | 21 | import os |
22 | 22 | import sys |
23 | 23 | from collections.abc import MutableSequence, Sequence |
24 | | -from typing import Any, TypedDict |
| 24 | +from typing import Any |
25 | 25 |
|
26 | 26 | import smart_open |
27 | 27 | import yaml |
|
30 | 30 | from rich import logging as rich_logging |
31 | 31 |
|
32 | 32 |
|
33 | | -class GarfQueryParameters(TypedDict): |
34 | | - """Annotation for dictionary of query specific parameters passed via CLI. |
35 | | -
|
36 | | - Attributes: |
37 | | - macros: Mapping for elements that will be replaced in the queries. |
38 | | - template: Mapping for elements that will rendered via Jinja templates. |
39 | | - """ |
40 | | - |
41 | | - macros: dict[str, str] |
42 | | - template: dict[str, str] |
43 | | - |
44 | | - |
45 | | -@dataclasses.dataclass |
46 | | -class BaseConfig: |
47 | | - """Base config to inherit other configs from.""" |
48 | | - |
49 | | - def __add__(self, other: BaseConfig) -> BaseConfig: |
50 | | - """Creates new config of the same type from two configs. |
51 | | -
|
52 | | - Parameters from added config overwrite already present parameters. |
53 | | -
|
54 | | - Args: |
55 | | - other: Config that could be merged with the original one. |
56 | | -
|
57 | | - Returns: |
58 | | - New config with values from both configs. |
59 | | - """ |
60 | | - right_dict = _remove_empty_values(self.__dict__) |
61 | | - left_dict = _remove_empty_values(other.__dict__) |
62 | | - new_dict = {**right_dict, **left_dict} |
63 | | - return self.__class__(**new_dict) |
64 | | - |
65 | | - @classmethod |
66 | | - def from_dict( |
67 | | - cls, config_parameters: dict[str, str | GarfQueryParameters] |
68 | | - ) -> BaseConfig: |
69 | | - """Builds config from provided parameters ignoring empty ones.""" |
70 | | - return cls(**_remove_empty_values(config_parameters)) |
71 | | - |
72 | | - |
73 | | -@dataclasses.dataclass |
74 | | -class GarfConfig(BaseConfig): |
75 | | - """Stores values to run garf from command line. |
76 | | -
|
77 | | - Attributes: |
78 | | - account: |
79 | | - Account(s) to get data from. |
80 | | - output: |
81 | | - Specifies where to store fetched data (console, csv, BQ.) |
82 | | - api_version: |
83 | | - Google Ads API version. |
84 | | - params: |
85 | | - Any parameters passed to Garf query for substitution. |
86 | | - writer_params: |
87 | | - Any parameters that can be passed to writer for data saving. |
88 | | - customer_ids_query: |
89 | | - Query text to limit accounts fetched from Ads API. |
90 | | - customer_ids_query_file: |
91 | | - Path to query to limit accounts fetched from Ads API. |
92 | | - """ |
93 | | - |
94 | | - account: str | list[str] | None = None |
95 | | - output: str = 'console' |
96 | | - params: GarfQueryParameters = dataclasses.field(default_factory=dict) |
97 | | - writer_params: dict[str, str | int] = dataclasses.field(default_factory=dict) |
98 | | - customer_ids_query: str | None = None |
99 | | - customer_ids_query_file: str | None = None |
100 | | - |
101 | | - def __post_init__(self) -> None: |
102 | | - """Ensures that values passed during __init__ correctly formatted.""" |
103 | | - if isinstance(self.account, MutableSequence): |
104 | | - self.account = [ |
105 | | - str(account).replace('-', '').strip() for account in self.account |
106 | | - ] |
107 | | - else: |
108 | | - self.account = ( |
109 | | - str(self.account).replace('-', '').strip() if self.account else None |
110 | | - ) |
111 | | - self.writer_params = { |
112 | | - key.replace('-', '_'): value for key, value in self.writer_params.items() |
113 | | - } |
114 | | - |
115 | | - |
116 | | -class GarfConfigException(Exception): |
117 | | - """Exception for invalid GarfConfig.""" |
118 | | - |
119 | | - |
120 | | -@dataclasses.dataclass |
121 | | -class GarfBqConfig(BaseConfig): |
122 | | - """Stores values to run garf-bq from command line. |
123 | | -
|
124 | | - Attributes: |
125 | | - project: |
126 | | - Google Cloud project name. |
127 | | - dataset_location: |
128 | | - Location of BigQuery dataset. |
129 | | - params: |
130 | | - Any parameters passed to BigQuery query for substitution. |
131 | | - """ |
132 | | - |
133 | | - project: str | None = None |
134 | | - dataset_location: str | None = None |
135 | | - params: GarfQueryParameters = dataclasses.field(default_factory=dict) |
136 | | - |
137 | | - |
138 | | -@dataclasses.dataclass |
139 | | -class GarfSqlConfig(BaseConfig): |
140 | | - """Stores values to run garf-sql from command line. |
141 | | -
|
142 | | - Attributes: |
143 | | - connection_string: |
144 | | - Connection string to SqlAlchemy database engine. |
145 | | - params: |
146 | | - Any parameters passed to SQL query for substitution. |
147 | | - """ |
148 | | - |
149 | | - connection_string: str | None = None |
150 | | - params: GarfQueryParameters = dataclasses.field(default_factory=dict) |
151 | | - |
152 | | - |
153 | | -class ConfigBuilder: |
154 | | - """Builds config of provided type. |
155 | | -
|
156 | | - Config can be created from file, build from arguments or both. |
157 | | -
|
158 | | - Attributes: |
159 | | - config: Concrete config class that needs to be built. |
160 | | - """ |
161 | | - |
162 | | - _config_mapping: dict[str, BaseConfig] = { |
163 | | - 'garf': GarfConfig, |
164 | | - 'garf-bq': GarfBqConfig, |
165 | | - 'garf-sql': GarfSqlConfig, |
166 | | - } |
167 | | - |
168 | | - def __init__(self, config_type: str) -> None: |
169 | | - """Sets concrete config type. |
170 | | -
|
171 | | - Args: |
172 | | - config_type: Type of config that should be built. |
173 | | -
|
174 | | - Raises: |
175 | | - GarfConfigException: When incorrect config_type is supplied. |
176 | | - """ |
177 | | - if config_type not in self._config_mapping: |
178 | | - raise GarfConfigException(f'Invalid config_type: {config_type}') |
179 | | - self._config_type = config_type |
180 | | - self.config = self._config_mapping.get(config_type) |
181 | | - |
182 | | - def build( |
183 | | - self, parameters: dict[str, str], cli_named_args: Sequence[str] |
184 | | - ) -> BaseConfig | None: |
185 | | - """Builds config from file, build from arguments or both ways. |
186 | | -
|
187 | | - When there are both config_file and CLI arguments the latter have more |
188 | | - priority. |
189 | | -
|
190 | | - Args: |
191 | | - parameters: Parsed CLI arguments. |
192 | | - cli_named_args: Unparsed CLI args in a form `--key.subkey=value`. |
193 | | -
|
194 | | - Returns: |
195 | | - Concrete config with injected values. |
196 | | - """ |
197 | | - if not (garf_config_path := parameters.get('garf_config')): |
198 | | - return self._build_config(parameters, cli_named_args) |
199 | | - config_file = self._load_config(garf_config_path) |
200 | | - config_cli = self._build_config( |
201 | | - parameters, cli_named_args, init_defaults=False |
202 | | - ) |
203 | | - if config_file and config_cli: |
204 | | - config_file = config_file + config_cli |
205 | | - return config_file |
206 | | - |
207 | | - def _build_config( |
208 | | - self, |
209 | | - parameters: dict[str, str], |
210 | | - cli_named_args: Sequence[str], |
211 | | - init_defaults: bool = True, |
212 | | - ) -> BaseConfig | None: |
213 | | - """Builds config from named and unnamed CLI parameters. |
214 | | -
|
215 | | - Args: |
216 | | - parameters: Parsed CLI arguments. |
217 | | - cli_named_args: Unparsed CLI args in a form `--key.subkey=value`. |
218 | | - init_defaults: Whether to provided default config values if |
219 | | - expected parameter is missing |
220 | | -
|
221 | | - Returns: |
222 | | - Concrete config with injected values. |
223 | | - """ |
224 | | - output = parameters.get('output') |
225 | | - config_parameters = { |
226 | | - k: v for k, v in parameters.items() if k in self.config.__annotations__ |
227 | | - } |
228 | | - cli_params = ParamsParser(['macro', 'template', output]).parse( |
229 | | - cli_named_args |
230 | | - ) |
231 | | - cli_params = _remove_empty_values(cli_params) |
232 | | - if output and (writer_params := cli_params.get(output)): |
233 | | - _ = cli_params.pop(output) |
234 | | - config_parameters.update({'writer_params': writer_params}) |
235 | | - if cli_params: |
236 | | - config_parameters.update({'params': cli_params}) |
237 | | - if not config_parameters: |
238 | | - return None |
239 | | - if init_defaults: |
240 | | - return self.config.from_dict(config_parameters) |
241 | | - return self.config(**config_parameters) |
242 | | - |
243 | | - def _load_config(self, garf_config_path: str) -> BaseConfig: |
244 | | - """Loads config from provided path. |
245 | | -
|
246 | | - Args: |
247 | | - garf_config_path: Path to local or remote storage. |
248 | | -
|
249 | | - Returns: |
250 | | - Concreate config with values taken from config file. |
251 | | -
|
252 | | - Raises: |
253 | | - GarfConfigException: |
254 | | - If config file missing `garf` section. |
255 | | - """ |
256 | | - with smart_open.open(garf_config_path, encoding='utf-8') as f: |
257 | | - config = yaml.safe_load(f) |
258 | | - garf_section = config.get(self._config_type) |
259 | | - if not garf_section: |
260 | | - raise GarfConfigException( |
261 | | - f'Invalid config, must have `{self._config_type}` section!' |
262 | | - ) |
263 | | - config_parameters = { |
264 | | - k: v for k, v in garf_section.items() if k in self.config.__annotations__ |
265 | | - } |
266 | | - if params := garf_section.get('params', {}): |
267 | | - config_parameters.update({'params': params}) |
268 | | - if writer_params := garf_section.get(garf_section.get('output', '')): |
269 | | - config_parameters.update({'writer_params': writer_params}) |
270 | | - return self.config(**config_parameters) |
271 | | - |
272 | | - |
273 | 33 | class ParamsParser: |
274 | 34 | def __init__(self, identifiers: Sequence[str]) -> None: |
275 | 35 | self.identifiers = identifiers |
@@ -373,78 +133,6 @@ def convert_date(date_string: str) -> str: |
373 | 133 | return (new_date - delta).strftime('%Y-%m-%d') |
374 | 134 |
|
375 | 135 |
|
376 | | -class ConfigSaver: |
377 | | - def __init__(self, path: str) -> None: |
378 | | - self.path = path |
379 | | - |
380 | | - def save(self, garf_config: BaseConfig): |
381 | | - if os.path.exists(self.path): |
382 | | - with smart_open.open(self.path, 'r', encoding='utf-8') as f: |
383 | | - config = yaml.safe_load(f) |
384 | | - else: |
385 | | - config = {} |
386 | | - config = self.prepare_config(config, garf_config) |
387 | | - with smart_open.open(self.path, 'w', encoding='utf-8') as f: |
388 | | - yaml.dump( |
389 | | - config, f, default_flow_style=False, sort_keys=False, encoding='utf-8' |
390 | | - ) |
391 | | - |
392 | | - def prepare_config(self, config: dict, garf_config: BaseConfig) -> dict: |
393 | | - garf = dataclasses.asdict(garf_config) |
394 | | - if isinstance(garf_config, GarfConfig): |
395 | | - garf[garf_config.output] = garf_config.writer_params |
396 | | - if not isinstance(garf_config.account, MutableSequence): |
397 | | - garf['account'] = garf_config.account.split(',') |
398 | | - del garf['writer_params'] |
399 | | - garf = _remove_empty_values(garf) |
400 | | - config.update({'garf': garf}) |
401 | | - if isinstance(garf_config, GarfBqConfig): |
402 | | - garf = _remove_empty_values(garf) |
403 | | - config.update({'garf-bq': garf}) |
404 | | - if isinstance(garf_config, GarfSqlConfig): |
405 | | - garf = _remove_empty_values(garf) |
406 | | - config.update({'garf-sql': garf}) |
407 | | - return config |
408 | | - |
409 | | - |
410 | | -def initialize_runtime_parameters(config: BaseConfig) -> BaseConfig: |
411 | | - """Formats parameters and add common parameter in config. |
412 | | -
|
413 | | - Initialization identifies whether there are `date` parameters and performs |
414 | | - necessary date conversions. |
415 | | - Set of parameters that need to be generally available are injected into |
416 | | - every parameter section of the config. |
417 | | -
|
418 | | - Args: |
419 | | - config: Instantiated config. |
420 | | -
|
421 | | - Returns: |
422 | | - Config with formatted parameters. |
423 | | - """ |
424 | | - common_params = query_editor.CommonParametersMixin().common_params |
425 | | - for key, param in config.params.items(): |
426 | | - for key_param, value_param in param.items(): |
427 | | - config.params[key][key_param] = convert_date(value_param) |
428 | | - for common_param_key, common_param_value in common_params.items(): |
429 | | - if common_param_key not in config.params[key]: |
430 | | - config.params[key][common_param_key] = common_param_value |
431 | | - return config |
432 | | - |
433 | | - |
434 | | -def _remove_empty_values(dict_object: dict[str, Any]) -> dict[str, Any]: |
435 | | - """Remove all empty elements: strings, dictionaries from a dictionary.""" |
436 | | - if isinstance(dict_object, dict): |
437 | | - return { |
438 | | - key: value |
439 | | - for key, value in ( |
440 | | - (key, _remove_empty_values(value)) for key, value in dict_object.items() |
441 | | - ) |
442 | | - if value |
443 | | - } |
444 | | - if isinstance(dict_object, (int, str, MutableSequence)): |
445 | | - return dict_object |
446 | | - |
447 | | - |
448 | 136 | def init_logging( |
449 | 137 | loglevel: str = 'INFO', logger_type: str = 'local', name: str = __name__ |
450 | 138 | ) -> logging.Logger: |
|
0 commit comments