ddeutil-workflow 0.0.6__py3-none-any.whl → 0.0.8__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.
ddeutil/workflow/utils.py CHANGED
@@ -6,28 +6,40 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import inspect
9
+ import logging
9
10
  import os
10
11
  import stat
11
12
  from abc import ABC, abstractmethod
13
+ from ast import Call, Constant, Expr, Module, Name, parse
12
14
  from collections.abc import Iterator
13
- from dataclasses import dataclass, field
14
15
  from datetime import date, datetime
15
16
  from functools import wraps
16
17
  from hashlib import md5
17
18
  from importlib import import_module
19
+ from inspect import isfunction
18
20
  from itertools import product
19
21
  from pathlib import Path
20
22
  from typing import Any, Callable, Literal, Optional, Protocol, Union
21
23
  from zoneinfo import ZoneInfo
22
24
 
23
- from ddeutil.core import getdot, hasdot, lazy
24
- from ddeutil.io import PathData
25
+ from ddeutil.core import getdot, hasdot, hash_str, import_string, lazy, str2bool
26
+ from ddeutil.io import PathData, search_env_replace
25
27
  from ddeutil.io.models.lineage import dt_now
26
- from pydantic import BaseModel, Field
28
+ from pydantic import BaseModel, ConfigDict, Field
27
29
  from pydantic.functional_validators import model_validator
28
30
  from typing_extensions import Self
29
31
 
30
32
  from .__types import DictData, Matrix, Re
33
+ from .exceptions import ParamValueException, UtilException
34
+
35
+
36
+ def get_diff_sec(dt: datetime, tz: ZoneInfo | None = None) -> int:
37
+ """Return second value that come from diff of an input datetime and the
38
+ current datetime with specific timezone.
39
+ """
40
+ return round(
41
+ (dt - datetime.now(tz=(tz or ZoneInfo("UTC")))).total_seconds()
42
+ )
31
43
 
32
44
 
33
45
  class Engine(BaseModel):
@@ -35,9 +47,10 @@ class Engine(BaseModel):
35
47
 
36
48
  paths: PathData = Field(default_factory=PathData)
37
49
  registry: list[str] = Field(
38
- default_factory=lambda: [
39
- "ddeutil.workflow",
40
- ],
50
+ default_factory=lambda: ["ddeutil.workflow"], # pragma: no cover
51
+ )
52
+ registry_filter: list[str] = Field(
53
+ default_factory=lambda: ["ddeutil.workflow.utils"], # pragma: no cover
41
54
  )
42
55
 
43
56
  @model_validator(mode="before")
@@ -47,9 +60,21 @@ class Engine(BaseModel):
47
60
  """
48
61
  if (_regis := values.get("registry")) and isinstance(_regis, str):
49
62
  values["registry"] = [_regis]
63
+ if (_regis_filter := values.get("registry_filter")) and isinstance(
64
+ _regis, str
65
+ ):
66
+ values["registry_filter"] = [_regis_filter]
50
67
  return values
51
68
 
52
69
 
70
+ class CoreConf(BaseModel):
71
+ """Core Config Model"""
72
+
73
+ model_config = ConfigDict(arbitrary_types_allowed=True)
74
+
75
+ tz: ZoneInfo = Field(default_factory=lambda: ZoneInfo("UTC"))
76
+
77
+
53
78
  class ConfParams(BaseModel):
54
79
  """Params Model"""
55
80
 
@@ -57,16 +82,32 @@ class ConfParams(BaseModel):
57
82
  default_factory=Engine,
58
83
  description="A engine mapping values.",
59
84
  )
85
+ core: CoreConf = Field(
86
+ default_factory=CoreConf,
87
+ description="A core config value",
88
+ )
60
89
 
61
90
 
62
91
  def config() -> ConfParams:
63
- """Load Config data from ``workflows-conf.yaml`` file."""
92
+ """Load Config data from ``workflows-conf.yaml`` file.
93
+
94
+ Configuration Docs:
95
+ ---
96
+ :var engine.registry:
97
+ :var engine.registry_filter:
98
+ :var paths.root:
99
+ :var paths.conf:
100
+ """
64
101
  root_path: str = os.getenv("WORKFLOW_ROOT_PATH", ".")
65
102
 
66
- regis: list[str] = []
103
+ regis: list[str] = ["ddeutil.workflow"]
67
104
  if regis_env := os.getenv("WORKFLOW_CORE_REGISTRY"):
68
105
  regis = [r.strip() for r in regis_env.split(",")]
69
106
 
107
+ regis_filter: list[str] = ["ddeutil.workflow.utils"]
108
+ if regis_filter_env := os.getenv("WORKFLOW_CORE_REGISTRY_FILTER"):
109
+ regis_filter = [r.strip() for r in regis_filter_env.split(",")]
110
+
70
111
  conf_path: str = (
71
112
  f"{root_path}/{conf_env}"
72
113
  if (conf_env := os.getenv("WORKFLOW_CORE_PATH_CONF"))
@@ -76,6 +117,7 @@ def config() -> ConfParams:
76
117
  obj={
77
118
  "engine": {
78
119
  "registry": regis,
120
+ "registry_filter": regis_filter,
79
121
  "paths": {
80
122
  "root": root_path,
81
123
  "conf": conf_path,
@@ -85,19 +127,31 @@ def config() -> ConfParams:
85
127
  )
86
128
 
87
129
 
88
- def gen_id(value: Any, *, sensitive: bool = True, unique: bool = False) -> str:
130
+ def gen_id(
131
+ value: Any,
132
+ *,
133
+ sensitive: bool = True,
134
+ unique: bool = False,
135
+ ) -> str:
89
136
  """Generate running ID for able to tracking. This generate process use `md5`
90
- function.
91
-
92
- :param value:
93
- :param sensitive:
94
- :param unique:
137
+ algorithm function if ``WORKFLOW_CORE_PIPELINE_ID_SIMPLE`` set to false.
138
+ But it will cut this hashing value length to 10 it the setting value set to
139
+ true.
140
+
141
+ :param value: A value that want to add to prefix before hashing with md5.
142
+ :param sensitive: A flag that convert the value to lower case before hashing
143
+ :param unique: A flag that add timestamp at microsecond level to value
144
+ before hashing.
95
145
  :rtype: str
96
146
  """
97
147
  if not isinstance(value, str):
98
148
  value: str = str(value)
99
149
 
100
150
  tz: ZoneInfo = ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
151
+ if str2bool(os.getenv("WORKFLOW_CORE_PIPELINE_ID_SIMPLE", "true")):
152
+ return hash_str(f"{(value if sensitive else value.lower())}", n=10) + (
153
+ f"{datetime.now(tz=tz):%Y%m%d%H%M%S%f}" if unique else ""
154
+ )
101
155
  return md5(
102
156
  (
103
157
  f"{(value if sensitive else value.lower())}"
@@ -115,24 +169,24 @@ class TagFunc(Protocol):
115
169
  def __call__(self, *args, **kwargs): ...
116
170
 
117
171
 
118
- def tag(value: str, name: str | None = None):
172
+ def tag(name: str, alias: str | None = None):
119
173
  """Tag decorator function that set function attributes, ``tag`` and ``name``
120
174
  for making registries variable.
121
175
 
122
- :param: value: A tag value for make different use-case of a function.
123
- :param: name: A name that keeping in registries.
176
+ :param: name: A tag value for make different use-case of a function.
177
+ :param: alias: A alias function name that keeping in registries. If this
178
+ value does not supply, it will use original function name from __name__.
124
179
  """
125
180
 
126
- def func_internal(func: callable) -> TagFunc:
127
- func.tag = value
128
- func.name = name or func.__name__.replace("_", "-")
181
+ def func_internal(func: Callable[[...], Any]) -> TagFunc:
182
+ func.tag = name
183
+ func.name = alias or func.__name__.replace("_", "-")
129
184
 
130
185
  @wraps(func)
131
186
  def wrapped(*args, **kwargs):
187
+ # NOTE: Able to do anything before calling hook function.
132
188
  return func(*args, **kwargs)
133
189
 
134
- # TODO: pass result from a wrapped to Result model
135
- # >>> return Result.model_validate(obj=wrapped)
136
190
  return wrapped
137
191
 
138
192
  return func_internal
@@ -145,6 +199,7 @@ def make_registry(submodule: str) -> dict[str, Registry]:
145
199
  """Return registries of all functions that able to called with task.
146
200
 
147
201
  :param submodule: A module prefix that want to import registry.
202
+ :rtype: dict[str, Registry]
148
203
  """
149
204
  rs: dict[str, Registry] = {}
150
205
  for module in config().engine.registry:
@@ -185,7 +240,7 @@ class BaseParam(BaseModel, ABC):
185
240
 
186
241
  @abstractmethod
187
242
  def receive(self, value: Optional[Any] = None) -> Any:
188
- raise ValueError(
243
+ raise NotImplementedError(
189
244
  "Receive value and validate typing before return valid value."
190
245
  )
191
246
 
@@ -197,14 +252,14 @@ class DefaultParam(BaseParam):
197
252
 
198
253
  @abstractmethod
199
254
  def receive(self, value: Optional[Any] = None) -> Any:
200
- raise ValueError(
255
+ raise NotImplementedError(
201
256
  "Receive value and validate typing before return valid value."
202
257
  )
203
258
 
204
259
  @model_validator(mode="after")
205
260
  def check_default(self) -> Self:
206
261
  if not self.required and self.default is None:
207
- raise ValueError(
262
+ raise ParamValueException(
208
263
  "Default should set when this parameter does not required."
209
264
  )
210
265
  return self
@@ -218,6 +273,7 @@ class DatetimeParam(DefaultParam):
218
273
  default: datetime = Field(default_factory=dt_now)
219
274
 
220
275
  def receive(self, value: str | datetime | date | None = None) -> datetime:
276
+ """Receive value that match with datetime."""
221
277
  if value is None:
222
278
  return self.default
223
279
 
@@ -226,7 +282,7 @@ class DatetimeParam(DefaultParam):
226
282
  elif isinstance(value, date):
227
283
  return datetime(value.year, value.month, value.day)
228
284
  elif not isinstance(value, str):
229
- raise ValueError(
285
+ raise ParamValueException(
230
286
  f"Value that want to convert to datetime does not support for "
231
287
  f"type: {type(value)}"
232
288
  )
@@ -239,6 +295,7 @@ class StrParam(DefaultParam):
239
295
  type: Literal["str"] = "str"
240
296
 
241
297
  def receive(self, value: Optional[str] = None) -> str | None:
298
+ """Receive value that match with str."""
242
299
  if value is None:
243
300
  return self.default
244
301
  return str(value)
@@ -250,13 +307,14 @@ class IntParam(DefaultParam):
250
307
  type: Literal["int"] = "int"
251
308
 
252
309
  def receive(self, value: Optional[int] = None) -> int | None:
310
+ """Receive value that match with int."""
253
311
  if value is None:
254
312
  return self.default
255
313
  if not isinstance(value, int):
256
314
  try:
257
315
  return int(str(value))
258
316
  except TypeError as err:
259
- raise ValueError(
317
+ raise ParamValueException(
260
318
  f"Value that want to convert to integer does not support "
261
319
  f"for type: {type(value)}"
262
320
  ) from err
@@ -264,6 +322,8 @@ class IntParam(DefaultParam):
264
322
 
265
323
 
266
324
  class ChoiceParam(BaseParam):
325
+ """Choice parameter."""
326
+
267
327
  type: Literal["choice"] = "choice"
268
328
  options: list[str]
269
329
 
@@ -274,25 +334,72 @@ class ChoiceParam(BaseParam):
274
334
  if value is None:
275
335
  return self.options[0]
276
336
  if any(value not in self.options):
277
- raise ValueError(f"{value} does not match any value in options")
337
+ raise ParamValueException(
338
+ f"{value!r} does not match any value in choice options."
339
+ )
278
340
  return value
279
341
 
280
342
 
281
343
  Param = Union[
282
344
  ChoiceParam,
283
345
  DatetimeParam,
346
+ IntParam,
284
347
  StrParam,
285
348
  ]
286
349
 
287
350
 
288
- @dataclass
289
- class Result:
290
- """Result Dataclass object for passing parameter and receiving output from
351
+ class Context(BaseModel):
352
+ """Context Pydantic Model"""
353
+
354
+ params: dict = Field(default_factory=dict)
355
+ jobs: dict = Field(default_factory=dict)
356
+ error: dict = Field(default_factory=dict)
357
+
358
+
359
+ class Result(BaseModel):
360
+ """Result Pydantic Model for passing parameter and receiving output from
361
+ the pipeline execution.
362
+ """
363
+
364
+ # TODO: Add running ID to this result dataclass.
365
+ # ---
366
+ # parent_run_id: str
367
+ # run_id: str
368
+ #
369
+ status: int = Field(default=2)
370
+ context: DictData = Field(default_factory=dict)
371
+
372
+ def receive(self, result: Result) -> Result:
373
+ self.__dict__["status"] = result.status
374
+ self.__dict__["context"].update(result.context)
375
+ return self
376
+
377
+ def receive_jobs(self, result: Result) -> Result:
378
+ self.__dict__["status"] = result.status
379
+ if "jobs" not in self.__dict__["context"]:
380
+ self.__dict__["context"]["jobs"] = {}
381
+ self.__dict__["context"]["jobs"].update(result.context)
382
+ return self
383
+
384
+
385
+ class ReResult(BaseModel):
386
+ """Result Pydantic Model for passing parameter and receiving output from
291
387
  the pipeline execution.
292
388
  """
293
389
 
294
- status: int = field(default=2)
295
- context: DictData = field(default_factory=dict)
390
+ # TODO: Add running ID to this result dataclass.
391
+ # ---
392
+ # parent_run_id: str
393
+ # run_id: str
394
+ #
395
+ status: int = Field(default=2)
396
+ context: Context = Field(default_factory=Context)
397
+
398
+ def receive(self, result: ReResult) -> ReResult:
399
+ self.__dict__["status"] = result.status
400
+ self.__dict__["context"].__dict__["jobs"].update(result.context.jobs)
401
+ self.__dict__["context"].__dict__["error"].update(result.context.error)
402
+ return self
296
403
 
297
404
 
298
405
  def make_exec(path: str | Path):
@@ -301,11 +408,216 @@ def make_exec(path: str | Path):
301
408
  f.chmod(f.stat().st_mode | stat.S_IEXEC)
302
409
 
303
410
 
304
- def param2template(
411
+ FILTERS: dict[str, callable] = {
412
+ "abs": abs,
413
+ "str": str,
414
+ "int": int,
415
+ "upper": lambda x: x.upper(),
416
+ "lower": lambda x: x.lower(),
417
+ "rstr": [str, repr],
418
+ }
419
+
420
+
421
+ class FilterFunc(Protocol):
422
+ """Tag Function Protocol"""
423
+
424
+ name: str
425
+
426
+ def __call__(self, *args, **kwargs): ...
427
+
428
+
429
+ def custom_filter(name: str):
430
+ """Custom filter decorator function that set function attributes, ``filter``
431
+ for making filter registries variable.
432
+
433
+ :param: name: A filter name for make different use-case of a function.
434
+ """
435
+
436
+ def func_internal(func: Callable[[...], Any]) -> TagFunc:
437
+ func.filter = name
438
+
439
+ @wraps(func)
440
+ def wrapped(*args, **kwargs):
441
+ # NOTE: Able to do anything before calling custom filter function.
442
+ return func(*args, **kwargs)
443
+
444
+ return wrapped
445
+
446
+ return func_internal
447
+
448
+
449
+ FilterRegistry = Union[FilterFunc, Callable[[...], Any]]
450
+
451
+
452
+ def make_filter_registry() -> dict[str, FilterRegistry]:
453
+ """Return registries of all functions that able to called with task.
454
+
455
+ :rtype: dict[str, Registry]
456
+ """
457
+ rs: dict[str, Registry] = {}
458
+ for module in config().engine.registry_filter:
459
+ # NOTE: try to sequential import task functions
460
+ try:
461
+ importer = import_module(module)
462
+ except ModuleNotFoundError:
463
+ continue
464
+
465
+ for fstr, func in inspect.getmembers(importer, inspect.isfunction):
466
+ # NOTE: check function attribute that already set tag by
467
+ # ``utils.tag`` decorator.
468
+ if not hasattr(func, "filter"):
469
+ continue
470
+
471
+ rs[func.filter] = import_string(f"{module}.{fstr}")
472
+
473
+ rs.update(FILTERS)
474
+ return rs
475
+
476
+
477
+ def get_args_const(
478
+ expr: str,
479
+ ) -> tuple[str, list[Constant], dict[str, Constant]]:
480
+ """Get arguments and keyword-arguments from function calling string."""
481
+ try:
482
+ mod: Module = parse(expr)
483
+ except SyntaxError:
484
+ raise UtilException(
485
+ f"Post-filter: {expr} does not valid because it raise syntax error."
486
+ ) from None
487
+ body: list[Expr] = mod.body
488
+
489
+ if len(body) > 1:
490
+ raise UtilException(
491
+ "Post-filter function should be only one calling per pipe"
492
+ )
493
+
494
+ caller: Union[Name, Call]
495
+ if isinstance((caller := body[0].value), Name):
496
+ return caller.id, [], {}
497
+ elif not isinstance(caller, Call):
498
+ raise UtilException(
499
+ f"Get arguments does not support for caller type: {type(caller)}"
500
+ )
501
+
502
+ name: Name = caller.func
503
+ args: list[Constant] = caller.args
504
+ keywords: dict[str, Constant] = {k.arg: k.value for k in caller.keywords}
505
+
506
+ if any(not isinstance(i, Constant) for i in args):
507
+ raise UtilException("Argument should be constant.")
508
+
509
+ return name.id, args, keywords
510
+
511
+
512
+ @custom_filter("fmt")
513
+ def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
514
+ return value.strftime(fmt)
515
+
516
+
517
+ def map_post_filter(
305
518
  value: Any,
306
- params: dict[str, Any],
519
+ post_filter: list[str],
520
+ filters: dict[str, FilterRegistry],
521
+ ) -> Any:
522
+ """Mapping post-filter to value with sequence list of filter function name
523
+ that will get from the filter registry.
524
+
525
+ :param value: A string value that want to mapped with filter function.
526
+ :param post_filter: A list of post-filter function name.
527
+ :param filters: A filter registry.
528
+ """
529
+ for _filter in post_filter:
530
+ func_name, _args, _kwargs = get_args_const(_filter)
531
+ args = [arg.value for arg in _args]
532
+ kwargs = {k: v.value for k, v in _kwargs.items()}
533
+
534
+ if func_name not in filters:
535
+ raise UtilException(
536
+ f"The post-filter: {func_name} does not support yet."
537
+ )
538
+
539
+ try:
540
+ if isinstance((f_func := filters[func_name]), list):
541
+ if args or kwargs:
542
+ raise UtilException(
543
+ "Chain filter function does not support for passing "
544
+ "arguments."
545
+ )
546
+ for func in f_func:
547
+ value: Any = func(value)
548
+ else:
549
+ value: Any = f_func(value, *args, **kwargs)
550
+ except Exception as err:
551
+ logging.warning(str(err))
552
+ raise UtilException(
553
+ f"The post-filter function: {func_name} does not fit with "
554
+ f"{value} (type: {type(value).__name__})."
555
+ ) from None
556
+ return value
557
+
558
+
559
+ def str2template(
560
+ value: str,
561
+ params: DictData,
307
562
  *,
308
- repr_flag: bool = False,
563
+ filters: dict[str, FilterRegistry] | None = None,
564
+ ) -> Any:
565
+ """(Sub-function) Pass param to template string that can search by
566
+ ``RE_CALLER`` regular expression.
567
+
568
+ The getter value that map a template should have typing support align
569
+ with the pipeline parameter types that is `str`, `int`, `datetime`, and
570
+ `list`.
571
+
572
+ :param value: A string value that want to mapped with an params
573
+ :param params: A parameter value that getting with matched regular
574
+ expression.
575
+ :param filters:
576
+ """
577
+ filters: dict[str, FilterRegistry] = filters or make_filter_registry()
578
+
579
+ # NOTE: remove space before and after this string value.
580
+ value: str = value.strip()
581
+ for found in Re.RE_CALLER.finditer(value):
582
+ # NOTE:
583
+ # Get caller and filter values that setting inside;
584
+ #
585
+ # ... ``${{ <caller-value> [ | <filter-value>] ... }}``
586
+ #
587
+ caller: str = found.group("caller")
588
+ pfilter: list[str] = [
589
+ i.strip()
590
+ for i in (
591
+ found.group("post_filters").strip().removeprefix("|").split("|")
592
+ )
593
+ if i != ""
594
+ ]
595
+ if not hasdot(caller, params):
596
+ raise UtilException(f"The params does not set caller: {caller!r}.")
597
+
598
+ # NOTE: from validate step, it guarantee that caller exists in params.
599
+ getter: Any = getdot(caller, params)
600
+
601
+ # NOTE:
602
+ # If type of getter caller is not string type and it does not use to
603
+ # concat other string value, it will return origin value from the
604
+ # ``getdot`` function.
605
+ if value.replace(found.group(0), "", 1) == "":
606
+ return map_post_filter(getter, pfilter, filters=filters)
607
+
608
+ # NOTE: map post-filter function.
609
+ getter: Any = map_post_filter(getter, pfilter, filters=filters)
610
+ if not isinstance(getter, str):
611
+ getter: str = str(getter)
612
+
613
+ value: str = value.replace(found.group(0), getter, 1)
614
+
615
+ return search_env_replace(value)
616
+
617
+
618
+ def param2template(
619
+ value: Any,
620
+ params: DictData,
309
621
  ) -> Any:
310
622
  """Pass param to template string that can search by ``RE_CALLER`` regular
311
623
  expression.
@@ -313,46 +625,36 @@ def param2template(
313
625
  :param value: A value that want to mapped with an params
314
626
  :param params: A parameter value that getting with matched regular
315
627
  expression.
316
- :param repr_flag: A repr flag for using repr instead of str if it set be
317
- true.
318
628
 
319
629
  :rtype: Any
320
630
  :returns: An any getter value from the params input.
321
631
  """
632
+ filters: dict[str, FilterRegistry] = make_filter_registry()
322
633
  if isinstance(value, dict):
323
634
  return {k: param2template(value[k], params) for k in value}
324
635
  elif isinstance(value, (list, tuple, set)):
325
636
  return type(value)([param2template(i, params) for i in value])
326
637
  elif not isinstance(value, str):
327
638
  return value
639
+ return str2template(value, params, filters=filters)
328
640
 
329
- if not Re.RE_CALLER.search(value):
330
- return value
331
-
332
- for found in Re.RE_CALLER.finditer(value):
333
-
334
- # NOTE: get caller value that setting inside; ``${{ <caller-value> }}``
335
- caller: str = found.group("caller")
336
- if not hasdot(caller, params):
337
- raise ValueError(f"params does not set caller: {caller!r}")
338
-
339
- getter: Any = getdot(caller, params)
340
641
 
341
- # NOTE: check type of vars
342
- if isinstance(getter, (str, int)):
343
- value: str = value.replace(
344
- found.group(0), (repr(getter) if repr_flag else str(getter)), 1
345
- )
346
- continue
347
-
348
- # NOTE:
349
- # If type of getter caller does not formatting, it will return origin
350
- # value from the ``getdot`` function.
351
- if value.replace(found.group(0), "", 1) != "":
352
- raise ValueError(
353
- "Callable variable should not pass other outside ${{ ... }}"
354
- )
355
- return getter
642
+ def filter_func(value: Any):
643
+ """Filter own created function out of any value with replace it to its
644
+ function name. If it is built-in function, it does not have any changing.
645
+ """
646
+ if isinstance(value, dict):
647
+ return {k: filter_func(value[k]) for k in value}
648
+ elif isinstance(value, (list, tuple, set)):
649
+ return type(value)([filter_func(i) for i in value])
650
+
651
+ if isfunction(value):
652
+ # NOTE: If it want to improve to get this function, it able to save to
653
+ # some global memory storage.
654
+ # ---
655
+ # >>> GLOBAL_DICT[value.__name__] = value
656
+ #
657
+ return value.__name__
356
658
  return value
357
659
 
358
660
 
@@ -368,7 +670,7 @@ def dash2underscore(
368
670
  return values
369
671
 
370
672
 
371
- def cross_product(matrix: Matrix) -> Iterator:
673
+ def cross_product(matrix: Matrix) -> Iterator[DictData]:
372
674
  """Iterator of products value from matrix."""
373
675
  yield from (
374
676
  {_k: _v for e in mapped for _k, _v in e.items()}