Merge lp:~renamer-developers/renamer/ditch-metalanguage into lp:renamer
- ditch-metalanguage
- Merge into trunk
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 | ||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Tristan Seligmann | Approve | ||
Andrew Snowden | Approve | ||
Review via email: mp+37035@code.launchpad.net |
Commit message
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.
- 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.
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 AxiomaticSubCom
Tristan Seligmann (mithrandi) wrote : | # |
148 + return (get,)
149 +
150 + subCommands = property(
This is a bit pointless, just use @property on the inner function and be done.
- 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.
Jonathan Jacobs (jjacobs) wrote : | # |
Address review commentary.
Tristan Seligmann (mithrandi) wrote : | # |
2397 + if not (newcls.__name__ == typeName and
2398 + newcls.__module__ == moduleName):
2399 + directlyProvide
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.
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(
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!
- 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 DirectlyProvidi
ngMetaclass insanity with something slightly less insane.
Jonathan Jacobs (jjacobs) wrote : | # |
> Whew. I hope that's all!
Fixed.
Tristan Seligmann (mithrandi) : | # |
Preview Diff
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", |
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.