Merge lp:~renamer-developers/renamer/undo-command into lp:renamer

Proposed by Jonathan Jacobs
Status: Merged
Approved by: Tristan Seligmann
Approved revision: 129
Merged at revision: 85
Proposed branch: lp:~renamer-developers/renamer/undo-command
Merge into: lp:renamer
Diff against target: 1854 lines (+1393/-177)
19 files modified
DEPS (+1/-0)
bin/rn.cmd (+2/-0)
renamer/application.py (+115/-111)
renamer/errors.py (+14/-0)
renamer/history.py (+243/-0)
renamer/irenamer.py (+52/-5)
renamer/logging.py (+1/-1)
renamer/main.py (+39/-13)
renamer/plugin.py (+245/-37)
renamer/plugins/actions.py (+52/-0)
renamer/plugins/audio.py (+3/-3)
renamer/plugins/tv.py (+2/-3)
renamer/plugins/undo.py (+175/-0)
renamer/test/test_actions.py (+173/-0)
renamer/test/test_history.py (+186/-0)
renamer/test/test_plugin.py (+1/-1)
renamer/test/test_util.py (+53/-0)
renamer/util.py (+25/-1)
setup.py (+11/-2)
To merge this branch: bzr merge lp:~renamer-developers/renamer/undo-command
Reviewer Review Type Date Requested Status
Tristan Seligmann Approve
Jeremy Thurgood Approve
Review via email: mp+38632@code.launchpad.net
To post a comment you must log in.
123. By Jonathan Jacobs

Handle SystemExit properly in renamer.main.

124. By Jonathan Jacobs

Fix typoes.

125. By Jonathan Jacobs

Prune even with no-act.

126. By Jonathan Jacobs

Remove unused test data.

127. By Jonathan Jacobs

Tweak setup.py.

Revision history for this message
Jeremy Thurgood (jerith) wrote :

This looks good.

The only concern I have is that it breaks Python 2.5 compat, 2.5 is still the default on a bunch of systems. (Including the box I use renamer on, as it happens.) I'll commit a 2.5 compat fix to this branch and then we're good to go.

review: Approve
128. By Jeremy Thurgood

Bring back Python 2.5 support.

Revision history for this message
Tristan Seligmann (mithrandi) wrote :

394 + Remove any L{renamer.history.Action}s that to not have references to a

Typo: to -> do.

701 + status = 0

This creates a local variable "status", which will always mask the global variable that you later set. Move this to module scope to get what I assume was the intended effect.

review: Needs Fixing
129. By Jonathan Jacobs

Address review commentary.

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

> 394 + Remove any L{renamer.history.Action}s that to not have references to
> a
>
> Typo: to -> do.
>
> 701 + status = 0
>
> This creates a local variable "status", which will always mask the global
> variable that you later set. Move this to module scope to get what I assume
> was the intended effect.

Done.

Revision history for this message
Tristan Seligmann (mithrandi) wrote :

394 + Remove any L{renamer.history.Action}s that to not have references to a

Typo: to -> do.

701 + status = 0

This creates a local variable "status", which will always mask the global variable that you later set. Move this to module scope to get what I assume was the intended effect.

review: Needs Fixing
Revision history for this message
Tristan Seligmann (mithrandi) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'DEPS'
2--- DEPS 2009-05-10 18:57:26 +0000
3+++ DEPS 2010-10-24 17:31:17 +0000
4@@ -1,3 +1,4 @@
5+Axiom 0.6.0
6 Epsilon 0.5.0
7 Mutagen 1.15
8 PyParsing 1.5.1
9
10=== added file 'bin/rn.cmd'
11--- bin/rn.cmd 1970-01-01 00:00:00 +0000
12+++ bin/rn.cmd 2010-10-24 17:31:17 +0000
13@@ -0,0 +1,2 @@
14+@echo off
15+python -c "from renamer.main import main; main()" %*
16
17=== modified file 'renamer/application.py'
18--- renamer/application.py 2010-10-10 10:10:03 +0000
19+++ renamer/application.py 2010-10-24 17:31:17 +0000
20@@ -1,23 +1,24 @@
21 """
22 Renamer application logic.
23 """
24-import glob
25+import itertools
26 import os
27 import string
28 import sys
29
30-from twisted.internet import reactor, defer
31-from twisted.python import usage
32+from axiom.store import Store
33+
34+from twisted.internet import defer
35+from twisted.python import usage, log
36 from twisted.python.filepath import FilePath
37
38 from renamer import logging, plugin, util
39-
40-
41-
42-class Options(usage.Options, plugin.RenamerSubCommandMixin):
43- synopsis = '[options] command argument [argument ...]'
44-
45-
46+from renamer.irenamer import IRenamingCommand
47+from renamer.history import History
48+
49+
50+
51+class Options(usage.Options, plugin._CommandMixin):
52 optFlags = [
53 ('glob', 'g', 'Expand arguments as UNIX-style globs.'),
54 ('one-file-system', 'x', "Don't cross filesystems."),
55@@ -37,7 +38,9 @@
56
57 @property
58 def subCommands(self):
59- for plg in plugin.getPlugins():
60+ commands = itertools.chain(
61+ plugin.getRenamingCommands(), plugin.getCommands())
62+ for plg in commands:
63 try:
64 yield plg.name, None, plg, plg.description
65 except AttributeError:
66@@ -49,6 +52,12 @@
67 self['verbosity'] = 1
68
69
70+ @property
71+ def synopsis(self):
72+ return 'Usage: %s [options]' % (
73+ os.path.basename(sys.argv[0]),)
74+
75+
76 def opt_verbose(self):
77 """
78 Increase output, use more times for greater effect.
79@@ -67,35 +76,11 @@
80 opt_q = opt_quiet
81
82
83- def glob(self, args):
84- """
85- Glob arguments.
86- """
87- def _glob():
88- return (arg
89- for _arg in args
90- for arg in glob.glob(_arg))
91-
92- def _globWin32():
93- for arg in args:
94- if not os.path.exists(arg):
95- globbed = glob.glob(arg)
96- if globbed:
97- for a in globbed:
98- yield a
99- continue
100- yield arg
101-
102- if sys.platform == 'win32':
103- return _globWin32()
104- return _glob()
105-
106-
107 def parseArgs(self, *args):
108 args = (self.decodeCommandLine(arg) for arg in args)
109 if self['glob']:
110- args = self.glob(args)
111- self.args = [FilePath(arg) for arg in args]
112+ args = util.globArguments(args)
113+ self.args = (FilePath(arg) for arg in args)
114
115
116
117@@ -103,96 +88,115 @@
118 """
119 Renamer main logic.
120
121+ @type store: L{axiom.store.Store}
122+ @ivar store: Renamer database Store.
123+
124+ @type history: L{renamer.history.History}
125+ @ivar history: Renamer history Item.
126+
127 @type options: L{renamer.application.Options}
128 @ivar options: Parsed command-line options.
129+
130+ @type command: L{renamer.irenamer.ICommand}
131+ @ivar command: Renamer command being executed.
132 """
133- def __init__(self, options):
134- self.options = options
135-
136-
137- def rename(self, dst, src):
138- """
139- Rename C{src} to {dst}.
140-
141- Perform symlinking if specified and create any required directory
142- hiearchy.
143+ def __init__(self):
144+ obs = logging.RenamerObserver()
145+ log.startLoggingWithObserver(obs.emit, setStdout=False)
146+
147+ self.store = Store(os.path.expanduser('~/.renamer/renamer.axiom'))
148+ # XXX: One day there might be more than one History item.
149+ self.history = self.store.findOrCreate(History)
150+
151+ self.options = Options()
152+ self.options.parseOptions()
153+ obs.verbosity = self.options['verbosity']
154+ self.command = self.getCommand()
155+
156+
157+ def getCommand(self):
158+ """
159+ Get the L{twisted.python.usage.Options} command that was invoked.
160+ """
161+ command = getattr(self.options, 'subOptions', None)
162+ if command is None:
163+ raise usage.UsageError('At least one command must be specified')
164+
165+ while getattr(command, 'subOptions', None) is not None:
166+ command = command.subOptions
167+
168+ return command
169+
170+
171+ def performRename(self, dst, src):
172+ """
173+ Perform a file rename.
174 """
175 options = self.options
176-
177- if options['dry-run']:
178- logging.msg('Dry-run: %s => %s' % (src.path, dst.path))
179+ if options['no-act']:
180+ logging.msg('Simulating: %s => %s' % (src.path, dst.path))
181 return
182
183 if src == dst:
184 logging.msg('Skipping noop "%s"' % (src.path,), verbosity=2)
185 return
186
187- if dst.exists():
188- logging.msg('Refusing to clobber existing file "%s"' % (
189- dst.path,))
190- return
191-
192- parent = dst.parent()
193- if not parent.exists():
194- logging.msg('Creating directory structure for "%s"' % (
195- parent.path,), verbosity=2)
196- parent.makedirs()
197-
198- # Linking at the destination requires no moving.
199 if options['link-dst']:
200- logging.msg('Symlink: %s => %s' % (src.path, dst.path))
201- src.linkTo(dst)
202+ self.changeset.do(
203+ self.changeset.newAction(u'symlink', src, dst),
204+ options)
205 else:
206- logging.msg('Move: %s => %s' % (src.path, dst.path))
207- util.rename(src, dst, oneFileSystem=options['one-file-system'])
208+ self.changeset.do(
209+ self.changeset.newAction(u'move', src, dst),
210+ options)
211 if options['link-src']:
212- logging.msg('Symlink: %s => %s' % (dst.path, src.path))
213- dst.linkTo(src)
214-
215-
216- def _processOne(self, src):
217- logging.msg('Processing "%s"' % (src.path,),
218- verbosity=3)
219- command = self.options.command
220-
221- def buildDestination(mapping):
222- prefixTemplate = self.options['prefix']
223- if prefixTemplate is None:
224- prefixTemplate = command.defaultPrefixTemplate
225-
226- if prefixTemplate is not None:
227- prefix = os.path.expanduser(
228- prefixTemplate.safe_substitute(mapping))
229- else:
230- prefixTemplate = string.Template(src.dirname())
231- prefix = prefixTemplate.template
232-
233- ext = src.splitext()[-1]
234-
235- nameTemplate = self.options['name']
236- if nameTemplate is None:
237- nameTemplate = command.defaultNameTemplate
238-
239- filename = nameTemplate.safe_substitute(mapping)
240- logging.msg(
241- 'Building filename: prefix=%r name=%r mapping=%r' % (
242- prefixTemplate.template, nameTemplate.template, mapping),
243- verbosity=3)
244- return FilePath(prefix).child(filename).siblingExtension(ext)
245-
246- d = defer.maybeDeferred(command.processArgument, src)
247- d.addCallback(buildDestination)
248- d.addCallback(self.rename, src)
249- return d
250-
251-
252- def run(self):
253+ self.changeset.do(
254+ self.changeset.newAction(u'symlink', dst, src),
255+ options)
256+
257+
258+ def runCommand(self, command):
259+ """
260+ Run a generic command.
261+ """
262+ return defer.maybeDeferred(command.process, self)
263+
264+
265+ def runRenamingCommand(self, command):
266+ """
267+ Run a renaming command.
268+ """
269+ def _processOne(src):
270+ self.currentArgument = src
271+ d = self.runCommand(command)
272+ d.addCallback(self.performRename, src)
273+ return d
274+
275+ self.changeset = self.history.newChangeset()
276 logging.msg(
277 'Running, doing at most %d concurrent operations' % (
278 self.options['concurrent'],),
279 verbosity=3)
280- d = util.parallel(
281- self.options.args, self.options['concurrent'], self._processOne)
282- d.addErrback(logging.err)
283- d.addBoth(lambda ignored: reactor.stop())
284+ return util.parallel(
285+ self.options.args, self.options['concurrent'], _processOne)
286+
287+
288+ def run(self):
289+ """
290+ Begin processing commands.
291+ """
292+ if IRenamingCommand(type(self.command), None) is not None:
293+ d = self.runRenamingCommand(self.command)
294+ else:
295+ d = self.runCommand(self.command)
296+ d.addCallback(self.exit)
297 return d
298+
299+
300+ def exit(self, ignored):
301+ """
302+ Perform the exit routine.
303+ """
304+ # We can safely do this even with "no-act", since nothing was actioned
305+ # and there is no point leaving orphaned Items around.
306+ self.history.pruneChangesets()
307
308=== modified file 'renamer/errors.py'
309--- renamer/errors.py 2010-10-10 10:10:03 +0000
310+++ renamer/errors.py 2010-10-24 17:31:17 +0000
311@@ -9,3 +9,17 @@
312 """
313 An attempt to move a file to a different logical device was made.
314 """
315+
316+
317+
318+class NoSuchAction(ValueError):
319+ """
320+ An invalid or unknown action name was specified.
321+ """
322+
323+
324+
325+class NoClobber(RuntimeError):
326+ """
327+ A destination file already exists.
328+ """
329
330=== added file 'renamer/history.py'
331--- renamer/history.py 1970-01-01 00:00:00 +0000
332+++ renamer/history.py 2010-10-24 17:31:17 +0000
333@@ -0,0 +1,243 @@
334+from epsilon.extime import Time
335+
336+from twisted.python.components import registerAdapter
337+from twisted.python.filepath import FilePath
338+
339+from axiom.attributes import reference, text, timestamp
340+from axiom.item import Item, transacted
341+
342+from renamer import logging
343+from renamer.irenamer import IRenamingAction
344+from renamer.plugin import getActionByName
345+
346+
347+
348+class History(Item):
349+ """
350+ Changeset management.
351+ """
352+ created = timestamp(doc="""
353+ Timestamp of when the history was first created.
354+ """, defaultFactory=lambda: Time())
355+
356+
357+ def getChangesets(self):
358+ """
359+ Get L{renamer.history.Changeset}s for this history.
360+ """
361+ return iter(self.store.query(
362+ Changeset,
363+ Changeset.history == self,
364+ sort=Changeset.modified.ascending))
365+
366+
367+ @transacted
368+ def pruneChangesets(self):
369+ """
370+ Prune empty changesets from the currently active changesets.
371+ """
372+ prunedChangesets = 0
373+ prunedActions = self.pruneActions()
374+ for cs in self.store.query(Changeset):
375+ if not cs.numActions:
376+ cs.deleteFromStore()
377+ prunedChangesets += 1
378+ elif cs.history is None:
379+ cs.history = self
380+
381+ logging.msg(
382+ 'Pruned %d changesets' % (prunedChangesets,),
383+ verbosity=3)
384+
385+ return prunedChangesets, prunedActions
386+
387+
388+ @transacted
389+ def pruneActions(self):
390+ """
391+ Remove any L{renamer.history.Action}s that do not have references to a
392+ L{renamer.history.Changeset}. These are actions most likely created and
393+ never used, so there is no need to store them.
394+ """
395+ count = 0
396+ for action in self.store.query(Action, Action.changeset == None):
397+ action.deleteFromStore()
398+ count += 1
399+
400+ logging.msg(
401+ 'Pruned %d actions' % (count,),
402+ verbosity=3)
403+
404+ return count
405+
406+
407+ def newChangeset(self):
408+ """
409+ Begin a new changeset.
410+
411+ @rtype: L{renamer.history.Changeset}
412+ """
413+ return Changeset(
414+ store=self.store)
415+
416+
417+
418+class Changeset(Item):
419+ """
420+ A history changeset containing at least one action.
421+ """
422+ created = timestamp(doc="""
423+ Timestamp of when the changeset was first created.
424+ """, defaultFactory=lambda: Time())
425+
426+
427+ modified = timestamp(doc="""
428+ Timestamp of when the changeset was last modified.
429+ """, defaultFactory=lambda: Time())
430+
431+
432+ history = reference(doc="""
433+ Parent history Item.
434+ """, reftype=History, whenDeleted=reference.CASCADE)
435+
436+
437+ def __repr__(self):
438+ return '<%s %d action(s) created=%r modified=%r>' % (
439+ type(self).__name__,
440+ self.numActions,
441+ self.created,
442+ self.modified)
443+
444+
445+ def getActions(self):
446+ """
447+ Get an iterable of L{renamer.history.Action}s for this changeset,
448+ sorted by ascending order of creation.
449+ """
450+ return iter(
451+ self.store.query(
452+ Action,
453+ Action.changeset == self,
454+ sort=Action.created.ascending))
455+
456+
457+ @property
458+ def numActions(self):
459+ """
460+ Number of actions contained in this changeset.
461+ """
462+ return self.store.query(Action, Action.changeset == self).count()
463+
464+
465+ def asHumanly(self):
466+ """
467+ Construct a human readable representation of the changeset.
468+ """
469+ return u'Changeset with %d action(s) (%s)' % (
470+ self.numActions,
471+ self.modified.asHumanly())
472+
473+
474+ def newAction(self, name, src, dst, verify=True):
475+ """
476+ Create a new L{renamer.history.Action}.
477+ """
478+ if verify:
479+ # Check that "name" is in fact a valid action.
480+ getActionByName(name)
481+ return Action(
482+ store=self.store,
483+ name=name,
484+ src=src.path,
485+ dst=dst.path)
486+
487+
488+ @transacted
489+ def do(self, action, options, _adapter=IRenamingAction):
490+ """
491+ Perform an action.
492+
493+ @type action: L{renamer.history.Action}
494+
495+ @type options: L{twisted.python.usage.Options}
496+ """
497+ renamingAction = _adapter(action)
498+ renamingAction.do(options)
499+ action.changeset = self
500+ self.modified = Time()
501+
502+
503+ @transacted
504+ def undo(self, action, options, _adapter=IRenamingAction):
505+ """
506+ Perform a reverse action.
507+
508+ @type action: L{renamer.irenamer.IRenamingAction}
509+
510+ @type options: L{twisted.python.usage.Options}
511+ """
512+ renamingAction = _adapter(action)
513+ renamingAction.undo(options)
514+ action.deleteFromStore()
515+ self.modified = Time()
516+
517+
518+
519+class Action(Item):
520+ """
521+ A single action in a changeset.
522+
523+ Can be adapted to L{renamer.irenamer.IRenamingAction}.
524+ """
525+ created = timestamp(doc="""
526+ Timestamp of when the action was first created.
527+ """, defaultFactory=lambda: Time())
528+
529+
530+ name = text(doc="""
531+ Action name, should be a value that can be passed to
532+ L{renamer.plugin.getActionByName}.
533+ """, allowNone=False)
534+
535+
536+ src = text(doc="""
537+ Path to the source file of the action.
538+ """, allowNone=False)
539+
540+
541+ dst = text(doc="""
542+ Path to the destination file of the action.
543+ """, allowNone=False)
544+
545+
546+ changeset = reference(doc="""
547+ Parent changeset Item.
548+ """, reftype=Changeset, whenDeleted=reference.CASCADE)
549+
550+
551+ def __repr__(self):
552+ return '<%s name=%r src=%r dst=%r created=%r>' % (
553+ type(self).__name__,
554+ self.name,
555+ self.src,
556+ self.dst,
557+ self.created)
558+
559+
560+ def asHumanly(self):
561+ """
562+ Construct a human readable representation of the action.
563+ """
564+ return u'%s: %s => %s (%s)' % (
565+ self.name.title(), self.src, self.dst, self.created.asHumanly())
566+
567+
568+ def toRenamingAction(self):
569+ """
570+ Create a L{renamer.irenamer.IRenamingAction} from this Item.
571+ """
572+ return getActionByName(self.name)(
573+ src=FilePath(self.src),
574+ dst=FilePath(self.dst))
575+
576+registerAdapter(Action.toRenamingAction, Action, IRenamingAction)
577
578=== modified file 'renamer/irenamer.py'
579--- renamer/irenamer.py 2010-09-29 16:58:00 +0000
580+++ renamer/irenamer.py 2010-10-24 17:31:17 +0000
581@@ -2,9 +2,9 @@
582
583
584
585-class IRenamerCommand(Interface):
586+class ICommand(Interface):
587 """
588- Renamer command.
589+ Generic Renamer command.
590 """
591 name = Attribute("""
592 Command name.
593@@ -16,9 +16,25 @@
594 """)
595
596
597- defaultNameFormat = Attribute("""
598- String template for the default name format to use if one is not supplied
599- to Renamer.
600+ def process(renamer):
601+ """
602+ Called once command line parsing is complete.
603+ """
604+
605+
606+
607+class IRenamingCommand(ICommand):
608+ """
609+ Command that performs renaming on one argument at a time.
610+ """
611+ defaultNameTemplate = Attribute("""
612+ String template for the default name format to use if one is not supplied.
613+ """)
614+
615+
616+ defaultPrefixTemplate = Attribute("""
617+ String template for the default prefix format to use if one is not
618+ supplied.
619 """)
620
621
622@@ -30,3 +46,34 @@
623 @return: Mapping of keys to values to substitute info the name
624 template.
625 """
626+
627+
628+
629+class IRenamingAction(Interface):
630+ """
631+ An action that performs some renaming-related function and is undoable.
632+ """
633+ src = Attribute("""
634+ L{twisted.python.filepath.FilePath} to the source file.
635+ """)
636+
637+
638+ dst = Attribute("""
639+ L{twisted.python.filepath.FilePath} to the destination file.
640+ """)
641+
642+
643+ def do(options):
644+ """
645+ Perform the action.
646+
647+ @type options: L{twisted.python.usage.Options}
648+ """
649+
650+
651+ def undo(options):
652+ """
653+ Perform the reverse action.
654+
655+ @type options: L{twisted.python.usage.Options}
656+ """
657
658=== modified file 'renamer/logging.py'
659--- renamer/logging.py 2010-09-29 23:30:45 +0000
660+++ renamer/logging.py 2010-10-24 17:31:17 +0000
661@@ -8,7 +8,7 @@
662 """
663 Twisted event log observer for Renamer.
664 """
665- def __init__(self, verbosity):
666+ def __init__(self, verbosity=1):
667 self.verbosity = verbosity
668
669
670
671=== modified file 'renamer/main.py'
672--- renamer/main.py 2010-09-29 15:37:22 +0000
673+++ renamer/main.py 2010-10-24 17:31:17 +0000
674@@ -1,17 +1,43 @@
675-from twisted.python import log
676-from twisted.internet import reactor
677-
678-from renamer import application
679-from renamer.logging import RenamerObserver
680+import os, sys
681+
682+from twisted.python import failure, usage
683+from twisted.internet import defer, reactor
684+
685+from renamer import application, logging
686+
687+
688+
689+status = 0
690+
691
692
693 def main():
694- options = application.Options()
695- options.parseOptions()
696-
697- obs = RenamerObserver(options['verbosity'])
698- log.startLoggingWithObserver(obs.emit, setStdout=False)
699-
700- r = application.Renamer(options)
701- reactor.callWhenRunning(r.run)
702+ def logError(f):
703+ logging.err(f)
704+ global status
705+ status = 1
706+
707+ def usageError(f):
708+ f.trap(usage.UsageError)
709+ prog = os.path.basename(sys.argv[0])
710+ sys.stderr.write('%s: %s\n' % (prog, f.value))
711+ sys.stderr.write('Consult --help for usage information\n')
712+ global status
713+ status = 1
714+
715+ def run():
716+ try:
717+ d = defer.succeed(application.Renamer())
718+ except SystemExit:
719+ d = defer.succeed(None)
720+ except:
721+ d = defer.fail(failure.Failure())
722+ else:
723+ d.addCallback(lambda r: r.run())
724+ d.addErrback(usageError)
725+ d.addErrback(logError)
726+ d.addBoth(lambda ign: reactor.stop())
727+
728+ reactor.callWhenRunning(run)
729 reactor.run()
730+ sys.exit(status)
731
732=== modified file 'renamer/plugin.py'
733--- renamer/plugin.py 2010-10-10 11:25:05 +0000
734+++ renamer/plugin.py 2010-10-24 17:31:17 +0000
735@@ -1,24 +1,64 @@
736+import os
737+import string
738 import sys
739 from zope.interface import noLongerProvides
740
741-from twisted import plugin
742+from twisted.plugin import getPlugins, IPlugin
743+from twisted.internet import defer
744 from twisted.python import usage
745+from twisted.python.filepath import FilePath
746
747-from renamer import plugins
748-from renamer.irenamer import IRenamerCommand
749+from renamer import errors, logging, plugins
750+from renamer.irenamer import ICommand, IRenamingCommand, IRenamingAction
751 from renamer.util import InterfaceProvidingMetaclass
752
753
754
755-def getPlugins():
756- """
757- Get all available Renamer plugins.
758- """
759- return plugin.getPlugins(IRenamerCommand, plugins)
760-
761-
762-
763-class RenamerSubCommandMixin(object):
764+def getCommands():
765+ """
766+ Get all available L{renamer.irenamer.ICommand}s.
767+ """
768+ return getPlugins(ICommand, plugins)
769+
770+
771+
772+def getRenamingCommands():
773+ """
774+ Get all available L{renamer.irenamer.IRenamingCommand}s.
775+ """
776+ return getPlugins(IRenamingCommand, plugins)
777+
778+
779+
780+def getActions():
781+ """
782+ Get all available L{renamer.irenamer.IRenamingAction}s.
783+ """
784+ return getPlugins(IRenamingAction, plugins)
785+
786+
787+
788+def getActionByName(name):
789+ """
790+ Get an L{renamer.irenamer.IRenamingAction} by name.
791+
792+ @type name: C{unicode}
793+ @param name: Action name.
794+
795+ @raises L{renamer.errors.NoSuchAction}: If no action named C{name} could be
796+ found.
797+
798+ @rtype: L{renamer.irenamer.IRenamingAction}
799+ """
800+ for action in getActions():
801+ if action.name == name:
802+ return action
803+
804+ raise errors.NoSuchAction(name)
805+
806+
807+
808+class _CommandMixin(object):
809 """
810 Mixin for Renamer commands.
811 """
812@@ -30,37 +70,205 @@
813 return unicode(cmdline, codec)
814
815
816-
817-class RenamerSubCommand(usage.Options, RenamerSubCommandMixin):
818- """
819- Sub-level Renamer command.
820- """
821-
822-
823-
824-class RenamerCommandMeta(InterfaceProvidingMetaclass):
825- providedInterfaces = [plugin.IPlugin, IRenamerCommand]
826-
827-
828-
829-class RenamerCommand(usage.Options, RenamerSubCommandMixin):
830- """
831- Top-level Renamer command.
832-
833- These commands will display in the main help listing.
834- """
835- __metaclass__ = RenamerCommandMeta
836+ # ICommand
837+
838+ def process(self, renamer):
839+ raise NotImplementedError('Commands must implement "process"')
840+
841+
842+
843+class CommandMeta(InterfaceProvidingMetaclass):
844+ providedInterfaces = [IPlugin, ICommand]
845+
846+
847+
848+class Command(_CommandMixin, usage.Options):
849+ """
850+ Top-level generic command.
851+
852+ This command will display in the main help listing.
853+ """
854+ __metaclass__ = CommandMeta
855+
856+noLongerProvides(Command, IPlugin)
857+noLongerProvides(Command, ICommand)
858+
859+
860+
861+class SubCommand(_CommandMixin, usage.Options):
862+ """
863+ Sub-level generic command.
864+ """
865+
866+
867+
868+class RenamingCommandMeta(InterfaceProvidingMetaclass):
869+ providedInterfaces = [IPlugin, IRenamingCommand]
870+
871+
872+
873+class RenamingCommand(_CommandMixin, usage.Options):
874+ """
875+ Top-level renaming command.
876+
877+ This command will display in the main help listing.
878+ """
879+ __metaclass__ = RenamingCommandMeta
880+
881+
882+ synopsis = '[options] <argument> [argument ...]'
883+
884+
885+ # IRenamingCommand
886
887 defaultPrefixTemplate = None
888 defaultNameTemplate = None
889
890
891+ def buildDestination(self, mapping, options, src):
892+ """
893+ Build a destination path.
894+
895+ Substitution of C{mapping} into the C{'prefix'} command-line option
896+ (defaulting to L{defaultPrefixTemplate}) and the C{'name'} command-line
897+ option (defaulting to L{defaultNameTemplate}) is performed.
898+
899+ @type mapping: C{dict} mapping C{str} to C{unicode}
900+ @param mapping: Mapping of template variables, used for template
901+ substitution.
902+
903+ @type options: L{twisted.python.usage.Options}
904+
905+ @type src: L{twisted.python.filepath.FilePath}
906+ @param src: Source path.
907+
908+ @rtype: L{twisted.python.filepath.FilePath}
909+ @return: Destination path.
910+ """
911+ prefixTemplate = options['prefix']
912+ if prefixTemplate is None:
913+ prefixTemplate = self.defaultPrefixTemplate
914+
915+ if prefixTemplate is not None:
916+ prefix = os.path.expanduser(
917+ prefixTemplate.safe_substitute(mapping))
918+ else:
919+ prefixTemplate = string.Template(src.dirname())
920+ prefix = prefixTemplate.template
921+
922+ ext = src.splitext()[-1]
923+
924+ nameTemplate = options['name']
925+ if nameTemplate is None:
926+ nameTemplate = self.defaultNameTemplate
927+
928+ filename = nameTemplate.safe_substitute(mapping)
929+ logging.msg(
930+ 'Building filename: prefix=%r name=%r mapping=%r' % (
931+ prefixTemplate.template, nameTemplate.template, mapping),
932+ verbosity=3)
933+ return FilePath(prefix).child(filename).siblingExtension(ext)
934+
935+
936 def parseArgs(self, *args):
937+ # Parse args like our parent (hopefully renamer.application.Options)
938+ # which decodes and unglobs stuff.
939+ # XXX: This is probably not great.
940 self.parent.parseArgs(*args)
941
942
943- def postOptions(self):
944- self.parent.command = self
945-
946-noLongerProvides(RenamerCommand, plugin.IPlugin)
947-noLongerProvides(RenamerCommand, IRenamerCommand)
948+ # ICommand
949+
950+ def process(self, renamer):
951+ arg = renamer.currentArgument
952+ logging.msg('Processing "%s"' % (arg.path,),
953+ verbosity=3)
954+ d = defer.maybeDeferred(self.processArgument, arg)
955+ d.addCallback(self.buildDestination, renamer.options, arg)
956+ return d
957+
958+
959+ # IRenamingCommand
960+
961+ def processArgument(self, argument):
962+ raise NotImplementedError(
963+ 'RenamingCommands must implement "processArgument"')
964+
965+noLongerProvides(RenamingCommand, IPlugin)
966+noLongerProvides(RenamingCommand, IRenamingCommand)
967+
968+
969+
970+class RenamingActionMeta(InterfaceProvidingMetaclass):
971+ providedInterfaces = [IPlugin, IRenamingAction]
972+
973+
974+
975+class RenamingAction(object):
976+ """
977+ An action that performs some renaming-related function and is undoable.
978+
979+ @see: L{renamer.irenamer.IRenamingAction}
980+ """
981+ __metaclass__ = RenamingActionMeta
982+
983+
984+ def __init__(self, src, dst):
985+ self.src = src
986+ self.dst = dst
987+
988+
989+ def __repr__(self):
990+ return '<%s name=%r src=%r dst=%r>' % (
991+ type(self).__name__,
992+ self.name,
993+ self.src,
994+ self.dst)
995+
996+
997+ def makedirs(self, parent):
998+ """
999+ Create any directory structure that does not yet exist.
1000+ """
1001+ if not parent.exists():
1002+ logging.msg('Creating directory structure for "%s"' % (
1003+ parent.path,), verbosity=2)
1004+ parent.makedirs()
1005+
1006+
1007+ def checkExisting(self, dst):
1008+ """
1009+ Ensure that the destination file does not yet exist.
1010+ """
1011+ if dst.exists():
1012+ msg = 'Refusing to clobber existing file "%s"' % (
1013+ dst.path,)
1014+ logging.msg(msg)
1015+ raise errors.NoClobber(msg)
1016+
1017+
1018+ def prepare(self, dst, options):
1019+ """
1020+ Prepare for an action about to be performed.
1021+
1022+ The following preparations are done:
1023+
1024+ * Check that C{dst} does not already exist.
1025+
1026+ * Create any directory structure required for C{dst}.
1027+ """
1028+ self.checkExisting(dst)
1029+ self.makedirs(dst.parent())
1030+
1031+
1032+ # IRenamingAction
1033+
1034+ def do(self, options):
1035+ raise NotImplementedError('Base classes must implement "do"')
1036+
1037+
1038+ def undo(self, options):
1039+ raise NotImplementedError('Base classes must implement "undo"')
1040+
1041+noLongerProvides(RenamingAction, IPlugin)
1042+noLongerProvides(RenamingAction, IRenamingAction)
1043
1044=== added file 'renamer/plugins/actions.py'
1045--- renamer/plugins/actions.py 1970-01-01 00:00:00 +0000
1046+++ renamer/plugins/actions.py 2010-10-24 17:31:17 +0000
1047@@ -0,0 +1,52 @@
1048+from renamer import logging, util
1049+from renamer.plugin import RenamingAction
1050+
1051+
1052+
1053+class MoveAction(RenamingAction):
1054+ """
1055+ File move action.
1056+
1057+ If the source and destination are on different logical devices a
1058+ copy-delete will be used, unless the C{'one-file-system'} option is
1059+ specified.
1060+ """
1061+ name = 'move'
1062+
1063+
1064+ def _move(self, src, dst, options):
1065+ self.prepare(dst, options)
1066+ logging.msg('Move: %s => %s' % (src.path, dst.path))
1067+ util.rename(src, dst, oneFileSystem=options['one-file-system'])
1068+
1069+
1070+ # IRenamingAction
1071+
1072+ def do(self, options):
1073+ self._move(self.src, self.dst, options)
1074+
1075+
1076+ def undo(self, options):
1077+ self._move(self.dst, self.src, options)
1078+
1079+
1080+
1081+class SymlinkAction(RenamingAction):
1082+ """
1083+ Symlink action.
1084+ """
1085+ name = 'symlink'
1086+
1087+
1088+ # IRenamingAction
1089+
1090+ def do(self, options):
1091+ self.prepare(self.dst, options)
1092+ logging.msg('Symlink: %s => %s' % (self.src.path, self.dst.path))
1093+ self.src.linkTo(self.dst)
1094+
1095+
1096+ def undo(self, options):
1097+ if self.dst.islink():
1098+ logging.msg('Symlink: Removing %s' % (self.dst.path,))
1099+ self.dst.remove()
1100
1101=== modified file 'renamer/plugins/audio.py'
1102--- renamer/plugins/audio.py 2010-09-29 23:30:45 +0000
1103+++ renamer/plugins/audio.py 2010-10-24 17:31:17 +0000
1104@@ -8,12 +8,12 @@
1105 mutagen = None
1106
1107 from renamer import logging
1108-from renamer.plugin import RenamerCommand
1109+from renamer.plugin import RenamingCommand
1110 from renamer.errors import PluginError
1111
1112
1113
1114-class Audio(RenamerCommand):
1115+class Audio(RenamingCommand):
1116 name = 'audio'
1117
1118
1119@@ -41,7 +41,6 @@
1120 if mutagen is None:
1121 raise PluginError(
1122 'The "mutagen" package is required for this command')
1123- super(Audio, self).postOptions()
1124 self._metadataCache = {}
1125
1126
1127@@ -77,6 +76,7 @@
1128
1129 return default
1130
1131+
1132 def _saneTracknumber(self, tracknumber):
1133 if u'/' in tracknumber:
1134 tracknumber = tracknumber.split(u'/')[0]
1135
1136=== modified file 'renamer/plugins/tv.py'
1137--- renamer/plugins/tv.py 2010-10-10 10:13:42 +0000
1138+++ renamer/plugins/tv.py 2010-10-24 17:31:17 +0000
1139@@ -13,12 +13,12 @@
1140 pyparsing = None
1141
1142 from renamer import logging
1143-from renamer.plugin import RenamerCommand
1144+from renamer.plugin import RenamingCommand
1145 from renamer.errors import PluginError
1146
1147
1148
1149-class TVRage(RenamerCommand):
1150+class TVRage(RenamingCommand):
1151 name = 'tvrage'
1152
1153
1154@@ -40,7 +40,6 @@
1155
1156
1157 def postOptions(self):
1158- super(TVRage, self).postOptions()
1159 self.filenameParser = self._createParser()
1160
1161
1162
1163=== added file 'renamer/plugins/undo.py'
1164--- renamer/plugins/undo.py 1970-01-01 00:00:00 +0000
1165+++ renamer/plugins/undo.py 2010-10-24 17:31:17 +0000
1166@@ -0,0 +1,175 @@
1167+from twisted.python import usage
1168+
1169+from renamer import logging
1170+from renamer.history import Action, Changeset
1171+from renamer.plugin import Command, SubCommand
1172+
1173+
1174+
1175+def getItem(store, storeID, acceptableTypes):
1176+ """
1177+ Get an Axiom Item from a store by ID and verify that it is an acceptable
1178+ type.
1179+ """
1180+ try:
1181+ storeID = int(storeID)
1182+ except (ValueError, TypeError):
1183+ raise usage.UsageError(
1184+ 'Identifier %r is not an integer' % (storeID,))
1185+ else:
1186+ item = store.getItemByID(storeID, default=None)
1187+ if not isinstance(item, acceptableTypes):
1188+ raise usage.UsageError(
1189+ 'Invalid identifier %r' % (storeID,))
1190+ return item
1191+
1192+
1193+
1194+class _UndoMixin(object):
1195+ optFlags = [
1196+ ('ignore-errors', None, 'Do not stop the process when encountering OS errors')]
1197+
1198+
1199+ def undoActions(self, renamer, changeset, actions):
1200+ """
1201+ Undo specific actions from a changeset.
1202+ """
1203+ for action in actions:
1204+ msg = 'Simulating undo'
1205+ if not renamer.options['no-act']:
1206+ msg = 'Undo'
1207+
1208+ logging.msg('%s: %s' % (msg, action.asHumanly()), verbosity=3)
1209+ if not renamer.options['no-act']:
1210+ try:
1211+ changeset.undo(action, renamer.options)
1212+ except OSError, e:
1213+ if not self['ignore-errors']:
1214+ raise e
1215+ logging.msg('Ignoring %r' % (e,), verbosity=3)
1216+
1217+
1218+
1219+class UndoAction(SubCommand, _UndoMixin):
1220+ name = 'action'
1221+
1222+
1223+ synopsis = '[options] <actionID>'
1224+
1225+
1226+ longdesc = """
1227+ Undo a single action from a changeset. Consult "undo list" for action
1228+ identifiers.
1229+ """
1230+
1231+
1232+ def parseArgs(self, action):
1233+ self['action'] = action
1234+
1235+
1236+ def process(self, renamer):
1237+ action = getItem(renamer.store, self['action'], Action)
1238+ self.undoActions(
1239+ renamer, action.changeset, [action])
1240+
1241+
1242+
1243+class UndoChangeset(SubCommand, _UndoMixin):
1244+ name = 'changeset'
1245+
1246+
1247+ synopsis = '[options] <changesetID>'
1248+
1249+
1250+ longdesc = """
1251+ Undo an entire changeset. Consult "undo list" for changeset identifiers.
1252+ """
1253+
1254+
1255+ def parseArgs(self, changeset):
1256+ self['changeset'] = changeset
1257+
1258+
1259+ def process(self, renamer):
1260+ changeset = getItem(renamer.store, self['changeset'], Changeset)
1261+ logging.msg('Undoing: %s' % (changeset.asHumanly(),),
1262+ verbosity=3)
1263+ actions = list(changeset.getActions())
1264+ self.undoActions(
1265+ renamer, changeset, reversed(actions))
1266+
1267+
1268+
1269+class UndoList(SubCommand):
1270+ name = 'list'
1271+
1272+
1273+ longdesc = """
1274+ List undoable changesets and actions.
1275+ """
1276+
1277+
1278+ def process(self, renamer):
1279+ changesets = list(renamer.history.getChangesets())
1280+ for cs in changesets:
1281+ print 'Changeset ID=%d: %s' % (cs.storeID, cs.asHumanly())
1282+ for a in cs.getActions():
1283+ print ' Action ID=%d: %s' % (a.storeID, a.asHumanly())
1284+ print
1285+
1286+ if not changesets:
1287+ print 'No changesets!'
1288+
1289+
1290+
1291+class UndoForget(SubCommand):
1292+ name = 'forget'
1293+
1294+
1295+ synopsis = '<identifier>'
1296+
1297+
1298+ longdesc = """
1299+ Forget (permanently remove) an undo history action or entire changeset.
1300+ Consult "undo list" for identifiers.
1301+ """
1302+
1303+
1304+ def parseArgs(self, identifier):
1305+ self['identifier'] = identifier
1306+
1307+
1308+ def process(self, renamer):
1309+ item = getItem(renamer.store, self['identifier'], (Action, Changeset))
1310+ if not renamer.options['no-act']:
1311+ logging.msg('Forgetting: %s' % (item.asHumanly(),), verbosity=2)
1312+ item.deleteFromStore()
1313+
1314+
1315+
1316+class Undo(Command):
1317+ name = 'undo'
1318+
1319+
1320+ description = 'Undo previous Renamer actions'
1321+
1322+
1323+ longdesc = """
1324+ Every invocation of Renamer stores the actions taken as a changeset, this
1325+ allows Renamer to undo entire changesets or previously performed individual
1326+ actions.
1327+
1328+ Undo actions are communicated by identifiers, which can be discovered by
1329+ consulting "undo list".
1330+ """
1331+
1332+
1333+ subCommands = [
1334+ ('action', None, UndoAction, 'Undo a single action from a changeset'),
1335+ ('changeset', None, UndoChangeset, 'Undo a whole changeset'),
1336+ ('forget', None, UndoForget, 'Forget an undo history item'),
1337+ ('list', None, UndoList, 'List changesets')]
1338+
1339+
1340+ def parseArgs(self, *args):
1341+ raise usage.UsageError('Issue an undo subcommand to perform')
1342
1343=== added file 'renamer/test/test_actions.py'
1344--- renamer/test/test_actions.py 1970-01-01 00:00:00 +0000
1345+++ renamer/test/test_actions.py 2010-10-24 17:31:17 +0000
1346@@ -0,0 +1,173 @@
1347+from twisted.python.filepath import FilePath
1348+from twisted.trial.unittest import TestCase
1349+
1350+from renamer import errors
1351+from renamer.application import Options
1352+from renamer.plugins import actions
1353+
1354+
1355+
1356+class _ActionTestMixin(object):
1357+ actionType = None
1358+
1359+
1360+ def setUp(self):
1361+ self.path = FilePath(self.mktemp())
1362+ self.path.makedirs()
1363+ self.options = Options()
1364+ self.src, self.dst = self.createFiles()
1365+
1366+
1367+ def createFiles(self):
1368+ """
1369+ Create paths for source and destination files.
1370+ """
1371+ return self.path.child('src'), self.path.child('dst')
1372+
1373+
1374+ def createAction(self, src=None, dst=None):
1375+ """
1376+ Create an action from L{actionType}.
1377+ """
1378+ if src is None:
1379+ src = self.src
1380+ if dst is None:
1381+ dst = self.dst
1382+ return self.actionType(src, dst)
1383+
1384+
1385+ def test_do(self):
1386+ """
1387+ Perform the action.
1388+ """
1389+
1390+
1391+ def test_doWithSubdirs(self):
1392+ """
1393+ Performing an action involving a subdirectory results in that
1394+ subdirectory being created if it didn't already exist.
1395+ """
1396+ self.dst = self.path.child('subdir').child('dst')
1397+ parent = self.dst.parent()
1398+ self.assertFalse(parent.exists())
1399+ self.test_do()
1400+ self.assertTrue(parent.exists())
1401+ self.assertEquals(parent.listdir(), ['dst'])
1402+
1403+
1404+ def test_doClobber(self):
1405+ """
1406+ Performing an action raises L{renames.errors.NoClobber} when the
1407+ destination file already exists.
1408+ """
1409+ self.dst.touch()
1410+ action = self.createAction()
1411+ self.assertRaises(
1412+ errors.NoClobber, action.do, self.options)
1413+
1414+
1415+ def test_undo(self):
1416+ """
1417+ Perform the reverse action.
1418+ """
1419+
1420+
1421+ def test_undoWithSubdirs(self):
1422+ """
1423+ Performing a reverse action does not remove existing directories.
1424+ """
1425+ self.dst = self.path.child('subdir').child('dst')
1426+ parent = self.dst.parent()
1427+ parent.makedirs()
1428+ self.assertTrue(parent.exists())
1429+ self.test_undo()
1430+ self.assertTrue(parent.exists())
1431+ self.assertEquals(parent.listdir(), [])
1432+
1433+
1434+ def test_undoClobber(self):
1435+ """
1436+ Performing a reverse action raises L{renames.errors.NoClobber} when the
1437+ destination file already exists.
1438+ """
1439+ self.src.touch()
1440+ action = self.createAction()
1441+ self.assertRaises(
1442+ errors.NoClobber, action.undo, self.options)
1443+
1444+
1445+
1446+class MoveActionTests(_ActionTestMixin, TestCase):
1447+ """
1448+ Tests for L{renamer.plugins.actions.MoveAction}.
1449+ """
1450+ actionType = actions.MoveAction
1451+
1452+
1453+ def test_do(self):
1454+ self.src.touch()
1455+
1456+ self.assertTrue(self.src.exists())
1457+ self.assertFalse(self.dst.exists())
1458+
1459+ action = self.createAction()
1460+ action.do(self.options)
1461+
1462+ self.assertFalse(self.src.exists())
1463+ self.assertTrue(self.dst.exists())
1464+
1465+
1466+ def test_undo(self):
1467+ self.dst.touch()
1468+
1469+ self.assertFalse(self.src.exists())
1470+ self.assertTrue(self.dst.exists())
1471+
1472+ action = self.createAction()
1473+ action.undo(self.options)
1474+
1475+ self.assertTrue(self.src.exists())
1476+ self.assertFalse(self.dst.exists())
1477+
1478+
1479+
1480+class SymlinkActionTests(_ActionTestMixin, TestCase):
1481+ """
1482+ Tests for L{renamer.plugins.actions.SymlinkAction}.
1483+ """
1484+ actionType = actions.SymlinkAction
1485+
1486+
1487+ def test_do(self):
1488+ self.src.touch()
1489+
1490+ self.assertTrue(self.src.exists())
1491+ self.assertFalse(self.dst.exists())
1492+
1493+ action = self.createAction()
1494+ action.do(self.options)
1495+
1496+ self.assertTrue(self.src.exists())
1497+ self.assertTrue(self.dst.exists())
1498+ self.assertTrue(self.dst.islink())
1499+
1500+
1501+ def test_undo(self):
1502+ self.src.touch()
1503+ self.src.linkTo(self.dst)
1504+
1505+ self.assertTrue(self.src.exists())
1506+ self.assertTrue(self.dst.exists())
1507+ self.assertTrue(self.dst.islink())
1508+
1509+ action = self.createAction()
1510+ action.undo(self.options)
1511+
1512+ self.assertTrue(self.src.exists())
1513+ self.assertFalse(self.dst.exists())
1514+
1515+
1516+ def test_undoClobber(self):
1517+ """
1518+ Undoing a symlink cannot raise L{renamer.errors.NoClobber}.
1519+ """
1520
1521=== added file 'renamer/test/test_history.py'
1522--- renamer/test/test_history.py 1970-01-01 00:00:00 +0000
1523+++ renamer/test/test_history.py 2010-10-24 17:31:17 +0000
1524@@ -0,0 +1,186 @@
1525+from axiom.store import Store
1526+
1527+from twisted.python.filepath import FilePath
1528+from twisted.trial.unittest import TestCase
1529+
1530+from renamer import errors, history, irenamer
1531+from renamer.plugins.actions import SymlinkAction
1532+
1533+
1534+
1535+def FakeOptions():
1536+ return {}
1537+
1538+
1539+
1540+class FakeAction(object):
1541+ def do(self, options):
1542+ pass
1543+
1544+
1545+ def undo(self, options):
1546+ pass
1547+
1548+
1549+
1550+class HistoryTests(TestCase):
1551+ """
1552+ Tests for L{renamer.history.History}.
1553+ """
1554+ def setUp(self):
1555+ self.store = Store()
1556+ self.history = history.History(store=self.store)
1557+
1558+
1559+ def test_newChangeset(self):
1560+ """
1561+ L{renamer.history.History.newChangeset} creates a new changeset
1562+ instance and does not track it immediately.
1563+ """
1564+ cs = self.history.newChangeset()
1565+ self.assertIdentical(type(cs), history.Changeset)
1566+ self.assertEquals(list(self.history.getChangesets()), [])
1567+
1568+
1569+ def test_pruneChangesets(self):
1570+ """
1571+ L{renamer.history.History.pruneChangesets} removes empty changesets
1572+ (changesets without any actions) from the database.
1573+ """
1574+ cs = self.history.newChangeset()
1575+ self.assertEquals(list(self.history.getChangesets()), [])
1576+
1577+ action = cs.newAction(
1578+ u'fake', FilePath(u'src'), FilePath(u'dst'), verify=False)
1579+
1580+ # Unused action.
1581+ cs.newAction(
1582+ u'fake', FilePath(u'src'), FilePath(u'dst'), verify=False)
1583+
1584+ self.assertEquals(list(cs.getActions()), [])
1585+ self.assertEquals(cs.numActions, 0)
1586+
1587+ def _adapter(action):
1588+ return FakeAction()
1589+
1590+ cs.do(action, FakeOptions(), _adapter=_adapter)
1591+
1592+ self.assertEquals(
1593+ list(cs.getActions()), [action])
1594+ self.assertEquals(cs.numActions, 1)
1595+ prunedChangesets, prunedActions = self.history.pruneChangesets()
1596+ self.assertEquals(prunedChangesets, 0)
1597+ self.assertEquals(prunedActions, 1)
1598+ self.assertEquals(list(self.history.getChangesets()), [cs])
1599+
1600+ cs.undo(action, FakeOptions(), _adapter=_adapter)
1601+ self.assertEquals(list(cs.getActions()), [])
1602+ self.assertEquals(cs.numActions, 0)
1603+ prunedChangesets, prunedActions = self.history.pruneChangesets()
1604+ self.assertEquals(prunedChangesets, 1)
1605+ self.assertEquals(prunedActions, 0)
1606+ self.assertEquals(list(self.history.getChangesets()), [])
1607+
1608+
1609+
1610+class ChangesetTests(TestCase):
1611+ """
1612+ Tests for L{renamer.history.Changeset}.
1613+ """
1614+ def setUp(self):
1615+ self.store = Store()
1616+ self.history = history.History(store=self.store)
1617+
1618+
1619+ def test_newInvalidAction(self):
1620+ """
1621+ L{renamer.history.Changeset.newAction} raises
1622+ L{renamer.errors.NoSuchAction} if the action name specified does not
1623+ refer to a valid action.
1624+ """
1625+ cs = self.history.newChangeset()
1626+ self.assertRaises(errors.NoSuchAction,
1627+ cs.newAction, 'THIS_IS_NOT_REAL', FilePath(u'a'), FilePath(u'b'))
1628+
1629+
1630+ def test_representations(self):
1631+ """
1632+ L{renamer.history.Changeset.asHumanly} returns a human-readable and
1633+ accurate representation of a changeset.
1634+
1635+ L{renamer.history.Changeset.__repr__} returns a useful and accurate
1636+ representation of a changeset.
1637+ """
1638+ cs = self.history.newChangeset()
1639+
1640+ self.assertTrue(
1641+ cs.asHumanly().startswith(
1642+ u'Changeset with 0 action(s) ('))
1643+
1644+ self.assertEquals(
1645+ repr(cs),
1646+ '<Changeset 0 action(s) created=%r modified=%r>' % (
1647+ cs.created, cs.modified))
1648+
1649+ action = cs.newAction(
1650+ u'fake', FilePath(u'src'), FilePath(u'dst'), verify=False)
1651+
1652+ def _adapter(action):
1653+ return FakeAction()
1654+
1655+ cs.do(action, FakeOptions(), _adapter=_adapter)
1656+
1657+ self.assertTrue(
1658+ cs.asHumanly().startswith(
1659+ u'Changeset with 1 action(s) ('))
1660+
1661+ self.assertEquals(
1662+ repr(cs),
1663+ '<Changeset 1 action(s) created=%r modified=%r>' % (
1664+ cs.created, cs.modified))
1665+
1666+
1667+
1668+class ActionTests(TestCase):
1669+ """
1670+ Tests for L{renamer.history.Action}.
1671+ """
1672+ def setUp(self):
1673+ self.store = Store()
1674+ self.history = history.History(store=self.store)
1675+
1676+
1677+ def test_adaption(self):
1678+ """
1679+ Adapting a L{renamer.history.Action} object to
1680+ L{renamer.irenamer.IRenamingAction} results in an object implementing
1681+ C{IRenamingAction} that can perform forward and reverse actions.
1682+ """
1683+ cs = self.history.newChangeset()
1684+ action = cs.newAction(u'symlink', FilePath(u'src'), FilePath(u'dst'))
1685+ a = irenamer.IRenamingAction(action)
1686+ self.assertIdentical(type(a), SymlinkAction)
1687+ self.assertTrue(irenamer.IRenamingAction.providedBy(type(a)))
1688+
1689+
1690+ def test_representations(self):
1691+ """
1692+ L{renamer.history.Action.asHumanly} returns a human-readable and
1693+ accurate representation of an action.
1694+
1695+ L{renamer.history.Action.__repr__} returns a useful and accurate
1696+ representation of an action.
1697+ """
1698+ cs = self.history.newChangeset()
1699+ src = FilePath(u'src')
1700+ dst = FilePath(u'dst')
1701+ action = cs.newAction(u'fake', src, dst, verify=False)
1702+
1703+ self.assertTrue(
1704+ action.asHumanly().startswith(
1705+ u'Fake: %s => %s (' % (src.path, dst.path)))
1706+
1707+ self.assertEquals(
1708+ repr(action),
1709+ '<Action name=%r src=%r dst=%r created=%r>' % (
1710+ action.name, action.src, action.dst, action.created))
1711
1712=== modified file 'renamer/test/test_plugin.py'
1713--- renamer/test/test_plugin.py 2010-10-01 23:22:38 +0000
1714+++ renamer/test/test_plugin.py 2010-10-24 17:31:17 +0000
1715@@ -15,7 +15,7 @@
1716 L{renamer.plugin.RenamerSubCommandMixin.decodeCommandLine} turns a byte
1717 string from the command line into a unicode string.
1718 """
1719- decodeCommandLine = plugin.RenamerSubCommandMixin().decodeCommandLine
1720+ decodeCommandLine = plugin._CommandMixin().decodeCommandLine
1721
1722 class MockFile(object):
1723 pass
1724
1725=== modified file 'renamer/test/test_util.py'
1726--- renamer/test/test_util.py 2010-10-10 11:25:05 +0000
1727+++ renamer/test/test_util.py 2010-10-24 17:31:17 +0000
1728@@ -55,6 +55,59 @@
1729
1730
1731
1732+class GlobTests(TestCase):
1733+ """
1734+ Tests for L{renamer.util.globArguments}.
1735+ """
1736+ def setUp(self):
1737+ self.path = FilePath(self.mktemp())
1738+ self.path.makedirs()
1739+
1740+ filenames = ['a', 'ab', '.a', 'a[b]c']
1741+ for fn in filenames:
1742+ self.path.child(fn).touch()
1743+
1744+
1745+ def assertGlob(self, expected, paths, **kw):
1746+ """
1747+ Assert that L{renamer.util.globArguments}C{(paths, **kw)} returns a
1748+ result equal to C{expected}.
1749+ """
1750+ paths = [self.path.child(p).path for p in paths]
1751+ res = util.globArguments(paths, **kw)
1752+ self.assertEquals(
1753+ sorted(expected), sorted(FilePath(v).basename() for v in res))
1754+
1755+
1756+ def test_glob(self):
1757+ """
1758+ Non-Windows globbing will expand an iterable of glob arguments
1759+ according to regular globbing rules.
1760+ """
1761+ values = [
1762+ (['a*'], ['a', 'a[b]c', 'ab']),
1763+ (['a?'], ['ab'])]
1764+ for paths, expected in values:
1765+ self.assertGlob(expected, paths)
1766+
1767+ self.assertGlob(
1768+ [], ['a[b]c'],
1769+ platform='notwin32')
1770+
1771+
1772+ def test_globWin32(self):
1773+ """
1774+ On Windows globbing will only occur if the glob argument is not the
1775+ name of an existing file, in which case the existing file name will be
1776+ the only result of globbing that argument.
1777+ """
1778+ self.assertGlob(
1779+ ['a', 'a[b]c'],
1780+ ['[ab]', 'a[b]c'],
1781+ platform='win32')
1782+
1783+
1784+
1785 class IThing(Interface):
1786 """
1787 Silly test interface.
1788
1789=== modified file 'renamer/util.py'
1790--- renamer/util.py 2010-10-10 11:25:05 +0000
1791+++ renamer/util.py 2010-10-24 17:31:17 +0000
1792@@ -1,4 +1,8 @@
1793-import errno, os
1794+import errno
1795+import glob
1796+import itertools
1797+import os
1798+import sys
1799 from zope.interface import alsoProvides
1800
1801 from twisted.internet.defer import DeferredList
1802@@ -73,3 +77,23 @@
1803 newcls = type.__new__(cls, name, bases, attrs)
1804 alsoProvides(newcls, *cls.providedInterfaces)
1805 return newcls
1806+
1807+
1808+
1809+def globArguments(args, platform=sys.platform, exists=os.path.exists):
1810+ """
1811+ Glob arguments.
1812+ """
1813+ def _iglobWin32(pathname):
1814+ if not exists(pathname):
1815+ return glob.iglob(pathname)
1816+ return [pathname]
1817+
1818+ def _glob(globbing):
1819+ return itertools.chain(
1820+ *itertools.imap(globbing, args))
1821+
1822+ globbing = glob.iglob
1823+ if platform == 'win32':
1824+ globbing = _iglobWin32
1825+ return _glob(globbing)
1826
1827=== modified file 'setup.py'
1828--- setup.py 2010-10-01 12:43:32 +0000
1829+++ setup.py 2010-10-24 17:31:17 +0000
1830@@ -1,7 +1,17 @@
1831+import sys
1832 from epsilon.setuphelper import autosetup
1833
1834 import renamer
1835
1836+
1837+
1838+def scripts():
1839+ if sys.platform == 'win32':
1840+ yield 'bin/rn.cmd'
1841+ yield 'bin/rn'
1842+
1843+
1844+
1845 distobj = autosetup(
1846 name="Renamer",
1847 version=renamer.version.short(),
1848@@ -15,5 +25,4 @@
1849 "Programming Language :: Python",
1850 "Development Status :: 3 - Alpha",
1851 "Topic :: Utilities"],
1852-
1853- scripts=['bin/rn'])
1854+ scripts=list(scripts()))

Subscribers

People subscribed via source and target branches