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:
|
|||
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.