1 """
2 :mod:`pyffi.spells` --- High level file operations
3 ==================================================
4
5 .. note::
6
7 This module is based on wz's NifTester module, although
8 nothing of wz's original code is left in this module.
9
10 A :term:`toaster`, implemented by subclasses of the abstract
11 :class:`Toaster` class, walks over all files in a folder, and applies
12 one or more transformations on each file. Such transformations are
13 called :term:`spell`\ s, and are implemented by subclasses of the
14 abstract :class:`Spell` class.
15
16 A :term:`spell` can also run independently of a :term:`toaster` and be
17 applied on a branch directly. The recommended way of doing this is via
18 the :meth:`Spell.recurse` method.
19
20 Supported spells
21 ----------------
22
23 For format specific spells, refer to the corresponding module.
24
25 .. toctree::
26 :maxdepth: 2
27
28 pyffi.spells.cgf
29 pyffi.spells.dae
30 pyffi.spells.dds
31 pyffi.spells.kfm
32 pyffi.spells.nif
33 pyffi.spells.tga
34
35 Some spells are applicable on every file format, and those are documented
36 here.
37
38 .. autoclass:: SpellApplyPatch
39 :show-inheritance:
40 :members:
41
42 Adding new spells
43 -----------------
44
45 To create new spells, derive your custom spells from the :class:`Spell`
46 class, and include them in the :attr:`Toaster.SPELLS` attribute of your
47 toaster.
48
49 .. autoclass:: Spell
50 :show-inheritance:
51 :members: READONLY, SPELLNAME, data, stream, toaster,
52 __init__, recurse, _datainspect, datainspect, _branchinspect,
53 branchinspect, dataentry, dataexit, branchentry,
54 branchexit, toastentry, toastexit
55
56 Grouping spells together
57 ------------------------
58
59 It is also possible to create composite spells, that is, spells that
60 simply execute other spells. The following functions and classes can
61 be used for this purpose.
62
63 .. autofunction:: SpellGroupParallel
64
65 .. autofunction:: SpellGroupSeries
66
67 .. autoclass:: SpellGroupBase
68 :show-inheritance:
69 :members:
70 :undoc-members:
71
72
73 .. autoclass:: SpellGroupParallelBase
74 :show-inheritance:
75 :members:
76 :undoc-members:
77
78
79 .. autoclass:: SpellGroupSeriesBase
80 :show-inheritance:
81 :members:
82 :undoc-members:
83
84 Creating toaster scripts
85 ------------------------
86
87 To create a new toaster script, derive your toaster from the :class:`Toaster`
88 class, and set the :attr:`Toaster.FILEFORMAT` attribute of your toaster to
89 the file format class of the files it can toast.
90
91 .. autoclass:: Toaster
92 :show-inheritance:
93 :members:
94 :inherited-members:
95 :undoc-members:
96
97 """
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138 from ConfigParser import ConfigParser
139 from copy import deepcopy
140 from cStringIO import StringIO
141 import gc
142 from itertools import izip
143 import logging
144 try:
145 import multiprocessing
146 except ImportError:
147
148 multiprocessing = None
149 import optparse
150 import os
151 import os.path
152 import re
153 import shlex
154 import subprocess
155 import sys
156 import tempfile
157
158 import pyffi
159 import pyffi.object_models
162 """Spell base class. A spell takes a data file and then does something
163 useful with it. The main entry point for spells is :meth:`recurse`, so if you
164 are writing new spells, start with reading the documentation with
165 :meth:`recurse`.
166 """
167
168 data = None
169 """The :class:`~pyffi.object_models.FileFormat.Data` instance
170 this spell acts on."""
171
172 stream = None
173 """The current ``file`` being processed."""
174
175 toaster = None
176 """The :class:`Toaster` instance this spell is called from."""
177
178 changed = False
179 """Whether the spell changed the data. If ``True``, the file will be
180 written back, otherwise not.
181 """
182
183
184 READONLY = True
185 """A ``bool`` which determines whether the spell is read only or
186 not. Default value is ``True``. Override this class attribute, and
187 set to ``False``, when subclassing a spell that must write files
188 back to the disk.
189 """
190
191 SPELLNAME = None
192 """A ``str`` describing how to refer to the spell from the command line.
193 Override this class attribute when subclassing.
194 """
195
196 - def __init__(self, toaster=None, data=None, stream=None):
197 """Initialize the spell data.
198
199 :param data: The file :attr:`data`.
200 :type data: :class:`~pyffi.object_models.FileFormat.Data`
201 :param stream: The file :attr:`stream`.
202 :type stream: ``file``
203 :param toaster: The :attr:`toaster` this spell is called from (optional).
204 :type toaster: :class:`Toaster`
205 """
206 self.data = data
207 self.stream = stream
208 self.toaster = toaster if toaster else Toaster()
209
211 """This is called after :meth:`pyffi.object_models.FileFormat.Data.inspect` has
212 been called, and before :meth:`pyffi.object_models.FileFormat.Data.read` is
213 called.
214
215 :return: ``True`` if the file must be processed, ``False`` otherwise.
216 :rtype: ``bool``
217 """
218
219 return True
220
222 """This is called after :meth:`pyffi.object_models.FileFormat.Data.inspect` has
223 been called, and before :meth:`pyffi.object_models.FileFormat.Data.read` is
224 called. Override this function for customization.
225
226 :return: ``True`` if the file must be processed, ``False`` otherwise.
227 :rtype: ``bool``
228 """
229
230
231 return True
232
234 """Check if spell should be cast on this branch or not, based on
235 exclude and include options passed on the command line. You should
236 not need to override this function: if you need additional checks on
237 whether a branch must be parsed or not, override the :meth:`branchinspect`
238 method.
239
240 :param branch: The branch to check.
241 :type branch: :class:`~pyffi.utils.graph.GlobalNode`
242 :return: ``True`` if the branch must be processed, ``False`` otherwise.
243 :rtype: ``bool``
244 """
245
246 return self.toaster.is_admissible_branch_class(branch.__class__)
247
249 """Like :meth:`_branchinspect`, but for customization: can be overridden to
250 perform an extra inspection (the default implementation always
251 returns ``True``).
252
253 :param branch: The branch to check.
254 :type branch: :class:`~pyffi.utils.graph.GlobalNode`
255 :return: ``True`` if the branch must be processed, ``False`` otherwise.
256 :rtype: ``bool``
257 """
258 return True
259
261 """Helper function which calls :meth:`_branchinspect` and :meth:`branchinspect`
262 on the branch,
263 if both successful then :meth:`branchentry` on the branch, and if this is
264 succesful it calls :meth:`recurse` on the branch's children, and
265 once all children are done, it calls :meth:`branchexit`.
266
267 Note that :meth:`_branchinspect` and :meth:`branchinspect` are not called upon
268 first entry of this function, that is, when called with :attr:`data` as
269 branch argument. Use :meth:`datainspect` to stop recursion into this branch.
270
271 Do not override this function.
272
273 :param branch: The branch to start the recursion from, or ``None``
274 to recurse the whole tree.
275 :type branch: :class:`~pyffi.utils.graph.GlobalNode`
276 """
277
278 if branch is None:
279 branch = self.data
280
281 if branch is self.data:
282 self.toaster.msgblockbegin(
283 "--- %s ---" % self.SPELLNAME)
284 if self.dataentry():
285
286
287
288 for child in branch.get_global_child_nodes():
289 self.recurse(child)
290 self.dataexit()
291 self.toaster.msgblockend()
292 elif self._branchinspect(branch) and self.branchinspect(branch):
293 self.toaster.msgblockbegin(
294 """~~~ %s [%s] ~~~"""
295 % (branch.__class__.__name__,
296 branch.get_global_display()))
297
298 if self.branchentry(branch):
299
300
301
302 for child in branch.get_global_child_nodes():
303 self.recurse(child)
304 self.branchexit(branch)
305 self.toaster.msgblockend()
306
307 - def dataentry(self):
308 """Called before all blocks are recursed.
309 The default implementation simply returns ``True``.
310 You can access the data via :attr:`data`, and unlike in the
311 :meth:`datainspect` method, the full file has been processed at this stage.
312
313 Typically, you will override this function to perform a global
314 operation on the file data.
315
316 :return: ``True`` if the children must be processed, ``False`` otherwise.
317 :rtype: ``bool``
318 """
319 return True
320
321 - def branchentry(self, branch):
322 """Cast the spell on the given branch. First called with branch equal to
323 :attr:`data`'s children, then the grandchildren, and so on.
324 The default implementation simply returns ``True``.
325
326 Typically, you will override this function to perform an operation
327 on a particular block type and/or to stop recursion at particular
328 block types.
329
330 :param branch: The branch to cast the spell on.
331 :type branch: :class:`~pyffi.utils.graph.GlobalNode`
332 :return: ``True`` if the children must be processed, ``False`` otherwise.
333 :rtype: ``bool``
334 """
335 return True
336
338 """Cast a spell on the given branch, after all its children,
339 grandchildren, have been processed, if :meth:`branchentry` returned
340 ``True`` on the given branch.
341
342 Typically, you will override this function to perform a particular
343 operation on a block type, but you rely on the fact that the children
344 must have been processed first.
345
346 :param branch: The branch to cast the spell on.
347 :type branch: :class:`~pyffi.utils.graph.GlobalNode`
348 """
349 pass
350
352 """Called after all blocks have been processed, if :meth:`dataentry`
353 returned ``True``.
354
355 Typically, you will override this function to perform a final spell
356 operation, such as writing back the file in a special way, or making a
357 summary log.
358 """
359 pass
360
361 @classmethod
362 - def toastentry(cls, toaster):
363 """Called just before the toaster starts processing
364 all files. If it returns ``False``, then the spell is not used.
365 The default implementation simply returns ``True``.
366
367 For example, if the spell only acts on a particular block type, but
368 that block type is excluded, then you can use this function to flag
369 that this spell can be skipped. You can also use this function to
370 initialize statistics data to be aggregated from files, to
371 initialize a log file, and so.
372
373 :param toaster: The toaster this spell is called from.
374 :type toaster: :class:`Toaster`
375 :return: ``True`` if the spell applies, ``False`` otherwise.
376 :rtype: ``bool``
377 """
378 return True
379
380 @classmethod
382 """Called when the toaster has finished processing
383 all files.
384
385 :param toaster: The toaster this spell is called from.
386 :type toaster: :class:`Toaster`
387 """
388 pass
389
390 @classmethod
392 """Returns the stream that the toaster will write to. The
393 default implementation calls ``toaster.get_toast_stream``, but
394 spells that write to different file(s) can override this
395 method.
396 """
397 return toaster.get_toast_stream(filename, test_exists=test_exists)
398
400 """Base class for grouping spells. This implements all the spell grouping
401 functions that fall outside of the actual recursing (:meth:`__init__`,
402 :meth:`toastentry`, :meth:`_datainspect`, :meth:`datainspect`, and :meth:`toastexit`).
403 """
404
405 SPELLCLASSES = []
406 """List of :class:`Spell`\ s of this group (not instantiated)."""
407
408 ACTIVESPELLCLASSES = []
409 """List of active spells of this group (not instantiated).
410 This list is automatically built when :meth:`toastentry` is called.
411 """
412
413 spells = []
414 """List of active spell instances."""
415
416 - def __init__(self, toaster=None, data=None, stream=None):
431
433 """Inspect every spell with L{Spell.datainspect} and keep
434 those spells that must be cast."""
435 self.spells = [spell for spell in self.spells
436 if spell.datainspect()]
437 return bool(self.spells)
438
439 @classmethod
440 - def toastentry(cls, toaster):
441 cls.ACTIVESPELLCLASSES = [
442 spellclass for spellclass in cls.SPELLCLASSES
443 if spellclass.toastentry(toaster)]
444 return bool(cls.ACTIVESPELLCLASSES)
445
446 @classmethod
450
452 """Base class for running spells in series."""
454 """Recurse spells in series."""
455 for spell in self.spells:
456 spell.recurse(branch)
457
458
459
460
462 raise RuntimeError("use recurse")
463
464 - def branchentry(self, branch):
465 raise RuntimeError("use recurse")
466
468 raise RuntimeError("use recurse")
469
470 - def dataentry(self):
471 raise RuntimeError("use recurse")
472
474 raise RuntimeError("use recurse")
475
476 @property
479
481 """Base class for running spells in parallel (that is, with only
482 a single recursion in the tree).
483 """
485 """Inspect spells with :meth:`Spell.branchinspect` (not all checks are
486 executed, only keeps going until a spell inspection returns ``True``).
487 """
488 return any(spell.branchinspect(branch) for spell in self.spells)
489
490 - def branchentry(self, branch):
491 """Run all spells."""
492
493 return bool([spell.branchentry(branch) for spell in self.spells])
494
498
499 - def dataentry(self):
500 """Look into every spell with :meth:`Spell.dataentry`."""
501 self.spells = [spell for spell in self.spells
502 if spell.dataentry()]
503 return bool(self.spells)
504
506 """Look into every spell with :meth:`Spell.dataexit`."""
507 for spell in self.spells:
508 spell.dataexit()
509
510 @property
513
515 """Class factory for grouping spells in series."""
516 return type("".join(spellclass.__name__ for spellclass in args),
517 (SpellGroupSeriesBase,),
518 {"SPELLCLASSES": args,
519 "SPELLNAME":
520 " | ".join(spellclass.SPELLNAME for spellclass in args),
521 "READONLY":
522 all(spellclass.READONLY for spellclass in args)})
523
525 """Class factory for grouping spells in parallel."""
526 return type("".join(spellclass.__name__ for spellclass in args),
527 (SpellGroupParallelBase,),
528 {"SPELLCLASSES": args,
529 "SPELLNAME":
530 " & ".join(spellclass.SPELLNAME for spellclass in args),
531 "READONLY":
532 all(spellclass.READONLY for spellclass in args)})
533
535 """A spell for applying a patch on files."""
536
537 SPELLNAME = "applypatch"
538
540 """There is no need to read the whole file, so we apply the patch
541 already at inspection stage, and stop the spell process by returning
542 ``False``.
543
544 :return: ``False``
545 :rtype: ``bool``
546 """
547
548 patchcmd = self.toaster.options["patchcmd"]
549 if not patchcmd:
550 raise ValueError("must specify a patch command")
551
552 oldfile = self.stream
553 oldfilename = oldfile.name
554 newfilename = oldfilename + ".patched"
555 patchfilename = oldfilename + ".patch"
556 self.toaster.msg("writing %s..." % newfilename)
557
558 oldfile.close()
559 subprocess.call([patchcmd, oldfilename, newfilename, patchfilename])
560
561
562 return False
563
566 """Simple logger for testing."""
567 level = logging.DEBUG
568
569 @classmethod
570 - def _log(cls, level, level_str, msg):
571
572 if level >= cls.level:
573 print("pyffi.toaster:%s:%s" % (level_str, msg))
574
575 @classmethod
577 cls._log(logging.ERROR, "ERROR", msg)
578
579 @classmethod
580 - def warn(cls, msg):
581 cls._log(logging.WARNING, "WARNING", msg)
582
583 @classmethod
584 - def info(cls, msg):
585 cls._log(logging.INFO, "INFO", msg)
586
587 @classmethod
589 cls._log(logging.DEBUG, "DEBUG", msg)
590
591 @classmethod
594
596 """For multiprocessing. This function creates a new toaster, with the
597 given options and spells, and calls the toaster on filename.
598 """
599
600 class multiprocessing_fake_logger(fake_logger):
601 """Simple logger which works well along with multiprocessing
602 on all platforms.
603 """
604 @classmethod
605 def _log(cls, level, level_str, msg):
606
607 if level >= cls.level:
608 print("pyffi.toaster:%i:%s:%s"
609 % (multiprocessing.current_process().pid,
610 level_str, msg))
611
612 toasterclass, filename, options, spellnames = args
613 toaster = toasterclass(options=options, spellnames=spellnames,
614 logger=multiprocessing_fake_logger)
615
616
617 if not toaster.spellclass.toastentry(toaster):
618 self.msg("spell does not apply! quiting early...")
619 return
620
621
622 stream = open(filename, mode='rb' if toaster.spellclass.READONLY else 'r+b')
623 toaster._toast(stream)
624
625
626 toaster.spellclass.toastexit(toaster)
627
628
629 if multiprocessing:
630 try:
631 CPU_COUNT = multiprocessing.cpu_count()
632 except NotImplementedError:
633 CPU_COUNT = 1
634 else:
635 CPU_COUNT = 1
638 """Toaster base class. Toasters run spells on large quantities of files.
639 They load each file and pass the data structure to any number of spells.
640 """
641
642 FILEFORMAT = pyffi.object_models.FileFormat
643 """The file format class (a subclass of
644 :class:`~pyffi.object_models.FileFormat`)."""
645
646 SPELLS = []
647 """List of all available :class:`~pyffi.spells.Spell` classes."""
648
649 EXAMPLES = ""
650 """Some examples which describe typical use of the toaster."""
651
652 ALIASDICT = {}
653 """Dictionary with aliases for spells."""
654
655 DEFAULT_OPTIONS = dict(
656 raisetesterror=False, verbose=1, pause=False,
657 exclude=[], include=[], examples=False,
658 spells=False,
659 interactive=True,
660 helpspell=False, dryrun=False, prefix="", suffix="", arg="",
661 createpatch=False, applypatch=False, diffcmd="", patchcmd="",
662 series=False,
663 skip=[], only=[],
664 jobs=CPU_COUNT, refresh=32,
665 sourcedir="", destdir="",
666 archives=False,
667 resume=False,
668 inifile="")
669
670 """List of spell classes of the particular :class:`Toaster` instance."""
671
672 options = {}
673 """The options of the toaster, as ``dict``."""
674
675 spellnames = []
676 """A list of the names of the spells."""
677
678 top = ""
679 """Name of the top folder to toast."""
680
681 indent = 0
682 """An ``int`` which describes the current indentation level for messages."""
683
684 logger = logging.getLogger("pyffi.toaster")
685 """A :class:`logging.Logger` for toaster log messages."""
686
687 include_types = []
688 """Tuple of types corresponding to the include key of :attr:`options`."""
689
690 exclude_types = []
691 """Tuple of types corresponding to the exclude key of :attr:`options`."""
692
693 only_regexs = []
694 """Tuple of regular expressions corresponding to the only key of
695 :attr:`options`."""
696
697 skip_regexs = []
698 """Tuple of regular expressions corresponding to the skip key of
699 :attr:`options`."""
700
701 - def __init__(self, spellclass=None, options=None, spellnames=None,
702 logger=None):
727
729 """Synchronize some fields with given options."""
730
731 if self.options["verbose"] <= 0:
732 logging.getLogger("pyffi").setLevel(logging.WARNING)
733 self.logger.setLevel(logging.WARNING)
734 elif self.options["verbose"] == 1:
735 logging.getLogger("pyffi").setLevel(logging.INFO)
736 self.logger.setLevel(logging.INFO)
737 else:
738 logging.getLogger("pyffi").setLevel(logging.DEBUG)
739 self.logger.setLevel(logging.DEBUG)
740
741 if self.options["createpatch"] and self.options["applypatch"]:
742 raise ValueError(
743 "options --diff and --patch are mutually exclusive")
744 if self.options["diffcmd"] and not(self.options["createpatch"]):
745 raise ValueError(
746 "option --diff-cmd can only be used with --diff")
747 if self.options["patchcmd"] and not(self.options["applypatch"]):
748 raise ValueError(
749 "option --patch-cmd can only be used with --patch")
750
751 if (multiprocessing is None) and self.options["jobs"] > 1:
752 self.logger.warn(
753 "multiprocessing not supported on this platform")
754 self.options["jobs"] = 1
755
756 self.include_types = tuple(
757 getattr(self.FILEFORMAT, block_type)
758 for block_type in self.options["include"])
759 self.exclude_types = tuple(
760 getattr(self.FILEFORMAT, block_type)
761 for block_type in self.options["exclude"])
762
763 self.skip_regexs = tuple(
764 re.compile(regex) for regex in self.options["skip"])
765 self.only_regexs = tuple(
766 re.compile(regex) for regex in self.options["only"])
767
769 """Update spell class from given list of spell names."""
770
771 spellclasses = []
772 if not self.spellnames:
773 raise ValueError("no spells specified")
774 for spellname in self.spellnames:
775
776 if spellname in self.ALIASDICT:
777 self.logger.warning(
778 "The %s spell is deprecated and will be removed"
779 " from a future release; use the %s spell as a"
780 " replacement" % (spellname, self.ALIASDICT[spellname]))
781 spellname = self.ALIASDICT[spellname]
782
783 spellklasses = [spellclass for spellclass in self.SPELLS
784 if spellclass.SPELLNAME == spellname]
785 if not spellklasses:
786 raise ValueError(
787 "%s is not a known spell" % spellname)
788 if len(spellklasses) > 1:
789 raise ValueError(
790 "multiple spells are called %s (BUG?)" % spellname)
791 spellclasses.extend(spellklasses)
792
793 if len(spellclasses) > 1:
794 if self.options["series"]:
795 self.spellclass = SpellGroupSeries(*spellclasses)
796 else:
797 self.spellclass = SpellGroupParallel(*spellclasses)
798 else:
799 self.spellclass = spellclasses[0]
800
801 - def msg(self, message):
802 """Write log message with :meth:`logger.info`, taking into account
803 :attr:`indent`.
804
805 :param message: The message to write.
806 :type message: ``str``
807 """
808 for line in message.split("\n"):
809 self.logger.info(" " * self.indent + line)
810
812 """Acts like :meth:`msg`, but also increases :attr:`indent` after writing the
813 message."""
814 self.msg(message)
815 self.indent += 1
816
818 """Acts like :meth:`msg`, but also decreases :attr:`indent` before writing the
819 message, but if the message argument is ``None``, then no message is
820 printed."""
821 self.indent -= 1
822 if not(message is None):
823 self.msg(message)
824
826 """Helper function which checks whether a given branch type should
827 have spells cast on it or not, based in exclude and include options.
828
829 >>> from pyffi.formats.nif import NifFormat
830 >>> class MyToaster(Toaster):
831 ... FILEFORMAT = NifFormat
832 >>> toaster = MyToaster() # no include or exclude: all admissible
833 >>> toaster.is_admissible_branch_class(NifFormat.NiProperty)
834 True
835 >>> toaster.is_admissible_branch_class(NifFormat.NiNode)
836 True
837 >>> toaster.is_admissible_branch_class(NifFormat.NiAVObject)
838 True
839 >>> toaster.is_admissible_branch_class(NifFormat.NiLODNode)
840 True
841 >>> toaster.is_admissible_branch_class(NifFormat.NiMaterialProperty)
842 True
843 >>> toaster = MyToaster(options={"exclude": ["NiProperty", "NiNode"]})
844 >>> toaster.is_admissible_branch_class(NifFormat.NiProperty)
845 False
846 >>> toaster.is_admissible_branch_class(NifFormat.NiNode)
847 False
848 >>> toaster.is_admissible_branch_class(NifFormat.NiAVObject)
849 True
850 >>> toaster.is_admissible_branch_class(NifFormat.NiLODNode)
851 False
852 >>> toaster.is_admissible_branch_class(NifFormat.NiMaterialProperty)
853 False
854 >>> toaster = MyToaster(options={"include": ["NiProperty", "NiNode"]})
855 >>> toaster.is_admissible_branch_class(NifFormat.NiProperty)
856 True
857 >>> toaster.is_admissible_branch_class(NifFormat.NiNode)
858 True
859 >>> toaster.is_admissible_branch_class(NifFormat.NiAVObject)
860 False
861 >>> toaster.is_admissible_branch_class(NifFormat.NiLODNode) # NiNodes are!
862 True
863 >>> toaster.is_admissible_branch_class(NifFormat.NiMaterialProperty) # NiProperties are!
864 True
865 >>> toaster = MyToaster(options={"include": ["NiProperty", "NiNode"], "exclude": ["NiMaterialProperty", "NiLODNode"]})
866 >>> toaster.is_admissible_branch_class(NifFormat.NiProperty)
867 True
868 >>> toaster.is_admissible_branch_class(NifFormat.NiNode)
869 True
870 >>> toaster.is_admissible_branch_class(NifFormat.NiAVObject)
871 False
872 >>> toaster.is_admissible_branch_class(NifFormat.NiLODNode)
873 False
874 >>> toaster.is_admissible_branch_class(NifFormat.NiSwitchNode)
875 True
876 >>> toaster.is_admissible_branch_class(NifFormat.NiMaterialProperty)
877 False
878 >>> toaster.is_admissible_branch_class(NifFormat.NiAlphaProperty)
879 True
880 """
881
882
883 if not issubclass(branchtype, self.exclude_types):
884
885
886 if not self.include_types:
887
888
889 return True
890 elif issubclass(branchtype, self.include_types):
891
892 return True
893
894
895 return False
896
897 @staticmethod
899 r"""Initializes spell classes and options from an ini file.
900
901 >>> import pyffi.spells.nif
902 >>> import pyffi.spells.nif.modify
903 >>> class NifToaster(pyffi.spells.nif.NifToaster):
904 ... SPELLS = [pyffi.spells.nif.modify.SpellDelBranches]
905 >>> import tempfile
906 >>> cfg = tempfile.NamedTemporaryFile(delete=False)
907 >>> cfg.write("[main]\n")
908 >>> cfg.write("spell = modify_delbranches\n")
909 >>> cfg.write("folder = tests/nif/test_vertexcolor.nif\n")
910 >>> cfg.write("[options]\n")
911 >>> cfg.write("source-dir = tests/\n")
912 >>> cfg.write("dest-dir = _tests/\n")
913 >>> cfg.write("exclude = NiVertexColorProperty NiStencilProperty\n")
914 >>> cfg.write("skip = 'testing quoted string' normal_string\n")
915 >>> cfg.close()
916 >>> toaster = NifToaster(logger=fake_logger)
917 >>> import sys
918 >>> sys.argv = [
919 ... "niftoaster.py",
920 ... "--ini-file=utilities/toaster/default.ini",
921 ... "--ini-file=%s" % cfg.name,
922 ... "--noninteractive", "--jobs=1"]
923 >>> toaster.cli()
924 pyffi.toaster:INFO:=== tests/nif/test_vertexcolor.nif ===
925 pyffi.toaster:INFO: --- modify_delbranches ---
926 pyffi.toaster:INFO: ~~~ NiNode [Scene Root] ~~~
927 pyffi.toaster:INFO: ~~~ NiTriStrips [Cube] ~~~
928 pyffi.toaster:INFO: ~~~ NiStencilProperty [] ~~~
929 pyffi.toaster:INFO: stripping this branch
930 pyffi.toaster:INFO: ~~~ NiSpecularProperty [] ~~~
931 pyffi.toaster:INFO: ~~~ NiMaterialProperty [Material] ~~~
932 pyffi.toaster:INFO: ~~~ NiVertexColorProperty [] ~~~
933 pyffi.toaster:INFO: stripping this branch
934 pyffi.toaster:INFO: ~~~ NiTriStripsData [] ~~~
935 pyffi.toaster:INFO:creating destination path _tests/nif
936 pyffi.toaster:INFO: writing _tests/nif/test_vertexcolor.nif
937 pyffi.toaster:INFO:Finished.
938 >>> import os
939 >>> os.remove(cfg.name)
940 >>> os.remove("_tests/nif/test_vertexcolor.nif")
941 >>> os.rmdir("_tests/nif/")
942 >>> os.rmdir("_tests/")
943 >>> for name, value in sorted(toaster.options.items()):
944 ... print("%s: %s" % (name, value))
945 applypatch: False
946 archives: False
947 arg:
948 createpatch: False
949 destdir: _tests/
950 diffcmd:
951 dryrun: False
952 examples: False
953 exclude: ['NiVertexColorProperty', 'NiStencilProperty']
954 helpspell: False
955 include: []
956 inifile:
957 interactive: False
958 jobs: 1
959 only: []
960 patchcmd:
961 pause: True
962 prefix:
963 raisetesterror: False
964 refresh: 32
965 resume: True
966 series: False
967 skip: ['testing quoted string', 'normal_string']
968 sourcedir: tests/
969 spells: False
970 suffix:
971 verbose: 1
972 """
973 ini_parser = ConfigParser()
974
975 ini_parser.read(value)
976
977 if ini_parser.has_section("options"):
978 for opt_str, opt_values in ini_parser.items("options"):
979 option = parser._long_opt["--" + opt_str]
980 for opt_value in shlex.split(opt_values):
981 option.process(opt_str, opt_value, parser.values, parser)
982
983 if ini_parser.has_section("main"):
984 if ini_parser.has_option("main", "spell"):
985 toaster.spellnames.extend(ini_parser.get("main", "spell").split())
986 if ini_parser.has_option("main", "folder"):
987 toaster.top = ini_parser.get("main", "folder")
988
990 """Command line interface: initializes spell classes and options from
991 the command line, and run the :meth:`toast` method.
992 """
993
994 usage = "%prog [options] <spell1> <spell2> ... <file>|<folder>"
995 description = (
996 "Apply the spells <spell1>, <spell2>, and so on,"
997 " on <file>, or recursively on <folder>.")
998 errormessage_numargs = (
999 "incorrect number of arguments (use the --help option for help)")
1000
1001 parser = optparse.OptionParser(
1002 usage,
1003 version="%%prog (PyFFI %s)" % pyffi.__version__,
1004 description=description)
1005 parser.add_option(
1006 "--archives", dest="archives",
1007 action="store_true",
1008 help="also parse files inside archives")
1009 parser.add_option(
1010 "-a", "--arg", dest="arg",
1011 type="string",
1012 metavar="ARG",
1013 help="pass argument ARG to each spell")
1014 parser.add_option(
1015 "--dest-dir", dest="destdir",
1016 type="string",
1017 metavar="DESTDIR",
1018 help=
1019 "write files to DESTDIR"
1020 " instead of overwriting the original;"
1021 " this is done by replacing SOURCEDIR by DESTDIR"
1022 " in all source file paths")
1023 parser.add_option(
1024 "--diff", dest="createpatch",
1025 action="store_true",
1026 help=
1027 "write a binary patch"
1028 " instead of overwriting the original")
1029 parser.add_option(
1030 "--diff-cmd", dest="diffcmd",
1031 type="string",
1032 metavar="CMD",
1033 help=
1034 "use CMD as diff command; this command must accept precisely"
1035 " 3 arguments: 'CMD oldfile newfile patchfile'.")
1036 parser.add_option(
1037 "--dry-run", dest="dryrun",
1038 action="store_true",
1039 help=
1040 "save modification to temporary file"
1041 " instead of overwriting the original"
1042 " (for debugging)")
1043 parser.add_option(
1044 "--examples", dest="examples",
1045 action="store_true",
1046 help="show examples of usage and exit")
1047 parser.add_option(
1048 "--help-spell", dest="helpspell",
1049 action="store_true",
1050 help="show help specific to the given spells")
1051 parser.add_option(
1052 "-i", "--include", dest="include",
1053 type="string",
1054 action="append",
1055 metavar="BLOCK",
1056 help=
1057 "include only block type BLOCK in spell; if this option is"
1058 " not specified, then all block types are included except"
1059 " those specified under --exclude; include multiple block"
1060 " types by specifying this option more than once")
1061 parser.add_option(
1062 "--ini-file", dest="inifile",
1063 type="string",
1064 action="callback",
1065 callback=self.parse_inifile,
1066 callback_kwargs={'toaster': self},
1067 metavar="FILE",
1068 help=
1069 "read all options from FILE; if specified, all other arguments"
1070 " are ignored; to take options from multiple ini files, specify"
1071 " more than once")
1072 parser.add_option(
1073 "-j", "--jobs", dest="jobs",
1074 type="int",
1075 metavar="JOBS",
1076 help="allow JOBS jobs at once [default: %default]")
1077 parser.add_option(
1078 "--noninteractive", dest="interactive",
1079 action="store_false",
1080 help="non-interactive session (overwrites files without warning)")
1081 parser.add_option(
1082 "--only", dest="only",
1083 type="string",
1084 action="append",
1085 metavar="REGEX",
1086 help=
1087 "only toast files whose names"
1088 " (i) contain the regular expression REGEX, and"
1089 " (ii) do not contain any regular expression specified with --skip;"
1090 " if specified multiple times, the expressions are 'ored'")
1091 parser.add_option(
1092 "--overwrite", dest="resume",
1093 action="store_false",
1094 help="overwrite existing files (also see --resume)")
1095 parser.add_option(
1096 "--patch", dest="applypatch",
1097 action="store_true",
1098 help="apply all binary patches")
1099 parser.add_option(
1100 "--patch-cmd", dest="patchcmd",
1101 type="string",
1102 metavar="CMD",
1103 help=
1104 "use CMD as patch command; this command must accept precisely "
1105 "3 arguments: 'CMD oldfile newfile patchfile'.""")
1106 parser.add_option(
1107 "-p", "--pause", dest="pause",
1108 action="store_true",
1109 help="pause when done")
1110 parser.add_option(
1111 "--prefix", dest="prefix",
1112 type="string",
1113 metavar="PREFIX",
1114 help=
1115 "prepend PREFIX to file name when saving modification"
1116 " instead of overwriting the original")
1117 parser.add_option(
1118 "-r", "--raise", dest="raisetesterror",
1119 action="store_true",
1120 help="raise exception on errors during the spell (for debugging)")
1121 parser.add_option(
1122 "--refresh", dest="refresh",
1123 type="int",
1124 metavar="REFRESH",
1125 help=
1126 "start new process pool every JOBS * REFRESH files"
1127 " if JOBS is 2 or more"
1128 " (when processing a large number of files, this prevents"
1129 " leaking memory on some operating systems) [default: %default]")
1130 parser.add_option(
1131 "--resume", dest="resume",
1132 action="store_true",
1133 help="do not overwrite existing files")
1134 parser.add_option(
1135 "--series", dest="series",
1136 action="store_true",
1137 help="run spells in series rather than in parallel")
1138 parser.add_option(
1139 "--skip", dest="skip",
1140 type="string",
1141 action="append",
1142 metavar="REGEX",
1143 help=
1144 "skip all files whose names contain the regular expression REGEX"
1145 " (takes precedence over --only);"
1146 " if specified multiple times, the expressions are 'ored'")
1147 parser.add_option(
1148 "--source-dir", dest="sourcedir",
1149 type="string",
1150 metavar="SOURCEDIR",
1151 help=
1152 "see --dest-dir")
1153 parser.add_option(
1154 "--spells", dest="spells",
1155 action="store_true",
1156 help="list all spells and exit")
1157 parser.add_option(
1158 "--suffix", dest="suffix",
1159 type="string",
1160 metavar="SUFFIX",
1161 help="append SUFFIX to file name when saving modification"
1162 " instead of overwriting the original")
1163 parser.add_option(
1164 "-v", "--verbose", dest="verbose",
1165 type="int",
1166 metavar="LEVEL",
1167 help="verbosity level: 0, 1, or 2 [default: %default]")
1168 parser.add_option(
1169 "-x", "--exclude", dest="exclude",
1170 type="string",
1171 action="append",
1172 metavar="BLOCK",
1173 help=
1174 "exclude block type BLOCK from spell; exclude multiple"
1175 " block types by specifying this option more than once")
1176 parser.set_defaults(**deepcopy(self.DEFAULT_OPTIONS))
1177 (options, args) = parser.parse_args()
1178
1179
1180 self.options = {}
1181 for optionname in dir(options):
1182
1183 if not optionname in dir(optparse.Values):
1184 self.options[optionname] = getattr(options, optionname)
1185
1186
1187 self._update_options()
1188
1189
1190 if options.spells:
1191 for spellclass in self.SPELLS:
1192 print(spellclass.SPELLNAME)
1193 return
1194 if options.examples:
1195 print(self.EXAMPLES)
1196 return
1197
1198
1199 if options.applypatch:
1200 if len(args) > 1:
1201 parser.error("when using --patch, do not specify a spell")
1202
1203 self.spellclass = SpellApplyPatch
1204
1205 if args:
1206 self.top = args[-1]
1207 elif not self.top:
1208 parser.error("no folder or file specified")
1209 else:
1210
1211 if options.helpspell:
1212
1213 self.spellnames = args[:]
1214 self._update_spellclass()
1215 self.msg(self.spellclass.__doc__)
1216 return
1217 if not args:
1218
1219 if not(self.top and self.spellnames):
1220 parser.error(errormessage_numargs)
1221 elif len(args) == 1:
1222
1223 if not(self.spellnames):
1224 parser.error(errormessage_numargs)
1225 self.top = args[-1]
1226 else:
1227
1228 self.spellnames.extend(args[:-1])
1229
1230 self.top = args[-1]
1231
1232 self._update_spellclass()
1233
1234 if not self.options["archives"]:
1235 self.toast(self.top)
1236 else:
1237 self.toast_archives(self.top)
1238
1239
1240 self.logger.info("Finished.")
1241 if options.pause and options.interactive:
1242 raw_input("Press enter...")
1243
1245 """Returns whether to toast a filename or not, based on
1246 skip_regexs and only_regexs.
1247 """
1248 if any(regex.search(filename) for regex in self.skip_regexs):
1249
1250 return False
1251 if not self.only_regexs:
1252
1253 return True
1254 if any(regex.search(filename) for regex in self.only_regexs):
1255
1256 return True
1257 else:
1258
1259 return False
1260
1262 """Walk over all files in a directory tree and cast spells
1263 on every file.
1264
1265 :param top: The directory or file to toast.
1266 :type top: str
1267 """
1268
1269 def file_pools(chunksize):
1270 """Helper function which generates list of files, sorted by size,
1271 in chunks of given size.
1272 """
1273 all_files = pyffi.utils.walk(
1274 top, onerror=None,
1275 re_filename=self.FILEFORMAT.RE_FILENAME)
1276 while True:
1277
1278 file_pool = [
1279 filename for i, filename in izip(
1280 xrange(chunksize), all_files)]
1281 if not file_pool:
1282
1283 break
1284
1285 file_pool.sort(key=os.path.getsize, reverse=True)
1286 yield file_pool
1287
1288
1289 if not self.spellclass.toastentry(self):
1290 self.msg("spell does not apply! quiting early...")
1291 return
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301 pause = self.options.get("pause", False)
1302
1303
1304 interactive = self.options.get("interactive", False)
1305
1306 dryrun = self.options.get("dryrun", False)
1307 prefix = self.options.get("prefix", "")
1308 suffix = self.options.get("suffix", "")
1309 destdir = self.options.get("destdir", "")
1310 sourcedir = self.options.get("sourcedir", "")
1311 createpatch = self.options.get("createpatch", False)
1312 applypatch = self.options.get("applypatch", False)
1313 jobs = self.options.get("jobs", CPU_COUNT)
1314
1315
1316 if not sourcedir:
1317
1318 if os.path.isfile(top):
1319 sourcedir = os.path.dirname(top)
1320 else:
1321 sourcedir = top
1322
1323 self.options["sourcedir"] = sourcedir
1324
1325
1326 if not top.startswith(sourcedir):
1327 raise ValueError(
1328 "invalid --source-dir: %s does not start with %s"
1329 % (top, sourcedir))
1330
1331
1332 if ((not self.spellclass.READONLY) and (not dryrun)
1333 and (not prefix) and (not createpatch)
1334 and interactive and (not suffix) and (not destdir)):
1335 print("""\
1336 This script will modify your files, in particular if something goes wrong it
1337 may destroy them. Make a backup of your files before running this script.
1338 """)
1339 if not raw_input(
1340 "Are you sure that you want to proceed? [n/y] ") in ("y", "Y"):
1341 self.logger.info("Script aborted by user.")
1342 if pause:
1343 raw_input("Press enter...")
1344 return
1345
1346
1347
1348 if jobs == 1:
1349 for stream in self.FILEFORMAT.walk(
1350 top, mode='rb' if self.spellclass.READONLY else 'r+b'):
1351 pass
1352 self._toast(stream)
1353
1354 gc.collect()
1355 pass
1356 else:
1357 chunksize = self.options["refresh"] * self.options["jobs"]
1358 self.msg("toasting with %i threads in chunks of %i files"
1359 % (jobs, chunksize))
1360 for file_pool in file_pools(chunksize):
1361 self.logger.debug("process file pool:")
1362 for filename in file_pool:
1363 self.logger.debug(" " + filename)
1364 pool = multiprocessing.Pool(processes=jobs)
1365
1366
1367
1368 result = pool.map_async(
1369 _toaster_job,
1370 ((self.__class__, filename, self.options, self.spellnames)
1371 for filename in file_pool),
1372 chunksize=1)
1373
1374
1375 result.wait(timeout=99999999)
1376 pool.close()
1377 pool.join()
1378
1379
1380 self.spellclass.toastexit(self)
1381
1383 """Toast all files in all archives."""
1384 if not self.FILEFORMAT.ARCHIVE_CLASSES:
1385 self.logger.info("No known archives contain this file format.")
1386
1387 for filename_in in pyffi.utils.walk(top):
1388 for ARCHIVE_CLASS in self.FILEFORMAT.ARCHIVE_CLASSES:
1389
1390 if not ARCHIVE_CLASS.RE_FILENAME.match(filename_in):
1391 continue
1392
1393 try:
1394 archive_in = ARCHIVE_CLASS.Data(name=filename_in, mode='r')
1395 except ValueError:
1396 self.logger.warn("archive format not recognized, skipped")
1397 continue
1398
1399
1400 if not self.spellclass.READONLY:
1401 for member in archive_in.get_members():
1402 self._toast_member(member)
1403 else:
1404 file_out = tempfile.TemporaryFile()
1405 archive_out = ARCHIVE_CLASS.Data(fileobj=file_out, mode='w')
1406 for member in archive_in.get_members():
1407 self._toast(member)
1408 archive_out.add(member)
1409 archive_out.close()
1410 archive_in.close()
1411
1467
1469 """Get the name of where the input file *filename* would
1470 be written to by the toaster: head, root, and extension.
1471
1472 :param filename: The name of the hypothetical file to be
1473 toasted.
1474 :type filename: :class:`str`
1475 :return: The head, root, and extension of the destination, or
1476 ``(None, None, None)`` if ``--dryrun`` is specified.
1477 :rtype: :class:`tuple` of three :class:`str`\ s
1478 """
1479
1480 if self.options["dryrun"]:
1481 return (None, None, None)
1482
1483 head, tail = os.path.split(filename)
1484 root, ext = os.path.splitext(tail)
1485
1486
1487 if self.options["destdir"]:
1488 if not self.options["sourcedir"]:
1489 raise ValueError(
1490 "--dest-dir specified without --source-dir")
1491 if not head.startswith(self.options["sourcedir"]):
1492 raise ValueError(
1493 "invalid --source-dir: %s does not start with %s"
1494 % (filename, self.options["sourcedir"]))
1495 head = head.replace(
1496 self.options["sourcedir"], self.options["destdir"], 1)
1497 return (
1498 head,
1499 self.options["prefix"] + root + self.options["suffix"],
1500 ext,
1501 )
1502
1504 """Calls :meth:`get_toast_head_root_ext(filename)`
1505 to determine the name of the toast file, and return
1506 a stream for writing accordingly.
1507
1508 Then return a stream where result can be written to, or
1509 in case test_exists is True, test if result would overwrite a
1510 file. More specifically, if test_exists is True, then no
1511 streams are created, and True is returned if the file
1512 already exists, and False is returned otherwise.
1513 """
1514 if self.options["dryrun"]:
1515 if test_exists:
1516 return False
1517 else:
1518 self.msg("writing to temporary file")
1519 return tempfile.TemporaryFile()
1520 head, root, ext = self.get_toast_head_root_ext(filename)
1521 if not os.path.exists(head):
1522 if test_exists:
1523
1524
1525 return False
1526 else:
1527 self.logger.info("creating destination path %s" % head)
1528 os.makedirs(head)
1529 filename = os.path.join(head, root + ext)
1530 if test_exists:
1531 return os.path.exists(filename)
1532 else:
1533 if os.path.exists(filename):
1534 self.msg("overwriting %s" % filename)
1535 else:
1536 self.msg("writing %s" % filename)
1537 return open(filename, "wb")
1538
1539 - def write(self, stream, data):
1540 """Writes the data to data and raises an exception if the
1541 write fails, but restores file if fails on overwrite.
1542 """
1543 outstream = self.spellclass.get_toast_stream(self, stream.name)
1544 if stream is outstream:
1545
1546 stream.seek(0)
1547 backup = stream.read(-1)
1548 stream.seek(0)
1549 try:
1550 try:
1551 data.write(outstream)
1552 except:
1553 self.msg("write failed!!!")
1554 if stream is outstream:
1555 self.msg("attempting to restore original file...")
1556 stream.seek(0)
1557 stream.write(backup)
1558 stream.truncate()
1559 else:
1560 outstream_name = outstream.name
1561 self.msg("removing incompletely written file...")
1562 outstream.close()
1563
1564
1565 if os.path.exists(outstream_name):
1566 os.remove(outstream_name)
1567 raise
1568 if stream is outstream:
1569 stream.truncate()
1570 finally:
1571 outstream.close()
1572
1574 """Creates a binary patch for the updated file."""
1575 diffcmd = self.options.get('diffcmd')
1576 if not diffcmd:
1577 raise ValueError("must specify a diff command")
1578
1579
1580
1581 self.options["suffix"] = ".tmp"
1582 newfile = self.spellclass.get_toast_stream(self, stream.name)
1583 try:
1584 data.write(newfile)
1585 except:
1586 self.msg("write failed!!!")
1587 raise
1588
1589 oldfile = stream
1590 oldfilename = oldfile.name
1591 patchfilename = newfile.name[:-4] + ".patch"
1592
1593 oldfile.close()
1594 newfile.close()
1595 self.msg("calling %s" % diffcmd)
1596 subprocess.call([diffcmd, oldfilename, newfilename, patchfilename])
1597
1598 os.remove(newfilename)
1599
1600 if __name__ == '__main__':
1601 import doctest
1602 doctest.testmod()
1603