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