Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# (c) Stefan Countryman 2019 

2 

3""" 

4A mechanism for vetoing steps in an analysis. 

5""" 

6 

7import os 

8import datetime 

9import uuid 

10import json 

11import logging 

12import functools 

13import shutil 

14from collections import namedtuple 

15from llama.flags import FlagsMixin 

16from llama.filehandler.classes import GenerationResult 

17 

18LOGGER = logging.getLogger(__name__) 

19 

20# this is just a function, so don't give it an uppercase constant var name 

21# pylint: disable=invalid-name 

22utcnow = datetime.datetime.utcnow 

23 

24 

25# define some common veto use cases 

26def eventdir_path_contains_string_scratch(eventdir): 

27 """If the path to the trigger directory contains the substring 'SCRATCH' 

28 (case-insensitive), this veto is triggered (returns True).""" 

29 return eventdir.upper().find('SCRATCH') != -1 

30 

31 

32def eventdir_path_contains_string_manual(eventdir): 

33 """If the path to the trigger directory contains the substring 'MANUAL' 

34 (case-insensitive), this veto is triggered (returns True).""" 

35 return eventdir.upper().find('MANUAL') != -1 

36 

37 

38def eventdir_path_contains_string_test(eventdir): 

39 """If the path to the trigger directory contains the substring 'TEST' 

40 (case-insensitive), this veto is triggered (returns True).""" 

41 return eventdir.upper().find('TEST') != -1 

42 

43 

44def eventdir_path_contains_string_injection(eventdir): 

45 """If the path to the trigger directory contains the substring 'INJECTION' 

46 (case-insensitive), this veto is triggered (returns True).""" 

47 return eventdir.upper().find('INJECTION') != -1 

48 

49 

50# this docstring component is also used in FileHandler, so explicitly make sure 

51# that it's the same string in both. 

52VETO_CHECKS_DOCSTRING=""" 

53An iterable of N length-2 iterables of the form: 

54 

55 ((veto1, action1), ..., (vetoN, actionN)) 

56 

57matching ``veto`` functions to ``action`` functions. 

58 

59``veto`` functions are functions that take ``eventdir`` as 

60arguments and return ``True`` if that particular veto criterion has 

61been met (for example, a ``veto`` could return ``True`` if a trigger 

62has somehow been marked as a test trigger). 

63 

64``action`` functions are callables that indicate some action that 

65should be taken to handle the ``veto``. ``action`` can also be ``None`` 

66for the default behavior: to mark the set of filenames (specified 

67by ``manifest``) for this trigger as vetoed, which would prevent 

68those files from being generated. 

69 

70Alternatively, you can also use an actual function for ``action`` 

71to do something more subtle than just vetoing the file (e.g. 

72delaying file generation, advanced error logging, dummy testing, 

73etc; this could even involve an alternative file generation method 

74for test cases or edge cases). The ``veto`` functions will be run in 

75order and the first to return ``True`` will have its ``action`` 

76executed; the rest will be ignored. 

77 

78*NOTE* that if you specify an action, the file will NOT be 

79vetoed by default (since your veto-handling might involve something 

80like delaying file generation or generating it through some other 

81method, both of which are cases where you are not actually trying 

82to prevent the file from being generated). If you want to fully 

83veto file generation, you will have to manually call this 

84``VetoHandler`` instance at the end of your ``action`` to mark these 

85files as PERMANENTLY vetoed and prevent file generation. 

86 

87A ``VetoException`` WILL always be raised, however, since the 

88called ``action`` is supposed to cancel any *IMMEDIATE* file 

89generation by the default method (even if later attempts are still 

90allowed). 

91""" 

92 

93 

94class VetoException(IOError): 

95 """ 

96 Indicates that a FileHandler's generation was vetoed and should proceed 

97 no futher at this time. This is a benign exception that should be caught at 

98 file generation time. 

99 """ 

100 

101 

102VetoHandlerTuple = namedtuple('VetoHandlerTuple', ('eventdir', 'manifest', 

103 'vetoes')) 

104 

105 

106def generation_dir_copy_vetoes(vetoed, tmpdir): 

107 """ 

108 Copy any vetofiles for the ``vetoed`` filehandler from the temporary 

109 directory ``tmpdir`` back to the eventdir. Vital for cleaning up after a 

110 failed generation attempt (in which the filehandler manifest would NOT be 

111 copied because the files should never have been generated; see 

112 _generation_dir_copy_manifest). 

113 """ 

114 veto_src_dest_paths = [ 

115 (os.path.join(tmpdir, f), os.path.join(vetoed.eventdir, f)) 

116 for f in vetoed.veto.vetofilenames 

117 ] 

118 for src, dest in veto_src_dest_paths: 

119 if os.path.isfile(src): 

120 LOGGER.info("Found vetofile %s in the temp directory; copying" 

121 "back to the real eventdir.", src) 

122 try: 

123 os.link(src, dest) 

124 except OSError: 

125 try: 

126 shutil.copy(src, dest) 

127 except shutil.SameFileError: 

128 pass 

129 

130 

131class VetoHandler(VetoHandlerTuple, FlagsMixin): 

132 """ 

133 A class that performs veto checks to see whether a FileHandler for a 

134 *specific* trigger should be vetoed. Instances can be called like functions 

135 to mark a FileHandler as vetoed. 

136 """ 

137 

138 def __new__(cls, eventdir, manifest, vetoes): 

139 """See ``__init__`` for signature. Coerces arguments into the proper 

140 (mutable) classes.""" 

141 return VetoHandlerTuple.__new__(cls, eventdir, tuple(manifest), 

142 tuple(vetoes)) 

143 

144 def __init__(self, eventdir, manifest, vetoes): 

145 """Specify which eventdir and filenames need to be checked for 

146 vetoes. 

147 

148 Parameters: 

149 ----------- 

150 eventdir : str 

151 The path to the directory in which the possibly-vetoed files would 

152 be stored if generated. 

153 manifest : array-like 

154 An iterable of file names for the files which might need to be 

155 vetoed. Should just be the same as the manifest from the 

156 FileHandler instance using this VetoHandler. 

157 vetoes : array-like 

158 """ 

159 # NOTE: This is a no-op since tuples don't use __init__. I'm putting 

160 # __init__ here just to store the docstring and __init__ signature in a 

161 # way accessible to ipython. 

162 

163 # pylint: disable=no-member 

164 __init__.__doc__ += 4*' '+VETO_CHECKS_DOCSTRING.replace('\n', 12*' '+'\n') 

165 

166 def __call__(self, message="", veto_function=None): 

167 """Mark this set of files as *PERMANENTLY* ``vetoed`` and hence not 

168 meant to be generated. 

169 

170 Parameters 

171 ---------- 

172 message : string, optional 

173 Optionally provide a descriptive string explaining why this veto 

174 occured (and possibly other details) to be saved in the 

175 vetofile. 

176 veto_function : callable or None, optional 

177 Optionally provide the veto check function that caused these files 

178 to be vetoed. If provided, that function's string representation 

179 and docstring will be logged to the permanent vetofiles to help 

180 determine why a veto was triggered. 

181 """ 

182 veto_metadata = { 

183 'manifest': format(self.manifest), 

184 'veto_uuid': str(uuid.uuid1()), 

185 'veto_time': utcnow().isoformat(), 

186 'veto_message': format(message), 

187 'veto_function': format(getattr(veto_function, '__name__', None)), 

188 'veto_function_doc': format( 

189 getattr(veto_function, '__doc__', None) 

190 ), 

191 } 

192 LOGGER.debug("Files vetoed: %s", json.dumps(veto_metadata, indent=4)) 

193 for vfp in self.vetofilepaths: 

194 with open(vfp, 'w') as vetofile: 

195 json.dump(veto_metadata, vetofile, indent=4, sort_keys=True) 

196 

197 def read_json(self): 

198 """Read in the JSON dump of this vetofile. Returns ``None`` if the file 

199 is not vetoed.""" 

200 if not self.permanently_vetoed(): 

201 return None 

202 with open(next(self.vetofilepaths)) as vetofile: 

203 return json.load(vetofile) 

204 

205 def unveto(self): 

206 """Remove *PERMANENT* veto status by deleting vetofiles for the file 

207 names specified in ``self.manifest`` and event directory specified by 

208 ``eventdir``. This does not affect the checks contained in 

209 ``self.vetoes`` that are called by ``self.check``, so file generation 

210 will still get vetoed if those automated checks fail. This method is 

211 more useful for removing permanent vetoes from files that were manually 

212 vetoed or vetoed under older pipeline versions.""" 

213 for vfp in self.vetofilepaths: 

214 if os.path.isfile(vfp): 

215 os.remove(vfp) 

216 

217 @property 

218 def vetofilenames(self): 

219 """A list of filenames whose existence indicates that these files 

220 should *never* be generated (at least for as long as these files 

221 exist). The contents of these files can optionally contain text 

222 descriptions of why the veto happened.""" 

223 return ('.{}.veto.json'.format(fname) for fname in self.manifest) 

224 

225 @property 

226 def vetofilepaths(self): 

227 """Absolute paths to vetofiles listed in ``self.vetofilenames`` 

228 (created by joining ``self.eventdir`` to those filenames).""" 

229 return (os.path.join(self.eventdir, vfn) for vfn in self.vetofilenames) 

230 

231 def permanently_vetoed(self): 

232 """Check whether the files in ``self.manifest`` should be generated 

233 based on whether their vetofiles exist (see ``vetofilepaths`` and 

234 ``vetofilenames``); as long as this is the case, the files will be 

235 considered permanently vetoed and should not be generated.""" 

236 return any(os.path.isfile(vfp) for vfp in self.vetofilepaths) 

237 

238 def check(self): 

239 """ 

240 No return value. Raises a ``VetoException`` if this file has somehow 

241 been vetoed, which should be interpreted as meaning that file 

242 generation should be aborted. 

243 

244 First checks if ``self.permanently_vetoed()`` returns ``True`` (this is 

245 to be interpreted as meaning that the file should never be generated); 

246 if so, ``VetoException`` is raised. 

247 

248 Next, runs all veto functions in ``self.vetoes`` to see if these 

249 files have been vetoed in any way. If and when the first veto fails 

250 (returns True), its corresponding action (see the ``vetoes`` argument 

251 in ``__init__``) is run, further vetoes are not checked, and 

252 ``VetoException`` is raised. 

253 

254 If these procedures are completed without a ``VetoException`` being 

255 raised, the method returns ``None``. 

256 """ 

257 LOGGER.debug("Running veto checks.") 

258 if self.flags['VETOED'] == 'true': 

259 LOGGER.debug("flagged as VETOED, vetoing %s", self) 

260 raise VetoException(("Event flagged as vetoed, so {} " 

261 "is vetoed implicitly.").format(self)) 

262 if self.permanently_vetoed(): 

263 LOGGER.debug("Permanently vetoed, raising VetoException.") 

264 raise VetoException(("Already vetoed: eventdir {} with " 

265 "manifest {}").format(self.eventdir, 

266 self.manifest)) 

267 for veto, action in self.vetoes: 

268 if veto(self.eventdir): 

269 if action is None: 

270 LOGGER.debug("Veto %s triggered, permanently vetoing.", 

271 veto.__name__ or veto) 

272 self("Permanently vetoed.", veto_function=veto) 

273 else: 

274 LOGGER.debug("Veto %s triggered, taking action: %s", 

275 veto.__name__ or veto, action.__name__) 

276 action() 

277 raise VetoException 

278 

279 

280class VetoMixin: 

281 """ 

282 A feature for FileHandler-like objects (producing a manifest and an 

283 eventdir) that defines a list of vetoes in a way that inherits additively 

284 from superclasses. Notably, provides a ``veto`` property returning a 

285 ``VetoHandler`` for this instance. New vetoes can be added to classes by 

286 defining them in ``class_vetoes`` (vetoes from superclasses 

287 ``class_vetoes`` attributes are dynamically included in the 

288 ``VetoHandler`` returned by ``veto``. See ``VetoMixin.vetoes`` docstring 

289 for details. 

290 """ 

291 

292 class_vetoes = tuple() 

293 

294 @property 

295 def veto(self): 

296 """Returns a ``VetoHandler`` instance for this ``FileHandler``. See 

297 ``VetoHandler`` docstring for more info. Combines vetoes from all 

298 superclasses.""" 

299 return VetoHandler(self.eventdir, self.manifest, self.vetoes()) 

300 

301 @classmethod 

302 def vetoes(cls): 

303 r"""The list of veto checks and their corresponding actions called by 

304 ``self.veto.checks()``. Returns the vetoes defined in 

305 ``cls.class_vetoes``, where vetoes specific to this class are defined, 

306 along with the vetoes returned by ``cls.class_vetoes`` in all 

307 superclasses; for example, the order of vetoes pulled from 

308 class_vetoes (in a class and and its superclasses) for a class F with 

309 inheritance tree 

310 

311 .. code:: 

312 

313 A B C 

314 \ / / 

315 D E 

316 \ / 

317 F 

318 

319 would be: F, D, A, B, E, C (with duplicates after the first instance of 

320 a veto check removed, where the ordering of superclasses is listed 

321 left-to-right in the above graph). 

322 

323 By default, no vetoes are applied to a base FileHandler. Vetoes must be 

324 added to FileHandler subclasses. If you really, truly need to get rid 

325 of vetoes from superclasses, consider not inheriting from those 

326 superclasses or factoring the veto functionality out of those 

327 superclasses as a mixin before overriding this property to only return 

328 a reduced veto list. 

329 

330 A description of the form of this data structure from the 

331 ``VetoHandler.__init__`` docstring: 

332 

333 """ 

334 # make sure every veto is a tuple for hashability 

335 vetoes = list(tuple(v) for v in cls.class_vetoes) 

336 for base in cls.__bases__: 

337 if hasattr(base, 'vetoes'): 

338 for veto in base.vetoes(): # pylint: disable=no-member 

339 veto_tuple = tuple(veto) 

340 if veto_tuple not in vetoes: 

341 vetoes.append(veto_tuple) 

342 return tuple(vetoes) 

343 vetoes.__func__.__doc__ += ( 

344 4*' ' + VETO_CHECKS_DOCSTRING.replace('\n', 12*' '+'\n') 

345 ) 

346 

347 @staticmethod 

348 def decorate_checkout(func): 

349 """ 

350 Copy any vetoes for this filehandler over to the generation directory. 

351 """ 

352 

353 @functools.wraps(func) 

354 def wrapper(self, *args, **kwargs): 

355 """ 

356 Copy any vetoes for this filehandler over to the generation 

357 directory. 

358 """ 

359 tmp_self = func(self, *args, **kwargs) 

360 try: 

361 for vfile in self.veto.vetofilenames: 

362 src = os.path.join(self.eventdir, vfile) 

363 dest = os.path.join(tmp_self.eventdir, vfile) 

364 if os.path.isfile(src): 

365 LOGGER.info('Found vetofile %s, copying to temp directory.', 

366 src) 

367 shutil.copy(src, dest) 

368 os.chmod(dest, 0o400) 

369 return tmp_self 

370 except: # noqa 

371 shutil.rmtree(tmp_self.rundir) 

372 raise 

373 

374 return wrapper 

375 

376 @staticmethod 

377 def decorate_checkin(func): 

378 """ 

379 If a ``VetoException`` was raised during generation, copy vetoes from 

380 the temporary event directory back to the parent directory and commit 

381 them. 

382 """ 

383 

384 @functools.wraps(func) 

385 def wrapper(self, gen_result, *args, **kwargs): 

386 """ 

387 If a ``VetoException`` was raised during generation, copy vetoes 

388 from the temporary event directory back to the parent directory and 

389 commit them. ``gen_result`` here refers to the ``GenerationResult`` 

390 that is being checked in. 

391 """ 

392 try: 

393 return func(self, gen_result, *args, **kwargs) 

394 except VetoException as exc: 

395 commit_msg = ( 

396 ("Veto activated on {}: {}.\nCopying vetofiles:\n" 

397 "{}\n").format(self, exc, 

398 '\n'.join(self.veto.vetofilenames)) 

399 ) 

400 LOGGER.info("Veto activated; comitting vetofiles: %s", 

401 self.veto.vetofilepaths) 

402 generation_dir_copy_vetoes(self, gen_result.fh.eventdir) 

403 self.git.commit_changes(commit_msg) 

404 raise 

405 

406 return wrapper 

407 

408 @staticmethod 

409 def decorate_generate(func): 

410 """ 

411 Check whether this filehandler has been vetoed before proceeding with 

412 generation. 

413 """ 

414 

415 @functools.wraps(func) 

416 def wrapper(self, *args, **kwargs): 

417 """ 

418 Check whether this filehandler has been vetoed before proceeding 

419 with generation. 

420 """ 

421 try: 

422 self.veto.check() 

423 except VetoException as err: 

424 return GenerationResult(self, err=err) 

425 return func(self, *args, **kwargs) 

426 

427 return wrapper