Merge lp:~renamer-developers/renamer/undo-command into lp:renamer
- undo-command
- Merge into trunk
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 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Tristan Seligmann | Approve | ||
Jeremy Thurgood | Approve | ||
Review via email: mp+38632@code.launchpad.net |
Commit message
Description of the change
- 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.
- 128. By Jeremy Thurgood
-
Bring back Python 2.5 support.
Tristan Seligmann (mithrandi) wrote : | # |
394 + Remove any L{renamer.
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.
- 129. By Jonathan Jacobs
-
Address review commentary.
Jonathan Jacobs (jjacobs) wrote : | # |
> 394 + Remove any L{renamer.
> 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.
Tristan Seligmann (mithrandi) wrote : | # |
394 + Remove any L{renamer.
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.
Tristan Seligmann (mithrandi) : | # |
Preview Diff
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 | |
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())) |
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.