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