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
@@ -0,0 +1,940 @@
1
+ """
2
+ Standard services to be used by user defined actions.
3
+
4
+ With the abstract class Service (inheritating from FootprintBase)
5
+ a default Mail Service is provided.
6
+ """
7
+
8
+ import configparser
9
+ import contextlib
10
+ import hashlib
11
+ import pprint
12
+ from string import Template
13
+
14
+
15
+ import footprints
16
+ from bronx.fancies import loggers
17
+ from bronx.fancies.display import print_tablelike
18
+ from bronx.stdtypes import date
19
+ from bronx.stdtypes.dictionaries import UpperCaseDict
20
+ from bronx.syntax.pretty import EncodedPrettyPrinter
21
+ from vortex import sessions
22
+ from vortex.util.config import (
23
+ GenericConfigParser,
24
+ load_template,
25
+ LegacyTemplatingAdapter,
26
+ )
27
+
28
+ #: No automatic export
29
+ __all__ = []
30
+
31
+ logger = loggers.getLogger(__name__)
32
+
33
+ # See logging.handlers.SysLogHandler.priority_map
34
+ criticals = ["debug", "info", "error", "warning", "critical"]
35
+
36
+
37
+ class Service(footprints.FootprintBase):
38
+ """
39
+ Abstract base class for services.
40
+ """
41
+
42
+ _abstract = True
43
+ _collector = ("service",)
44
+ _footprint = dict(
45
+ info="Abstract services class",
46
+ attr=dict(
47
+ kind=dict(),
48
+ level=dict(
49
+ optional=True,
50
+ default="info",
51
+ values=criticals,
52
+ ),
53
+ ),
54
+ )
55
+
56
+ def __init__(self, *args, **kw):
57
+ logger.debug("Abstract service init %s", self.__class__)
58
+ t = sessions.current()
59
+ glove = kw.pop("glove", t.glove)
60
+ sh = kw.pop("sh", t.system())
61
+ super().__init__(*args, **kw)
62
+ self._glove = glove
63
+ self._sh = sh
64
+
65
+ @property
66
+ def realkind(self):
67
+ return "service"
68
+
69
+ @property
70
+ def sh(self):
71
+ return self._sh
72
+
73
+ @property
74
+ def env(self):
75
+ return self._sh.env
76
+
77
+ @property
78
+ def glove(self):
79
+ return self._glove
80
+
81
+ def actual_value(self, key, as_var=None, as_conf=None, default=None):
82
+ """
83
+ Return for a given ``attr`` a value from several sources in turn:
84
+ - a defined attribute value (e.g. from the footprint)
85
+ - a shell environment variable
86
+ - a variable from an ini file section
87
+ - a default value as specified.
88
+ """
89
+ if as_var is None:
90
+ as_var = key.upper()
91
+ value = getattr(self, key, None)
92
+ if not value:
93
+ value = self.env.get(as_var, None)
94
+ if not value:
95
+ if as_conf is None:
96
+ as_conf = "services:" + key.lower()
97
+ value = self.sh.default_target.get(as_conf, default)
98
+ return value
99
+
100
+ def __call__(self, *args):
101
+ pass
102
+
103
+
104
+ class MailService(Service):
105
+ """
106
+ Class responsible for handling email data.
107
+ This class should not be called directly.
108
+ """
109
+
110
+ _footprint = dict(
111
+ info="Mail services class",
112
+ attr=dict(
113
+ kind=dict(
114
+ values=["sendmail"],
115
+ ),
116
+ sender=dict(
117
+ optional=True,
118
+ default="[glove::xmail]",
119
+ ),
120
+ to=dict(
121
+ alias=("receiver", "recipients"),
122
+ ),
123
+ replyto=dict(
124
+ optional=True,
125
+ alias=("reply", "reply_to"),
126
+ default=None,
127
+ ),
128
+ message=dict(
129
+ optional=True,
130
+ default="",
131
+ alias=("contents", "body"),
132
+ type=str,
133
+ ),
134
+ filename=dict(
135
+ optional=True,
136
+ default=None,
137
+ ),
138
+ attachments=dict(
139
+ type=footprints.FPList,
140
+ optional=True,
141
+ default=footprints.FPList(),
142
+ alias=("files", "attach"),
143
+ ),
144
+ subject=dict(
145
+ type=str,
146
+ ),
147
+ smtpserver=dict(
148
+ optional=True,
149
+ ),
150
+ smtpport=dict(
151
+ type=int,
152
+ optional=True,
153
+ ),
154
+ smtpuser=dict(
155
+ optional=True,
156
+ ),
157
+ smtppass=dict(
158
+ optional=True,
159
+ ),
160
+ charset=dict(
161
+ info="The encoding that should be used when sending the email",
162
+ optional=True,
163
+ default="utf-8",
164
+ ),
165
+ inputs_charset=dict(
166
+ info="The encoding that should be used when reading input files",
167
+ optional=True,
168
+ ),
169
+ commaspace=dict(optional=True, default=", "),
170
+ ),
171
+ )
172
+
173
+ def attach(self, *args):
174
+ """Extend the internal attachments of the next mail to send."""
175
+ self.attachments.extend(args)
176
+ return len(self.attachments)
177
+
178
+ @staticmethod
179
+ def is_not_plain_ascii(string):
180
+ """Return True if any character in string is not ascii-7."""
181
+ return not all(ord(c) < 128 for c in string)
182
+
183
+ def get_message_body(self):
184
+ """Returns the internal body contents as a MIMEText object."""
185
+ body = self.message
186
+ if self.filename:
187
+ with open(self.filename, encoding=self.inputs_charset) as tmp:
188
+ body += tmp.read()
189
+ from email.message import EmailMessage
190
+
191
+ msg = EmailMessage()
192
+ msg.set_content(
193
+ body,
194
+ subtype="plain",
195
+ charset=(
196
+ self.charset if self.is_not_plain_ascii(body) else "us-ascii"
197
+ ),
198
+ )
199
+ return msg
200
+
201
+ def as_multipart(self, msg):
202
+ """Build a new multipart mail with default text contents and attachments."""
203
+ from email.message import MIMEPart
204
+
205
+ for xtra in self.attachments:
206
+ if isinstance(xtra, MIMEPart):
207
+ msg.add_attachment(xtra)
208
+ elif self.sh.path.isfile(xtra):
209
+ import mimetypes
210
+
211
+ ctype, encoding = mimetypes.guess_type(xtra)
212
+ if ctype is None or encoding is not None:
213
+ # No guess could be made, or the file is encoded
214
+ # (compressed), so use a generic bag-of-bits type.
215
+ ctype = "application/octet-stream"
216
+ maintype, subtype = ctype.split("/", 1)
217
+ with open(xtra, "rb") as fp:
218
+ msg.add_attachment(
219
+ fp.read(),
220
+ maintype,
221
+ subtype,
222
+ cte="base64",
223
+ filename=xtra,
224
+ )
225
+ return msg
226
+
227
+ def _set_header(self, msg, header, value):
228
+ msg[header] = value
229
+
230
+ def set_headers(self, msg):
231
+ """Put on the current message the header items associated to footprint attributes."""
232
+ self._set_header(msg, "From", self.sender)
233
+ self._set_header(msg, "To", self.commaspace.join(self.to.split()))
234
+ self._set_header(msg, "Subject", self.subject)
235
+ if self.replyto is not None:
236
+ self._set_header(
237
+ msg, "Reply-To", self.commaspace.join(self.replyto.split())
238
+ )
239
+
240
+ @contextlib.contextmanager
241
+ def smtp_entrypoints(self):
242
+ import smtplib
243
+
244
+ my_smtpserver = self.actual_value(
245
+ "smtpserver", as_var="VORTEX_SMTPSERVER", default="localhost"
246
+ )
247
+ my_smtpport = self.actual_value(
248
+ "smtpport", as_var="VORTEX_SMTPPORT", default=smtplib.SMTP_PORT
249
+ )
250
+ if not self.sh.default_target.isnetworknode:
251
+ sshobj = self.sh.ssh(
252
+ "network", virtualnode=True, mandatory_hostcheck=False
253
+ )
254
+ with sshobj.tunnel(my_smtpserver, my_smtpport) as tun:
255
+ yield "localhost", tun.entranceport
256
+ else:
257
+ yield my_smtpserver, my_smtpport
258
+
259
+ def __call__(self):
260
+ """Main action: pack the message body, add the attachments, and send via SMTP."""
261
+ msg = self.get_message_body()
262
+ if self.attachments:
263
+ msg = self.as_multipart(msg)
264
+ self.set_headers(msg)
265
+ msgcorpus = msg.as_string()
266
+ with self.smtp_entrypoints() as (smtpserver, smtpport):
267
+ import smtplib
268
+
269
+ extras = dict()
270
+ if smtpport:
271
+ extras["port"] = smtpport
272
+ smtp = smtplib.SMTP(smtpserver, **extras)
273
+ if self.smtpuser and self.smtppass:
274
+ smtp.login(self.smtpuser, self.smtppass)
275
+ smtp.sendmail(self.sender, self.to.split(), msgcorpus)
276
+ smtp.quit()
277
+ return len(msgcorpus)
278
+
279
+
280
+ class ReportService(Service):
281
+ """
282
+ Class responsible for handling report data.
283
+ This class should not be called directly.
284
+ """
285
+
286
+ _abstract = True
287
+ _footprint = dict(
288
+ info="Report services class",
289
+ attr=dict(
290
+ kind=dict(values=["sendreport"]),
291
+ sender=dict(
292
+ optional=True,
293
+ default="[glove::user]",
294
+ ),
295
+ subject=dict(optional=True, default="Test"),
296
+ ),
297
+ )
298
+
299
+ def __call__(self, *args):
300
+ """Main action: ..."""
301
+ pass
302
+
303
+
304
+ class FileReportService(ReportService):
305
+ """Building the report as a simple file."""
306
+
307
+ _abstract = True
308
+ _footprint = dict(
309
+ info="File Report services class",
310
+ attr=dict(
311
+ kind=dict(
312
+ values=["sendreport", "sendfilereport"],
313
+ remap=dict(sendfilereport="sendreport"),
314
+ ),
315
+ filename=dict(),
316
+ ),
317
+ )
318
+
319
+
320
+ class SSHProxy(Service):
321
+ """Remote execution via ssh on a generic target.
322
+
323
+ If ``node`` is the specified :attr:`hostname` value, some target hostname
324
+ will be built on the basis of attributes, :attr:`genericnode`,
325
+ and :attr:`nodetype`.
326
+
327
+ In this case, if :attr:`genericnode` is defined it will be used. If not,
328
+ the configuration file will be checked for a configuration key matching
329
+ the :attr:`nodetype`.
330
+
331
+ When several nodes are available, the first responding ``hostname`` will be
332
+ selected.
333
+ """
334
+
335
+ _footprint = dict(
336
+ info="Remote command proxy",
337
+ attr=dict(
338
+ kind=dict(
339
+ values=["ssh", "ssh_proxy"],
340
+ remap=dict(autoremap="first"),
341
+ ),
342
+ hostname=dict(),
343
+ genericnode=dict(
344
+ optional=True,
345
+ default=None,
346
+ access="rwx",
347
+ ),
348
+ nodetype=dict(
349
+ optional=True,
350
+ values=[
351
+ "login",
352
+ "transfer",
353
+ "transfert",
354
+ "network",
355
+ "agt",
356
+ "syslog",
357
+ ],
358
+ default="network",
359
+ remap=dict(transfer="transfert"),
360
+ ),
361
+ permut=dict(
362
+ type=bool,
363
+ optional=True,
364
+ default=True,
365
+ ),
366
+ maxtries=dict(
367
+ type=int,
368
+ optional=True,
369
+ default=2,
370
+ ),
371
+ sshopts=dict(
372
+ optional=True,
373
+ type=footprints.FPList,
374
+ default=None,
375
+ ),
376
+ ),
377
+ )
378
+
379
+ def __init__(self, *args, **kw):
380
+ logger.debug("Remote command proxy init %s", self.__class__)
381
+ super().__init__(*args, **kw)
382
+ hostname, virtualnode = self._actual_hostname()
383
+ extra_sshopts = (
384
+ None if self.sshopts is None else " ".join(self.sshopts)
385
+ )
386
+ self._sshobj = self.sh.ssh(
387
+ hostname,
388
+ sshopts=extra_sshopts,
389
+ maxtries=self.maxtries,
390
+ virtualnode=virtualnode,
391
+ permut=self.permut,
392
+ mandatory_hostcheck=False,
393
+ )
394
+
395
+ def _actual_hostname(self):
396
+ """Build a list of candidate target hostnames."""
397
+ myhostname = self.hostname.strip().lower()
398
+ virtualnode = False
399
+ if myhostname == "node":
400
+ if (
401
+ self.genericnode is not None
402
+ and self.genericnode != "no_generic"
403
+ ):
404
+ myhostname = self.genericnode
405
+ else:
406
+ myhostname = self.nodetype
407
+ virtualnode = True
408
+ return myhostname, virtualnode
409
+
410
+ @property
411
+ def retries(self):
412
+ return self._sshobj.retries
413
+
414
+ def __call__(self, *args):
415
+ """Remote execution."""
416
+ return self._sshobj.execute(" ".join(args))
417
+
418
+
419
+ class JeevesService(Service):
420
+ """
421
+ Class acting as a standard Bertie asking Jeeves to do something.
422
+ """
423
+
424
+ _footprint = dict(
425
+ info="Jeeves services class",
426
+ attr=dict(
427
+ kind=dict(values=["askjeeves"]),
428
+ todo=dict(),
429
+ jname=dict(
430
+ optional=True,
431
+ default="test",
432
+ ),
433
+ juser=dict(
434
+ optional=True,
435
+ default="[glove::user]",
436
+ ),
437
+ jpath=dict(
438
+ optional=True,
439
+ default=None,
440
+ access="rwx",
441
+ ),
442
+ jfile=dict(
443
+ optional=True,
444
+ default="vortex",
445
+ ),
446
+ ),
447
+ )
448
+
449
+ def __call__(self, *args):
450
+ """Main action: ..."""
451
+ if self.jpath is None:
452
+ self.jpath = self.sh.path.join(
453
+ self.env.HOME, "jeeves", self.jname, "depot"
454
+ )
455
+ if self.sh.path.isdir(self.jpath):
456
+ from jeeves import bertie
457
+
458
+ data = dict()
459
+ for arg in args:
460
+ data.update(arg)
461
+ fulltalk = dict(
462
+ user=self.juser,
463
+ jtag=self.sh.path.join(self.jpath, self.jfile),
464
+ todo=self.todo,
465
+ mail=data.pop("mail", self.glove.xmail),
466
+ apps=data.pop("apps", (self.glove.vapp,)),
467
+ conf=data.pop("conf", (self.glove.vconf,)),
468
+ task=self.env.get("JOBNAME")
469
+ or self.env.get("SMSNAME", "interactif"),
470
+ )
471
+ fulltalk.update(
472
+ data=data,
473
+ )
474
+ jr = bertie.ask(**fulltalk)
475
+ return jr.todo, jr.last
476
+ else:
477
+ logger.error("No valid path to jeeves <%s>", self.jpath)
478
+ return None
479
+
480
+
481
+ class HideService(Service):
482
+ """
483
+ A service to hide data.
484
+
485
+ Mainly used to store files to be handled asynchronously
486
+ (and then deleted) by Jeeves.
487
+ """
488
+
489
+ _footprint = dict(
490
+ info="Hide a given object on current filesystem",
491
+ attr=dict(
492
+ kind=dict(
493
+ values=["hidden", "hide", "hiddencache"],
494
+ remap=dict(autoremap="first"),
495
+ ),
496
+ rootdir=dict(
497
+ optional=True,
498
+ default=None,
499
+ ),
500
+ headdir=dict(
501
+ optional=True,
502
+ default="hidden",
503
+ ),
504
+ asfmt=dict(
505
+ optional=True,
506
+ default=None,
507
+ ),
508
+ ),
509
+ )
510
+
511
+ def find_rootdir(self, filename):
512
+ """Find a path for hiding files on the same filesystem."""
513
+ username = self.sh.getlogname()
514
+ work_dir = self.sh.path.join(
515
+ self.sh.find_mount_point(filename), "work"
516
+ )
517
+ if not self.sh.path.exists(work_dir):
518
+ logger.warning("path <%s> doesn't exist", work_dir)
519
+ fullpath = self.sh.path.realpath(filename)
520
+ if username not in fullpath:
521
+ logger.error("No login <%s> in path <%s>", username, fullpath)
522
+ raise ValueError(
523
+ "Login name not in actual path for hiding data"
524
+ )
525
+ work_dir = fullpath.partition(username)[0]
526
+ logger.warning("using work_dir = <%s>", work_dir)
527
+ hidden_path = self.sh.path.join(work_dir, username, self.headdir)
528
+ return hidden_path
529
+
530
+ def __call__(self, filename):
531
+ """Main action: hide a cheap copy of this file under a unique name."""
532
+
533
+ rootdir = self.rootdir
534
+ if rootdir is None:
535
+ rootdir = self.sh.default_target.get("hidden_rootdir", None)
536
+ if rootdir is not None:
537
+ rootdir = self.sh.path.expanduser(rootdir)
538
+
539
+ actual_rootdir = rootdir or self.find_rootdir(filename)
540
+ destination = self.sh.path.join(
541
+ actual_rootdir,
542
+ ".".join(
543
+ (
544
+ "HIDDEN",
545
+ date.now().strftime("%Y%m%d%H%M%S.%f"),
546
+ "P{:06d}".format(self.sh.getpid()),
547
+ hashlib.md5(
548
+ self.sh.path.abspath(filename).encode(encoding="utf-8")
549
+ ).hexdigest(),
550
+ )
551
+ ),
552
+ )
553
+ self.sh.cp(filename, destination, intent="in", fmt=self.asfmt)
554
+ return destination
555
+
556
+
557
+ class Directory:
558
+ """
559
+ A class to represent and use mail aliases.
560
+
561
+ Directory (en) means Annuaire (fr).
562
+ """
563
+
564
+ def __init__(self, inifile, domain="meteo.fr", encoding=None):
565
+ """Keep aliases in memory, as a dict of sets."""
566
+ config = GenericConfigParser(inifile, encoding=encoding)
567
+ try:
568
+ self.domain = config.get("general", "default_domain")
569
+ except configparser.NoOptionError:
570
+ self.domain = domain
571
+ self.aliases = {
572
+ k.lower(): set(v.lower().replace(",", " ").split())
573
+ for (k, v) in config.items("aliases")
574
+ }
575
+ count = self._flatten()
576
+ logger.debug(
577
+ "opmail aliases flattened in %d iterations:\n%s", count, str(self)
578
+ )
579
+
580
+ def get_addresses(self, definition, add_domain=True):
581
+ """
582
+ Build a space separated list of unique mail addresses from a string that
583
+ may reference aliases.
584
+ """
585
+ addresses = set()
586
+ for item in definition.lower().replace(",", " ").split():
587
+ if item in self.aliases:
588
+ addresses |= self.aliases[item]
589
+ else:
590
+ addresses |= {item}
591
+ if add_domain:
592
+ return " ".join(self._add_domain(addresses))
593
+ return " ".join(addresses)
594
+
595
+ def __str__(self):
596
+ return "\n".join(
597
+ sorted(
598
+ [
599
+ "{}: {}".format(k, " ".join(sorted(v)))
600
+ for (k, v) in self.aliases.items()
601
+ ]
602
+ )
603
+ )
604
+
605
+ def _flatten(self):
606
+ """Resolve recursive definitions from the dict of sets."""
607
+ changed = True
608
+ count = 0
609
+ while changed:
610
+ changed = False
611
+ count += 1
612
+ for kref, vref in self.aliases.items():
613
+ if kref in vref:
614
+ logger.error(
615
+ "Cycle detected in the aliases directory.\n"
616
+ "offending key: %s.\n"
617
+ "directory being flattened:\n%s",
618
+ str(kref),
619
+ str(self),
620
+ )
621
+ raise ValueError(
622
+ "Cycle for key <{}> in directory definition".format(
623
+ kref
624
+ )
625
+ )
626
+ for k, v in self.aliases.items():
627
+ if kref in v:
628
+ v -= {kref}
629
+ v |= vref
630
+ self.aliases[k] = v
631
+ changed = True
632
+ return count
633
+
634
+ def _add_domain(self, aset):
635
+ """Add domain where missing in a set of addresses."""
636
+ return {v if "@" in v else v + "@" + self.domain for v in aset}
637
+
638
+
639
+ class PromptService(Service):
640
+ """
641
+ Class used to simulate a real Service: logs the argument it receives.
642
+ This class should not be called directly.
643
+ """
644
+
645
+ _footprint = dict(
646
+ info="Simulate a call to a Service.",
647
+ attr=dict(
648
+ kind=dict(
649
+ values=("prompt",),
650
+ ),
651
+ comment=dict(
652
+ optional=True,
653
+ default=None,
654
+ ),
655
+ ),
656
+ )
657
+
658
+ def __call__(self, options):
659
+ """Prints what arguments the action was called with."""
660
+
661
+ pf = EncodedPrettyPrinter().pformat
662
+ logger_action = getattr(logger, self.level, logger.warning)
663
+ msg = (self.comment or "PromptService was called.") + "\noptions = {}"
664
+ logger_action(msg.format(pf(options)).replace("\n", "\n<prompt>"))
665
+ return True
666
+
667
+
668
+ class TemplatedMailService(MailService):
669
+ """
670
+ Class responsible for sending templated mails.
671
+ This class should not be called directly.
672
+ """
673
+
674
+ _footprint = dict(
675
+ info="Templated mail services class",
676
+ attr=dict(
677
+ kind=dict(
678
+ values=["templatedmail"],
679
+ ),
680
+ id=dict(
681
+ alias=("template",),
682
+ ),
683
+ subject=dict(
684
+ optional=True,
685
+ default=None,
686
+ access="rwx",
687
+ ),
688
+ to=dict(
689
+ optional=True,
690
+ default=None,
691
+ access="rwx",
692
+ ),
693
+ message=dict(
694
+ access="rwx",
695
+ ),
696
+ directory=dict(
697
+ type=Directory,
698
+ optional=True,
699
+ default=None,
700
+ ),
701
+ catalog=dict(
702
+ type=configparser.ConfigParser,
703
+ ),
704
+ dryrun=dict(
705
+ info="Do not actually send the email. Just render the template.",
706
+ type=bool,
707
+ optional=True,
708
+ default=False,
709
+ ),
710
+ ),
711
+ )
712
+
713
+ _TEMPLATES_SUBDIR = None
714
+
715
+ def __init__(self, *args, **kw):
716
+ ticket = kw.pop("ticket", sessions.get())
717
+ super().__init__(*args, **kw)
718
+ self._ticket = ticket
719
+ logger.debug("TemplatedMail init for id <%s>", self.id)
720
+
721
+ @property
722
+ def ticket(self):
723
+ return self._ticket
724
+
725
+ def header(self):
726
+ """String prepended to the message body."""
727
+ return ""
728
+
729
+ def trailer(self):
730
+ """String appended to the message body."""
731
+ return ""
732
+
733
+ def get_catalog_section(self):
734
+ """Read section <id> (a dict-like) from the catalog."""
735
+ try:
736
+ section = dict(self.catalog.items(self.id))
737
+ except configparser.NoSectionError:
738
+ logger.error(
739
+ "Section <%s> is missing in catalog",
740
+ self.id,
741
+ )
742
+ section = None
743
+ return section
744
+
745
+ def substitution_dictionary(self, add_ons=None):
746
+ """Dictionary used for template substitutions: env + add_ons."""
747
+ dico = UpperCaseDict(self.env)
748
+ if add_ons is not None:
749
+ dico.update(add_ons)
750
+ return dico
751
+
752
+ @staticmethod
753
+ def substitute(tpl, tpldict, depth=1):
754
+ """Safely apply template substitution.
755
+
756
+ * Syntactic and missing keys errors are detected and logged.
757
+ * on error, a safe substitution is applied.
758
+ * The substitution is iterated ``depth`` times.
759
+ """
760
+ if not isinstance(tpl, (Template, LegacyTemplatingAdapter)):
761
+ tpl = Template(tpl)
762
+ result = ""
763
+ for level in range(depth):
764
+ try:
765
+ result = tpl.substitute(tpldict)
766
+ except KeyError as exc:
767
+ logger.error(
768
+ "Undefined key <%s> in template substitution level %d",
769
+ str(exc),
770
+ level + 1,
771
+ )
772
+ result = tpl.safe_substitute(tpldict)
773
+ except ValueError as exc:
774
+ logger.error("Illegal syntax in template: %s", exc)
775
+ result = tpl.safe_substitute(tpldict)
776
+ tpl = Template(result)
777
+ return result
778
+
779
+ def _template_name_rewrite(self, tplguess):
780
+ base = "@"
781
+ if self._TEMPLATES_SUBDIR is not None:
782
+ base = "@{!s}/".format(self._TEMPLATES_SUBDIR)
783
+ if not tplguess.startswith(base):
784
+ tplguess = base + tplguess
785
+ if not tplguess.endswith(".tpl"):
786
+ tplguess += ".tpl"
787
+ return tplguess
788
+
789
+ def get_message(self, tpldict):
790
+ """Contents:
791
+
792
+ * from the fp if given, else the catalog gives the template file name.
793
+ * template-substituted.
794
+ * header and trailer are added.
795
+ """
796
+ tpl = self.message
797
+ if tpl == "":
798
+ tplfile = self.section.get("template", self.id)
799
+ tplfile = self._template_name_rewrite(tplfile)
800
+ try:
801
+ tpl = load_template(
802
+ self.ticket, tplfile, encoding=self.inputs_charset
803
+ )
804
+ except ValueError as exc:
805
+ logger.error("%s", exc.message)
806
+ return None
807
+ message = self.substitute(tpl, tpldict)
808
+ return self.header() + message + self.trailer()
809
+
810
+ def get_subject(self, tpldict):
811
+ """Subject:
812
+
813
+ * from the fp if given, else from the catalog.
814
+ * template-substituted.
815
+ """
816
+ tpl = self.subject
817
+ if tpl is None:
818
+ tpl = self.section.get("subject", None)
819
+ if tpl is None:
820
+ logger.error(
821
+ "Missing <subject> definition for id <%s>.", self.id
822
+ )
823
+ return None
824
+ subject = self.substitute(tpl, tpldict)
825
+ return subject
826
+
827
+ def get_to(self, tpldict):
828
+ """Recipients:
829
+
830
+ * from the fp if given, else from the catalog.
831
+ * template-substituted.
832
+ * expanded by the directory (if any).
833
+ * substituted again, to allow for $vars in the directory.
834
+ * directory-expanded again for domain completion and unicity.
835
+ """
836
+ tpl = self.to
837
+ if tpl is None:
838
+ tpl = self.section.get("to", None)
839
+ if tpl is None:
840
+ logger.error("Missing <to> definition for id <%s>.", self.id)
841
+ return None
842
+ to = self.substitute(tpl, tpldict)
843
+ if self.directory:
844
+ to = self.directory.get_addresses(to, add_domain=False)
845
+ # substitute again for directory definitions
846
+ to = self.substitute(to, tpldict)
847
+ # last resolution, plus add domain and remove duplicates
848
+ if self.directory:
849
+ to = self.directory.get_addresses(to)
850
+ return to
851
+
852
+ def prepare(self, add_ons=None):
853
+ """Prepare elements in turn, return True iff all succeeded."""
854
+ self.section = self.get_catalog_section()
855
+ if self.section is None:
856
+ return False
857
+
858
+ tpldict = self.substitution_dictionary(add_ons)
859
+ # Convert everything to unicode
860
+ for k in tpldict.keys():
861
+ tpldict[k] = str(tpldict[k])
862
+
863
+ self.message = self.get_message(tpldict)
864
+ if self.message is None:
865
+ return False
866
+
867
+ self.subject = self.get_subject(tpldict)
868
+ if self.subject is None:
869
+ return False
870
+
871
+ self.to = self.get_to(tpldict)
872
+ if self.to is None:
873
+ return False
874
+
875
+ return True
876
+
877
+ def __call__(self, *args):
878
+ """Main action:
879
+
880
+ * substitute templates where needed.
881
+ * apply directory definitions to recipients.
882
+ * activation is checked before sending via the Mail Service.
883
+
884
+ Arguments are passed as add_ons to the substitution dictionary.
885
+ """
886
+ add_ons = dict()
887
+ for arg in args:
888
+ add_ons.update(arg)
889
+ rc = False
890
+ if self.prepare(add_ons) and not self.dryrun:
891
+ rc = super().__call__()
892
+ return rc
893
+
894
+
895
+ class AbstractRdTemplatedMailService(TemplatedMailService):
896
+ _abstract = True
897
+
898
+ def header(self):
899
+ """String prepended to the message body."""
900
+ now = date.now()
901
+ stamp1 = now.strftime("%A %d %B %Y")
902
+ stamp2 = now.strftime("%X")
903
+ return "Email sent on {} at {} (from: {}).\n--\n\n".format(
904
+ stamp1, stamp2, self.sh.default_target.hostname
905
+ )
906
+
907
+ def substitution_dictionary(self, add_ons=None):
908
+ sdict = super().substitution_dictionary(add_ons=add_ons)
909
+ sdict["jobid"] = self.sh.guess_job_identifier()
910
+ # Try to detect MTOOL data (this may be empty if MTOOL is not used):
911
+ if self.env.MTOOL_STEP:
912
+ mt_stack = [
913
+ "\nMTOOL details:",
914
+ ]
915
+ mt_items = [
916
+ "mtool_step_{:s}".format(i)
917
+ for i in ("abort", "depot", "spool", "idnum")
918
+ if "mtool_step_{:s}".format(i) in self.env
919
+ ]
920
+ print_tablelike(
921
+ "{:s} = {!s}",
922
+ mt_items,
923
+ [self.env[i] for i in mt_items],
924
+ output_callback=mt_stack.append,
925
+ )
926
+ sdict["mtool_info"] = "\n".join(mt_stack) + "\n"
927
+ else:
928
+ sdict["mtool_info"] = ""
929
+ # The list of footprints' defaults
930
+ fpdefaults = footprints.setup.defaults
931
+ sdict["fpdefaults"] = pprint.pformat(fpdefaults, indent=2)
932
+ # A condensed indication on date/cutoff
933
+ sdict["timeid"] = fpdefaults.get("date", None)
934
+ if sdict["timeid"]:
935
+ sdict["timeid"] = sdict["timeid"].vortex(
936
+ cutoff=fpdefaults.get("cutoff", "X")
937
+ )
938
+ # The generic host/cluster name
939
+ sdict["host"] = self.sh.default_target.inetname
940
+ return sdict