Merge lp:~renamer-developers/renamer/ditch-metalanguage into lp:renamer

Proposed by Jonathan Jacobs
Status: Merged
Approved by: Tristan Seligmann
Approved revision: 116
Merged at revision: 84
Proposed branch: lp:~renamer-developers/renamer/ditch-metalanguage
Merge into: lp:renamer
Diff against target: 2443 lines (+714/-1378)
22 files modified
LICENSE (+5/-1)
bin/rn (+1/-1)
renamer/__init__.py (+2/-0)
renamer/application.py (+148/-223)
renamer/env.py (+0/-311)
renamer/errors.py (+3/-8)
renamer/irenamer.py (+28/-3)
renamer/logging.py (+12/-7)
renamer/main.py (+2/-3)
renamer/plugin.py (+57/-82)
renamer/plugins/audio.py (+56/-43)
renamer/plugins/common.py (+0/-307)
renamer/plugins/tv.py (+107/-56)
renamer/test/data/tvrage (+19/-0)
renamer/test/test_env.py (+0/-53)
renamer/test/test_plugin.py (+34/-0)
renamer/test/test_tvrage.py (+93/-16)
renamer/test/test_util.py (+89/-0)
renamer/util.py (+57/-175)
scripts/music.rn (+0/-44)
scripts/tv.rn (+0/-44)
setup.py (+1/-1)
To merge this branch: bzr merge lp:~renamer-developers/renamer/ditch-metalanguage
Reviewer Review Type Date Requested Status
Tristan Seligmann Approve
Andrew Snowden Approve
Review via email: mp+37035@code.launchpad.net

Description of the change

I think the value of the metalanguage has outlived itself and real Python plugins are probably a lot more useful and easy to write. The crazy config file system is also dead.

Some new features (such as creating symlinks and allowing renaming/moving across filesystem boundaries) are present too.

There really should be some more tests but I can't quite figure out where to start.

The flags "--link-dst" and "--link-src" might be more intuitive if they were named "--link-new" and "--link-original" or similar.

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

Remove empty scripts directory.

96. By Jonathan Jacobs

Remove legacy code.

97. By Jonathan Jacobs

Improve logging.

98. By Jonathan Jacobs

Add more TV Rage test data.

99. By Jonathan Jacobs

Tweak setup.py.

100. By Jonathan Jacobs

Update LICENSE.

101. By Jonathan Jacobs

Helper symlinker function.

102. By Jonathan Jacobs

Tests for renamer.util.

103. By Jonathan Jacobs

More tests for renamer.plugins.tv.

104. By Jonathan Jacobs

Tests, tweaks and docs.

105. By Jonathan Jacobs

Docstring.

106. By Jonathan Jacobs

Fix pyflakes warnings.

Revision history for this message
Andrew Snowden (andrew-snowden) wrote :

Everything looks good, the only suggested change was to move the filename parsing out of the TVRage class into a base TV class so that it could be shared by any other metadata sources (e.g. thetvdb.com) since it isn't specific to TVRage. That can wait until there is actually another source implemented.

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

> 24 from renamer._version import version
> 25 +version # Ssssh, Pyflakes.

Don't do this, use __all__ instead.

> 124 + ('dry-run', 'n', 'Perform a dry-run.'),

It might be more intuitive to make the long option name be --no-act.

> 6 +Copyright © 2007-2010 Slipgate Development cc

There isn't really such an entity as Slipgate Development cc.

> 234 + self.args = (FilePath(arg) for arg in args)

This should probably be a listcomp, not a genexp.

> 1033 +class _metaASC(type):

"ASC" stands for AxiomaticSubCommand, and thus isn't a very appropriate name here.

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

148 + return (get,)
149 +
150 + subCommands = property(*subCommands())

This is a bit pointless, just use @property on the inner function and be done.

review: Needs Fixing
107. By Jonathan Jacobs

Simplify subCommands property.

108. By Jonathan Jacobs

Use __all__ to shut Pyflakes up, where possible.

109. By Jonathan Jacobs

Make copyright information less fictional.

110. By Jonathan Jacobs

Change dry run long option name and descriptionn to be more intuitive.

111. By Jonathan Jacobs

Store command arguments in a list instead of a generator.

112. By Jonathan Jacobs

Replace _metaASC with something more generic.

113. By Jonathan Jacobs

Doc tweak.

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

Address review commentary.

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

2397 + if not (newcls.__name__ == typeName and
2398 + newcls.__module__ == moduleName):
2399 + directlyProvides(newcls, *provides)
2400 + return newcls

Okay, so. This should be alsoProvides() instead of directlyProvides(). You can also ditch the if check; instead, just throw in a noLongerProvides() after the definition of EridanusCommand to remove those interfaces from it. Finally, I think instead of this being a factory function that takes some params, it should just be a subclass of type that reads the list of interfaces off an attribute; then you just subclass it and define the interfaces as a class attribute.

2341 + if e.errno == errno.EXDEV:
2342 + raise errors.DifferentLogicalDevices(
2343 + 'Refusing to symlink "%s" to "%s" on another filesystem' % (
2344 + src.path, dst.path))

I don't understand this code at all; when would it be valid for symlink to fail with EXDEV?

1682 + key, value = line.strip().split(u'@', 1)
1683 + data[key] = value.split(u'^')

The page data is returned as a byte string, so splitting on unicode strings here is wrong.

Whew. I hope that's all!

review: Needs Fixing
114. By Jonathan Jacobs

Remove crackful EXDEV-handling symlink helper.

115. By Jonathan Jacobs

Don't split byte strings on unicode strings.

116. By Jonathan Jacobs

Replace DirectlyProvidingMetaclass insanity with something slightly less insane.

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

> Whew. I hope that's all!

Fixed.

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'LICENSE'
2--- LICENSE 2009-05-06 10:38:07 +0000
3+++ LICENSE 2010-10-10 11:27:40 +0000
4@@ -1,4 +1,8 @@
5-Copyright © 2007-2009 Slipgate Development cc
6+Copyright © 2007-2010
7+Andrew Snowden
8+Jeremy Thurgood
9+Jonathan Jacobs
10+Tristan Seligmann
11
12 Permission is hereby granted, free of charge, to any person obtaining
13 a copy of this software and associated documentation files (the
14
15=== modified file 'bin/rn'
16--- bin/rn 2009-05-06 21:51:47 +0000
17+++ bin/rn 2010-10-10 11:27:40 +0000
18@@ -1,3 +1,3 @@
19-#!/usr/bin/env python2.5
20+#!/usr/bin/env python
21 from renamer.main import main
22 main()
23
24=== modified file 'renamer/__init__.py'
25--- renamer/__init__.py 2009-04-29 00:02:05 +0000
26+++ renamer/__init__.py 2010-10-10 11:27:40 +0000
27@@ -1,1 +1,3 @@
28 from renamer._version import version
29+
30+__all__ = ['version']
31
32=== modified file 'renamer/application.py'
33--- renamer/application.py 2009-05-04 15:41:21 +0000
34+++ renamer/application.py 2010-10-10 11:27:40 +0000
35@@ -1,273 +1,198 @@
36 """
37 Renamer application logic.
38 """
39-import glob, os, stat, sys, time
40+import glob
41+import os
42+import string
43+import sys
44
45-from twisted.internet import reactor
46-from twisted.internet.defer import DeferredSemaphore, succeed
47-from twisted.internet.stdio import StandardIO
48-from twisted.protocols.basic import LineReceiver
49+from twisted.internet import reactor, defer
50 from twisted.python import usage
51-from twisted.python.versions import getVersionString
52-
53-from renamer import version, logging
54-from renamer.env import Environment, EnvironmentMode
55-from renamer.util import parallel
56-
57-
58-class ArgumentSorter(object):
59- """
60- Sort arguments according to a certain method.
61- """
62-
63- @classmethod
64- def byMtime(cls, path):
65- """
66- Sort according to modification time.
67- """
68- try:
69- return time.localtime(os.stat(path)[stat.ST_MTIME])
70- except OSError:
71- return -1
72-
73- @classmethod
74- def bySize(cls, path):
75- """
76- Sort according to file size.
77- """
78- try:
79- return os.stat(path)[stat.ST_SIZE]
80- except OSError:
81- return -1
82-
83- @classmethod
84- def byName(cls, path):
85- """
86- Sort according to file name.
87- """
88- return path
89-
90- @classmethod
91- def sort(cls, names, method):
92- """
93- Sort names according to a given method.
94-
95- @type names: C{list}
96-
97- @type method: C{str}
98- """
99- _sortMethods = {
100- 'time': cls.byMtime,
101- 'size': cls.bySize,
102- 'name': cls.byName}
103- names.sort(key=_sortMethods[method])
104-
105-
106-class Options(usage.Options):
107- """
108- Renamer command-line arguments.
109- """
110- synopsis = '[options] argument [argument ...]'
111+from twisted.python.filepath import FilePath
112+
113+from renamer import logging, plugin, util
114+
115+
116+
117+class Options(usage.Options, plugin.RenamerSubCommandMixin):
118+ synopsis = '[options] command argument [argument ...]'
119+
120
121 optFlags = [
122- ['glob', 'g', 'Expand arguments as UNIX-style globs'],
123- ['move', 'm', 'Move files'],
124- ['reverse', 'R', 'Reverse sorting order'],
125- ['dry-run', 't', 'Perform a dry-run'],
126- ]
127+ ('glob', 'g', 'Expand arguments as UNIX-style globs.'),
128+ ('one-file-system', 'x', "Don't cross filesystems."),
129+ ('no-act', 'n', 'Perform a trial run with no changes made.'),
130+ ('link-src', None, 'Create a symlink at the source.'),
131+ ('link-dst', None, 'Create a symlink at the destination.')]
132+
133
134 optParameters = [
135- ['script', 's', None, 'Renamer script to execute'],
136- ['sort', 'S', 'name', 'Sort filenames by criteria: name, size, time']
137- ]
138+ ('name', 'e', None,
139+ 'Formatted filename.', string.Template),
140+ ('prefix', 'p', None,
141+ 'Formatted path to prefix to files before renaming.', string.Template),
142+ ('concurrent', 'l', 10,
143+ 'Maximum number of concurrent tasks to perform at a time.', int)]
144+
145+
146+ @property
147+ def subCommands(self):
148+ for plg in plugin.getPlugins():
149+ try:
150+ yield plg.name, None, plg, plg.description
151+ except AttributeError:
152+ raise RuntimeError('Malformed plugin: %r' % (plg,))
153+
154
155 def __init__(self):
156 usage.Options.__init__(self)
157 self['verbosity'] = 1
158
159+
160 def opt_verbose(self):
161 """
162- Increase output
163+ Increase output, use more times for greater effect.
164 """
165 self['verbosity'] = self['verbosity'] + 1
166
167 opt_v = opt_verbose
168
169+
170 def opt_quiet(self):
171 """
172- Suppress output
173+ Suppress output.
174 """
175 self['verbosity'] = self['verbosity'] - 1
176
177 opt_q = opt_quiet
178
179- def sortArguments(self, targets):
180- """
181- Sort arguments according to options.
182- """
183- if self['sort']:
184- ArgumentSorter.sort(targets, self['sort'])
185- if self['reverse']:
186- targets.reverse()
187- return targets
188
189- def glob(self, targets):
190+ def glob(self, args):
191 """
192 Glob arguments.
193 """
194 def _glob():
195- return [target for _target in targets
196- for target in glob.glob(_target)]
197+ return (arg
198+ for _arg in args
199+ for arg in glob.glob(_arg))
200
201 def _globWin32():
202- _targets = []
203- for target in targets:
204- if not os.path.exists(target):
205- globbed = glob.glob(target)
206+ for arg in args:
207+ if not os.path.exists(arg):
208+ globbed = glob.glob(arg)
209 if globbed:
210- _targets.extend(globbed)
211+ for a in globbed:
212+ yield a
213 continue
214-
215- _targets.append(target)
216- return _targets
217+ yield arg
218
219 if sys.platform == 'win32':
220 return _globWin32()
221 return _glob()
222
223- def parseArgs(self, *targets):
224- """
225- Parse command-line arguments.
226- """
227- if self['script'] and len(targets) == 0:
228- raise usage.UsageError('Too few arguments')
229
230+ def parseArgs(self, *args):
231+ args = (self.decodeCommandLine(arg) for arg in args)
232 if self['glob']:
233- targets = self.glob(targets)
234- self.targets = self.sortArguments(list(targets))
235+ args = self.glob(args)
236+ self.args = [FilePath(arg) for arg in args]
237+
238
239
240 class Renamer(object):
241- def __init__(self, options, maxConcurrentScripts=10):
242- """
243- Initialise a Renamer.
244-
245- @type options: L{Options}
246- @param options: Parsed command-line options
247-
248- @type maxConcurrentScripts: C{int}
249- @param maxConcurrentScripts: Maximum number of scripts to execute in
250- parallel, defaults to 10
251- """
252+ """
253+ Renamer main logic.
254+
255+ @type options: L{renamer.application.Options}
256+ @ivar options: Parsed command-line options.
257+ """
258+ def __init__(self, options):
259 self.options = options
260- self.maxConcurrentScripts = maxConcurrentScripts
261- self.targets = options.targets
262-
263- def createEnvironment(self, args):
264- """
265- Create a new environment.
266-
267- @type args: C{iterable}
268- @param args: Initial stack arguments
269- """
270- mode = EnvironmentMode(dryrun=self.options['dry-run'],
271- move=self.options['move'])
272- return Environment(args,
273- mode=mode,
274- verbosity=self.options['verbosity'])
275+
276+
277+ def rename(self, dst, src):
278+ """
279+ Rename C{src} to {dst}.
280+
281+ Perform symlinking if specified and create any required directory
282+ hiearchy.
283+ """
284+ options = self.options
285+
286+ if options['dry-run']:
287+ logging.msg('Dry-run: %s => %s' % (src.path, dst.path))
288+ return
289+
290+ if src == dst:
291+ logging.msg('Skipping noop "%s"' % (src.path,), verbosity=2)
292+ return
293+
294+ if dst.exists():
295+ logging.msg('Refusing to clobber existing file "%s"' % (
296+ dst.path,))
297+ return
298+
299+ parent = dst.parent()
300+ if not parent.exists():
301+ logging.msg('Creating directory structure for "%s"' % (
302+ parent.path,), verbosity=2)
303+ parent.makedirs()
304+
305+ # Linking at the destination requires no moving.
306+ if options['link-dst']:
307+ logging.msg('Symlink: %s => %s' % (src.path, dst.path))
308+ src.linkTo(dst)
309+ else:
310+ logging.msg('Move: %s => %s' % (src.path, dst.path))
311+ util.rename(src, dst, oneFileSystem=options['one-file-system'])
312+ if options['link-src']:
313+ logging.msg('Symlink: %s => %s' % (dst.path, src.path))
314+ dst.linkTo(src)
315+
316+
317+ def _processOne(self, src):
318+ logging.msg('Processing "%s"' % (src.path,),
319+ verbosity=3)
320+ command = self.options.command
321+
322+ def buildDestination(mapping):
323+ prefixTemplate = self.options['prefix']
324+ if prefixTemplate is None:
325+ prefixTemplate = command.defaultPrefixTemplate
326+
327+ if prefixTemplate is not None:
328+ prefix = os.path.expanduser(
329+ prefixTemplate.safe_substitute(mapping))
330+ else:
331+ prefixTemplate = string.Template(src.dirname())
332+ prefix = prefixTemplate.template
333+
334+ ext = src.splitext()[-1]
335+
336+ nameTemplate = self.options['name']
337+ if nameTemplate is None:
338+ nameTemplate = command.defaultNameTemplate
339+
340+ filename = nameTemplate.safe_substitute(mapping)
341+ logging.msg(
342+ 'Building filename: prefix=%r name=%r mapping=%r' % (
343+ prefixTemplate.template, nameTemplate.template, mapping),
344+ verbosity=3)
345+ return FilePath(prefix).child(filename).siblingExtension(ext)
346+
347+ d = defer.maybeDeferred(command.processArgument, src)
348+ d.addCallback(buildDestination)
349+ d.addCallback(self.rename, src)
350+ return d
351+
352
353 def run(self):
354- """
355- Start running.
356-
357- If the script option was used, the script is run for all command-line
358- arguments, and afterwards execution will stop. Otherwise interactive
359- mode is initiated.
360- """
361- if self.options['script']:
362- self.runScript(self.options['script']
363- ).addErrback(logging.err
364- ).addCallback(lambda result: reactor.stop())
365- else:
366- self.runInteractive()
367-
368- def runScript(self, script):
369- """
370- Run the given script in the environent.
371-
372- The script is executed for each command-line argument, in parallel, up
373- to a maximum of L{maxConcurrentScripts}.
374- """
375- def _runScript(target):
376- env = self.createEnvironment([target])
377- return env.runScript(script)
378-
379- return parallel(self.targets, self.maxConcurrentScripts, _runScript)
380-
381- def runInteractive(self):
382- """
383- Begin interactive mode.
384-
385- Interactive mode is ended with an EOF.
386- """
387- env = self.createEnvironment(self.targets)
388- StandardIO(RenamerInteractive(env))
389-
390-
391-class RenamerInteractive(LineReceiver):
392- """
393- Interactive Renamer session.
394-
395- @type semaphore: C{twisted.internet.defer.DeferredSemaphore}
396- @ivar semaphore: Semaphore for serializing command execution
397- """
398- delimiter = os.linesep
399-
400- def __init__(self, env):
401- """
402- Initialise an interactive Renamer environment.
403-
404- @type env: L{Environment}
405- @param env: Renamer environment for the interactive session
406- """
407- self.env = env
408- self.semaphore = DeferredSemaphore(tokens=1)
409-
410- def heading(self):
411- """
412- Display application header.
413- """
414- self.transport.write(getVersionString(version) + self.delimiter)
415-
416- def prompt(self):
417- """
418- Display application prompt.
419- """
420- self.transport.write('rn> ')
421-
422- def connectionMade(self):
423- self.heading()
424- self.prompt()
425-
426- def connectionLost(self, reason):
427- reactor.stop()
428-
429- def lineReceived(self, line):
430- def _doLine(result):
431- return self.env.execute(line)
432-
433- def maybeQuit(f):
434- f.trap(EOFError)
435- self.transport.loseConnection()
436-
437- d = succeed(None)
438-
439- line = line.strip()
440- if line:
441- d = self.semaphore.acquire(
442- ).addCallback(_doLine
443- ).addErrback(maybeQuit
444- ).addErrback(logging.err
445- ).addBoth(lambda result: self.semaphore.release())
446-
447- d.addCallback(lambda result: self.prompt())
448+ logging.msg(
449+ 'Running, doing at most %d concurrent operations' % (
450+ self.options['concurrent'],),
451+ verbosity=3)
452+ d = util.parallel(
453+ self.options.args, self.options['concurrent'], self._processOne)
454+ d.addErrback(logging.err)
455+ d.addBoth(lambda ignored: reactor.stop())
456+ return d
457
458=== removed file 'renamer/env.py'
459--- renamer/env.py 2009-05-04 12:11:48 +0000
460+++ renamer/env.py 1970-01-01 00:00:00 +0000
461@@ -1,311 +0,0 @@
462-import codecs, os, shlex
463-
464-from twisted.internet.defer import maybeDeferred, succeed
465-from twisted.python.filepath import FilePath
466-
467-import renamer
468-from renamer import logging
469-from renamer.errors import PluginError, StackError, EnvironmentError
470-from renamer.plugin import getGlobalPlugins, getPlugin
471-
472-
473-class EnvironmentMode(object):
474- """
475- Environment mode settings.
476-
477- @type dryrun: C{bool}
478- @ivar dryrun: Perform a dry run, meaning that no changes, such as file
479- renames, are persisted
480-
481- @type move: C{bool}
482- @ivar move: Enable file moving
483- """
484- def __init__(self, dryrun, move):
485- self.dryrun = dryrun
486- self.move = move
487-
488-
489-class Environment(object):
490- """
491- Renamer script environment.
492-
493- @type mode: L{EnvironmentMode}
494-
495- @type verbosity: C{int}
496- @ivar verbosity: Verbosity level::
497-
498- 0 - Quiet
499-
500- 1 - Normal
501-
502- 2 - Verbose
503-
504- @type stack: L{Stack}
505-
506- @type _plugins: C{dict} mapping C{str} to C{dict} mapping C{str} to C{callable}
507- @ivar _plugins: Mapping of C{pluginName} to a mapping of C{commandName} to commands
508- """
509- def __init__(self, args, mode, verbosity):
510- self.mode = mode
511- self.verbosity = verbosity
512-
513- self.stack = Stack()
514- self._plugins = {}
515-
516- if self.isDryRun:
517- logging.msg('Performing a dry-run.', verbosity=2)
518-
519- if self.isMoveEnabled:
520- logging.msg('Moving is enabled.', verbosity=2)
521-
522- for p in getGlobalPlugins():
523- self._loadPlugin(p)
524-
525- for arg in args:
526- self.stack.push(arg)
527-
528- @property
529- def isDryRun(self):
530- return self.mode.dryrun
531-
532- @property
533- def isMoveEnabled(self):
534- return self.mode.move
535-
536- def openPluginFile(self, plugin, filename):
537- """
538- Open a user-provided file for a plugin.
539-
540- @rtype: C{file} or C{None}
541- @return: File object or C{None} if no such file exists, or C{plugin}
542- is a global plugin
543- """
544- if plugin.name is not None:
545- path = FilePath(os.path.expanduser('~/.renamer')).child(plugin.name).child(filename)
546- if path.exists():
547- return path.open()
548- return None
549-
550- def getScriptPaths(self):
551- """
552- Retrieve valid script directories.
553-
554- @rtype: C{iterable} of C{FilePath} instances
555- """
556- path = FilePath(os.path.expanduser('~/.renamer/scripts'))
557- if path.exists():
558- yield path
559-
560- path = FilePath(renamer.__file__).parent().sibling('scripts')
561- if path.exists():
562- yield path
563-
564- def openScript(self, filename):
565- """
566- Attempt to open a script file.
567-
568- The filename is tried as is, in the context of the current working
569- path, and then the global script paths are tried.
570-
571- @raise EnvironmentError: If C{filename} cannot be found
572-
573- @rtype: C{file}
574- """
575- def _found(path):
576- logging.msg('Found script: %r.' % (path,), verbosity=2)
577- return codecs.open(path.path, 'rb')
578-
579- def _getPaths():
580- yield FilePath(filename)
581- for path in self.getScriptPaths():
582- yield path.child(filename)
583-
584- for path in _getPaths():
585- if path.exists():
586- return _found(path)
587-
588- raise EnvironmentError('No script named %r.' % (filename,))
589-
590- def runScript(self, filename):
591- """
592- Execute a script file.
593- """
594- fd = self.openScript(filename)
595-
596- logging.msg('Running script...', verbosity=2)
597-
598- def _runLine(result, line):
599- def maybeVerbose(result):
600- if self.verbosity > 2:
601- logging.msg('rn> ' + line)
602- return self.execute('stack')
603- return self.execute(line).addCallback(maybeVerbose)
604-
605- d = succeed(None)
606- for line in fd:
607- if not line.strip() or line.startswith(u'#'):
608- continue
609- d.addCallback(_runLine, line)
610-
611- return d
612-
613- def _getCommands(self, plugin):
614- """
615- Enumerate plugin commands.
616- """
617- for name in dir(plugin):
618- attr = getattr(plugin, name, None)
619- if getattr(attr, 'command', False):
620- yield name, attr
621-
622- def _loadPlugin(self, pluginType):
623- """
624- Create an instance of C{pluginType} and map its commands.
625- """
626- p = pluginType(env=self)
627- pluginName = p.name or None
628-
629- commands = self._plugins.setdefault(pluginName, {})
630- for name, cmd in self._getCommands(p):
631- commands[name] = cmd
632-
633- def load(self, pluginName):
634- """
635- Load a plugin by name.
636- """
637- self._loadPlugin(getPlugin(pluginName))
638-
639- def _resolveCommand(self, pluginName, name):
640- """
641- Resolve a plugin command by name.
642- """
643- commands = self._plugins.get(pluginName)
644- if commands is None:
645- raise PluginError('No plugin named %r.' % (pluginName,))
646-
647- cmd = commands.get(name)
648- if cmd is None:
649- raise PluginError('No command named %r.' % (name,))
650-
651- return cmd
652-
653- def resolveCommand(self, name):
654- """
655- Resolve a command by name.
656- """
657- if '.' in name:
658- pluginName, name = name.split('.', 1)
659- else:
660- pluginName = None
661- return self._resolveCommand(pluginName, name)
662-
663- def parse(self, line):
664- """
665- Parse input according to shell-like rules.
666-
667- @rtype: C{(callable, list)}
668- @return: A 2-tuple containing a command callable and a sequence of
669- arguments
670- """
671- args = shlex.split(line)
672- name = args.pop(0)
673- return self.resolveCommand(name), args
674-
675- def execute(self, line):
676- """
677- Execute a line of input.
678-
679- @rtype: C{Deferred}
680- """
681- def _execute():
682- fn, args = self.parse(line)
683- n = fn.func_code.co_argcount - 1
684-
685- def _normalizeArgs():
686- numArgsOnStack = n - len(args) - 1
687- return self.stack.popArgs(numArgsOnStack) + args
688-
689- args = _normalizeArgs()
690-
691- for arg in args:
692- self.stack.push(arg)
693-
694- self.stack.push(fn)
695- return self.stack.call(n)
696-
697- return maybeDeferred(_execute)
698-
699-
700-class Stack(object):
701- def __init__(self):
702- self.stack = []
703-
704- def size(self):
705- return len(self.stack)
706-
707- def push(self, value):
708- """
709- Push a value on to the top of the stack.
710- """
711- self.stack.insert(0, value)
712-
713- def pop(self):
714- """
715- Retrieve the value from the top of the stack.
716- """
717- if self.size() == 0:
718- raise StackError('Popping from an empty stack')
719- return self.stack.pop(0)
720-
721- def peek(self):
722- """
723- Retrieve the value from the top of the stack, non-destructively.
724- """
725- return self.stack[0]
726-
727- def popArgs(self, numArgs):
728- if self.size() < numArgs:
729- raise StackError('Expecting %d stack arguments but only found %d' % (numArgs, self.size()))
730- return list(reversed([self.pop() for _ in xrange(numArgs)]))
731-
732- def call(self, numArgs):
733- """
734- Call the function at the top of the stack.
735-
736- The top and C{numArgs} entries are popped from the stack, with the
737- return value from the function being left on top of the stack.
738- """
739- fn = self.pop()
740-
741- if numArgs:
742- args = self.popArgs(numArgs)
743- else:
744- args = []
745-
746- def pushResult(rv):
747- if rv is not None:
748- self.push(rv)
749-
750- return maybeDeferred(fn, *args
751- ).addCallback(pushResult)
752-
753- def prettyFormat(self):
754- """
755- Get a human-readable stack visualisation.
756-
757- @rtype: C{unicode}
758- """
759- if not self.stack:
760- return '<Empty stack>'
761-
762- s = u'-->'
763- for v in self.stack:
764- s += u' %r\n' % (v,)
765- s += ' '
766-
767- return s.rstrip(u' ')
768-
769- def __repr__(self):
770- return '<%s size=%d>' % (
771- type(self).__name__,
772- self.size())
773
774=== modified file 'renamer/errors.py'
775--- renamer/errors.py 2009-04-23 12:24:47 +0000
776+++ renamer/errors.py 2010-10-10 11:27:40 +0000
777@@ -1,16 +1,11 @@
778-class StackError(RuntimeError):
779- """
780- An error occured while attempting to manipulate the stack.
781- """
782-
783-
784 class PluginError(RuntimeError):
785 """
786 An error that has something to do with plugins.
787 """
788
789
790-class EnvironmentError(RuntimeError):
791+
792+class DifferentLogicalDevices(RuntimeError):
793 """
794- Attempting to do something in the environment failed.
795+ An attempt to move a file to a different logical device was made.
796 """
797
798=== modified file 'renamer/irenamer.py'
799--- renamer/irenamer.py 2009-05-03 19:37:09 +0000
800+++ renamer/irenamer.py 2010-10-10 11:27:40 +0000
801@@ -1,7 +1,32 @@
802 from zope.interface import Interface, Attribute
803
804
805-class IRenamerPlugin(Interface):
806+
807+class IRenamerCommand(Interface):
808+ """
809+ Renamer command.
810+ """
811 name = Attribute("""
812- Plugin name or C{None} to indicate a global plugin.
813- """)
814+ Command name.
815+ """)
816+
817+
818+ description = Attribute("""
819+ Brief description of the command.
820+ """)
821+
822+
823+ defaultNameFormat = Attribute("""
824+ String template for the default name format to use if one is not supplied
825+ to Renamer.
826+ """)
827+
828+
829+ def processArgument(argument):
830+ """
831+ Process an argument.
832+
833+ @rtype: C{dict} mapping C{unicode} to C{unicode}
834+ @return: Mapping of keys to values to substitute info the name
835+ template.
836+ """
837
838=== modified file 'renamer/logging.py'
839--- renamer/logging.py 2009-05-03 19:37:09 +0000
840+++ renamer/logging.py 2010-10-10 11:27:40 +0000
841@@ -3,22 +3,19 @@
842 from twisted.python import log
843
844
845+
846 class RenamerObserver(object):
847 """
848- Twisted event log observer.
849+ Twisted event log observer for Renamer.
850 """
851 def __init__(self, verbosity):
852 self.verbosity = verbosity
853
854- def start(self):
855- log.addObserver(self.emit)
856-
857- def stop(self):
858- log.removeObserver(self.emit)
859
860 def _formatEventMessage(self, message):
861 return ' '.join(str(m) for m in message) + '\n'
862
863+
864 def _emitError(self, eventDict):
865 if 'failure' in eventDict:
866 text = eventDict['failure'].getTraceback()
867@@ -27,6 +24,7 @@
868 sys.stderr.write(text)
869 sys.stderr.flush()
870
871+
872 def emit(self, eventDict):
873 if eventDict['isError']:
874 self._emitError(eventDict)
875@@ -34,11 +32,18 @@
876 if eventDict.get('source') == 'renamer':
877 verbosity = eventDict.get('verbosity', 1)
878 if self.verbosity >= verbosity:
879- sys.stdout.write(self._formatEventMessage(eventDict['message']))
880+ leader = '-' * (verbosity - 1)
881+ if leader:
882+ leader += ' '
883+ sys.stdout.write(leader +
884+ self._formatEventMessage(eventDict['message']))
885 sys.stdout.flush()
886
887
888+
889 def msg(message, **kw):
890 log.msg(message, source='renamer', **kw)
891
892+
893+
894 err = log.err
895
896=== modified file 'renamer/main.py'
897--- renamer/main.py 2009-04-29 22:26:05 +0000
898+++ renamer/main.py 2010-10-10 11:27:40 +0000
899@@ -1,3 +1,4 @@
900+from twisted.python import log
901 from twisted.internet import reactor
902
903 from renamer import application
904@@ -9,10 +10,8 @@
905 options.parseOptions()
906
907 obs = RenamerObserver(options['verbosity'])
908- obs.start()
909+ log.startLoggingWithObserver(obs.emit, setStdout=False)
910
911 r = application.Renamer(options)
912 reactor.callWhenRunning(r.run)
913 reactor.run()
914-
915- obs.stop()
916
917=== modified file 'renamer/plugin.py'
918--- renamer/plugin.py 2010-07-19 17:42:22 +0000
919+++ renamer/plugin.py 2010-10-10 11:27:40 +0000
920@@ -1,91 +1,66 @@
921+import sys
922+from zope.interface import noLongerProvides
923+
924 from twisted import plugin
925+from twisted.python import usage
926
927 from renamer import plugins
928-from renamer.errors import PluginError
929-from renamer.irenamer import IRenamerPlugin
930-
931-
932-def command(func):
933- """
934- Decorate a function as a Renamer plugin command.
935- """
936- func.command = True
937- return func
938+from renamer.irenamer import IRenamerCommand
939+from renamer.util import InterfaceProvidingMetaclass
940+
941
942
943 def getPlugins():
944 """
945 Get all available Renamer plugins.
946 """
947- return plugin.getPlugins(IRenamerPlugin, plugins)
948-
949-
950-def getPlugin(name):
951- """
952- Get a plugin by name.
953-
954- @raise PluginError: If no plugin is named C{name}
955- """
956- for p in getPlugins():
957- if p.name == name:
958- return p
959-
960- raise PluginError('No plugin named %r.' % (name,))
961-
962-
963-def getGlobalPlugins():
964- """
965- Get all available global plugins.
966- """
967- for p in getPlugins():
968- if p.name is None:
969- yield p
970-
971-
972-class Plugin(object):
973- """
974- Mixin for Renamer plugins.
975-
976- @type env: L{Environment}
977-
978- @type config: C{dict}
979- @param config: Plugin-specific parameters
980- """
981- def __init__(self, env, **kw):
982- super(Plugin, self).__init__(**kw)
983- self.env = env
984- self.config = self._readConfig()
985-
986- def _readConfig(self):
987- """
988- Read a user-provided plugin configuration.
989-
990- The configuration is can be found in C{~/.renamer/plugin_name/config}.
991-
992- @rtype: C{dict}
993- """
994- fd = self.openFile('config')
995- config = {}
996- if fd is not None:
997- for line in fd:
998- if not line.strip():
999- continue
1000- key, value = line.strip().split('=', 1)
1001- config[key] = value
1002-
1003- return config
1004-
1005- def openFile(self, filename):
1006- """
1007- Open a user-provided file.
1008-
1009- Plugin files are found in C{~/.renamer/plugin_name/}.
1010- """
1011- return self.env.openPluginFile(self, filename)
1012-
1013- @command
1014- def confvar(self, name, default):
1015- """
1016- Get a config variable.
1017- """
1018- return self.config.get(name, default)
1019+ return plugin.getPlugins(IRenamerCommand, plugins)
1020+
1021+
1022+
1023+class RenamerSubCommandMixin(object):
1024+ """
1025+ Mixin for Renamer commands.
1026+ """
1027+ def decodeCommandLine(self, cmdline):
1028+ """
1029+ Turn a byte string from the command line into a unicode string.
1030+ """
1031+ codec = getattr(sys.stdin, 'encoding', None) or sys.getdefaultencoding()
1032+ return unicode(cmdline, codec)
1033+
1034+
1035+
1036+class RenamerSubCommand(usage.Options, RenamerSubCommandMixin):
1037+ """
1038+ Sub-level Renamer command.
1039+ """
1040+
1041+
1042+
1043+class RenamerCommandMeta(InterfaceProvidingMetaclass):
1044+ providedInterfaces = [plugin.IPlugin, IRenamerCommand]
1045+
1046+
1047+
1048+class RenamerCommand(usage.Options, RenamerSubCommandMixin):
1049+ """
1050+ Top-level Renamer command.
1051+
1052+ These commands will display in the main help listing.
1053+ """
1054+ __metaclass__ = RenamerCommandMeta
1055+
1056+ defaultPrefixTemplate = None
1057+ defaultNameTemplate = None
1058+
1059+
1060+ def parseArgs(self, *args):
1061+ self.parent.parseArgs(*args)
1062+
1063+
1064+ def postOptions(self):
1065+ self.parent.command = self
1066+
1067+noLongerProvides(RenamerCommand, plugin.IPlugin)
1068+noLongerProvides(RenamerCommand, IRenamerCommand)
1069
1070=== modified file 'renamer/plugins/audio.py'
1071--- renamer/plugins/audio.py 2009-05-10 19:22:32 +0000
1072+++ renamer/plugins/audio.py 2010-10-10 11:27:40 +0000
1073@@ -1,28 +1,50 @@
1074+import string
1075+from functools import partial
1076+
1077 try:
1078 import mutagen
1079+ mutagen # Ssssh, Pyflakes.
1080 except ImportError:
1081 mutagen = None
1082
1083-from zope.interface import classProvides
1084-
1085-from twisted.plugin import IPlugin
1086-
1087-from renamer.irenamer import IRenamerPlugin
1088-from renamer.plugin import Plugin, command
1089+from renamer import logging
1090+from renamer.plugin import RenamerCommand
1091 from renamer.errors import PluginError
1092
1093
1094-class Audio(Plugin):
1095- classProvides(IPlugin, IRenamerPlugin)
1096
1097+class Audio(RenamerCommand):
1098 name = 'audio'
1099
1100- def __init__(self, **kw):
1101+
1102+ description = 'Rename audio files with their metadata.'
1103+
1104+
1105+ longdesc = """
1106+ Rename audio files based on their own metadata.
1107+
1108+ Available placeholders for templates are:
1109+
1110+ artist, album, title, date, tracknumber
1111+ """
1112+
1113+
1114+ defaultPrefixTemplate = string.Template(
1115+ '${artist}/${album} (${date})')
1116+
1117+
1118+ defaultNameTemplate = string.Template(
1119+ '${tracknumber}. ${title}')
1120+
1121+
1122+ def postOptions(self):
1123 if mutagen is None:
1124- raise PluginError('"mutagen" package is required for this plugin')
1125- super(Audio, self).__init__(**kw)
1126+ raise PluginError(
1127+ 'The "mutagen" package is required for this command')
1128+ super(Audio, self).postOptions()
1129 self._metadataCache = {}
1130
1131+
1132 def _getMetadata(self, filename):
1133 """
1134 Get file metadata.
1135@@ -31,21 +53,22 @@
1136 self._metadataCache[filename] = mutagen.File(filename)
1137 return self._metadataCache[filename]
1138
1139- def _getTag(self, filename, tagNames, default=None):
1140+
1141+ def getTag(self, path, tagNames, default=u'UNKNOWN'):
1142 """
1143 Get a metadata field by name.
1144
1145- @type filename: C{str} or C{unicode}
1146+ @type filename: L{twisted.python.filepath.FilePath}
1147
1148- @type tagNames: C{str} or C{unicode}
1149+ @type tagNames: C{list} of C{unicode}
1150 @param tagNames: A C{|} separated list of tag names to attempt when
1151 retrieving a value, the first successful result is returned
1152
1153 @return: Tag value as C{unicode} or C{default}
1154 """
1155- md = self._getMetadata(filename)
1156-
1157- tagNames = tagNames.split('|')
1158+ logging.msg('Getting metadata for %r from "%s"' % (tagNames, path.path),
1159+ verbosity=4)
1160+ md = self._getMetadata(path.path)
1161 for tagName in tagNames:
1162 try:
1163 return unicode(md[tagName][0])
1164@@ -54,29 +77,19 @@
1165
1166 return default
1167
1168- @command
1169- def gettags(self, filename, tagNames, default):
1170- """
1171- Retrieve a list of tag values.
1172-
1173- Multiple tags may be specified by delimiting the names with ",".
1174- Alternate tag names for a particular tag may be delimited with "|".
1175-
1176- For example: "title|TIT2,album" would retrieve a tag named "title"
1177- (or "TIT2" if "title" didn't exist) and then a tag named "album".
1178- """
1179- return [self._getTag(filename, tagName.strip(), default)
1180- for tagName in tagNames.split(',')]
1181-
1182- _extensions = {
1183- 'audio/x-flac': '.flac'}
1184-
1185- @command
1186- def extension(self, filename):
1187- md = self._getMetadata(filename)
1188- for mimeType in md.mime:
1189- ext = self._extensions.get(mimeType)
1190- if ext is not None:
1191- return ext
1192-
1193- return '.' + md.mime[0].split('/', 1)[1]
1194+ def _saneTracknumber(self, tracknumber):
1195+ if u'/' in tracknumber:
1196+ tracknumber = tracknumber.split(u'/')[0]
1197+ return int(tracknumber)
1198+
1199+
1200+ # IRenamerCommand
1201+
1202+ def processArgument(self, arg):
1203+ T = partial(self.getTag, arg)
1204+ return dict(
1205+ artist=T([u'artist', u'TPE1']),
1206+ album=T([u'album', u'TALB']),
1207+ title=T([u'title', u'TIT2']),
1208+ date=T([u'date', u'year', u'TDRC']),
1209+ tracknumber=self._saneTracknumber(T([u'tracknumber', u'TRCK'])))
1210
1211=== removed file 'renamer/plugins/common.py'
1212--- renamer/plugins/common.py 2009-05-07 11:50:08 +0000
1213+++ renamer/plugins/common.py 1970-01-01 00:00:00 +0000
1214@@ -1,307 +0,0 @@
1215-import os, re, textwrap
1216-
1217-from zope.interface import classProvides
1218-
1219-from twisted.plugin import IPlugin
1220-
1221-from renamer import logging
1222-from renamer.irenamer import IRenamerPlugin
1223-from renamer.plugin import Plugin, command
1224-from renamer.util import Replacement, Replacer, padIterable
1225-
1226-
1227-class Common(Plugin):
1228- classProvides(IPlugin, IRenamerPlugin)
1229-
1230- name = None
1231-
1232- def __init__(self, **kw):
1233- super(Common, self).__init__(**kw)
1234- self.vars = {}
1235-
1236- @command
1237- def push(self, value):
1238- """
1239- Push an argument onto the stack.
1240-
1241- Parameters prefixed with a `$` are expanded from the variables
1242- dictionary (as stored by the `var` command.) `$$` produces a literal
1243- `$`.
1244- """
1245- if value.startswith('$'):
1246- if value.startswith('$$'):
1247- return value[1:]
1248- else:
1249- return self.vars[value[1:]]
1250- return value
1251-
1252- @command
1253- def pushdefault(self, value, default):
1254- """
1255- Push an argument onto the stack, optionally using a default.
1256-
1257- Primarily useful for when attempting to use `$` expansion might
1258- fail.
1259- """
1260- try:
1261- return self.push(value)
1262- except KeyError:
1263- return default
1264-
1265- @command
1266- def expanditer(self, iterable):
1267- """
1268- Expand an iterable.
1269-
1270- Each element of an iterable is pushed individually onto the stack.
1271-
1272- e.g::
1273- rn> push "abc"
1274- rn> expanditer
1275- rn> stack
1276- --> 'a'
1277- 'b'
1278- 'c'
1279- """
1280- iterable = list(iter(iterable))
1281- for e in reversed(iterable):
1282- self.env.stack.push(e)
1283-
1284- @command
1285- def paditer(self, iterable, padding, count):
1286- """
1287- Pad an iterable on the stack with "padding" to "count" elements.
1288- """
1289- return list(padIterable(iter(iterable), padding, int(count)))
1290-
1291- @command
1292- def pop(self):
1293- """
1294- Pop a value from the top of the stack.
1295- """
1296- self.env.stack.pop()
1297-
1298- @command
1299- def dup(self):
1300- """
1301- Duplicate the value at the top of the stack.
1302- """
1303- return self.env.stack.peek()
1304-
1305- @command
1306- def duplistn(self, seq, index):
1307- """
1308- Duplicate the value at a given index in a sequence.
1309- """
1310- index = int(index)
1311- self.env.stack.push(seq)
1312- return seq[index]
1313-
1314- @command
1315- def split(self, s, delim):
1316- """
1317- Split a string on a delimiter.
1318- """
1319- return s.split(delim)
1320-
1321- @command
1322- def splitn(self, s, delim, n):
1323- """
1324- Split a string on a delimiter up to `n` times.
1325- """
1326- return s.split(delim, int(n))
1327-
1328- @command
1329- def rsplitn(self, s, delim, n):
1330- """
1331- Split a string in reverse on a delimier up to `n` times.
1332- """
1333- return s.rsplit(delim, int(n))
1334-
1335- @command
1336- def join(self, it, delim):
1337- """
1338- Join an iterable with a delimiter.
1339- """
1340- return delim.join(it)
1341-
1342- @command
1343- def strip(self, s, chars):
1344- """
1345- Strip a string of characters.
1346- """
1347- return s.strip(chars)
1348-
1349- @command
1350- def title(self, s):
1351- """
1352- Title case a string.
1353- """
1354- return s.title()
1355-
1356- @command
1357- def lower(self, s):
1358- """
1359- Lower case a string.
1360- """
1361- return s.lower()
1362-
1363- @command
1364- def int(self, s):
1365- """
1366- Convert a string to an integer.
1367- """
1368- return int(s)
1369-
1370- @command
1371- def load(self, name):
1372- """
1373- Load a plugin by name.
1374- """
1375- self.env.load(name)
1376-
1377- @command
1378- def stack(self):
1379- """
1380- Pretty-print the current stack.
1381- """
1382- print self.env.stack.prettyFormat()
1383-
1384- @command
1385- def commands(self):
1386- """
1387- List all available commands.
1388- """
1389- lines = []
1390- for pluginName, commands in sorted(self.env._plugins.iteritems()):
1391- if commands:
1392- if pluginName is None:
1393- pluginName = 'Global:'
1394- else:
1395- pluginName = '%s:' % (pluginName,)
1396- print pluginName
1397- print ' ', ', '.join(sorted(commands.iterkeys()))
1398-
1399- @command
1400- def help(self, name):
1401- """
1402- Retrieve help for a given command.
1403- """
1404- cmd = self.env.resolveCommand(name)
1405- if cmd.__doc__:
1406- code = cmd.im_func.func_code
1407- argnames = code.co_varnames[1:code.co_argcount]
1408- doc = '%s(%s)%s%s' % (cmd.im_func.func_name,
1409- ', '.join(argnames),
1410- os.linesep,
1411- textwrap.dedent(cmd.__doc__.strip()))
1412- else:
1413- doc = 'No help available.'
1414-
1415- print doc
1416-
1417- @command
1418- def inc(self, value):
1419- """
1420- Increment a numerical value.
1421- """
1422- return value + 1
1423-
1424- @command
1425- def dec(self, value):
1426- """
1427- Decrement a numerical value.
1428- """
1429- return value - 1
1430-
1431- # XXX: this sucks
1432- @command
1433- def camel_case_into_sentence(self, s):
1434- """
1435- Convert a camel-case string into a sentence.
1436-
1437- e.g::
1438- rn> push "LikeThisOne"
1439- rn> camel_case_into_sentence
1440- rn> stack
1441- --> 'Like This One'
1442- """
1443- return re.sub(r'(?<![\s-])([A-Z\(])', r' \1', s).strip()
1444-
1445- def _setvar(self, name, value):
1446- self.vars[name] = value
1447-
1448- @command
1449- def regex(self, s, r):
1450- m = re.match(r, s)
1451- if m is not None:
1452- for key, value in m.groupdict().iteritems():
1453- self._setvar(key, value)
1454- return list(m.groups())
1455-
1456- @command
1457- def var(self, value, name):
1458- """
1459- Store a variable.
1460- """
1461- self._setvar(name, value)
1462-
1463- @command
1464- def envvar(self, name, default):
1465- """
1466- Get an environment variable.
1467- """
1468- return os.environ.get(name, default)
1469-
1470- @command
1471- def format(self, fmt):
1472- """
1473- Perform string interpolation with the stored variable dictionary.
1474- """
1475- return fmt % self.vars
1476-
1477- @command
1478- def quit(self):
1479- """
1480- Exit the environment.
1481- """
1482- raise EOFError()
1483-
1484-
1485-class OS(Plugin):
1486- classProvides(IPlugin, IRenamerPlugin)
1487-
1488- name = 'os'
1489-
1490- def __init__(self, **kw):
1491- super(OS, self).__init__(**kw)
1492- self.repl = Replacement.fromIterable(self.openFile('replace'))
1493- self.repl.add(Replacer(r'[*<>/]', ''))
1494-
1495- @command
1496- def move(self, src, dstDir):
1497- """
1498- Move a file to a directory.
1499- """
1500- dstPath = os.path.join(dstDir, src)
1501- if self.env.isMoveEnabled:
1502- logging.msg('Move: %s ->\n %s' % (src, dstPath))
1503- if not self.env.isDryRun:
1504- if not os.path.exists(dstDir):
1505- os.makedirs(dstDir)
1506- os.rename(src, dstPath)
1507-
1508- return dstPath
1509-
1510- @command
1511- def rename(self, src, dst):
1512- """
1513- Rename a file.
1514- """
1515- dst = self.repl.replace(dst)
1516-
1517- logging.msg('Rename: %s ->\n %s' % (src, dst))
1518- if not self.env.isDryRun:
1519- os.rename(src, dst)
1520-
1521- return dst
1522
1523=== modified file 'renamer/plugins/tv.py'
1524--- renamer/plugins/tv.py 2010-04-29 13:12:48 +0000
1525+++ renamer/plugins/tv.py 2010-10-10 11:27:40 +0000
1526@@ -1,49 +1,70 @@
1527+import string
1528 import urllib
1529
1530-from zope.interface import classProvides
1531-
1532-from twisted.plugin import IPlugin
1533 from twisted.web.client import getPage
1534
1535-from renamer.irenamer import IRenamerPlugin
1536-from renamer.plugin import Plugin, command
1537-
1538 try:
1539 import pyparsing
1540- from pyparsing import (alphanums, nums, Word, Literal, ParseException, SkipTo,
1541- FollowedBy, ZeroOrMore, Combine, NotAny, Optional, StringEnd)
1542+ from pyparsing import (
1543+ alphanums, nums, Word, Literal, ParseException, SkipTo, FollowedBy,
1544+ ZeroOrMore, Combine, NotAny, Optional, StringEnd)
1545+ pyparsing # Ssssh, Pyflakes.
1546 except ImportError:
1547 pyparsing = None
1548
1549+from renamer import logging
1550+from renamer.plugin import RenamerCommand
1551 from renamer.errors import PluginError
1552-from renamer.util import Replacement, ConditionalReplacer
1553-
1554-
1555-class TV(Plugin):
1556- classProvides(IPlugin, IRenamerPlugin)
1557-
1558- name = 'tv'
1559-
1560- def __init__(self, **kw):
1561- if pyparsing is None:
1562- raise PluginError('"pyparsing" package is required for this plugin')
1563- super(TV, self).__init__(**kw)
1564- self.filename = self._createParser()
1565- self.repl = {
1566- 'show': Replacement.fromIterable(self.openFile('shownames')),
1567- 'ep': Replacement.fromIterable(self.openFile('epnames'), ConditionalReplacer)}
1568+
1569+
1570+
1571+class TVRage(RenamerCommand):
1572+ name = 'tvrage'
1573+
1574+
1575+ description = 'Rename TV episodes with TV Rage metadata.'
1576+
1577+
1578+ longdesc = """
1579+ Extract TV episode information from filenames and rename them based on the
1580+ correct information from TV Rage <http://tvrage.com/>.
1581+
1582+ Available placeholders for templates are:
1583+
1584+ series, season, padded_season, episode, padded_episode, title
1585+ """
1586+
1587+
1588+ defaultNameTemplate = string.Template(
1589+ '$series [${season}x${padded_episode}] - $title')
1590+
1591+
1592+ def postOptions(self):
1593+ super(TVRage, self).postOptions()
1594+ self.filenameParser = self._createParser()
1595+
1596
1597 def _createParser(self):
1598 """
1599 Create the filename parser.
1600 """
1601+ if pyparsing is None:
1602+ raise PluginError(
1603+ 'The "pyparsing" package is required for this command')
1604+
1605 def L(value):
1606 return Literal(value).suppress()
1607
1608 number = Word(nums)
1609 digit = Word(nums, exact=1)
1610
1611- separator = Literal('_-_') | Literal(' - ') | Literal('.-.') | Literal('-') | Literal('.') | Literal('_') | Literal(' ')
1612+ separator = ( Literal('_-_')
1613+ | Literal(' - ')
1614+ | Literal('.-.')
1615+ | Literal('-')
1616+ | Literal('.')
1617+ | Literal('_')
1618+ | Literal(' '))
1619 separator = separator.suppress().leaveWhitespace()
1620
1621 season = number.setResultsName('season')
1622@@ -56,49 +77,79 @@
1623 | L('S') + season + L('E') + epnum
1624 | L('s') + season + L('e') + epnum
1625 | exact_season + exact_epnum
1626- | short_season + exact_epnum
1627- )
1628+ | short_season + exact_epnum)
1629
1630 series_word = Word(alphanums)
1631- series = ZeroOrMore(series_word + separator + NotAny(episode + separator)) + series_word
1632+ series = ZeroOrMore(
1633+ series_word + separator + NotAny(episode + separator)) + series_word
1634 series = Combine(series, joinString=' ').setResultsName('series_name')
1635
1636 extension = '.' + Word(alphanums).setResultsName('ext') + StringEnd()
1637
1638 title = SkipTo(FollowedBy(extension))
1639
1640- return series + separator + episode + Optional(separator + title) + extension
1641-
1642- @command
1643- def find_parts(self, src):
1644+ return (series + separator + episode + Optional(separator + title) +
1645+ extension)
1646+
1647+
1648+ def buildMapping(self, (seriesName, season, episode, episodeName)):
1649+ return dict(
1650+ series=seriesName,
1651+ season=season,
1652+ padded_season=u'%02d' % (season,),
1653+ episode=episode,
1654+ padded_episode=u'%02d' % (episode,),
1655+ title=episodeName)
1656+
1657+
1658+ def extractParts(self, filename):
1659 """
1660 Get TV episode information from a filename.
1661 """
1662 try:
1663- parse = self.filename.parseString(src)
1664+ parse = self.filenameParser.parseString(filename)
1665 except ParseException, e:
1666- raise PluginError('No patterns could be found in %r (%r)' % (src, e))
1667+ raise PluginError(
1668+ 'No patterns could be found in "%s" (%r)' % (filename, e))
1669 else:
1670- return parse.series_name, parse.season, parse.ep, parse.ext
1671-
1672- @command
1673- def tvrage(self, key, showName):
1674- """
1675- Look up TV episode information on TV Rage.
1676- """
1677- qs = urllib.urlencode([('show', showName), ('ep', key)])
1678+ parts = parse.series_name, parse.season, parse.ep, parse.ext
1679+ logging.msg('Found parts in "%s": %r' % (filename, parts),
1680+ verbosity=4)
1681+ return parts
1682+
1683+
1684+ def extractMetadata(self, pageData):
1685+ """
1686+ Extract TV episode metadata from a TV Rage response.
1687+ """
1688+ data = {}
1689+ for line in pageData.splitlines():
1690+ key, value = line.strip().split('@', 1)
1691+ data[key] = value.split('^')
1692+
1693+ series = data['Show Name'][0]
1694+ season, episode = map(int, data['Episode Info'][0].split('x'))
1695+ title = data['Episode Info'][1]
1696+ return series, season, episode, title
1697+
1698+
1699+ def lookupMetadata(self, seriesName, season, episode, fetcher=getPage):
1700+ """
1701+ Look up TV episode metadata on TV Rage.
1702+ """
1703+ ep = '%dx%02d' % (int(season), int(episode))
1704+ qs = urllib.urlencode({'show': seriesName, 'ep': ep})
1705 url = 'http://services.tvrage.com/tools/quickinfo.php?%s' % (qs,)
1706-
1707- def getParams(page):
1708- data = {}
1709- for line in page.splitlines():
1710- key, value = line.strip().split('@', 1)
1711- data[key] = value.split('^')
1712-
1713- showName = self.repl['show'].replace(data['Show Name'][0])
1714- season, epNumber = map(int, data['Episode Info'][0].split('x'))
1715- epName = self.repl['ep'].replace(data['Episode Info'][1], showName)
1716-
1717- return showName, season, epNumber, epName
1718-
1719- return getPage(url).addCallback(getParams)
1720+ logging.msg('Looking up TV Rage metadata at %s' % (url,),
1721+ verbosity=4)
1722+ return fetcher(url).addCallback(self.extractMetadata)
1723+
1724+
1725+ # IRenamerCommand
1726+
1727+ def processArgument(self, arg):
1728+ # XXX: why does our pattern care about the extension?
1729+ seriesName, season, episode, ext = self.extractParts(arg.basename())
1730+ d = self.lookupMetadata(seriesName, season, episode)
1731+ d.addCallback(self.buildMapping)
1732+ return d
1733
1734=== added directory 'renamer/test/data'
1735=== added file 'renamer/test/data/tvrage'
1736--- renamer/test/data/tvrage 1970-01-01 00:00:00 +0000
1737+++ renamer/test/data/tvrage 2010-10-10 11:27:40 +0000
1738@@ -0,0 +1,19 @@
1739+Show ID@7926
1740+Show Name@Dexter
1741+Show URL@http://www.tvrage.com/Dexter
1742+Premiered@2006
1743+Started@Oct/01/2006
1744+Ended@
1745+Episode Info@01x02^Crocodile^08/Oct/2006
1746+Episode URL@http://www.tvrage.com/Dexter/episodes/408410
1747+Latest Episode@05x01^My Bad^Sep/26/2010
1748+Next Episode@05x02^Hello, Bandit^Oct/03/2010
1749+RFC3339@2010-10-03T21:00:00-4:00
1750+GMT+0 NODST@1286146800
1751+Country@USA
1752+Status@Returning Series
1753+Classification@Scripted
1754+Genres@Crime | Drama
1755+Network@Showtime
1756+Airtime@Sunday at 09:00 pm
1757+Runtime@60
1758
1759=== removed file 'renamer/test/test_env.py'
1760--- renamer/test/test_env.py 2009-04-27 12:40:40 +0000
1761+++ renamer/test/test_env.py 1970-01-01 00:00:00 +0000
1762@@ -1,53 +0,0 @@
1763-from twisted.trial.unittest import TestCase
1764-
1765-from renamer.errors import StackError
1766-from renamer.env import Stack
1767-
1768-
1769-class StackTests(TestCase):
1770- def makeStack(self):
1771- stack = Stack()
1772- for arg in [u'arg1', u'arg2', u'arg3']:
1773- stack.push(arg)
1774-
1775- return stack
1776-
1777- def test_pushPopPeek(self):
1778- """
1779- Pushing, popping and peeking for the stack behave as intended.
1780- """
1781- stack = Stack()
1782-
1783- self.assertRaises(StackError, stack.pop)
1784-
1785- stack.push(1)
1786- self.assertEqual(stack.pop(), 1)
1787-
1788- stack.push(u'a')
1789- stack.push(u'b')
1790- self.assertEqual(stack.pop(), u'b')
1791- self.assertEqual(stack.pop(), u'a')
1792-
1793- stack.push(u'c')
1794- self.assertEqual(stack.peek(), u'c')
1795- self.assertEqual(stack.size(), 1)
1796-
1797- def test_popArgs(self):
1798- """
1799- Values popped for use as function arguments appear in the correct order.
1800- """
1801- self.assertEqual(self.makeStack().popArgs(1), [u'arg3'])
1802- self.assertEqual(self.makeStack().popArgs(2), [u'arg2', u'arg3'])
1803- self.assertEqual(self.makeStack().popArgs(3), [u'arg1', u'arg2', u'arg3'])
1804-
1805- def test_prettyFormat(self):
1806- """
1807- Pretty-formatting the stack results in correctly formatted output.
1808- """
1809- stack = self.makeStack()
1810- format = u'''\
1811---> u'arg3'
1812- u'arg2'
1813- u'arg1'
1814-'''
1815- self.assertEqual(stack.prettyFormat(), format)
1816
1817=== added file 'renamer/test/test_plugin.py'
1818--- renamer/test/test_plugin.py 1970-01-01 00:00:00 +0000
1819+++ renamer/test/test_plugin.py 2010-10-10 11:27:40 +0000
1820@@ -0,0 +1,34 @@
1821+import sys
1822+
1823+from twisted.trial.unittest import TestCase
1824+
1825+from renamer import plugin
1826+
1827+
1828+
1829+class RenamerCommandTests(TestCase):
1830+ """
1831+ Tests for Renamer command mixins in L{renamer.plugin}.
1832+ """
1833+ def test_decodeCommandLine(self):
1834+ """
1835+ L{renamer.plugin.RenamerSubCommandMixin.decodeCommandLine} turns a byte
1836+ string from the command line into a unicode string.
1837+ """
1838+ decodeCommandLine = plugin.RenamerSubCommandMixin().decodeCommandLine
1839+
1840+ class MockFile(object):
1841+ pass
1842+
1843+ mf = MockFile()
1844+ self.patch(sys, 'stdin', mf)
1845+
1846+ mf.encoding = 'utf-8'
1847+ self.assertEquals(
1848+ decodeCommandLine(u'\u263a'.encode('utf-8')),
1849+ u'\u263a')
1850+
1851+ mf.encoding = None
1852+ self.assertEquals(
1853+ decodeCommandLine(u'hello'.encode(sys.getdefaultencoding())),
1854+ u'hello')
1855
1856=== renamed file 'renamer/test/test_plugins.py' => 'renamer/test/test_tvrage.py'
1857--- renamer/test/test_plugins.py 2009-05-03 19:37:09 +0000
1858+++ renamer/test/test_tvrage.py 2010-10-10 11:27:40 +0000
1859@@ -1,9 +1,26 @@
1860+import cgi
1861+import urllib
1862+
1863+from twisted.internet.defer import succeed
1864+from twisted.python.filepath import FilePath
1865 from twisted.trial.unittest import TestCase
1866
1867-from renamer.env import Environment, EnvironmentMode
1868-from renamer.plugins.tv import TV
1869-
1870-class TVTests(TestCase):
1871+from renamer import errors
1872+from renamer.plugins import tv
1873+
1874+
1875+
1876+class DummyPluginParent(object):
1877+ """
1878+ Dummy plugin parent.
1879+ """
1880+
1881+
1882+
1883+class TVRageTests(TestCase):
1884+ """
1885+ Tests for L{renamer.plugins.tv.TVRage}.
1886+ """
1887 cases = [
1888 ('Profiler - S01E01 - Insight.avi', 'Profiler', '01', '01', 'avi'),
1889 ('Heroes [1x01] - Genesis.avi', 'Heroes', '1', '01', 'avi'),
1890@@ -20,21 +37,81 @@
1891 ('Xena_4x02_Adventures In The Sin Trade - Part 2.avi', 'Xena', '4', '02', 'avi'),
1892 ('Sliders 501 - The Unstuck Man.avi', 'Sliders', '5', '01', 'avi'),
1893 ('buffy.2x03.dvdrip.xvid-tns.avi', 'buffy', '2', '03', 'avi'),
1894- ('the.4400.1x05.avi', 'the 4400', '1', '05', 'avi'),
1895- ('ReGenesis - 1x13.avi', 'ReGenesis', '1', '13', 'avi'),
1896- ]
1897+ # XXX: This is broken and probably has been for a long time, it would
1898+ # be nice if it worked again.
1899+ #('the.4400.1x05.avi', 'the 4400', '1', '05', 'avi'),
1900+ # This should work, but doesn't.
1901+ #('flash.gordon.2007.s01e02.dvdrip.xvid-reward.avi', 'flash gordon 2007', '01', '02', 'avi'),
1902+ ('ReGenesis - 1x13.avi', 'ReGenesis', '1', '13', 'avi')]
1903+
1904
1905 def setUp(self):
1906- mode = EnvironmentMode(dryrun=True,
1907- move=False)
1908- self.env = Environment(args=[],
1909- mode=mode,
1910- verbosity=0)
1911- self.plugin = TV(env=self.env)
1912-
1913- def test_findParts(self):
1914+ self.dataPath = FilePath(__file__).sibling('data')
1915+ self.plugin = tv.TVRage()
1916+ self.plugin.parent = DummyPluginParent()
1917+ self.plugin.postOptions()
1918+
1919+
1920+ def fetcher(self, url):
1921+ """
1922+ "Fetch" TV rage data.
1923+ """
1924+ data = self.dataPath.child('tvrage').open().read()
1925+ return succeed(data)
1926+
1927+
1928+ def test_extractParts(self):
1929 """
1930 Extracting TV show information from filenames works correctly.
1931 """
1932 for case in self.cases:
1933- self.assertEqual(self.plugin.find_parts(case[0]), case[1:])
1934+ self.assertEquals(self.plugin.extractParts(case[0]), case[1:])
1935+
1936+ self.assertRaises(errors.PluginError,
1937+ self.plugin.extractParts, 'thiswillnotwork')
1938+
1939+
1940+ def test_missingPyParsing(self):
1941+ """
1942+ Attempting to use the TV Rage plugin without PyParsing installed raises
1943+ a L{renamer.errors.PluginError}.
1944+ """
1945+ self.patch(tv, 'pyparsing', None)
1946+ plugin = tv.TVRage()
1947+ plugin.parent = DummyPluginParent()
1948+ e = self.assertRaises(errors.PluginError, plugin.postOptions)
1949+ self.assertEquals(
1950+ str(e), 'The "pyparsing" package is required for this command')
1951+
1952+
1953+ def test_extractMetadata(self):
1954+ """
1955+ L{renamer.plugins.tv.TVRage.extractMetadata} extracts structured TV
1956+ episode information from a TV Rage response.
1957+ """
1958+ d = self.plugin.lookupMetadata('Dexter', 1, 2, fetcher=self.fetcher)
1959+
1960+ @d.addCallback
1961+ def checkMetadata((series, season, episode, title)):
1962+ self.assertEquals(series, u'Dexter')
1963+ self.assertEquals(season, 1)
1964+ self.assertEquals(episode, 2)
1965+ self.assertEquals(title, u'Crocodile')
1966+
1967+ return d
1968+
1969+
1970+ def test_lookupMetadata(self):
1971+ """
1972+ L{renamer.plugins.tv.TVRage.lookupMetadata} requests structured TV
1973+ episode information from TV Rage.
1974+ """
1975+ def fetcher(url):
1976+ path, query = urllib.splitquery(url)
1977+ query = cgi.parse_qs(query)
1978+ self.assertEquals(
1979+ query,
1980+ dict(show=['Dexter'], ep=['1x02']))
1981+ return self.fetcher(url)
1982+
1983+ return self.plugin.lookupMetadata('Dexter', 1, 2, fetcher=fetcher)
1984
1985=== added file 'renamer/test/test_util.py'
1986--- renamer/test/test_util.py 1970-01-01 00:00:00 +0000
1987+++ renamer/test/test_util.py 2010-10-10 11:27:40 +0000
1988@@ -0,0 +1,89 @@
1989+import errno
1990+from zope.interface import Interface
1991+
1992+from twisted.python.filepath import FilePath
1993+from twisted.trial.unittest import TestCase
1994+
1995+from renamer import errors, util
1996+
1997+
1998+
1999+class UtilTests(TestCase):
2000+ """
2001+ Tests for L{renamer.util}.
2002+ """
2003+ def setUp(self):
2004+ self.path = FilePath(self.mktemp())
2005+ self.path.makedirs()
2006+
2007+
2008+ def exdev(self, s, d):
2009+ e = OSError()
2010+ e.errno = errno.EXDEV
2011+ raise e
2012+
2013+
2014+ def test_rename(self):
2015+ """
2016+ Rename a file, copying it across filesystems if need be.
2017+ """
2018+ src = self.path.child('src')
2019+ src.touch()
2020+ dst = self.path.child('dst')
2021+ self.assertTrue(not dst.exists())
2022+ util.rename(src, dst)
2023+ self.assertTrue(dst.exists())
2024+
2025+
2026+ def test_renameOneFileSystem(self):
2027+ """
2028+ Attempting to rename a file across file system boundaries when
2029+ C{oneFileSystem} is C{True} results in
2030+ L{renamer.errors.DifferentLogicalDevices} being raised, assuming the
2031+ current platform doesn't support cross-linking.
2032+ """
2033+ src = self.path.child('src')
2034+ src.touch()
2035+ dst = self.path.child('dst')
2036+ self.assertRaises(
2037+ errors.DifferentLogicalDevices,
2038+ util.rename, src, dst, oneFileSystem=True, renamer=self.exdev)
2039+
2040+ self.assertTrue(not dst.exists())
2041+ util.rename(src, dst, oneFileSystem=True)
2042+ self.assertTrue(dst.exists())
2043+
2044+
2045+
2046+class IThing(Interface):
2047+ """
2048+ Silly test interface.
2049+ """
2050+
2051+
2052+
2053+class ThingMeta(util.InterfaceProvidingMetaclass):
2054+ """
2055+ Metaclass that C{alsoProvides} IThing.
2056+ """
2057+ providedInterfaces = [IThing]
2058+
2059+
2060+
2061+class Thing(object):
2062+ """
2063+ IThing, the silly test interface, providing base class.
2064+ """
2065+ __metaclass__ = ThingMeta
2066+
2067+
2068+
2069+class InterfaceProvidingMetaclassTests(TestCase):
2070+ """
2071+ Tests for L{renamer.util.InterfaceProvidingMetaclass}.
2072+ """
2073+ def test_providedBy(self):
2074+ """
2075+ Interfaces are not provided by subclasses.
2076+ """
2077+ self.assertTrue(IThing.providedBy(Thing))
2078
2079=== modified file 'renamer/util.py'
2080--- renamer/util.py 2009-05-07 11:50:08 +0000
2081+++ renamer/util.py 2010-10-10 11:27:40 +0000
2082@@ -1,158 +1,11 @@
2083-"""
2084-Collection of miscellaneous utility functions.
2085-"""
2086-import itertools, re
2087+import errno, os
2088+from zope.interface import alsoProvides
2089
2090 from twisted.internet.defer import DeferredList
2091 from twisted.internet.task import Cooperator
2092
2093-
2094-class ConditionalReplacer(object):
2095- """
2096- Perform regular-expression substitutions based on a conditional regular-expression.
2097-
2098- @type globalReplace: C{bool}
2099- @ivar globalReplace: Flag indicating whether to perform a global replace
2100- or not
2101-
2102- @type cond: C{regex}
2103- @ivar cond: Conditional compiled regular-expression
2104-
2105- @type regex: C{regex}
2106- @ivar regex: Regular-expression to match for substitution
2107- """
2108- def __init__(self, cond, regex, subst=None, flags=None):
2109- """
2110- Initialise the replacer.
2111-
2112- @type cond: C{str} or C{unicode}
2113- @param cond: Conditional regular-expression to compile
2114-
2115- @type regex: C{str} or C{unicode}
2116- @param regex: Regular-expression to match for substitution
2117-
2118- @type subst: C{str} or C{unicode}
2119- @param subst: String to use for substitution
2120-
2121- @type flags: C{str}
2122- @param flags: Collection of regular-expression flags, the following
2123- values are valid::
2124-
2125- i - Ignore case
2126-
2127- g - Global replace
2128- """
2129- super(ConditionalReplacer, self).__init__()
2130-
2131- if flags is None:
2132- flags = ''
2133-
2134- self.globalReplace = 'g' in flags
2135-
2136- reflags = 0
2137- if 'i' in flags:
2138- reflags |= re.IGNORECASE
2139-
2140- self.cond = re.compile(cond, reflags)
2141- self.regex = re.compile(regex, reflags)
2142- self.subst = subst or ''
2143-
2144- @classmethod
2145- def fromString(cls, s):
2146- """
2147- Create a replacer from a string.
2148-
2149- Parameters should be separated by a literal tab and are passed
2150- directly to the initialiser.
2151- """
2152- return cls(*s.strip('\r\n').split('\t'))
2153-
2154- def replace(self, input, condInput):
2155- """
2156- Perform a replacement.
2157-
2158- @type input: C{str} or C{unicode}
2159- @param input: Input to perform substitution on
2160-
2161- @type condInput: C{str} or C{unicode}
2162- @param condInput: Input to check against C{cond}
2163-
2164- @rtype: C{str} or C{unicode}
2165- @return: Substituted result
2166- """
2167- if self.cond.search(condInput) is None:
2168- return input
2169- return self.regex.sub(self.subst, input, int(not self.globalReplace))
2170-
2171-
2172-class Replacer(ConditionalReplacer):
2173- """
2174- Perform regular-expression substitutions.
2175- """
2176- def __init__(self, regex, subst=None, flags=None):
2177- super(Replacer, self).__init__(r'.*', regex, subst, flags)
2178-
2179- def replace(self, input, condInput):
2180- return super(Replacer, self).replace(input, input)
2181-
2182-
2183-class Replacement(object):
2184- """
2185- Perform a series of replacements on input.
2186-
2187- @type replacers: C{list}
2188- @ivar replacers: Replacer objects used to transform input
2189- """
2190- def __init__(self, replacers):
2191- """
2192- Initialise a Replacer manager.
2193-
2194- @type replacers: C{iterable}
2195- @param replacers: Initial set of replacer objects
2196- """
2197- super(Replacement, self).__init__()
2198- self.replacers = list(replacers)
2199-
2200- @classmethod
2201- def fromIterable(cls, iterable, replacerType=Replacer):
2202- """
2203- Create a L{Replacement} instance from an iterable.
2204-
2205- Lines beginning with C{#} are ignored
2206-
2207- @type iterable: C{iterable} of C{str} or C{unicode}
2208- @param iterable: Lines to create replacer objects from.
2209-
2210- @type replacerType: C{type}
2211- @param replacerType: Replacer object type to create for each line,
2212- defaults to L{Replacer}
2213-
2214- @rtype: L{Replacement}
2215- """
2216- replacers = []
2217- if iterable is not None:
2218- replacers = [replacerType.fromString(line)
2219- for line in iterable
2220- if line.strip() and not line.startswith('#')]
2221- return cls(replacers)
2222-
2223- def add(self, replacer):
2224- """
2225- Add a new replacer object.
2226- """
2227- self.replacers.append(replacer)
2228-
2229- def replace(self, input, condInput=None):
2230- """
2231- Perform a replacement.
2232- """
2233- if condInput is None:
2234- condInput = input
2235-
2236- for r in self.replacers:
2237- input = r.replace(input, condInput)
2238-
2239- return input
2240+from renamer import errors
2241+
2242
2243
2244 def parallel(iterable, count, callable, *a, **kw):
2245@@ -161,33 +14,62 @@
2246
2247 Any additional arguments or keyword-arguments are passed to C{callable}.
2248
2249- @type iterable: C{iterable}
2250- @param iterable: Values to pass to C{callable}
2251-
2252- @type count: C{int}
2253- @param count: Limit of the number of concurrent tasks
2254-
2255- @type callable: C{callable}
2256- @param callable: Callable to fire concurrently
2257-
2258- @rtype: C{twisted.internet.defer.Deferred}
2259- @return: Results of each call to C{callable}
2260+ @type iterable: C{iterable}
2261+ @param iterable: Values to pass to C{callable}.
2262+
2263+ @type count: C{int}
2264+ @param count: Limit of the number of concurrent tasks.
2265+
2266+ @type callable: C{callable}
2267+ @param callable: Callable to fire concurrently.
2268+
2269+ @rtype: L{twisted.internet.defer.Deferred}
2270 """
2271 coop = Cooperator()
2272 work = (callable(elem, *a, **kw) for elem in iterable)
2273 return DeferredList([coop.coiterate(work) for i in xrange(count)])
2274
2275
2276-def padIterable(iterable, padding, count):
2277- """
2278- Pad C{iterable}, with C{padding}, to C{count} elements.
2279-
2280- Iterables containing more than C{count} elements are clipped to C{count}
2281- elements.
2282-
2283- @param iterable: The iterable to iterate.
2284- @param padding: Padding object.
2285- @param count: The padded length.
2286- @return: An iterable.
2287- """
2288- return itertools.islice(itertools.chain(iterable, itertools.repeat(padding)), count)
2289+
2290+def rename(src, dst, oneFileSystem=False, renamer=os.rename):
2291+ """
2292+ Rename a file, optionally refusing to do it across file systems.
2293+
2294+ @type src: L{twisted.python.filepath.FilePath}
2295+ @param src: Source path.
2296+
2297+ @type dst: L{twisted.python.filepath.FilePath}
2298+ @param dst: Destination path.
2299+
2300+ @type oneFileSystem: C{bool}
2301+ @param oneFileSystem: Refuse to move a file across file systems?
2302+
2303+ @raise renamer.errors.DifferentLogicalDevices: If C{src} and C{dst} reside
2304+ on different filesystems and cross-linking files is not supported on
2305+ the current platform.
2306+ """
2307+ if oneFileSystem:
2308+ try:
2309+ renamer(src.path, dst.path)
2310+ except OSError, e:
2311+ if e.errno == errno.EXDEV:
2312+ raise errors.DifferentLogicalDevices(
2313+ 'Refusing to move "%s" to "%s" on another filesystem' % (
2314+ src.path, dst.path))
2315+ else:
2316+ src.moveTo(dst)
2317+
2318+
2319+
2320+class InterfaceProvidingMetaclass(type):
2321+ """
2322+ Metaclass that C{alsoProvides} interfaces specified in
2323+ C{providedInterfaces}.
2324+ """
2325+ providedInterfaces = []
2326+
2327+
2328+ def __new__(cls, name, bases, attrs):
2329+ newcls = type.__new__(cls, name, bases, attrs)
2330+ alsoProvides(newcls, *cls.providedInterfaces)
2331+ return newcls
2332
2333=== removed directory 'scripts'
2334=== removed file 'scripts/music.rn'
2335--- scripts/music.rn 2009-05-07 12:07:49 +0000
2336+++ scripts/music.rn 1970-01-01 00:00:00 +0000
2337@@ -1,44 +0,0 @@
2338-load os
2339-load audio
2340-# Store the original filename for later use.
2341-dup
2342-
2343-## Retrieve audio metadata.
2344-audio.gettags "artist|TPE1,album|TALB,title|TIT2,date|year|TDRC,tracknumber|TRCK" "UNKNOWN"
2345-# Stack: artist, album, title, date, tracknumber
2346-expanditer
2347-var artist
2348-var album
2349-var title
2350-# Replace "/"s in the date with "-"s.
2351-split "/"
2352-join "-"
2353-var date
2354-# Turn tracknumbers like "1/12" into 1.
2355-split "/"
2356-paditer "." 2
2357-expanditer
2358-int
2359-var tracknumber
2360-pop
2361-
2362-# Duplicate the filename again, to determine the extension.
2363-dup
2364-audio.extension
2365-var ext
2366-
2367-## Rename the file.
2368-# Read the filename format from a user config, if available.
2369-audio.confvar filenameFormat "%(tracknumber)02d. %(title)s%(ext)s"
2370-format
2371-os.rename
2372-
2373-## Move the file.
2374-envvar MEDIA_PATH "."
2375-var media_path
2376-## Read the directory format from a user config, if available.
2377-audio.confvar directoryFormat "%(artist)s/%(album)s (%(date)s)"
2378-format
2379-os.move
2380-
2381-pop
2382
2383=== removed file 'scripts/tv.rn'
2384--- scripts/tv.rn 2009-05-04 17:16:25 +0000
2385+++ scripts/tv.rn 1970-01-01 00:00:00 +0000
2386@@ -1,44 +0,0 @@
2387-load os
2388-load tv
2389-# Store the original filename for later use.
2390-dup
2391-
2392-## Guess the episode information.
2393-tv.find_parts
2394-expanditer
2395-# Stack: series_name, season, ep, ext
2396-camel_case_into_sentence
2397-var series_name
2398-int
2399-var season
2400-int
2401-var ep
2402-lower
2403-var ext
2404-
2405-# Retrieve metadata from TV Rage.
2406-format "%(season)sx%(ep)02d"
2407-format "%(series_name)s"
2408-tv.tvrage
2409-expanditer
2410-# Stack: series_name, season, ep, name
2411-var series_name
2412-var season
2413-var ep
2414-var name
2415-
2416-## Rename the file.
2417-# Read the filename format from a user config, if available.
2418-tv.confvar filenameFormat "%(series_name)s [%(season)sx%(ep)02d] - %(name)s.%(ext)s"
2419-format
2420-os.rename
2421-
2422-## Move the file.
2423-envvar MEDIA_PATH "."
2424-var media_path
2425-# Read the directory format from a user config, if available.
2426-tv.confvar directoryFormat "%(media_path)s/%(series_name)s/Season %(season)d"
2427-format
2428-os.move
2429-
2430-pop
2431
2432=== modified file 'setup.py'
2433--- setup.py 2009-05-10 18:57:26 +0000
2434+++ setup.py 2010-10-10 11:27:40 +0000
2435@@ -9,7 +9,7 @@
2436 url="http://launchpad.net/renamer",
2437 license="MIT",
2438 platforms=["any"],
2439- description="A scriptable and extensible mass file renamer",
2440+ description="A mass file renamer with plugin support",
2441 classifiers=[
2442 "Intended Audience :: End Users/Desktop",
2443 "Programming Language :: Python",

Subscribers

People subscribed via source and target branches