Merge lp:~renamer-developers/renamer/intents into lp:renamer

Proposed by Jonathan Jacobs
Status: Needs review
Proposed branch: lp:~renamer-developers/renamer/intents
Merge into: lp:renamer
Diff against target: 1087 lines (+565/-217)
11 files modified
docs/code/plugins_command.py (+6/-7)
docs/plugins.rst (+12/-7)
renamer/application.py (+42/-57)
renamer/errors.py (+7/-0)
renamer/intents.py (+146/-0)
renamer/irenamer.py (+38/-14)
renamer/plugin.py (+5/-74)
renamer/plugins/audio.py (+12/-10)
renamer/plugins/tv.py (+5/-5)
renamer/plugins/undo.py (+29/-43)
renamer/test/test_intents.py (+263/-0)
To merge this branch: bzr merge lp:~renamer-developers/renamer/intents
Reviewer Review Type Date Requested Status
Renamer developers Pending
Review via email: mp+39634@code.launchpad.net

Description of the change

Move hardcoded renaming functionality from the core to a more flexible system.

To post a comment you must log in.
91. By Jonathan Jacobs

Merge trunk.

Revision history for this message
Jonathan Jacobs (jjacobs) wrote :

Conflicts between this branch and trunk have been resolved.

92. By Jonathan Jacobs

Fix option name typo.

93. By Jonathan Jacobs

Merge trunk.

94. By Jonathan Jacobs

Update documentation and examples to use the new intent API.

Unmerged revisions

94. By Jonathan Jacobs

Update documentation and examples to use the new intent API.

93. By Jonathan Jacobs

Merge trunk.

92. By Jonathan Jacobs

Fix option name typo.

91. By Jonathan Jacobs

Merge trunk.

90. By Jonathan Jacobs

Fix bugs exposed by tests.

89. By Jonathan Jacobs

Tests for renamer.intents.

88. By Jonathan Jacobs

Tweaks.

87. By Jonathan Jacobs

Implement command intents which are returned from ICommand.process and acted on by the core.

Intents allow commands to specify custom behaviour (such as performing custom actions) and reuse existing behaviour, as well as making the Renamer core cleaner.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'docs/code/plugins_command.py'
2--- docs/code/plugins_command.py 2010-10-24 18:57:10 +0000
3+++ docs/code/plugins_command.py 2010-11-10 10:12:38 +0000
4@@ -1,25 +1,24 @@
5-import os
6 import string
7 import time
8
9+from renamer.intents import Rename
10+from renamer.errors import PluginError
11 from renamer.plugin import RenamingCommand
12-from renamer.errors import PluginError
13
14
15 class ReadableTimestamps(RenamingCommand):
16 # The name of our command, as it will be used from the command-line.
17 name = 'timestamp'
18
19+
20 # A brief description of the command's purpose, displayed in --help output.
21 description = 'Rename files with POSIX timestamps to human-readble times.'
22
23+
24 # Command-line parameters we support.
25 optParameters = [
26 ('format', 'f', '%Y-%m-%d %H-%M-%S', 'strftime format.')]
27
28- # The default name template to use if no name template is specified via the
29- # command-line or configuration file.
30- defaultNameTemplate = string.Template('$time')
31
32 # IRenamerCommand
33
34@@ -38,5 +37,5 @@
35 # Convert and format the timestamp according to the "format"
36 # command-line parameter.
37 t = time.localtime(timestamp)
38- return {
39- 'time': time.strftime(self['format'], t)}
40+ name = time.strftime(self['format'], t)
41+ return Rename({}, arg, nameTemplate=name)
42
43=== modified file 'docs/plugins.rst'
44--- docs/plugins.rst 2010-10-24 18:57:10 +0000
45+++ docs/plugins.rst 2010-11-10 10:12:38 +0000
46@@ -43,11 +43,16 @@
47 invoking your command and performing the actual file renaming.
48
49 At the heart of a renaming command is ``processArgument`` which accepts one
50-argument and returns a Python dictionary. That dictionary is then used to
51-perform `template substitution`_ on the ``name`` and ``prefix`` command-line
52-options (or, if they're not given, command-specific defaults.) This process of
53-calling ``processArgument`` is repeated for each argument given, letting your
54-command process one argument at a time.
55+argument and returns an intent (something implementing ``ICommandIntent``) to
56+be acted on by the core, which will ultimately perform some actions and produce
57+an end result for the command. This process of calling ``processArgument`` is
58+repeated for each argument, letting your command process one argument at a
59+time.
60+
61+The ``Rename`` intent, probably the most common intent in Renamer, performs
62+`template substitution`_ on the ``name`` and ``prefix`` command-line options
63+(or, if they're not given, command-specific defaults) before finally renaming
64+the argument.
65
66 .. _template substitution:
67 http://docs.python.org/library/string.html#template-strings
68@@ -58,8 +63,8 @@
69
70 If your command performs a long-running task, such as fetching data from a web
71 server, you can return a `Deferred`_ from ``processArgument`` that should
72-ultimately return with a Python dictionary to be used in assembling the
73-destination filename.
74+ultimately return an ``ICommandIntent`` provider that will be acted on by the
75+Renamer core.
76
77 .. _Deferred:
78 http://twistedmatrix.com/documents/current/core/howto/deferredindepth.html
79
80=== modified file 'renamer/application.py'
81--- renamer/application.py 2010-11-01 08:48:08 +0000
82+++ renamer/application.py 2010-11-10 10:12:38 +0000
83@@ -1,7 +1,6 @@
84 """
85 Renamer application logic.
86 """
87-import itertools
88 import os
89 import string
90 import sys
91@@ -13,7 +12,7 @@
92 from twisted.python.filepath import FilePath
93
94 from renamer import config, logging, plugin, util, version
95-from renamer.irenamer import IRenamingCommand
96+from renamer.irenamer import ICommandIntent
97 from renamer.history import History
98
99
100@@ -40,9 +39,7 @@
101
102 @property
103 def subCommands(self):
104- commands = itertools.chain(
105- plugin.getRenamingCommands(), plugin.getCommands())
106- for plg in commands:
107+ for plg in plugin.getCommands():
108 try:
109 yield (
110 plg.name,
111@@ -57,6 +54,7 @@
112 super(Options, self).__init__()
113 self['verbosity'] = 1
114 self.config = config
115+ self.args = [None]
116
117
118 @property
119@@ -172,35 +170,9 @@
120 return command
121
122
123- def performRename(self, dst, src):
124- """
125- Perform a file rename.
126- """
127- if self.options['no-act']:
128- logging.msg('Simulating: %s => %s' % (src.path, dst.path))
129- return
130-
131- if src == dst:
132- logging.msg('Skipping noop "%s"' % (src.path,), verbosity=2)
133- return
134-
135- if self.options['link-dst']:
136- self.changeset.do(
137- self.changeset.newAction(u'symlink', src, dst),
138- self.options)
139- else:
140- self.changeset.do(
141- self.changeset.newAction(u'move', src, dst),
142- self.options)
143- if self.options['link-src']:
144- self.changeset.do(
145- self.changeset.newAction(u'symlink', dst, src),
146- self.options)
147-
148-
149 def runCommand(self, command):
150 """
151- Run a generic command.
152+ Run a command.
153 """
154 logging.msg(
155 'Using command "%s"' % (command.name,),
156@@ -211,33 +183,46 @@
157 return defer.maybeDeferred(command.process, self, self.options)
158
159
160- def runRenamingCommand(self, command):
161- """
162- Run a renaming command.
163- """
164- def _processOne(src):
165- self.currentArgument = src
166- d = self.runCommand(command)
167- d.addCallback(self.performRename, src)
168- return d
169-
170- self.changeset = self.history.newChangeset()
171+ def _actOnIntent(self, intent, changeset):
172+ """
173+ Act on a L{renamer.irenamer.ICommandIntent}.
174+ """
175+ if intent is None:
176+ return
177+
178+ logging.msg('Intent: %s' % (intent.asHumanly(self.options),),
179+ verbosity=2)
180+ if self.options['no-act']:
181+ return
182+ return intent.act(changeset, self.command, self.options)
183+
184+
185+ def _processOne(self, src, changeset):
186+ """
187+ Process a single command argument and act on the resulting
188+ L{renamer.irenamer.ICommandIntent}.
189+ """
190+ self.currentArgument = src
191+ d = self.runCommand(self.command)
192+ d.addCallback(ICommandIntent, None)
193+ d.addCallback(self._actOnIntent, changeset)
194+ return d
195+
196+
197+ def run(self):
198+ """
199+ Begin processing commands.
200+ """
201 logging.msg(
202 'Running, doing at most %d concurrent operations' % (
203 self.options['concurrency'],),
204 verbosity=3)
205- return util.parallel(
206- self.args, self.options['concurrency'], _processOne)
207-
208-
209- def run(self):
210- """
211- Begin processing commands.
212- """
213- if IRenamingCommand(type(self.command), None) is not None:
214- d = self.runRenamingCommand(self.command)
215- else:
216- d = self.runCommand(self.command)
217+ changeset = self.history.newChangeset()
218+ d = util.parallel(
219+ self.options.args,
220+ self.options['concurrency'],
221+ self._processOne,
222+ changeset)
223 d.addCallback(self.exit)
224 return d
225
226@@ -246,6 +231,6 @@
227 """
228 Perform the exit routine.
229 """
230- # We can safely do this even with "no-act", since nothing was actioned
231- # and there is no point leaving orphaned Items around.
232+ # We can safely do this even with "no-act", since if nothing was
233+ # actioned and there is no point leaving orphaned Items around.
234 self.history.pruneChangesets()
235
236=== modified file 'renamer/errors.py'
237--- renamer/errors.py 2010-10-10 22:37:54 +0000
238+++ renamer/errors.py 2010-11-10 10:12:38 +0000
239@@ -23,3 +23,10 @@
240 """
241 A destination file already exists.
242 """
243+
244+
245+
246+class IntentError(ValueError):
247+ """
248+ An errror occured while acting on a L{renamer.irenamer.ICommandIntent}.
249+ """
250
251=== added file 'renamer/intents.py'
252--- renamer/intents.py 1970-01-01 00:00:00 +0000
253+++ renamer/intents.py 2010-11-10 10:12:38 +0000
254@@ -0,0 +1,146 @@
255+import os
256+import string
257+from zope.interface import implements
258+
259+from twisted.python.filepath import FilePath
260+
261+from renamer import errors, logging
262+from renamer.irenamer import ICommandIntent
263+
264+
265+
266+class Rename(object):
267+ """
268+ Intent to rename a file.
269+
270+ Template substitution is applied to filenames and prefixes.
271+
272+ @type mapping: C{dict} mapping C{str} to C{unicode}
273+ @ivar mapping: Mapping of template variables, used for template
274+ substitution.
275+
276+ @type src: L{twisted.python.filepath.FilePath}
277+ @ivar src: Source path.
278+
279+ @type prefixTemplate: C{string.Template}
280+ @ivar prefixTemplate: String template for the prefix format to use if one
281+ is not supplied in the configuration. Defaults to the directory path of
282+ L{src}.
283+
284+ @type nameTemplate: C{string.Template}
285+ @ivar nameTemplate: String template for the name format to use if one is
286+ not supplied in the configuration.
287+ """
288+ implements(ICommandIntent)
289+
290+
291+ def __init__(self, mapping, src, prefixTemplate=None, nameTemplate=None):
292+ self.mapping = mapping
293+ self.src = src
294+ if prefixTemplate is None:
295+ prefixTemplate = self.src.dirname()
296+ if isinstance(prefixTemplate, (unicode, str)):
297+ prefixTemplate = string.Template(prefixTemplate)
298+ self.prefixTemplate = prefixTemplate
299+
300+ if isinstance(nameTemplate, (unicode, str)):
301+ nameTemplate = string.Template(nameTemplate)
302+ self.nameTemplate = nameTemplate
303+
304+
305+ def getDestination(self, options):
306+ """
307+ Get the destination path.
308+
309+ Substitution of L{mapping} into the C{'prefix'} command-line option
310+ (defaulting to L{prefixTemplate}) and the C{'name'} command-line option
311+ (defaulting to L{nameTemplate}) is performed.
312+
313+ @type options: L{twisted.python.usage.Options}
314+
315+ @rtype: L{twisted.python.filepath.FilePath}
316+ @return: Destination path.
317+ """
318+ prefixTemplate = options['prefix']
319+ if prefixTemplate is None:
320+ prefixTemplate = self.prefixTemplate
321+
322+ prefix = os.path.expanduser(
323+ prefixTemplate.safe_substitute(self.mapping))
324+
325+ ext = self.src.splitext()[-1]
326+
327+ nameTemplate = options['name']
328+ if nameTemplate is None:
329+ nameTemplate = self.nameTemplate
330+
331+ if nameTemplate is None:
332+ raise errors.IntentError('Missing name template')
333+
334+ filename = nameTemplate.safe_substitute(self.mapping)
335+ logging.msg(
336+ 'Building filename: prefix=%r name=%r mapping=%r' % (
337+ prefixTemplate.template, nameTemplate.template, self.mapping),
338+ verbosity=3)
339+ return FilePath(prefix).child(filename).siblingExtension(ext)
340+
341+
342+ # ICommandIntent
343+
344+ def act(self, changeset, command, options):
345+ src = self.src
346+ dst = self.getDestination(options)
347+
348+ if options['link-dst']:
349+ changeset.do(
350+ changeset.newAction(u'symlink', src, dst),
351+ options)
352+ else:
353+ changeset.do(
354+ changeset.newAction(u'move', src, dst),
355+ options)
356+ if options['link-src']:
357+ changeset.do(
358+ changeset.newAction(u'symlink', dst, src),
359+ options)
360+
361+
362+ def asHumanly(self, options):
363+ src = self.src.path
364+ dst = self.getDestination(options).path
365+ return u'%s: %s => %s' % (
366+ type(self).__name__, src, dst)
367+
368+
369+
370+class Undo(object):
371+ """
372+ Intent to undo history action.
373+
374+ @type actions: C{list} of L{renamer.history.Action}
375+ """
376+ implements(ICommandIntent)
377+
378+
379+ def __init__(self, actions):
380+ self.actions = list(actions)
381+
382+
383+ # ICommandIntent
384+
385+ def act(self, changeset, command, options):
386+ for action in self.actions:
387+ cs = action.changeset
388+
389+ logging.msg('Undo: %s' % (action.asHumanly()), verbosity=3)
390+ try:
391+ cs.undo(action, options)
392+ except OSError, e:
393+ if not command['ignore-errors']:
394+ raise e
395+ logging.msg('Ignoring %r' % (e,), verbosity=3)
396+
397+
398+ def asHumanly(self, options):
399+ return u'%s: %r' % (
400+ type(self).__name__, self.actions)
401
402=== modified file 'renamer/irenamer.py'
403--- renamer/irenamer.py 2010-10-20 18:08:59 +0000
404+++ renamer/irenamer.py 2010-11-10 10:12:38 +0000
405@@ -23,6 +23,9 @@
406 @type renamer: L{renamer.application.Renamer}
407
408 @type options: C{dict}
409+
410+ @rtype: L{renamer.irenamer.ICommandIntent}
411+ @return: Intent to act on; or C{None}.
412 """
413
414
415@@ -31,24 +34,12 @@
416 """
417 Command that performs renaming on one argument at a time.
418 """
419- defaultNameTemplate = Attribute("""
420- String template for the default name format to use if one is not supplied.
421- """)
422-
423-
424- defaultPrefixTemplate = Attribute("""
425- String template for the default prefix format to use if one is not
426- supplied.
427- """)
428-
429-
430 def processArgument(argument):
431 """
432 Process an argument.
433
434- @rtype: C{dict} mapping C{unicode} to C{unicode}
435- @return: Mapping of keys to values to substitute info the name
436- template.
437+ @rtype: L{renamer.irenamer.ICommandIntent}
438+ @return: Intent to act on; or C{None}.
439 """
440
441
442@@ -81,3 +72,36 @@
443
444 @type options: C{dict}
445 """
446+
447+
448+
449+class ICommandIntent(Interface):
450+ """
451+ A command intent that can be acted on.
452+
453+ Intents to be acted on are returned from
454+ L{renamer.irenamer.ICommand.process}.
455+ """
456+ def act(changeset, command, options):
457+ """
458+ Act on an intent.
459+
460+ @type changeset: L{renamer.history.Changeset}
461+ @param changeset: Changeset this intent will recorded in, any undo-able
462+ actions taken should be recorded in this changeset.
463+
464+ @type command: L{renamer.irenamer.ICommand}
465+ @param command: Command being executed.
466+
467+ @type options: L{dict}
468+ @param options: Configuration options.
469+ """
470+
471+
472+ def asHumanly(options):
473+ """
474+ Construct a human readable representation of the intent.
475+
476+ @type options: L{dict}
477+ @param options: Configuration options.
478+ """
479
480=== modified file 'renamer/plugin.py'
481--- renamer/plugin.py 2010-10-20 18:08:59 +0000
482+++ renamer/plugin.py 2010-11-10 10:12:38 +0000
483@@ -1,12 +1,9 @@
484-import os
485-import string
486 import sys
487-from zope.interface import noLongerProvides
488+from zope.interface import implements, noLongerProvides
489
490 from twisted.plugin import getPlugins, IPlugin
491 from twisted.internet import defer
492 from twisted.python import usage
493-from twisted.python.filepath import FilePath
494
495 from renamer import errors, logging, plugins
496 from renamer.irenamer import ICommand, IRenamingCommand, IRenamingAction
497@@ -22,14 +19,6 @@
498
499
500
501-def getRenamingCommands():
502- """
503- Get all available L{renamer.irenamer.IRenamingCommand}s.
504- """
505- return getPlugins(IRenamingCommand, plugins)
506-
507-
508-
509 def getActions():
510 """
511 Get all available L{renamer.irenamer.IRenamingAction}s.
512@@ -102,74 +91,18 @@
513
514
515
516-class RenamingCommandMeta(InterfaceProvidingMetaclass):
517- providedInterfaces = [IPlugin, IRenamingCommand]
518-
519-
520-
521-class RenamingCommand(_CommandMixin, usage.Options):
522+class RenamingCommand(Command):
523 """
524 Top-level renaming command.
525
526 This command will display in the main help listing.
527 """
528- __metaclass__ = RenamingCommandMeta
529+ implements(IRenamingCommand)
530
531
532 synopsis = '[options] <argument> [argument ...]'
533
534
535- # IRenamingCommand
536-
537- defaultPrefixTemplate = None
538- defaultNameTemplate = None
539-
540-
541- def buildDestination(self, mapping, options, src):
542- """
543- Build a destination path.
544-
545- Substitution of C{mapping} into the C{'prefix'} command-line option
546- (defaulting to L{defaultPrefixTemplate}) and the C{'name'} command-line
547- option (defaulting to L{defaultNameTemplate}) is performed.
548-
549- @type mapping: C{dict} mapping C{str} to C{unicode}
550- @param mapping: Mapping of template variables, used for template
551- substitution.
552-
553- @type options: C{dict}
554-
555- @type src: L{twisted.python.filepath.FilePath}
556- @param src: Source path.
557-
558- @rtype: L{twisted.python.filepath.FilePath}
559- @return: Destination path.
560- """
561- prefixTemplate = options['prefix']
562- if prefixTemplate is None:
563- prefixTemplate = self.defaultPrefixTemplate
564-
565- if prefixTemplate is not None:
566- prefix = os.path.expanduser(
567- prefixTemplate.safe_substitute(mapping))
568- else:
569- prefixTemplate = string.Template(src.dirname())
570- prefix = prefixTemplate.template
571-
572- ext = src.splitext()[-1]
573-
574- nameTemplate = options['name']
575- if nameTemplate is None:
576- nameTemplate = self.defaultNameTemplate
577-
578- filename = nameTemplate.safe_substitute(mapping)
579- logging.msg(
580- 'Building filename: prefix=%r name=%r mapping=%r' % (
581- prefixTemplate.template, nameTemplate.template, mapping),
582- verbosity=3)
583- return FilePath(prefix).child(filename).siblingExtension(ext)
584-
585-
586 def parseArgs(self, *args):
587 # Parse args like our parent (hopefully renamer.application.Options)
588 # which decodes and unglobs stuff.
589@@ -183,9 +116,7 @@
590 arg = renamer.currentArgument
591 logging.msg('Processing "%s"' % (arg.path,),
592 verbosity=3)
593- d = defer.maybeDeferred(self.processArgument, arg)
594- d.addCallback(self.buildDestination, options, arg)
595- return d
596+ return defer.maybeDeferred(self.processArgument, arg)
597
598
599 # IRenamingCommand
600@@ -195,7 +126,7 @@
601 'RenamingCommands must implement "processArgument"')
602
603 noLongerProvides(RenamingCommand, IPlugin)
604-noLongerProvides(RenamingCommand, IRenamingCommand)
605+noLongerProvides(RenamingCommand, ICommand)
606
607
608
609
610=== modified file 'renamer/plugins/audio.py'
611--- renamer/plugins/audio.py 2010-10-15 08:51:27 +0000
612+++ renamer/plugins/audio.py 2010-11-10 10:12:38 +0000
613@@ -1,4 +1,3 @@
614-import string
615 from functools import partial
616
617 try:
618@@ -7,7 +6,7 @@
619 except ImportError:
620 mutagen = None
621
622-from renamer import logging
623+from renamer import intents, logging
624 from renamer.plugin import RenamingCommand
625 from renamer.errors import PluginError
626
627@@ -29,12 +28,10 @@
628 """
629
630
631- defaultPrefixTemplate = string.Template(
632- '${artist}/${album} (${date})')
633-
634-
635- defaultNameTemplate = string.Template(
636- '${tracknumber}. ${title}')
637+ defaultPrefixTemplate = '${artist}/${album} (${date})'
638+
639+
640+ defaultNameTemplate = '${tracknumber}. ${title}'
641
642
643 def postOptions(self):
644@@ -83,13 +80,18 @@
645 return int(tracknumber)
646
647
648- # IRenamerCommand
649+ # IRenamingCommand
650
651 def processArgument(self, arg):
652 T = partial(self.getTag, arg)
653- return dict(
654+ mapping = dict(
655 artist=T([u'artist', u'TPE1']),
656 album=T([u'album', u'TALB']),
657 title=T([u'title', u'TIT2']),
658 date=T([u'date', u'year', u'TDRC']),
659 tracknumber=self._saneTracknumber(T([u'tracknumber', u'TRCK'])))
660+ return intents.Rename(
661+ mapping,
662+ arg,
663+ prefixTemplate=self.defaultPrefixTemplate,
664+ nameTemplate=self.defaultNameTemplate)
665
666=== modified file 'renamer/plugins/tv.py'
667--- renamer/plugins/tv.py 2010-10-27 00:48:13 +0000
668+++ renamer/plugins/tv.py 2010-11-10 10:12:38 +0000
669@@ -1,4 +1,3 @@
670-import string
671 import urllib
672
673 try:
674@@ -11,7 +10,7 @@
675
676 from twisted.web.client import getPage
677
678-from renamer import logging
679+from renamer import intents, logging
680 from renamer.plugin import RenamingCommand
681 from renamer.errors import PluginError
682 try:
683@@ -92,8 +91,7 @@
684 """
685
686
687- defaultNameTemplate = string.Template(
688- '$series [${season}x${padded_episode}] - $title')
689+ defaultNameTemplate = '$series [${season}x${padded_episode}] - $title'
690
691
692 optParameters = [
693@@ -190,11 +188,13 @@
694 return fetcher(url).addCallback(self.extractMetadata)
695
696
697- # IRenamerCommand
698+ # IRenamingCommand
699
700 def processArgument(self, arg):
701 seriesName, season, episode = self.extractParts(
702 arg.basename(), overrides=self)
703 d = self.lookupMetadata(seriesName, season, episode)
704 d.addCallback(self.buildMapping)
705+ d.addCallback(
706+ intents.Rename, arg, nameTemplate=self.defaultNameTemplate)
707 return d
708
709=== modified file 'renamer/plugins/undo.py'
710--- renamer/plugins/undo.py 2010-10-31 19:34:03 +0000
711+++ renamer/plugins/undo.py 2010-11-10 10:12:38 +0000
712@@ -1,6 +1,6 @@
713 from twisted.python import usage
714
715-from renamer import logging
716+from renamer import intents, logging
717 from renamer.history import Action, Changeset
718 from renamer.plugin import Command, SubCommand
719
720@@ -25,55 +25,36 @@
721
722
723
724-class _UndoMixin(object):
725- optFlags = [
726- ('ignore-errors', None, 'Do not stop the process when encountering OS errors.')]
727-
728-
729- def undoActions(self, options, changeset, actions):
730- """
731- Undo specific actions from a changeset.
732- """
733- for action in actions:
734- msg = 'Simulating undo'
735- if not options['no-act']:
736- msg = 'Undo'
737-
738- logging.msg('%s: %s' % (msg, action.asHumanly()), verbosity=3)
739- if not options['no-act']:
740- try:
741- changeset.undo(action, options)
742- except OSError, e:
743- if not self['ignore-errors']:
744- raise e
745- logging.msg('Ignoring %r' % (e,), verbosity=3)
746-
747-
748-
749-class UndoAction(SubCommand, _UndoMixin):
750+class UndoAction(SubCommand):
751 name = 'action'
752
753
754- synopsis = '[options] <actionID>'
755+ synopsis = '[options] <actionID> [actionID ...]'
756
757
758 longdesc = """
759- Undo a single action from a changeset. Consult "undo list" for action
760- identifiers.
761+ Undo individual actions. Consult "undo list" for action identifiers.
762 """
763
764
765- def parseArgs(self, action):
766- self['action'] = action
767-
768-
769- def process(self, renamer, options):
770- action = getItem(renamer.store, self['action'], Action)
771- self.undoActions(options, action.changeset, [action])
772-
773-
774-
775-class UndoChangeset(SubCommand, _UndoMixin):
776+ optFlags = [
777+ ('ignore-errors', None,
778+ 'Do not stop the process when encountering OS errors')]
779+
780+
781+ def parseArgs(self, action, *actions):
782+ self['actions'] = (action,) + actions
783+
784+
785+ def process(self, renamer):
786+ actions = [
787+ getItem(renamer.store, identifier, Action)
788+ for identifier in self['actions']]
789+ return intents.Undo(actions)
790+
791+
792+
793+class UndoChangeset(SubCommand):
794 name = 'changeset'
795
796
797@@ -85,6 +66,11 @@
798 """
799
800
801+ optFlags = [
802+ ('ignore-errors', None,
803+ 'Do not stop the process when encountering OS errors')]
804+
805+
806 def parseArgs(self, changeset):
807 self['changeset'] = changeset
808
809@@ -93,8 +79,8 @@
810 changeset = getItem(renamer.store, self['changeset'], Changeset)
811 logging.msg('Undoing: %s' % (changeset.asHumanly(),),
812 verbosity=3)
813- actions = list(changeset.getActions())
814- self.undoActions(options, changeset, reversed(actions))
815+ actions = reversed(list(changeset.getActions()))
816+ return intents.Undo(actions)
817
818
819
820
821=== added file 'renamer/test/test_intents.py'
822--- renamer/test/test_intents.py 1970-01-01 00:00:00 +0000
823+++ renamer/test/test_intents.py 2010-11-10 10:12:38 +0000
824@@ -0,0 +1,263 @@
825+import os
826+import string
827+
828+from twisted.python.filepath import FilePath
829+from twisted.trial.unittest import TestCase
830+
831+from axiom.store import Store
832+
833+from renamer import application, errors, history, intents
834+
835+
836+
837+class RenameTests(TestCase):
838+ """
839+ Tests for L{renamer.intents.Rename}.
840+ """
841+ def setUp(self):
842+ self.store = Store()
843+ self.src = FilePath(u'src.ext')
844+ self.history = history.History(store=self.store)
845+
846+
847+ def act(self, options):
848+ """
849+ Act on a L{renamer.intents.Rename} intent.
850+ """
851+ cs = self.history.newChangeset()
852+
853+ path = FilePath(self.mktemp())
854+ path.makedirs()
855+
856+ src = path.child(u'src')
857+ src.touch()
858+ self.assertTrue(src.exists())
859+ self.assertTrue(src.isfile())
860+
861+ dst = src.sibling(u'dst')
862+ self.assertFalse(dst.exists())
863+
864+ i = intents.Rename({}, src)
865+ options['name'] = string.Template(u'dst')
866+ i.act(cs, {}, options)
867+ src.restat(False)
868+ dst.restat(False)
869+
870+ return src, dst
871+
872+
873+ def test_asHumanly(self):
874+ """
875+ L{renamer.intents.Rename.asHumanly} produces accurate human-readable
876+ output.
877+ """
878+ i = intents.Rename({}, self.src)
879+ dst = self.src.sibling(u'dst.ext')
880+ options = dict(prefix=None, name=string.Template(u'dst'))
881+ self.assertEquals(
882+ i.asHumanly(options),
883+ u'Rename: %s => %s' % (self.src.path, dst.path))
884+
885+
886+ def test_act(self):
887+ """
888+ Acting on a L{renamer.intents.Rename} intent renames a file.
889+ """
890+ options = application.Options(None)
891+
892+ src, dst = self.act(options)
893+ self.assertFalse(src.exists())
894+ self.assertTrue(dst.exists())
895+ self.assertTrue(dst.isfile())
896+
897+
898+ def test_actSourceLink(self):
899+ """
900+ Acting on a L{renamer.intents.Rename} intent with the C{'link-src'}
901+ configuration option renames a file and creates a symlink at the source
902+ to the new path.
903+ """
904+ options = application.Options(None)
905+ options['link-src'] = True
906+
907+ src, dst = self.act(options)
908+ self.assertTrue(src.exists())
909+ self.assertTrue(src.islink())
910+ self.assertTrue(dst.exists())
911+ self.assertTrue(dst.isfile())
912+ link = os.readlink(src.path)
913+ self.assertEquals(link, dst.path)
914+
915+
916+ def test_actDestinationLink(self):
917+ """
918+ Acting on a L{renamer.intents.Rename} intent with the C{'link-dst'}
919+ configuration option leaves the file inplace and creates a symlink at
920+ the destination to the original path.
921+ """
922+ options = application.Options(None)
923+ options['link-dst'] = True
924+
925+ src, dst = self.act(options)
926+ self.assertTrue(src.exists())
927+ self.assertTrue(src.isfile())
928+ self.assertTrue(dst.exists())
929+ self.assertTrue(dst.islink())
930+ link = os.readlink(dst.path)
931+ self.assertEquals(link, src.path)
932+
933+
934+ def test_getDestinationErrors(self):
935+ """
936+ L{renamer.intents.Rename.getDestination} raises
937+ L{renamer.errors.IntentError} if not enough information is specified to
938+ build the destination path.
939+ """
940+ options = dict(prefix=None, name=None)
941+ i = intents.Rename({}, self.src)
942+ self.assertRaises(errors.IntentError, i.getDestination, options)
943+
944+
945+ def test_getDestinationDefaults(self):
946+ """
947+ L{renamer.intents.Rename.getDestination} builds a destination path from
948+ mapping and template data.
949+ """
950+ i = intents.Rename({}, self.src)
951+ options = dict(prefix=None, name=string.Template(u'dst'))
952+ self.assertEquals(
953+ i.getDestination(options),
954+ self.src.sibling(u'dst.ext'))
955+
956+
957+ def test_getDestination(self):
958+ """
959+ L{renamer.intents.Rename.getDestination} builds a destination path from
960+ mapping and template data.
961+ """
962+ options = dict(prefix=None, name=string.Template(u'dst'))
963+
964+ i = intents.Rename({}, self.src, prefixTemplate=u'foo')
965+ self.assertEquals(
966+ i.getDestination(options),
967+ self.src.sibling(u'foo').child(u'dst.ext'))
968+
969+ i = intents.Rename({}, self.src, prefixTemplate=u'~/foo')
970+ self.assertEquals(
971+ i.getDestination(options),
972+ FilePath(os.path.expanduser('~')).child(u'foo').child(u'dst.ext'))
973+
974+ i = intents.Rename(
975+ dict(var=u'bar'),
976+ self.src,
977+ prefixTemplate=string.Template(u'$var/foo'))
978+ self.assertEquals(
979+ i.getDestination(options),
980+ self.src.sibling(u'bar').child(u'foo').child(u'dst.ext'))
981+
982+ options = dict(prefix=None, name=None)
983+ i = intents.Rename(
984+ dict(var=u'bar'),
985+ self.src,
986+ nameTemplate=string.Template(u'$var'))
987+ self.assertEquals(
988+ i.getDestination(options),
989+ self.src.sibling(u'bar.ext'))
990+
991+ i = intents.Rename(
992+ {}, self.src, prefixTemplate=u'foo', nameTemplate=u'name')
993+ self.assertEquals(
994+ i.getDestination(options),
995+ self.src.sibling(u'foo').child(u'name.ext'))
996+
997+
998+
999+class UndoTests(TestCase):
1000+ """
1001+ Tests for L{renamer.intents.Undo}.
1002+ """
1003+ def setUp(self):
1004+ path = FilePath(self.mktemp())
1005+ path.makedirs()
1006+
1007+ self.src = path.child(u'src')
1008+ self.dst = self.src.sibling(u'dst')
1009+ self.dst.touch()
1010+
1011+ self.store = Store()
1012+ self.history = history.History(store=self.store)
1013+ self.changeset = self.history.newChangeset()
1014+ action = self.changeset.newAction(u'move', self.src, self.dst)
1015+ action.changeset = self.changeset
1016+ self.history.pruneChangesets()
1017+ self.options = application.Options(None)
1018+
1019+
1020+ def getActions(self):
1021+ """
1022+ Get L{renamer.history.Action}s in the changeset.
1023+ """
1024+ return list(self.changeset.getActions())
1025+
1026+
1027+ def makeIntent(self):
1028+ """
1029+ Make a new L{renamer.intents.Undo} intent.
1030+ """
1031+ actions = self.getActions()
1032+ self.assertEquals(len(actions), 1)
1033+ self.assertFalse(self.src.exists())
1034+ self.assertTrue(self.dst.exists())
1035+ return intents.Undo(actions)
1036+
1037+
1038+ def test_asHumanly(self):
1039+ """
1040+ L{renamer.intents.Undo.asHumanly} produces accurate human-readable
1041+ output.
1042+ """
1043+ i = self.makeIntent()
1044+ self.assertEquals(
1045+ i.asHumanly({}),
1046+ u'Undo: %r' % (self.getActions(),))
1047+
1048+
1049+ def test_act(self):
1050+ """
1051+ Acting on a L{renamer.intents.Undo} intent undoes the specified
1052+ actions.
1053+ """
1054+ i = self.makeIntent()
1055+ command = {'ignore-errors': False}
1056+ i.act(self.history.newChangeset(), command, self.options)
1057+ self.assertEquals(len(self.getActions()), 0)
1058+ self.src.restat(False)
1059+ self.assertTrue(self.src.exists())
1060+ self.dst.restat(False)
1061+ self.assertFalse(self.dst.exists())
1062+
1063+
1064+ def test_actOSError(self):
1065+ """
1066+ L{renamer.intents.Undo.act} re-raises C{OSError}.
1067+ """
1068+ i = self.makeIntent()
1069+ # Remove the file we are supposed to operate on.
1070+ self.dst.remove()
1071+ command = {'ignore-errors': False}
1072+ self.assertRaises(OSError,
1073+ i.act, self.history.newChangeset(), command, self.options)
1074+ self.assertEquals(len(self.getActions()), 1)
1075+
1076+
1077+ def test_actIgnoreErrors(self):
1078+ """
1079+ L{renamer.intents.Undo.act} ignores C{OSError} if the
1080+ C{'ignore-errors'} configuration option is given.
1081+ """
1082+ i = self.makeIntent()
1083+ # Remove the file we are supposed to operate on.
1084+ self.dst.remove()
1085+ command = {'ignore-errors': True}
1086+ i.act(self.history.newChangeset(), command, self.options)
1087+ self.assertEquals(len(self.getActions()), 1)

Subscribers

People subscribed via source and target branches