Merge lp:~renamer-developers/renamer/config-file into lp:renamer

Proposed by Jonathan Jacobs
Status: Merged
Approved by: Tristan Seligmann
Approved revision: 127
Merged at revision: 87
Proposed branch: lp:~renamer-developers/renamer/config-file
Merge into: lp:renamer
Prerequisite: lp:~renamer-developers/renamer/undo-command
Diff against target: 731 lines (+435/-47)
11 files modified
renamer/_version.py (+1/-1)
renamer/application.py (+74/-25)
renamer/config.py (+132/-0)
renamer/irenamer.py (+7/-3)
renamer/plugin.py (+4/-4)
renamer/plugins/undo.py (+11/-13)
renamer/test/data/test.conf (+8/-0)
renamer/test/test_actions.py (+1/-1)
renamer/test/test_config.py (+127/-0)
renamer/test/test_util.py (+50/-0)
renamer/util.py (+20/-0)
To merge this branch: bzr merge lp:~renamer-developers/renamer/config-file
Reviewer Review Type Date Requested Status
Tristan Seligmann Approve
Review via email: mp+39226@code.launchpad.net
To post a comment you must log in.
126. By Jonathan Jacobs

Tweak function name.

127. By Jonathan Jacobs

Merge trunk.

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

> 36 ('concurrent', 'l', 10,

I think a better long name for this option might be "concurrency".

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'renamer/_version.py'
2--- renamer/_version.py 2009-04-29 00:06:49 +0000
3+++ renamer/_version.py 2010-10-24 17:47:46 +0000
4@@ -1,3 +1,3 @@
5 from twisted.python.versions import Version
6
7-version = Version('Renamer', 1, 0, 0)
8+version = Version('Renamer', 0, 1, 0)
9
10=== modified file 'renamer/application.py'
11--- renamer/application.py 2010-10-20 00:38:33 +0000
12+++ renamer/application.py 2010-10-24 17:47:46 +0000
13@@ -9,10 +9,10 @@
14 from axiom.store import Store
15
16 from twisted.internet import defer
17-from twisted.python import usage, log
18+from twisted.python import usage, log, versions
19 from twisted.python.filepath import FilePath
20
21-from renamer import logging, plugin, util
22+from renamer import config, logging, plugin, util, version
23 from renamer.irenamer import IRenamingCommand
24 from renamer.history import History
25
26@@ -28,12 +28,14 @@
27
28
29 optParameters = [
30+ ('config', 'c', '~/.renamer/renamer.conf',
31+ 'Configuration file path.'),
32 ('name', 'e', None,
33 'Formatted filename.', string.Template),
34 ('prefix', 'p', None,
35 'Formatted path to prefix to files before renaming.', string.Template),
36 ('concurrent', 'l', 10,
37- 'Maximum number of concurrent tasks to perform at a time.', int)]
38+ 'Maximum number of asynchronous tasks to perform concurrently.', int)]
39
40
41 @property
42@@ -42,14 +44,19 @@
43 plugin.getRenamingCommands(), plugin.getCommands())
44 for plg in commands:
45 try:
46- yield plg.name, None, plg, plg.description
47+ yield (
48+ plg.name,
49+ None,
50+ config.defaultsFromConfigFactory(self.config, plg),
51+ plg.description)
52 except AttributeError:
53 raise RuntimeError('Malformed plugin: %r' % (plg,))
54
55
56- def __init__(self):
57- usage.Options.__init__(self)
58+ def __init__(self, config):
59+ super(Options, self).__init__()
60 self['verbosity'] = 1
61+ self.config = config
62
63
64 @property
65@@ -76,6 +83,14 @@
66 opt_q = opt_quiet
67
68
69+ def opt_version(self):
70+ """
71+ Display version information.
72+ """
73+ print versions.getVersionString(version)
74+ sys.exit(0)
75+
76+
77 def parseArgs(self, *args):
78 args = (self.decodeCommandLine(arg) for arg in args)
79 if self['glob']:
80@@ -101,24 +116,53 @@
81 @ivar command: Renamer command being executed.
82 """
83 def __init__(self):
84- obs = logging.RenamerObserver()
85- log.startLoggingWithObserver(obs.emit, setStdout=False)
86+ self._obs = logging.RenamerObserver()
87+ log.startLoggingWithObserver(self._obs.emit, setStdout=False)
88
89+ self.options = self.parseOptions()
90 self.store = Store(os.path.expanduser('~/.renamer/renamer.axiom'))
91 # XXX: One day there might be more than one History item.
92 self.history = self.store.findOrCreate(History)
93
94- self.options = Options()
95- self.options.parseOptions()
96- obs.verbosity = self.options['verbosity']
97- self.command = self.getCommand()
98-
99-
100- def getCommand(self):
101+ self.args = getattr(self.options, 'args', [])
102+ self.command = self.getCommand(self.options)
103+
104+
105+ def parseOptions(self):
106+ """
107+ Parse configuration file and command-line options.
108+ """
109+ _options = Options({})
110+ _options.parseOptions()
111+ self._obs.verbosity = _options['verbosity']
112+
113+ self._configFile = config.ConfigFile(
114+ FilePath(os.path.expanduser(_options['config'])))
115+ command = self.getCommand(_options)
116+
117+ options = Options(self._configFile)
118+ # Apply global defaults.
119+ options.update(self._configFile.get('renamer', options))
120+ # Apply command-specific overrides for the global config.
121+ options.update(
122+ (k, v) for k, v in
123+ self._configFile.get(command.name, options).iteritems()
124+ if k in options)
125+ # Command-line options trump the config file.
126+ options.parseOptions()
127+
128+ logging.msg(
129+ 'Global options: %r' % (options,),
130+ verbosity=5)
131+
132+ return options
133+
134+
135+ def getCommand(self, options):
136 """
137 Get the L{twisted.python.usage.Options} command that was invoked.
138 """
139- command = getattr(self.options, 'subOptions', None)
140+ command = getattr(options, 'subOptions', None)
141 if command is None:
142 raise usage.UsageError('At least one command must be specified')
143
144@@ -132,8 +176,7 @@
145 """
146 Perform a file rename.
147 """
148- options = self.options
149- if options['no-act']:
150+ if self.options['no-act']:
151 logging.msg('Simulating: %s => %s' % (src.path, dst.path))
152 return
153
154@@ -141,25 +184,31 @@
155 logging.msg('Skipping noop "%s"' % (src.path,), verbosity=2)
156 return
157
158- if options['link-dst']:
159+ if self.options['link-dst']:
160 self.changeset.do(
161 self.changeset.newAction(u'symlink', src, dst),
162- options)
163+ self.options)
164 else:
165 self.changeset.do(
166 self.changeset.newAction(u'move', src, dst),
167- options)
168- if options['link-src']:
169+ self.options)
170+ if self.options['link-src']:
171 self.changeset.do(
172 self.changeset.newAction(u'symlink', dst, src),
173- options)
174+ self.options)
175
176
177 def runCommand(self, command):
178 """
179 Run a generic command.
180 """
181- return defer.maybeDeferred(command.process, self)
182+ logging.msg(
183+ 'Using command "%s"' % (command.name,),
184+ verbosity=4)
185+ logging.msg(
186+ 'Command options: %r' % (command,),
187+ verbosity=5)
188+ return defer.maybeDeferred(command.process, self, self.options)
189
190
191 def runRenamingCommand(self, command):
192@@ -178,7 +227,7 @@
193 self.options['concurrent'],),
194 verbosity=3)
195 return util.parallel(
196- self.options.args, self.options['concurrent'], _processOne)
197+ self.args, self.options['concurrent'], _processOne)
198
199
200 def run(self):
201
202=== added file 'renamer/config.py'
203--- renamer/config.py 1970-01-01 00:00:00 +0000
204+++ renamer/config.py 2010-10-24 17:47:46 +0000
205@@ -0,0 +1,132 @@
206+from ConfigParser import SafeConfigParser
207+
208+from renamer import util
209+
210+
211+
212+_identity = lambda x: x
213+
214+
215+
216+def flag(value):
217+ """
218+ Transform the flag C{value} into a C{bool}.
219+ """
220+ if isinstance(value, str):
221+ value = unicode(value, 'ascii')
222+ if isinstance(value, unicode):
223+ return value.lower() in (u'true', u'yes', u'1')
224+ return bool(value)
225+
226+
227+
228+def transformersFromOptions(options):
229+ """
230+ Extract option transformers from an C{Options} subclass.
231+
232+ @rtype: C{iterable} of C{(str, callable)}
233+ @return: Iterable of C{(longOption, transformer)}.
234+ """
235+ flags = getattr(options, 'optFlags', [])
236+ for long, short, desc in flags:
237+ yield long, flag
238+
239+ parameters = getattr(options, 'optParameters', [])
240+ parameters = (util.padIterable(ps, _identity, 5) for ps in parameters)
241+ global _identity
242+ for long, short, default, desc, coerce in parameters:
243+ if coerce is None:
244+ coerce = _identity
245+ yield long, coerce
246+
247+
248+
249+def defaultsFromConfigFactory(config, commandClass):
250+ """
251+ Create a factory function that will create an C{ICommand} provider instance
252+ and apply defaults from a config file.
253+
254+ @type config: L{renamer.config.ConfigFile}
255+ @param config: Config file to use defaults from.
256+
257+ @type commandClass: C{type}
258+ @param commandClass: L{renamer.irenamer.ICommand} provider to apply config
259+ defaults to.
260+
261+ @return: C{callable} suitable for passing as the third (parser class)
262+ argument to C{"subCommands"}.
263+ """
264+ def initWrapper():
265+ conf = config.get(commandClass.name, {})
266+
267+ def _params():
268+ parameters = getattr(commandClass, 'optParameters', [])
269+ parameters = (util.padIterable(p, None, 5) for p in parameters)
270+ for long, short, default, desc, coerce in parameters:
271+ if long in conf:
272+ default = conf.get(long)
273+ if default is not None and coerce is not None:
274+ default = coerce(default)
275+ yield long, short, default, desc, coerce
276+
277+ commandClass.optParameters = list(_params())
278+ cmd = commandClass()
279+
280+ # Since we can't set defaults for flags, we have to apply the
281+ # defaults after __init__ and before parseOptions.
282+ flags = getattr(commandClass, 'optFlags', [])
283+ for long, short, desc in flags:
284+ if long in conf:
285+ cmd[long] = flag(conf[long])
286+
287+ return cmd
288+
289+ return initWrapper
290+
291+
292+
293+class ConfigFile(object):
294+ """
295+ Configuration file.
296+
297+ @type sections: C{dict} mapping C{str} to C{dict} mapping C{str} to C{str}.
298+ @ivar sections: Mapping of section names to mappings of configuration
299+ options to values.
300+ """
301+ def __init__(self, path):
302+ config = SafeConfigParser()
303+ if path.exists():
304+ fd = path.open()
305+ config.readfp(fd)
306+ fd.close()
307+
308+ self.sections = {}
309+ for section in config.sections():
310+ options = self.sections.setdefault(section, {})
311+ for option in config.options(section):
312+ options[option] = unicode(config.get(section, option))
313+
314+
315+ def get(self, section, options=None):
316+ """
317+ Get and coerce configuration values.
318+
319+ @type section: C{str}
320+ @param section: Section name to retrieve configuration values for.
321+
322+ @type options: L{twisted.python.usage.Options}
323+ @param options: Options to use for determining coercion types, or
324+ C{None} to perform no coercions.
325+
326+ @return: Mapping of configuration option names to their coerced values.
327+ """
328+ section = self.sections.get(section, {})
329+ if options is not None:
330+ for opt, optType in transformersFromOptions(options):
331+ value = section.get(opt)
332+ if value is not None:
333+ if optType is not None:
334+ value = optType(value)
335+ section[opt] = value
336+
337+ return section
338
339=== modified file 'renamer/irenamer.py'
340--- renamer/irenamer.py 2010-10-16 16:35:10 +0000
341+++ renamer/irenamer.py 2010-10-24 17:47:46 +0000
342@@ -16,9 +16,13 @@
343 """)
344
345
346- def process(renamer):
347+ def process(renamer, options):
348 """
349 Called once command line parsing is complete.
350+
351+ @type renamer: L{renamer.application.Renamer}
352+
353+ @type options: C{dict}
354 """
355
356
357@@ -67,7 +71,7 @@
358 """
359 Perform the action.
360
361- @type options: L{twisted.python.usage.Options}
362+ @type options: C{dict}
363 """
364
365
366@@ -75,5 +79,5 @@
367 """
368 Perform the reverse action.
369
370- @type options: L{twisted.python.usage.Options}
371+ @type options: C{dict}
372 """
373
374=== modified file 'renamer/plugin.py'
375--- renamer/plugin.py 2010-10-16 16:35:10 +0000
376+++ renamer/plugin.py 2010-10-24 17:47:46 +0000
377@@ -72,7 +72,7 @@
378
379 # ICommand
380
381- def process(self, renamer):
382+ def process(self, renamer, options):
383 raise NotImplementedError('Commands must implement "process"')
384
385
386@@ -137,7 +137,7 @@
387 @param mapping: Mapping of template variables, used for template
388 substitution.
389
390- @type options: L{twisted.python.usage.Options}
391+ @type options: C{dict}
392
393 @type src: L{twisted.python.filepath.FilePath}
394 @param src: Source path.
395@@ -179,12 +179,12 @@
396
397 # ICommand
398
399- def process(self, renamer):
400+ def process(self, renamer, options):
401 arg = renamer.currentArgument
402 logging.msg('Processing "%s"' % (arg.path,),
403 verbosity=3)
404 d = defer.maybeDeferred(self.processArgument, arg)
405- d.addCallback(self.buildDestination, renamer.options, arg)
406+ d.addCallback(self.buildDestination, options, arg)
407 return d
408
409
410
411=== modified file 'renamer/plugins/undo.py'
412--- renamer/plugins/undo.py 2010-10-18 15:12:54 +0000
413+++ renamer/plugins/undo.py 2010-10-24 17:47:46 +0000
414@@ -30,19 +30,19 @@
415 ('ignore-errors', None, 'Do not stop the process when encountering OS errors')]
416
417
418- def undoActions(self, renamer, changeset, actions):
419+ def undoActions(self, options, changeset, actions):
420 """
421 Undo specific actions from a changeset.
422 """
423 for action in actions:
424 msg = 'Simulating undo'
425- if not renamer.options['no-act']:
426+ if not options['no-act']:
427 msg = 'Undo'
428
429 logging.msg('%s: %s' % (msg, action.asHumanly()), verbosity=3)
430- if not renamer.options['no-act']:
431+ if not options['no-act']:
432 try:
433- changeset.undo(action, renamer.options)
434+ changeset.undo(action, options)
435 except OSError, e:
436 if not self['ignore-errors']:
437 raise e
438@@ -67,10 +67,9 @@
439 self['action'] = action
440
441
442- def process(self, renamer):
443+ def process(self, renamer, options):
444 action = getItem(renamer.store, self['action'], Action)
445- self.undoActions(
446- renamer, action.changeset, [action])
447+ self.undoActions(options, action.changeset, [action])
448
449
450
451@@ -90,13 +89,12 @@
452 self['changeset'] = changeset
453
454
455- def process(self, renamer):
456+ def process(self, renamer, options):
457 changeset = getItem(renamer.store, self['changeset'], Changeset)
458 logging.msg('Undoing: %s' % (changeset.asHumanly(),),
459 verbosity=3)
460 actions = list(changeset.getActions())
461- self.undoActions(
462- renamer, changeset, reversed(actions))
463+ self.undoActions(options, changeset, reversed(actions))
464
465
466
467@@ -109,7 +107,7 @@
468 """
469
470
471- def process(self, renamer):
472+ def process(self, renamer, options):
473 changesets = list(renamer.history.getChangesets())
474 for cs in changesets:
475 print 'Changeset ID=%d: %s' % (cs.storeID, cs.asHumanly())
476@@ -139,9 +137,9 @@
477 self['identifier'] = identifier
478
479
480- def process(self, renamer):
481+ def process(self, renamer, options):
482 item = getItem(renamer.store, self['identifier'], (Action, Changeset))
483- if not renamer.options['no-act']:
484+ if not options['no-act']:
485 logging.msg('Forgetting: %s' % (item.asHumanly(),), verbosity=2)
486 item.deleteFromStore()
487
488
489=== added file 'renamer/test/data/test.conf'
490--- renamer/test/data/test.conf 1970-01-01 00:00:00 +0000
491+++ renamer/test/data/test.conf 2010-10-24 17:47:46 +0000
492@@ -0,0 +1,8 @@
493+[foo]
494+bar=1
495+baz=apple
496+
497+[test]
498+aardvark=hello
499+bobcat=3
500+donut=no
501
502=== modified file 'renamer/test/test_actions.py'
503--- renamer/test/test_actions.py 2010-10-16 20:46:40 +0000
504+++ renamer/test/test_actions.py 2010-10-24 17:47:46 +0000
505@@ -14,7 +14,7 @@
506 def setUp(self):
507 self.path = FilePath(self.mktemp())
508 self.path.makedirs()
509- self.options = Options()
510+ self.options = Options(None)
511 self.src, self.dst = self.createFiles()
512
513
514
515=== added file 'renamer/test/test_config.py'
516--- renamer/test/test_config.py 1970-01-01 00:00:00 +0000
517+++ renamer/test/test_config.py 2010-10-24 17:47:46 +0000
518@@ -0,0 +1,127 @@
519+from twisted.python.filepath import FilePath
520+from twisted.trial.unittest import TestCase
521+
522+from renamer import config, plugin
523+
524+
525+
526+class TestCommand(plugin.Command):
527+ name = 'test'
528+
529+
530+ optParameters = [
531+ ('aardvark', 'a', None, 'desc'),
532+ ('bobcat', 'b', 5, 'desc', int),
533+ ('chocolate', 'c', None, 'desc')]
534+
535+
536+ optFlags = [
537+ ('donut', 'd', 'desc')]
538+
539+
540+
541+class ConfigTests(TestCase):
542+ """
543+ Tests for L{renamer.config}.
544+ """
545+ def setUp(self):
546+ path = FilePath(__file__).sibling('data').child('test.conf')
547+ self.config = config.ConfigFile(path)
548+
549+
550+ def test_flag(self):
551+ """
552+ L{renamer.config.flag} returns C{True} for true flag values such as
553+ C{'yes'}, C{'true'}, C{'1'} and otherwise C{False}.
554+ """
555+ truths = [
556+ 'yes', u'yes', 'YES', u'true', '1', 1]
557+ for value in truths:
558+ self.assertTrue(config.flag(value))
559+
560+ falsehoods = [
561+ 'no', u'NO', 'arst', 0, []]
562+ for value in falsehoods:
563+ self.assertFalse(config.flag(value))
564+
565+
566+ def test_transformersFromOptions(self):
567+ """
568+ L{renamer.config.transformersFromOptions} extracts coercion functions
569+ from a L{renamer.irenamer.ICommand} provider.
570+ """
571+ expected = {
572+ 'aardvark': config._identity,
573+ 'bobcat': int,
574+ 'chocolate': config._identity,
575+ 'donut': config.flag}
576+ res = dict(config.transformersFromOptions(TestCommand()))
577+ self.assertEquals(expected, res)
578+
579+
580+ def test_defaultsFromConfigFactory(self):
581+ """
582+ L{renamer.config.defaultsFromConfigFactory} creates a wrapper function
583+ around a L{renamer.irenamer.ICommand} that, when called, instantiates
584+ the L{renamer.irenamer.ICommand} and sets default values from a config
585+ file from a section with a name matched
586+ L{renamer.irenamer.ICommand.name}. Options that are explicitly provided
587+ to the command trump values from the config file.
588+ """
589+ wrapper = config.defaultsFromConfigFactory(self.config, TestCommand)
590+ cmd = wrapper()
591+ cmd.parseOptions([])
592+ self.assertEquals(cmd['aardvark'], u'hello')
593+ self.assertEquals(cmd['bobcat'], 3)
594+ self.assertIdentical(cmd['chocolate'], None)
595+ self.assertEquals(cmd['donut'], False)
596+
597+ cmd = wrapper()
598+ cmd.parseOptions(['--bobcat=7', '--donut'])
599+ self.assertEquals(cmd['aardvark'], u'hello')
600+ self.assertEquals(cmd['bobcat'], 7)
601+ self.assertIdentical(cmd['chocolate'], None)
602+ self.assertEquals(cmd['donut'], True)
603+
604+
605+
606+class ConfigFileTests(TestCase):
607+ """
608+ Tests for L{renamer.config.ConfigFile}.
609+ """
610+ def setUp(self):
611+ path = FilePath(__file__).sibling('data').child('test.conf')
612+ self.config = config.ConfigFile(path)
613+
614+
615+ def test_nonexistentConfig(self):
616+ """
617+ Specifying a nonexistent path to L{renamer.config.ConfigFile} results
618+ in an empty config object.
619+ """
620+ conf = config.ConfigFile(FilePath('no_such_config'))
621+ self.assertEquals(conf.sections, {})
622+
623+
624+ def test_getNoCoercion(self):
625+ """
626+ Getting a config section without specifying an C{'options'} argument
627+ simply returns a mapping of strings to strings.
628+ """
629+ expected = {
630+ 'bar': u'1',
631+ 'baz': u'apple'}
632+ self.assertEquals(expected, self.config.get('foo'))
633+
634+
635+ def test_getCoercion(self):
636+ """
637+ Specifying an C{'options'} argument when getting a config section will
638+ coerce recognized options to their corresponding type in the
639+ C{'options'} argument.
640+ """
641+ expected = {
642+ 'aardvark': u'hello',
643+ 'bobcat': 3,
644+ 'donut': False}
645+ self.assertEquals(expected, self.config.get('test', TestCommand))
646
647=== modified file 'renamer/test/test_util.py'
648--- renamer/test/test_util.py 2010-10-16 16:16:58 +0000
649+++ renamer/test/test_util.py 2010-10-24 17:47:46 +0000
650@@ -140,3 +140,53 @@
651 Interfaces are not provided by subclasses.
652 """
653 self.assertTrue(IThing.providedBy(Thing))
654+
655+
656+
657+class PadIterableTests(TestCase):
658+ """
659+ Tests for L{renamer.util.util.padIterable}.
660+ """
661+ def test_needsPadding(self):
662+ """
663+ Iterables that need padding are padded to the specified length with the
664+ padding value.
665+ """
666+ padded = list(util.padIterable(xrange(3), -1, 5))
667+ self.assertEquals(len(padded), 5)
668+ self.assertEquals(padded, [0, 1, 2, -1, -1])
669+
670+
671+ def test_exceedsPadding(self):
672+ """
673+ Iterables that exceed the padding amount are clipped to the specified
674+ length.
675+ """
676+ padded = list(util.padIterable(xrange(10), -1, 5))
677+ self.assertEquals(len(padded), 5)
678+ self.assertEquals(padded, [0, 1, 2, 3, 4])
679+
680+
681+ def test_negativeCount(self):
682+ """
683+ Negative padding counts raise C{ValueError}.
684+ """
685+ self.assertRaises(ValueError, util.padIterable, xrange(5), -1, -1)
686+
687+
688+ def test_exact(self):
689+ """
690+ Iterables that are the same length as the padding are not padded.
691+ """
692+ padded = list(util.padIterable(xrange(5), -1, 5))
693+ self.assertEquals(len(padded), 5)
694+ self.assertEquals(padded, [0, 1, 2, 3, 4])
695+
696+
697+ def test_empty(self):
698+ """
699+ Empty iterables are filled entirely with the padding value.
700+ """
701+ padded = list(util.padIterable([], -1, 5))
702+ self.assertEquals(len(padded), 5)
703+ self.assertEquals(padded, [-1, -1, -1, -1, -1])
704
705=== modified file 'renamer/util.py'
706--- renamer/util.py 2010-10-24 11:06:45 +0000
707+++ renamer/util.py 2010-10-24 17:47:46 +0000
708@@ -97,3 +97,23 @@
709 if platform == 'win32':
710 globbing = _iglobWin32
711 return _glob(globbing)
712+
713+
714+
715+def padIterable(iterable, padding, count):
716+ """
717+ Pad C{iterable}, with C{padding}, to C{count} elements.
718+
719+ Iterables containing more than C{count} elements are clipped to C{count}
720+ elements.
721+
722+ @param iterable: The iterable to iterate.
723+
724+ @param padding: Padding object.
725+
726+ @param count: The padded length.
727+
728+ @return: An iterable.
729+ """
730+ return itertools.islice(
731+ itertools.chain(iterable, itertools.repeat(padding)), count)

Subscribers

People subscribed via source and target branches