Merge lp:~jtv/launchpad/bug-814141 into lp:launchpad

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: 13525
Proposed branch: lp:~jtv/launchpad/bug-814141
Merge into: lp:launchpad
Diff against target: 311 lines (+108/-50)
5 files modified
lib/canonical/launchpad/scripts/__init__.py (+7/-4)
lib/canonical/launchpad/scripts/logger.py (+74/-31)
lib/lp/archivepublisher/scripts/generate_contents_files.py (+1/-2)
lib/lp/archivepublisher/scripts/publish_ftpmaster.py (+6/-4)
lib/lp/services/scripts/base.py (+20/-9)
To merge this branch: bzr merge lp:~jtv/launchpad/bug-814141
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Review via email: mp+69285@code.launchpad.net

Commit message

[r=gmb][bug=814141] Pass loggers to LaunchpadScripts to protect global logging config.

Description of the change

= Summary =

When publish-ftpmaster instantiates and calls a publish-distro script object, it overrides (without restoring it afterwards!) the logging options you passed in.

As it turns out, the LaunchpadScript constructor messes with the process-global logging settings, which causes strange effects.

== Proposed fix ==

When instantiating a script object, allow the caller to pass in a logger. This doesn't really do much, exactly because loggers are global, but I do like the clarity of it and it does kill two birds with one stone:

1. It ensures a meaningful self.logger (which we'd otherwise have to look up anyway).
2. It clearly says "don't configure logging; use this instead."

Now you can run publish-ftpmaster.py with -vvv or -qqq options, and their effects on logging verbosity will apply to the main script but also to the script that it calls in turn. Previously, the first time the script instantiated another script object the log level would get reset to INFO and never recover.

== Pre-implementation notes ==

We came up with several approaches. I tried even more. Almost gave up; Julian (who requested the fix, leading me to discover the underlying problem) would have been willing to accept a hack that spewed "-v" and "-q" options to the sub-script. But that would have been unspeakable.

Stuart suggested creating a new option. At first I tried to make do with test_args, but it wasn't quite as transparent and I didn't quite get it to work. I also tried just a boolean ("initialize global logging?") but that left self.logging uninitialized.

== Implementation details ==

The options code uses callbacks to change log levels. There was a fair bit of duplication, and they took the default value straight from a variable in the surrounding function. I replaced them with a single class which I hope is cleaner.

Hopefully in the future we'll be able to quench more test noise by passing logger=DevNullLogger() to script objects in tests.

== Tests ==

Since this messes with the global log level, I didn't add any unit tests. Obviously scripts still work though. However, do run this to ensure that I haven't broken anything:

{{{
./bin/test -vvc lp.archivepublisher.tests.test_publish_ftpmaster
}}}

== Demo and Q/A ==

Run "publish-ftpmaster.py -d ubuntu -v" to see DEBUG output, which will include the output from publish-distro. Or replace the -v with -q to get less output from both publish-ftpmaster and publish-distro.

You may need to “rm -rf /var/tmp/archive/ubuntu*” before the script will work well.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/canonical/launchpad/scripts/__init__.py
  lib/lp/services/scripts/base.py
  lib/canonical/launchpad/scripts/logger.py
  lib/lp/archivepublisher/scripts/publish_ftpmaster.py
  lib/lp/archivepublisher/scripts/generate_contents_files.py

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/scripts/__init__.py'
2--- lib/canonical/launchpad/scripts/__init__.py 2011-02-18 18:16:50 +0000
3+++ lib/canonical/launchpad/scripts/__init__.py 2011-07-26 13:56:51 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Library functions for use in all scripts.
10@@ -8,6 +8,7 @@
11
12 __all__ = [
13 'db_options',
14+ 'dummy_logger_options',
15 'execute_zcml_for_scripts',
16 'log',
17 'logger',
18@@ -31,6 +32,7 @@
19 from canonical.config import config
20 # these are intentional re-exports, apparently, used by *many* files.
21 from canonical.launchpad.scripts.logger import (
22+ dummy_logger_options,
23 log,
24 logger,
25 logger_options,
26@@ -126,8 +128,8 @@
27
28 dbname and dbhost are also propagated to config.database.dbname and
29 config.database.dbhost. dbname, dbhost and dbuser are also propagated to
30- lp.get_dbname(), lp.dbhost and lp.dbuser. This ensures that all systems will
31- be using the requested connection details.
32+ lp.get_dbname(), lp.dbhost and lp.dbuser. This ensures that all systems
33+ will be using the requested connection details.
34
35 To test, we first need to store the current values so we can reset them
36 later.
37@@ -170,7 +172,8 @@
38
39 parser.add_option(
40 "-d", "--dbname", action="callback", callback=dbname_callback,
41- type="string", dest="dbname", default=config.database.rw_main_master,
42+ type="string", dest="dbname",
43+ default=config.database.rw_main_master,
44 help="PostgreSQL database to connect to."
45 )
46
47
48=== modified file 'lib/canonical/launchpad/scripts/logger.py'
49--- lib/canonical/launchpad/scripts/logger.py 2011-06-30 11:28:59 +0000
50+++ lib/canonical/launchpad/scripts/logger.py 2011-07-26 13:56:51 +0000
51@@ -1,4 +1,4 @@
52-# Copyright 2009 Canonical Ltd. This software is licensed under the
53+# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
54 # GNU Affero General Public License version 3 (see the file LICENSE).
55
56 # pylint: disable-msg=W0702
57@@ -23,6 +23,7 @@
58 'DEBUG7',
59 'DEBUG8',
60 'DEBUG9',
61+ 'dummy_logger_options',
62 'LaunchpadFormatter',
63 'log',
64 'logger',
65@@ -112,7 +113,8 @@
66 if datefmt is None:
67 datefmt = "%Y-%m-%d %H:%M:%S"
68 logging.Formatter.__init__(self, fmt, datefmt)
69- self.converter = time.gmtime # Output should be UTC
70+ # Output should be UTC.
71+ self.converter = time.gmtime
72
73
74 class LibrarianFormatter(LaunchpadFormatter):
75@@ -168,6 +170,73 @@
76 LaunchpadFormatter.formatException(self, sys.exc_info()))
77
78
79+class LogLevelNudger:
80+ """Callable to adjust the global log level.
81+
82+ Use instances as callbacks for `optparse`.
83+ """
84+
85+ def __init__(self, default, increment=True):
86+ """Initialize nudger to increment or decrement log level.
87+
88+ :param default: Default starting level.
89+ :param increment: Whether to increase the log level (as when
90+ handling the --verbose option). If not, will decrease
91+ instead (as with the --quiet option).
92+ """
93+ self.default = default
94+ self.increment = increment
95+
96+ def getIncrement(self, current_level):
97+ """Figure out how much to increment the log level.
98+
99+ Increment is negative when decreasing log level, of course.
100+ """
101+ if self.increment:
102+ if current_level < 10:
103+ return 1
104+ else:
105+ return 10
106+ else:
107+ if current_level <= 10:
108+ return -1
109+ else:
110+ return -10
111+
112+ def __call__(self, option, opt_str, value, parser):
113+ """Callback for `optparse` to handle --verbose or --quiet option."""
114+ current_level = getattr(parser.values, 'loglevel', self.default)
115+ increment = self.getIncrement(current_level)
116+ parser.values.loglevel = current_level + increment
117+ parser.values.verbose = (parser.values.loglevel < self.default)
118+ # Reset the global log.
119+ log._log = _logger(parser.values.loglevel, out_stream=sys.stderr)
120+
121+
122+def define_verbosity_options(parser, default, verbose_callback,
123+ quiet_callback):
124+ """Define the -v and -q options on `parser`."""
125+ # Only one of these specifies dest and default. That's because
126+ # that's enough to make the parser create the option value; there's
127+ # no need for the other option to specify them as well.
128+ parser.add_option(
129+ "-v", "--verbose", dest="loglevel", default=default,
130+ action="callback", callback=verbose_callback,
131+ help="Increase stderr verbosity. May be specified multiple times.")
132+ parser.add_option(
133+ "-q", "--quiet", action="callback", callback=quiet_callback,
134+ help="Decrease stderr verbosity. May be specified multiple times.")
135+
136+
137+def do_nothing(*args, **kwargs):
138+ """Do absolutely nothing."""
139+
140+
141+def dummy_logger_options(parser):
142+ """Add dummy --verbose and --quiet options to `parser`."""
143+ define_verbosity_options(parser, None, do_nothing, do_nothing)
144+
145+
146 def logger_options(parser, default=logging.INFO):
147 """Add the --verbose and --quiet options to an optparse.OptionParser.
148
149@@ -219,35 +288,9 @@
150 # Undocumented use of the optparse module
151 parser.defaults['verbose'] = False
152
153- def inc_loglevel(option, opt_str, value, parser):
154- current_level = getattr(parser.values, 'loglevel', default)
155- if current_level < 10:
156- inc = 1
157- else:
158- inc = 10
159- parser.values.loglevel = current_level + inc
160- parser.values.verbose = (parser.values.loglevel < default)
161- # Reset the global log.
162- log._log = _logger(parser.values.loglevel, out_stream=sys.stderr)
163-
164- def dec_loglevel(option, opt_str, value, parser):
165- current_level = getattr(parser.values, 'loglevel', default)
166- if current_level <= 10:
167- dec = 1
168- else:
169- dec = 10
170- parser.values.loglevel = current_level - dec
171- parser.values.verbose = (parser.values.loglevel < default)
172- # Reset the global log.
173- log._log = _logger(parser.values.loglevel, out_stream=sys.stderr)
174-
175- parser.add_option(
176- "-v", "--verbose", dest="loglevel", default=default,
177- action="callback", callback=dec_loglevel,
178- help="Increase stderr verbosity. May be specified multiple times.")
179- parser.add_option(
180- "-q", "--quiet", action="callback", callback=inc_loglevel,
181- help="Decrease stderr verbosity. May be specified multiple times.")
182+ define_verbosity_options(
183+ parser, default,
184+ LogLevelNudger(default, False), LogLevelNudger(default, True))
185
186 debug_levels = ', '.join([
187 v for k, v in sorted(logging._levelNames.items(), reverse=True)
188
189=== modified file 'lib/lp/archivepublisher/scripts/generate_contents_files.py'
190--- lib/lp/archivepublisher/scripts/generate_contents_files.py 2011-07-26 12:07:33 +0000
191+++ lib/lp/archivepublisher/scripts/generate_contents_files.py 2011-07-26 13:56:51 +0000
192@@ -156,8 +156,7 @@
193 if options is not None:
194 args += options
195 args.append(request)
196- query_distro = LpQueryDistro(test_args=args)
197- query_distro.logger = self.logger
198+ query_distro = LpQueryDistro(test_args=args, logger=self.logger)
199 query_distro.txn = self.txn
200 receiver = StoreArgument()
201 query_distro.runAction(presenter=receiver)
202
203=== modified file 'lib/lp/archivepublisher/scripts/publish_ftpmaster.py'
204--- lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2011-07-18 21:50:38 +0000
205+++ lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2011-07-26 13:56:51 +0000
206@@ -285,16 +285,17 @@
207 """Run the process-accepted script."""
208 self.logger.debug(
209 "Processing the accepted queue into the publishing records...")
210- script = ProcessAccepted(test_args=[self.distribution.name])
211+ script = ProcessAccepted(
212+ test_args=[self.distribution.name], logger=self.logger)
213 script.txn = self.txn
214- script.logger = self.logger
215 script.main()
216
217 def getDirtySuites(self):
218 """Return list of suites that have packages pending publication."""
219 self.logger.debug("Querying which suites are pending publication...")
220 query_distro = LpQueryDistro(
221- test_args=['-d', self.distribution.name, "pending_suites"])
222+ test_args=['-d', self.distribution.name, "pending_suites"],
223+ logger=self.logger)
224 receiver = StoreArgument()
225 query_distro.runAction(presenter=receiver)
226 return receiver.argument.split()
227@@ -362,7 +363,8 @@
228 args +
229 sum([['-s', suite] for suite in suites], []))
230
231- publish_distro = PublishDistro(test_args=arguments)
232+ publish_distro = PublishDistro(
233+ test_args=arguments, logger=self.logger)
234 publish_distro.logger = self.logger
235 publish_distro.txn = self.txn
236 publish_distro.main()
237
238=== modified file 'lib/lp/services/scripts/base.py'
239--- lib/lp/services/scripts/base.py 2011-04-18 15:40:02 +0000
240+++ lib/lp/services/scripts/base.py 2011-07-26 13:56:51 +0000
241@@ -148,7 +148,7 @@
242 # State for the log_unhandled_exceptions decorator.
243 _log_unhandled_exceptions_level = 0
244
245- def __init__(self, name=None, dbuser=None, test_args=None):
246+ def __init__(self, name=None, dbuser=None, test_args=None, logger=None):
247 """Construct new LaunchpadScript.
248
249 Name is a short name for this script; it will be used to
250@@ -159,6 +159,9 @@
251
252 Specify test_args when you want to override sys.argv. This is
253 useful in test scripts.
254+
255+ :param logger: Use this logger, instead of initializing global
256+ logging.
257 """
258 if name is None:
259 self._name = self.__class__.__name__.lower()
260@@ -166,6 +169,7 @@
261 self._name = name
262
263 self._dbuser = dbuser
264+ self.logger = logger
265
266 # The construction of the option parser is a bit roundabout, but
267 # at least it's isolated here. First we build the parser, then
268@@ -178,11 +182,16 @@
269 description = self.description
270 self.parser = OptionParser(usage=self.usage,
271 description=description)
272- scripts.logger_options(self.parser, default=self.loglevel)
273- self.parser.add_option(
274- '--profile', dest='profile', metavar='FILE', help=(
275- "Run the script under the profiler and save the "
276- "profiling stats in FILE."))
277+
278+ if logger is None:
279+ scripts.logger_options(self.parser, default=self.loglevel)
280+ self.parser.add_option(
281+ '--profile', dest='profile', metavar='FILE', help=(
282+ "Run the script under the profiler and save the "
283+ "profiling stats in FILE."))
284+ else:
285+ scripts.dummy_logger_options(self.parser)
286+
287 self.add_my_options()
288 self.options, self.args = self.parser.parse_args(args=test_args)
289
290@@ -191,7 +200,8 @@
291 self.handle_options()
292
293 def handle_options(self):
294- self.logger = scripts.logger(self.options, self.name)
295+ if self.logger is None:
296+ self.logger = scripts.logger(self.options, self.name)
297
298 @property
299 def name(self):
300@@ -369,8 +379,9 @@
301 class LaunchpadCronScript(LaunchpadScript):
302 """Logs successful script runs in the database."""
303
304- def __init__(self, name=None, dbuser=None, test_args=None):
305- super(LaunchpadCronScript, self).__init__(name, dbuser, test_args)
306+ def __init__(self, name=None, dbuser=None, test_args=None, logger=None):
307+ super(LaunchpadCronScript, self).__init__(
308+ name, dbuser, test_args=test_args, logger=logger)
309
310 # self.name is used instead of the name argument, since it may have
311 # have been overridden by command-line parameters or by