vortex-nwp 2.0.0__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.
Files changed (144) hide show
  1. vortex/__init__.py +159 -0
  2. vortex/algo/__init__.py +13 -0
  3. vortex/algo/components.py +2462 -0
  4. vortex/algo/mpitools.py +1953 -0
  5. vortex/algo/mpitools_templates/__init__.py +1 -0
  6. vortex/algo/mpitools_templates/envelope_wrapper_default.tpl +27 -0
  7. vortex/algo/mpitools_templates/envelope_wrapper_mpiauto.tpl +29 -0
  8. vortex/algo/mpitools_templates/wrapstd_wrapper_default.tpl +18 -0
  9. vortex/algo/serversynctools.py +171 -0
  10. vortex/config.py +112 -0
  11. vortex/data/__init__.py +19 -0
  12. vortex/data/abstractstores.py +1510 -0
  13. vortex/data/containers.py +835 -0
  14. vortex/data/contents.py +622 -0
  15. vortex/data/executables.py +275 -0
  16. vortex/data/flow.py +119 -0
  17. vortex/data/geometries.ini +2689 -0
  18. vortex/data/geometries.py +799 -0
  19. vortex/data/handlers.py +1230 -0
  20. vortex/data/outflow.py +67 -0
  21. vortex/data/providers.py +487 -0
  22. vortex/data/resources.py +207 -0
  23. vortex/data/stores.py +1390 -0
  24. vortex/data/sync_templates/__init__.py +0 -0
  25. vortex/gloves.py +309 -0
  26. vortex/layout/__init__.py +20 -0
  27. vortex/layout/contexts.py +577 -0
  28. vortex/layout/dataflow.py +1220 -0
  29. vortex/layout/monitor.py +969 -0
  30. vortex/nwp/__init__.py +14 -0
  31. vortex/nwp/algo/__init__.py +21 -0
  32. vortex/nwp/algo/assim.py +537 -0
  33. vortex/nwp/algo/clim.py +1086 -0
  34. vortex/nwp/algo/coupling.py +831 -0
  35. vortex/nwp/algo/eda.py +840 -0
  36. vortex/nwp/algo/eps.py +785 -0
  37. vortex/nwp/algo/forecasts.py +886 -0
  38. vortex/nwp/algo/fpserver.py +1303 -0
  39. vortex/nwp/algo/ifsnaming.py +463 -0
  40. vortex/nwp/algo/ifsroot.py +404 -0
  41. vortex/nwp/algo/monitoring.py +263 -0
  42. vortex/nwp/algo/mpitools.py +694 -0
  43. vortex/nwp/algo/odbtools.py +1258 -0
  44. vortex/nwp/algo/oopsroot.py +916 -0
  45. vortex/nwp/algo/oopstests.py +220 -0
  46. vortex/nwp/algo/request.py +660 -0
  47. vortex/nwp/algo/stdpost.py +1641 -0
  48. vortex/nwp/data/__init__.py +30 -0
  49. vortex/nwp/data/assim.py +380 -0
  50. vortex/nwp/data/boundaries.py +314 -0
  51. vortex/nwp/data/climfiles.py +521 -0
  52. vortex/nwp/data/configfiles.py +153 -0
  53. vortex/nwp/data/consts.py +954 -0
  54. vortex/nwp/data/ctpini.py +149 -0
  55. vortex/nwp/data/diagnostics.py +209 -0
  56. vortex/nwp/data/eda.py +147 -0
  57. vortex/nwp/data/eps.py +432 -0
  58. vortex/nwp/data/executables.py +1045 -0
  59. vortex/nwp/data/fields.py +111 -0
  60. vortex/nwp/data/gridfiles.py +380 -0
  61. vortex/nwp/data/logs.py +584 -0
  62. vortex/nwp/data/modelstates.py +363 -0
  63. vortex/nwp/data/monitoring.py +193 -0
  64. vortex/nwp/data/namelists.py +696 -0
  65. vortex/nwp/data/obs.py +840 -0
  66. vortex/nwp/data/oopsexec.py +74 -0
  67. vortex/nwp/data/providers.py +207 -0
  68. vortex/nwp/data/query.py +206 -0
  69. vortex/nwp/data/stores.py +160 -0
  70. vortex/nwp/data/surfex.py +337 -0
  71. vortex/nwp/syntax/__init__.py +9 -0
  72. vortex/nwp/syntax/stdattrs.py +437 -0
  73. vortex/nwp/tools/__init__.py +10 -0
  74. vortex/nwp/tools/addons.py +40 -0
  75. vortex/nwp/tools/agt.py +67 -0
  76. vortex/nwp/tools/bdap.py +59 -0
  77. vortex/nwp/tools/bdcp.py +41 -0
  78. vortex/nwp/tools/bdm.py +24 -0
  79. vortex/nwp/tools/bdmp.py +54 -0
  80. vortex/nwp/tools/conftools.py +1661 -0
  81. vortex/nwp/tools/drhook.py +66 -0
  82. vortex/nwp/tools/grib.py +294 -0
  83. vortex/nwp/tools/gribdiff.py +104 -0
  84. vortex/nwp/tools/ifstools.py +203 -0
  85. vortex/nwp/tools/igastuff.py +273 -0
  86. vortex/nwp/tools/mars.py +68 -0
  87. vortex/nwp/tools/odb.py +657 -0
  88. vortex/nwp/tools/partitioning.py +258 -0
  89. vortex/nwp/tools/satrad.py +71 -0
  90. vortex/nwp/util/__init__.py +6 -0
  91. vortex/nwp/util/async.py +212 -0
  92. vortex/nwp/util/beacon.py +40 -0
  93. vortex/nwp/util/diffpygram.py +447 -0
  94. vortex/nwp/util/ens.py +279 -0
  95. vortex/nwp/util/hooks.py +139 -0
  96. vortex/nwp/util/taskdeco.py +85 -0
  97. vortex/nwp/util/usepygram.py +697 -0
  98. vortex/nwp/util/usetnt.py +101 -0
  99. vortex/proxy.py +6 -0
  100. vortex/sessions.py +374 -0
  101. vortex/syntax/__init__.py +9 -0
  102. vortex/syntax/stdattrs.py +867 -0
  103. vortex/syntax/stddeco.py +185 -0
  104. vortex/toolbox.py +1117 -0
  105. vortex/tools/__init__.py +20 -0
  106. vortex/tools/actions.py +523 -0
  107. vortex/tools/addons.py +316 -0
  108. vortex/tools/arm.py +96 -0
  109. vortex/tools/compression.py +325 -0
  110. vortex/tools/date.py +27 -0
  111. vortex/tools/ddhpack.py +10 -0
  112. vortex/tools/delayedactions.py +782 -0
  113. vortex/tools/env.py +541 -0
  114. vortex/tools/folder.py +834 -0
  115. vortex/tools/grib.py +738 -0
  116. vortex/tools/lfi.py +953 -0
  117. vortex/tools/listings.py +423 -0
  118. vortex/tools/names.py +637 -0
  119. vortex/tools/net.py +2124 -0
  120. vortex/tools/odb.py +10 -0
  121. vortex/tools/parallelism.py +368 -0
  122. vortex/tools/prestaging.py +210 -0
  123. vortex/tools/rawfiles.py +10 -0
  124. vortex/tools/schedulers.py +480 -0
  125. vortex/tools/services.py +940 -0
  126. vortex/tools/storage.py +996 -0
  127. vortex/tools/surfex.py +61 -0
  128. vortex/tools/systems.py +3976 -0
  129. vortex/tools/targets.py +440 -0
  130. vortex/util/__init__.py +9 -0
  131. vortex/util/config.py +1122 -0
  132. vortex/util/empty.py +24 -0
  133. vortex/util/helpers.py +216 -0
  134. vortex/util/introspection.py +69 -0
  135. vortex/util/iosponge.py +80 -0
  136. vortex/util/roles.py +49 -0
  137. vortex/util/storefunctions.py +129 -0
  138. vortex/util/structs.py +26 -0
  139. vortex/util/worker.py +162 -0
  140. vortex_nwp-2.0.0.dist-info/METADATA +67 -0
  141. vortex_nwp-2.0.0.dist-info/RECORD +144 -0
  142. vortex_nwp-2.0.0.dist-info/WHEEL +5 -0
  143. vortex_nwp-2.0.0.dist-info/licenses/LICENSE +517 -0
  144. vortex_nwp-2.0.0.dist-info/top_level.txt +1 -0
vortex/util/config.py ADDED
@@ -0,0 +1,1122 @@
1
+ """
2
+ Configuration management through ini and template files.
3
+
4
+ The :func:`load_template` function is the entry-point when looking for template files.
5
+ It returns an object compliant with the interface defined in
6
+ :class:`AbstractTemplatingAdapter`.
7
+ """
8
+
9
+ import abc
10
+ from configparser import NoOptionError, NoSectionError, InterpolationDepthError
11
+ from configparser import ConfigParser
12
+ import itertools
13
+ from pathlib import Path
14
+ import re
15
+ import string
16
+
17
+ import footprints
18
+ from bronx.fancies import loggers
19
+ from bronx.stdtypes import date as bdate
20
+ from bronx.syntax.parsing import StringDecoder, StringDecoderSyntaxError
21
+ from vortex import sessions
22
+
23
+ __all__ = []
24
+
25
+ logger = loggers.getLogger(__name__)
26
+
27
+ _RE_AUTO_TPL = re.compile(r"^@(([^/].*)\.tpl)$")
28
+
29
+ _RE_ENCODING = re.compile(r"^\s*#.*?coding[=:]\s*([-\w.]+)")
30
+
31
+ _RE_TEMPLATING = re.compile(r"^\s*#\s*vortex-templating\s*[=:]\s*([-\w.]+)$")
32
+
33
+ _DEFAULT_CONFIG_PARSER = ConfigParser
34
+
35
+
36
+ class AbstractTemplatingAdapter(metaclass=abc.ABCMeta):
37
+ """Interface to any templating system.
38
+
39
+ To render the template, just call the object with a list of named arguments
40
+ that should be used during template rendering.
41
+ """
42
+
43
+ def __init__(self, tpl_str, tpl_file, tpl_encoding):
44
+ """
45
+ :param tpl_str: The template (as a string)
46
+ :param tpl_file: The template filename (path object)
47
+ :param tpl_encoding: The template encoding (when read from disk)
48
+ """
49
+ self._tpl_file = tpl_file
50
+ self._tpl_encoding = tpl_encoding
51
+ self._tpl_obj = self._rendering_tool_init(tpl_str)
52
+
53
+ @property
54
+ def srcfile(self):
55
+ """The template filename (when read from disk)."""
56
+ return str(self._tpl_file)
57
+
58
+ @abc.abstractmethod
59
+ def _rendering_tool_init(self, tpl_str):
60
+ pass
61
+
62
+ def substitute(self, *kargs, **kwargs):
63
+ """Render the template using the kargs and kwargs dictionaries."""
64
+ todo = dict()
65
+ for m in kargs:
66
+ todo.update(m)
67
+ todo.update(kwargs)
68
+ return self(**todo)
69
+
70
+ safe_substitute = substitute
71
+
72
+ @abc.abstractmethod
73
+ def __call__(self, **kwargs):
74
+ """Render the template using the kwargs dictionary."""
75
+ pass
76
+
77
+
78
+ class LegacyTemplatingAdapter(AbstractTemplatingAdapter):
79
+ """Just use :class:`string.Template` for the rendering.
80
+
81
+ See :class:`AbstractTemplatingAdapter` for more details on this class usage.
82
+ """
83
+
84
+ def _rendering_tool_init(self, tpl_str):
85
+ return string.Template(tpl_str)
86
+
87
+ def safe_substitute(self, *kargs, **kwargs):
88
+ """Render the template using the kargs and kwargs dictionaries."""
89
+ return self._tpl_obj.safe_substitute(*kargs, **kwargs)
90
+
91
+ def __call__(self, **kwargs):
92
+ """Render the template using the kwargs dictionary."""
93
+ return self._tpl_obj.substitute(kwargs)
94
+
95
+
96
+ class TwoPassLegacyTemplatingAdapter(AbstractTemplatingAdapter):
97
+ """Just use :class:`string.Template`, but render the template two times.
98
+
99
+ (it allows for two level of nesting in the variable to be rendered).
100
+
101
+ See :class:`AbstractTemplatingAdapter` for more details on this class usage.
102
+ """
103
+
104
+ def _rendering_tool_init(self, tpl_str):
105
+ return string.Template(tpl_str)
106
+
107
+ def safe_substitute(self, *kargs, **kwargs):
108
+ """Render the template using the kargs and kwargs dictionaries."""
109
+ return string.Template(
110
+ self._tpl_obj.safe_substitute(*kargs, **kwargs)
111
+ ).safe_substitute(*kargs, **kwargs)
112
+
113
+ def __call__(self, **kwargs):
114
+ """Render the template using the kwargs dictionary."""
115
+ return string.Template(self._tpl_obj.substitute(kwargs)).substitute(
116
+ kwargs
117
+ )
118
+
119
+
120
+ _TEMPLATE_RENDERING_CLASSES = {
121
+ "legacy": LegacyTemplatingAdapter,
122
+ "twopasslegacy": TwoPassLegacyTemplatingAdapter,
123
+ }
124
+
125
+
126
+ def register_template_renderer(key, cls):
127
+ _TEMPLATE_RENDERING_CLASSES[key] = cls
128
+
129
+
130
+ def load_template(tplpath, encoding=None, default_templating="legacy"):
131
+ """Load a template according to *tplfile*.
132
+
133
+ :param str tplpath: path-like object for the template file
134
+ :param str encoding: The characters encoding of the template file
135
+ :param int version: Find a template file with version >= to version
136
+ :param str default_templating: The default templating engine that will
137
+ be used. The content of the template file is always searched in
138
+ order to detect a "# vortex-templating:" comment that will overrid
139
+ this default.
140
+ :return: A :class:`AbstractTemplatingAdapter` object
141
+
142
+ The characters encoding of the template file may be specified. If
143
+ *encoding* equals ``script``, a line looking like ``#
144
+ encoding:special-encoding`` will be searched for in the first ten
145
+ lines of the template file. If it exists, the ``special-encoding``
146
+ will be used as an encoding and the ``#
147
+ encoding:special-encoding`` line will be stripped from the
148
+ template.
149
+
150
+ Different templating engine may be used to render the template
151
+ file. It defaults to ``legacy`` that is compatible with Python's
152
+ :class:`string.Template` class. However, another default may be
153
+ provided using the *default_templating* argument. In any case, a
154
+ line looking like ``# vortex-templating:kind`` will be searched
155
+ for in the first ten lines of the template file. If it exists, the
156
+ ``kind`` templating engine will be used and the ``#
157
+ vortex-templating:kind`` line will be stripped.
158
+
159
+ Currently, only few templating engines are supported:
160
+
161
+ * ``legacy``: see :class:`LegacyTemplatingAdapter`
162
+ * ``twopasslegacy``: see :class:`TwoPassLegacyTemplatingAdapter`
163
+ * ``jinja2``: see :class:`Jinja2TemplatingAdapter`
164
+ """
165
+ tplpath = Path(tplpath).absolute()
166
+ if not tplpath.exists():
167
+ msg = f"Template file {tplpath} not found"
168
+ raise FileNotFoundError(msg)
169
+ ignored_lines = set()
170
+ actual_encoding = None if encoding == "script" else encoding
171
+ actual_templating = default_templating
172
+ # To determine the encoding & templating open the file with the default
173
+ # encoding (ignoring decoding errors) and look for comments
174
+ with open(tplpath, errors="replace") as tpfld_tmp:
175
+ if encoding is None:
176
+ actual_encoding = tpfld_tmp.encoding
177
+ # Only inspect the first 10 lines
178
+ for iline, line in enumerate(itertools.islice(tpfld_tmp, 10)):
179
+ # Encoding
180
+ if encoding == "script":
181
+ encoding_match = _RE_ENCODING.match(line)
182
+ if encoding_match:
183
+ ignored_lines.add(iline)
184
+ actual_encoding = encoding_match.group(1)
185
+ # Templating
186
+ templating_match = _RE_TEMPLATING.match(line)
187
+ if templating_match:
188
+ ignored_lines.add(iline)
189
+ actual_templating = templating_match.group(1)
190
+ # Read the template and delete the encoding line if present
191
+ logger.debug("Opening %s with encoding %s", tplpath, str(actual_encoding))
192
+ with open(tplpath, encoding=actual_encoding) as tpfld:
193
+ tpl_txt = "".join(
194
+ [l for (i, l) in enumerate(tpfld) if i not in ignored_lines]
195
+ )
196
+
197
+ try:
198
+ template_rendering_cls = _TEMPLATE_RENDERING_CLASSES[actual_templating]
199
+ except KeyError:
200
+ msg = (
201
+ f"Unknown templating systes < {actual_templating} >"
202
+ f"when trying to load template {tplpath}"
203
+ )
204
+ logger.error(msg)
205
+
206
+ return template_rendering_cls(
207
+ tpl_txt,
208
+ tplpath,
209
+ actual_encoding,
210
+ )
211
+
212
+
213
+ class GenericReadOnlyConfigParser:
214
+ """A Basic ReadOnly configuration file parser.
215
+
216
+ It relies on a :class:`ConfigParser.ConfigParser` parser (or another class
217
+ that satisfies the interface) to access the configuration data.
218
+
219
+ :param str inifile: Path to a configuration file or a configuration file name
220
+ (see the :meth:`setfile` method for more details)
221
+ :param ConfigParser.ConfigParser parser: an existing configuration parser
222
+ object the will be used to access the configuration
223
+ :param bool mkforce: If the configuration file doesn't exists. Create an empty
224
+ one in ``~/.vortexrc``
225
+ :param type clsparser: The class that will be used to create a parser object
226
+ (if needed)
227
+ :param str encoding: The configuration file encoding
228
+ :param str defaultinifile: The name of a default ini file (read before,
229
+ and possibly overwritten by **inifile**)
230
+
231
+ :note: Some of the parser's methods are directly accessible because ``__getattr__``
232
+ is implemented. For this ReadOnly class, only methods ``defaults``,
233
+ ``sections``, ``options``, ``items``, ``has_section`` and ``has_option``
234
+ are accessible. The user will refer to the Python's ConfigParser module
235
+ documentation for more details.
236
+ """
237
+
238
+ _RE_AUTO_SETFILE = re.compile(r"^@([^/]+\.ini)$")
239
+
240
+ def __init__(
241
+ self,
242
+ inifile=None,
243
+ parser=None,
244
+ mkforce=False,
245
+ clsparser=_DEFAULT_CONFIG_PARSER,
246
+ encoding=None,
247
+ defaultinifile=None,
248
+ ):
249
+ self.parser = parser
250
+ self.mkforce = mkforce
251
+ self.clsparser = clsparser
252
+ self.defaultencoding = encoding
253
+ self.defaultinifile = defaultinifile
254
+ if inifile:
255
+ self.setfile(inifile, encoding=None)
256
+ else:
257
+ self.file = None
258
+
259
+ def __deepcopy__(self, memo):
260
+ """Warning: deepcopy of any item of the class is... itself!"""
261
+ memo[id(self)] = self
262
+ return self
263
+
264
+ def as_dump(self):
265
+ """Return a nicely formated class name for dump in footprint."""
266
+ return "file={!s}".format(self.file)
267
+
268
+ def setfile(self, inifile, encoding=None):
269
+ """Read the specified **inifile** as new configuration.
270
+
271
+ **inifile** may be:
272
+
273
+ * A File like object
274
+ * A path to a file
275
+ * A file name preceded by '@'
276
+
277
+ In the latter case, the configuration file is looked for both in
278
+ ``~/.vortexrc`` and in the ``conf`` directory of the vortex installation.
279
+ If a section/option is defined in ``~/.vortexrc`` it takes precedence
280
+ over the one defined in ``conf``.
281
+
282
+ :example:
283
+
284
+ Let's consider the following declaration in ``conf``::
285
+
286
+ [mysection]
287
+ var1=Toto
288
+ var2=Titi
289
+
290
+ Let's consider the following declaration in ``~/.vortexrc``::
291
+
292
+ [mysection]
293
+ var1=Personalised
294
+
295
+ A call to ``get('mysection', 'var1')`` will return ``Personalised`` and a
296
+ call to ``get('mysection', 'var2')`` will return ``Titi``.
297
+ """
298
+ if self.parser is None:
299
+ self.parser = self.clsparser()
300
+ if encoding is None:
301
+ encoding = self.defaultencoding
302
+ self.file = None
303
+ filestack = list()
304
+ local = sessions.system()
305
+ glove = sessions.current().glove
306
+ if not isinstance(inifile, str):
307
+ if self.defaultinifile:
308
+ sitedefaultinifile = glove.siteconf + "/" + self.defaultinifile
309
+ if local.path.exists(sitedefaultinifile):
310
+ with open(sitedefaultinifile, encoding=encoding) as a_fh:
311
+ self.parser.read_file(a_fh)
312
+ else:
313
+ raise ValueError(
314
+ "Configuration file "
315
+ + sitedefaultinifile
316
+ + " not found"
317
+ )
318
+ # Assume it's an IO descriptor
319
+ inifile.seek(0)
320
+ self.parser.read_file(inifile)
321
+ self.file = repr(inifile)
322
+ if self.defaultinifile:
323
+ self.file = sitedefaultinifile + "," + self.file
324
+ else:
325
+ # Let's continue as usual
326
+ autofile = self._RE_AUTO_SETFILE.match(inifile)
327
+ if not autofile:
328
+ if local.path.exists(inifile):
329
+ filestack.append(local.path.abspath(inifile))
330
+ else:
331
+ raise ValueError(
332
+ "Configuration file " + inifile + " not found"
333
+ )
334
+ else:
335
+ autofile = autofile.group(1)
336
+ sitefile = glove.siteconf + "/" + autofile
337
+ persofile = glove.configrc + "/" + autofile
338
+ if local.path.exists(sitefile):
339
+ filestack.append(sitefile)
340
+ if local.path.exists(persofile):
341
+ filestack.append(persofile)
342
+ if not filestack:
343
+ if self.mkforce:
344
+ filestack.append(persofile)
345
+ local.filecocoon(persofile)
346
+ local.touch(persofile)
347
+ else:
348
+ raise ValueError(
349
+ "Configuration file " + inifile + " not found"
350
+ )
351
+ if self.defaultinifile:
352
+ sitedefaultinifile = glove.siteconf + "/" + self.defaultinifile
353
+ if local.path.exists(sitedefaultinifile):
354
+ # Insert at the beginning (i.e. smallest priority)
355
+ filestack.insert(0, local.path.abspath(sitedefaultinifile))
356
+ else:
357
+ raise ValueError(
358
+ "Configuration file "
359
+ + sitedefaultinifile
360
+ + " not found"
361
+ )
362
+ self.file = ",".join(filestack)
363
+ for a_file in filestack:
364
+ with open(a_file, encoding=encoding) as a_fh:
365
+ self.parser.read_file(a_fh)
366
+
367
+ def as_dict(self, merged=True):
368
+ """Export the configuration file as a dictionary."""
369
+ if merged:
370
+ dico = dict()
371
+ else:
372
+ dico = dict(defaults=dict(self.defaults()))
373
+ for section in self.sections():
374
+ if merged:
375
+ dico[section] = dict(self.items(section))
376
+ else:
377
+ dico[section] = {
378
+ k: v
379
+ for k, v in self.items(section)
380
+ if k in self.parser._sections[section]
381
+ }
382
+ return dico
383
+
384
+ def __getattr__(self, attr):
385
+ # Give access to a very limited set of methods
386
+ if attr.startswith("get") or attr in (
387
+ "defaults",
388
+ "sections",
389
+ "options",
390
+ "items",
391
+ "has_section",
392
+ "has_option",
393
+ ):
394
+ return getattr(self.parser, attr)
395
+ else:
396
+ raise AttributeError(
397
+ self.__class__.__name__
398
+ + " instance has no attribute '"
399
+ + str(attr)
400
+ + "'"
401
+ )
402
+
403
+ def footprint_export(self):
404
+ return self.file
405
+
406
+
407
+ class ExtendedReadOnlyConfigParser(GenericReadOnlyConfigParser):
408
+ """A ReadOnly configuration file parser with a nice inheritance feature.
409
+
410
+ Using this readonly configuration parser, a section can inherit from one or
411
+ several other sections. The basic interpolation (with the usual ``%(varname)s``
412
+ syntax) is available.
413
+
414
+ It relies on a :class:`ConfigParser.ConfigParser` parser (or another class
415
+ that satisfies the interface) to access the configuration data.
416
+
417
+ :param str inifile: Path to a configuration file or a configuration file name
418
+ :param ConfigParser.ConfigParser parser: an existing configuration parser
419
+ object the will be used to access the configuration
420
+ :param bool mkforce: If the configuration file doesn't exists. Create an empty
421
+ one in ``~/.vortexrc``
422
+ :param type clsparser: The class that will be used to create a parser object
423
+ (if needed)
424
+
425
+ :example: Here is an example using the inheritance mechanism. Let's consider
426
+ the following section declaration::
427
+
428
+ [newsection:base1:base2]
429
+ var1=...
430
+
431
+ ``newsection`` will inherit the variables contained in sections ``base1``
432
+ and ``base2``. In case of a conflict, ``base1`` takes precedence over ``base2``.
433
+ """
434
+
435
+ _RE_VALIDATE = re.compile(r"([\w-]+)[ \t]*:?")
436
+ _RE_KEYC = re.compile(r"%\(([^)]+)\)s")
437
+
438
+ _max_interpolation_depth = 20
439
+
440
+ def _get_section_list(self, zend_section):
441
+ """
442
+ Return the stack of sections that will be used to look for a given
443
+ variable. Somehow, it is close to python's MRO.
444
+ """
445
+ found_sections = []
446
+ if self.parser.has_section(zend_section):
447
+ found_sections.append(zend_section)
448
+ for section in self.parser.sections():
449
+ pieces = re.split(r"[ \t]*:[ \t]*", section)
450
+ if len(pieces) >= 2 and pieces[0] == zend_section:
451
+ found_sections.append(section)
452
+ for inherited in pieces[1:]:
453
+ found_sections.extend(self._get_section_list(inherited))
454
+ break
455
+ return found_sections
456
+
457
+ def _interpolate(self, section, rawval):
458
+ """Performs the basic interpolation."""
459
+ value = rawval
460
+ depth = self._max_interpolation_depth
461
+
462
+ def _interpolation_replace(match):
463
+ s = match.group(1)
464
+ return self.get(section, self.parser.optionxform(s), raw=False)
465
+
466
+ while depth: # Loop through this until it's done
467
+ depth -= 1
468
+ if value and self._RE_KEYC.match(value):
469
+ value = self._RE_KEYC.sub(_interpolation_replace, value)
470
+ else:
471
+ break
472
+ if value and self._RE_KEYC.match(value):
473
+ raise InterpolationDepthError(
474
+ self.options(section), section, rawval
475
+ )
476
+ return value
477
+
478
+ def get(self, section, option, raw=False, myvars=None):
479
+ """Behaves like the GenericConfigParser's ``get`` method."""
480
+ expanded = [
481
+ s for s in self._get_section_list(section) if s is not None
482
+ ]
483
+ if not expanded:
484
+ raise NoSectionError(section)
485
+ expanded.reverse()
486
+ acc_result = None
487
+ acc_except = None
488
+ mydefault = self.defaults().get(option, None)
489
+ for isection in expanded:
490
+ try:
491
+ tmp_result = self.parser.get(
492
+ isection, option, raw=True, vars=myvars
493
+ )
494
+ if tmp_result is not mydefault:
495
+ acc_result = tmp_result
496
+ except NoOptionError as err:
497
+ acc_except = err
498
+ if acc_result is None and mydefault is not None:
499
+ acc_result = mydefault
500
+ if acc_result is not None:
501
+ if not raw:
502
+ acc_result = self._interpolate(section, acc_result)
503
+ return acc_result
504
+ else:
505
+ raise acc_except
506
+
507
+ def sections(self):
508
+ """Behaves like the Python ConfigParser's ``section`` method."""
509
+ seen = set()
510
+ for section_m in [
511
+ self._RE_VALIDATE.match(s) for s in self.parser.sections()
512
+ ]:
513
+ if section_m is not None:
514
+ seen.add(section_m.group(1))
515
+ return list(seen)
516
+
517
+ def has_section(self, section):
518
+ """Return whether a section exists or not."""
519
+ return section in self.sections()
520
+
521
+ def options(self, section):
522
+ """Behaves like the Python ConfigParser's ``options`` method."""
523
+ expanded = self._get_section_list(section)
524
+ if not expanded:
525
+ return self.parser.options(
526
+ section
527
+ ) # A realistic exception will be thrown !
528
+ options = set()
529
+ for isection in [s for s in expanded]:
530
+ options.update(set(self.parser.options(isection)))
531
+ return list(options)
532
+
533
+ def has_option(self, section, option):
534
+ """Return whether an option exists or not."""
535
+ return option in self.options(section)
536
+
537
+ def items(self, section, raw=False, myvars=None):
538
+ """Behaves like the Python ConfigParser's ``items`` method."""
539
+ return [
540
+ (o, self.get(section, o, raw, myvars))
541
+ for o in self.options(section)
542
+ ]
543
+
544
+ def __getattr__(self, attr):
545
+ # Give access to a very limited set of methods
546
+ if attr in ("defaults",):
547
+ return getattr(self.parser, attr)
548
+ else:
549
+ raise AttributeError(
550
+ self.__class__.__name__
551
+ + " instance has no attribute '"
552
+ + str(attr)
553
+ + "'"
554
+ )
555
+
556
+ def as_dict(self, merged=True):
557
+ """Export the configuration file as a dictionary."""
558
+ if not merged:
559
+ raise ValueError(
560
+ "merged=False is not allowed with ExtendedReadOnlyConfigParser."
561
+ )
562
+ return super().as_dict(merged=True)
563
+
564
+
565
+ class GenericConfigParser(GenericReadOnlyConfigParser):
566
+ """A Basic Read/Write configuration file parser.
567
+
568
+ It relies on a :class:`ConfigParser.ConfigParser` parser (or another class
569
+ that satisfies the interface) to access the configuration data.
570
+
571
+ :param str inifile: Path to a configuration file or a configuration file name
572
+ :param ConfigParser.ConfigParser parser: an existing configuration parser
573
+ object the will be used to access the configuration
574
+ :param bool mkforce: If the configuration file doesn't exists. Create an empty
575
+ one in ``~/.vortexrc``
576
+ :param type clsparser: The class that will be used to create a parser object
577
+ (if needed)
578
+ :param str encoding: The configuration file encoding
579
+ :param str defaultinifile: The name of a default ini file (read before,
580
+ and possibly overwritten by **inifile**)
581
+
582
+ :note: All of the parser's methods are directly accessible because ``__getattr__``
583
+ is implemented. The user will refer to the Python's ConfigParser module
584
+ documentation for more details.
585
+ """
586
+
587
+ def __init__(
588
+ self,
589
+ inifile=None,
590
+ parser=None,
591
+ mkforce=False,
592
+ clsparser=_DEFAULT_CONFIG_PARSER,
593
+ encoding=None,
594
+ defaultinifile=None,
595
+ ):
596
+ super().__init__(
597
+ inifile, parser, mkforce, clsparser, encoding, defaultinifile
598
+ )
599
+ self.updates = list()
600
+
601
+ def setall(self, kw):
602
+ """Define in all sections the couples of ( key, values ) given as dictionary argument."""
603
+ self.updates.append(kw)
604
+ for section in self.sections():
605
+ for key, value in kw.items():
606
+ self.set(section, key, str(value))
607
+
608
+ def save(self):
609
+ """Write the current state of the configuration in the inital file."""
610
+ with open(self.file.split(",").pop(), "wb") as configfile:
611
+ self.write(configfile)
612
+
613
+ @property
614
+ def updated(self):
615
+ """Return if this configuration has been updated or not."""
616
+ return bool(self.updates)
617
+
618
+ def history(self):
619
+ """Return a list of the description for each update performed."""
620
+ return self.updates[:]
621
+
622
+ def __getattr__(self, attr):
623
+ # Give access to all of the parser's methods
624
+ if attr.startswith("__"):
625
+ raise AttributeError(
626
+ self.__class__.__name__
627
+ + " instance has no attribute '"
628
+ + str(attr)
629
+ + "'"
630
+ )
631
+ return getattr(self.parser, attr)
632
+
633
+
634
+ class DelayedConfigParser(GenericConfigParser):
635
+ """Configuration file parser with possible delayed loading.
636
+
637
+ :param str inifile: Path to a configuration file or a configuration file name
638
+
639
+ :note: All of the parser's methods are directly accessible because ``__getattr__``
640
+ is implemented. The user will refer to the Python's ConfigParser module
641
+ documentation for more details.
642
+ """
643
+
644
+ def __init__(self, inifile=None):
645
+ GenericConfigParser.__init__(self)
646
+ self.delay = inifile
647
+
648
+ def refresh(self):
649
+ """Load the delayed inifile."""
650
+ if self.delay:
651
+ self.setfile(self.delay)
652
+ self.delay = None
653
+
654
+ def __getattribute__(self, attr):
655
+ try:
656
+ logger.debug("Getattr %s < %s >", attr, self)
657
+ if attr in filter(
658
+ lambda x: not x.startswith("_"),
659
+ dir(_DEFAULT_CONFIG_PARSER) + ["setall", "save"],
660
+ ):
661
+ object.__getattribute__(self, "refresh")()
662
+ except Exception:
663
+ logger.critical("Trouble getattr %s < %s >", attr, self)
664
+ return object.__getattribute__(self, attr)
665
+
666
+
667
+ class JacketConfigParser(GenericConfigParser):
668
+ """Configuration parser for Jacket files.
669
+
670
+ :param str inifile: Path to a configuration file or a configuration file name
671
+ :param ConfigParser.ConfigParser parser: an existing configuration parser
672
+ object the will be used to access the configuration
673
+ :param bool mkforce: If the configuration file doesn't exists. Create an empty
674
+ one in ``~/.vortexrc``
675
+ :param type clsparser: The class that will be used to create a parser object
676
+ (if needed)
677
+
678
+ :note: All of the parser's methods are directly accessible because ``__getattr__``
679
+ is implemented. The user will refer to the Python's ConfigParser module
680
+ documentation for more details.
681
+ """
682
+
683
+ def get(self, section, option):
684
+ """
685
+ Return for the specified ``option`` in the ``section`` a sequence of values
686
+ build on the basis of a comma separated list.
687
+ """
688
+ s = _DEFAULT_CONFIG_PARSER.get(self, section, option)
689
+ tmplist = s.replace(" ", "").split(",")
690
+ if len(tmplist) > 1:
691
+ return tmplist
692
+ else:
693
+ return tmplist[0]
694
+
695
+
696
+ class AppConfigStringDecoder(StringDecoder):
697
+ """Convert a string from a configuration file into a proper Python's object.
698
+
699
+ See the :class:`StringDecoder` class documentation for a complete description
700
+ the configuration string's syntax.
701
+
702
+ This class extends the :class:`StringDecoder` as follow:
703
+
704
+ * It's possible to convert (i.e. remap) configuration lines to Vortex's
705
+ geometries: ``geometry(geo_tagname)``
706
+ * The :func:`footprints.util.rangex` can be called to generate a list:
707
+ ``dict(production:rangex(0-6-1) assim:rangex(0-3-1))`` will generate
708
+ the following object ``{u'assim': [0, 1, 2, 3], u'production': [0, 1, 2, 3, 4, 5, 6]}``
709
+ * It is possible to create an object using the *iniconf* footprint's collector:
710
+ ``'iniconf(family:pollutants kind:elements version:std)'`` will generate
711
+ the following object ``<intairpol.data.elements.PollutantsElementsTable at 0x...>``
712
+ (provided that the :mod:`intairpol` package has been imported).
713
+ * It is possible to create an object using the *conftools* footprint's collector
714
+ (following the previous example's syntax).
715
+
716
+ """
717
+
718
+ BUILDERS = StringDecoder.BUILDERS + [
719
+ "geometry",
720
+ "date",
721
+ "time",
722
+ "rangex",
723
+ "daterangex",
724
+ "iniconf",
725
+ "conftool",
726
+ ]
727
+
728
+ def remap_geometry(self, value):
729
+ """Convert all values to Geometry objects."""
730
+ from vortex.data import geometries
731
+
732
+ try:
733
+ value = geometries.get(tag=value)
734
+ except ValueError:
735
+ pass
736
+ return value
737
+
738
+ def remap_date(self, value):
739
+ """Convert all values to bronx' Date objects."""
740
+ try:
741
+ value = bdate.Date(value)
742
+ except (ValueError, TypeError):
743
+ pass
744
+ return value
745
+
746
+ def remap_time(self, value):
747
+ """Convert all values to bronx' Time objects."""
748
+ try:
749
+ value = bdate.Time(value)
750
+ except (ValueError, TypeError):
751
+ pass
752
+ return value
753
+
754
+ def _build_geometry(self, value, remap, subs):
755
+ val = self._value_expand(value, remap, subs)
756
+ from vortex.data import geometries
757
+
758
+ return geometries.get(tag=val)
759
+
760
+ def _build_date(self, value, remap, subs):
761
+ val = self._value_expand(value, remap, subs)
762
+ return bdate.Date(val)
763
+
764
+ def _build_time(self, value, remap, subs):
765
+ val = self._value_expand(value, remap, subs)
766
+ return bdate.Time(val)
767
+
768
+ def _build_generic_rangex(self, cb, value, remap, subs):
769
+ """Build a rangex or daterangex from the **value** string."""
770
+ # Try to read names arguments
771
+ try:
772
+ values = self._sparser(value, itemsep=" ", keysep=":")
773
+ if all(
774
+ [
775
+ k in ("start", "end", "step", "shift", "fmt", "prefix")
776
+ for k in values.keys()
777
+ ]
778
+ ):
779
+ return cb(
780
+ **{
781
+ k: self._value_expand(v, remap, subs)
782
+ for k, v in values.items()
783
+ }
784
+ )
785
+ except StringDecoderSyntaxError:
786
+ pass
787
+ # The usual case...
788
+ return cb(
789
+ [
790
+ self._value_expand(v, remap, subs)
791
+ for v in self._sparser(value, itemsep=",")
792
+ ]
793
+ )
794
+
795
+ def _build_rangex(self, value, remap, subs):
796
+ """Build a rangex from the **value** string."""
797
+ return self._build_generic_rangex(
798
+ bdate.timeintrangex, value, remap, subs
799
+ )
800
+
801
+ def _build_daterangex(self, value, remap, subs):
802
+ """Build a daterangex from the **value** string."""
803
+ return self._build_generic_rangex(bdate.daterangex, value, remap, subs)
804
+
805
+ def _build_fpgeneric(self, value, remap, subs, collector):
806
+ fp = {
807
+ k: self._value_expand(v, remap, subs)
808
+ for k, v in self._sparser(value, itemsep=" ", keysep=":").items()
809
+ }
810
+ obj = footprints.collectors.get(tag=collector).load(**fp)
811
+ if obj is None:
812
+ raise StringDecoderSyntaxError(
813
+ value,
814
+ "No object could be created from the {} collector".format(
815
+ collector
816
+ ),
817
+ )
818
+ return obj
819
+
820
+ def _build_iniconf(self, value, remap, subs):
821
+ return self._build_fpgeneric(value, remap, subs, "iniconf")
822
+
823
+ def _build_conftool(self, value, remap, subs):
824
+ return self._build_fpgeneric(value, remap, subs, "conftool")
825
+
826
+
827
+ class IniConf(footprints.FootprintBase):
828
+ """
829
+ Generic Python configuration file.
830
+ """
831
+
832
+ _collector = ("iniconf",)
833
+ _abstract = True
834
+ _footprint = dict(
835
+ info="Abstract Python Inifile",
836
+ attr=dict(
837
+ kind=dict(
838
+ info="The configuration object kind.",
839
+ values=[
840
+ "generic",
841
+ ],
842
+ ),
843
+ clsconfig=dict(
844
+ type=GenericReadOnlyConfigParser,
845
+ isclass=True,
846
+ optional=True,
847
+ default=GenericReadOnlyConfigParser,
848
+ doc_visibility=footprints.doc.visibility.ADVANCED,
849
+ ),
850
+ inifile=dict(
851
+ kind="The configuration file to look for.",
852
+ optional=True,
853
+ default="@[kind].ini",
854
+ ),
855
+ ),
856
+ )
857
+
858
+ def __init__(self, *args, **kw):
859
+ logger.debug("Ini Conf %s", self.__class__)
860
+ super().__init__(*args, **kw)
861
+ self._config = self.clsconfig(inifile=self.inifile)
862
+
863
+ @property
864
+ def config(self):
865
+ return self._config
866
+
867
+
868
+ class ConfigurationTable(IniConf):
869
+ """
870
+ A specialised version of :class:`IniConf` that automatically create a list of
871
+ items (instantiated from the tableitem footprint's collector) from a given
872
+ configuration file.
873
+ """
874
+
875
+ _abstract = True
876
+ _footprint = dict(
877
+ info="Abstract configuration tables",
878
+ attr=dict(
879
+ kind=dict(
880
+ info="The configuration's table kind.",
881
+ ),
882
+ family=dict(
883
+ info="The configuration's table family.",
884
+ ),
885
+ version=dict(
886
+ info="The configuration's table version.",
887
+ optional=True,
888
+ default="std",
889
+ ),
890
+ searchkeys=dict(
891
+ info="Item's attributes used to perform the lookup in the find method.",
892
+ type=footprints.FPTuple,
893
+ optional=True,
894
+ default=footprints.FPTuple(),
895
+ ),
896
+ groupname=dict(
897
+ info="The class attribute matching the configuration file groupname",
898
+ optional=True,
899
+ default="family",
900
+ ),
901
+ inifile=dict(
902
+ optional=True,
903
+ default="@[family]-[kind]-[version].ini",
904
+ ),
905
+ clsconfig=dict(
906
+ default=ExtendedReadOnlyConfigParser,
907
+ ),
908
+ language=dict(
909
+ info="The default language for the translator property.",
910
+ optional=True,
911
+ default="en",
912
+ ),
913
+ ),
914
+ )
915
+
916
+ @property
917
+ def realkind(self):
918
+ return "configuration-table"
919
+
920
+ def groups(self):
921
+ """Actual list of items groups described in the current iniconf."""
922
+ return [
923
+ x
924
+ for x in self.config.parser.sections()
925
+ if ":" not in x and not x.startswith("lang_")
926
+ ]
927
+
928
+ def keys(self):
929
+ """Actual list of different items in the current iniconf."""
930
+ return [
931
+ x
932
+ for x in self.config.sections()
933
+ if x not in self.groups() and not x.startswith("lang_")
934
+ ]
935
+
936
+ @property
937
+ def translator(self):
938
+ """The special section of the iniconf dedicated to translation, as a dict."""
939
+ if not hasattr(self, "_translator"):
940
+ if self.config.has_section("lang_" + self.language):
941
+ self._translator = self.config.as_dict()[
942
+ "lang_" + self.language
943
+ ]
944
+ else:
945
+ self._translator = None
946
+ return self._translator
947
+
948
+ @property
949
+ def tablelist(self):
950
+ """List of unique instances of items described in the current iniconf."""
951
+ if not hasattr(self, "_tablelist"):
952
+ self._tablelist = list()
953
+ d = self.config.as_dict()
954
+ for item, group in [
955
+ x.split(":") for x in self.config.parser.sections() if ":" in x
956
+ ]:
957
+ try:
958
+ for k, v in d[item].items():
959
+ # Can occur in case of a redundant entry in the config file
960
+ if isinstance(v, str) and v:
961
+ if re.match("none$", v, re.IGNORECASE):
962
+ d[item][k] = None
963
+ if re.search("[a-z]_[a-z]", v, re.IGNORECASE):
964
+ d[item][k] = v.replace("_", "'")
965
+ d[item][self.searchkeys[0]] = item
966
+ d[item][self.groupname] = group
967
+ d[item]["translator"] = self.translator
968
+ itemobj = footprints.proxy.tableitem(**d[item])
969
+ if itemobj is not None:
970
+ self._tablelist.append(itemobj)
971
+ else:
972
+ logger.error(
973
+ "Unable to create the %s item object. Check the footprint !",
974
+ item,
975
+ )
976
+ except (KeyError, IndexError):
977
+ logger.warning("Some item description could not match")
978
+ return self._tablelist
979
+
980
+ def get(self, item):
981
+ """Return the item with main key exactly matching the given argument."""
982
+ candidates = [
983
+ x
984
+ for x in self.tablelist
985
+ if x.footprint_getattr(self.searchkeys[0]) == item
986
+ ]
987
+ if candidates:
988
+ return candidates[0]
989
+ else:
990
+ return None
991
+
992
+ def match(self, item):
993
+ """Return the item with main key matching the given argument without case consideration."""
994
+ candidates = [
995
+ x
996
+ for x in self.tablelist
997
+ if x.footprint_getattr(self.searchkeys[0])
998
+ .lower()
999
+ .startswith(item.lower())
1000
+ ]
1001
+ if candidates:
1002
+ return candidates[0]
1003
+ else:
1004
+ return None
1005
+
1006
+ def grep(self, item):
1007
+ """Return a list of items with main key loosely matching the given argument."""
1008
+ return [
1009
+ x
1010
+ for x in self.tablelist
1011
+ if re.search(
1012
+ item, x.footprint_getattr(self.searchkeys[0]), re.IGNORECASE
1013
+ )
1014
+ ]
1015
+
1016
+ def find(self, item):
1017
+ """Return a list of items with main key or name loosely matching the given argument."""
1018
+ return [
1019
+ x
1020
+ for x in self.tablelist
1021
+ if any(
1022
+ [
1023
+ re.search(
1024
+ item, x.footprint_getattr(thiskey), re.IGNORECASE
1025
+ )
1026
+ for thiskey in self.searchkeys
1027
+ ]
1028
+ )
1029
+ ]
1030
+
1031
+
1032
+ class TableItem(footprints.FootprintBase):
1033
+ """
1034
+ Abstract configuration table's item.
1035
+ """
1036
+
1037
+ #: Attribute describing the item's name during RST exports
1038
+ _RST_NAME = ""
1039
+ #: Attributes that will appear on the top line of RST exports
1040
+ _RST_HOTKEYS = []
1041
+
1042
+ _abstract = True
1043
+ _collector = ("tableitem",)
1044
+ _footprint = dict(
1045
+ info="Abstract configuration table's item.",
1046
+ attr=dict(
1047
+ # Define your own...
1048
+ translator=dict(
1049
+ optional=True,
1050
+ type=footprints.FPDict,
1051
+ default=None,
1052
+ ),
1053
+ ),
1054
+ )
1055
+
1056
+ @property
1057
+ def realkind(self):
1058
+ return "tableitem"
1059
+
1060
+ def _translated_items(self, mkshort=True):
1061
+ """Returns a list of 3-elements tuples describing the item attributes.
1062
+
1063
+ [(translated_key, value, original_key), ...]
1064
+ """
1065
+ output_stack = list()
1066
+ if self.translator:
1067
+ for k in self.translator.get("ordered_dump", "").split(","):
1068
+ if not mkshort or self.footprint_getattr(k) is not None:
1069
+ output_stack.append(
1070
+ (
1071
+ self.translator.get(
1072
+ k, k.replace("_", " ").title()
1073
+ ),
1074
+ str(self.footprint_getattr(k)),
1075
+ k,
1076
+ )
1077
+ )
1078
+ else:
1079
+ for k in self.footprint_attributes:
1080
+ if (
1081
+ not mkshort or self.footprint_getattr(k) is not None
1082
+ ) and k != "translator":
1083
+ output_stack.append((k, str(self.footprint_getattr(k)), k))
1084
+ return output_stack
1085
+
1086
+ def nice_str(self, mkshort=True):
1087
+ """Produces a nice ordered representation of the item attributes."""
1088
+ output_stack = self._translated_items(mkshort=mkshort)
1089
+ output_list = []
1090
+ if output_stack:
1091
+ max_keylen = max([len(i[0]) for i in output_stack])
1092
+ print_fmt = "{0:" + str(max_keylen) + "s} : {1:s}"
1093
+ for item in output_stack:
1094
+ output_list.append(print_fmt.format(*item))
1095
+ return "\n".join(output_list)
1096
+
1097
+ def __str__(self):
1098
+ return self.nice_str()
1099
+
1100
+ def nice_print(self, mkshort=True):
1101
+ """Print a nice ordered output of the item attributes."""
1102
+ print(self.nice_str(mkshort=mkshort))
1103
+
1104
+ def nice_rst(self, mkshort=True):
1105
+ """Produces a nice ordered RST output of the item attributes."""
1106
+ assert self._RST_NAME, "Please override _RST_NAME"
1107
+ output_stack = self._translated_items(mkshort=mkshort)
1108
+ i_name = "????"
1109
+ i_hot = []
1110
+ i_other = []
1111
+ for item in output_stack:
1112
+ if item[2] == self._RST_NAME:
1113
+ i_name = item
1114
+ elif item[2] in self._RST_HOTKEYS:
1115
+ i_hot.append(item)
1116
+ else:
1117
+ i_other.append(item)
1118
+ return "**{}** : `{}`\n\n{}\n\n".format(
1119
+ i_name[1],
1120
+ ", ".join(["{:s}={:s}".format(*i) for i in i_hot]),
1121
+ "\n".join([" * {:s}: {:s}".format(*i) for i in i_other]),
1122
+ )