Merge lp:~zyga/lava-core/history-and-logging into lp:lava-core
- history-and-logging
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 8 |
Proposed branch: | lp:~zyga/lava-core/history-and-logging |
Merge into: | lp:lava-core |
Diff against target: |
956 lines (+929/-2) 4 files modified
lava/core/history.py (+522/-0) lava/core/logging.py (+120/-0) lava/core/tests/test_history.py (+278/-0) setup.py (+9/-2) |
To merge this branch: | bzr merge lp:~zyga/lava-core/history-and-logging |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Linaro Validation Team | Pending | ||
Review via email: mp+104882@code.launchpad.net |
Commit message
Description of the change
This is a re-spin of earlier merge proposal (https:/
Again, to quote my extensive commit message:
Add the logging and history modules
Both modules cooperate on centralized logging interface and have largely
identical APIs. LoggingMixIn class adds self.history and self.logging
properties to the class it is injected to.
Both properties return proxy objects that ultimately call classic python
logging and lava-core'esque history. History is much like logging but has two
advantages over plain logging.
The first advantage is that it was designed up front for being hierarchical.
This naturally constructs a tree of activities. I believe that such structure
can be visualized much better than a plain log file ever could. Any composite
operation also computes and records the duration of all the activities it
contains. This yields much more transparent duration awareness of entire
processes that lava performs internally.
The second advantage is that it can carry more data than regular python logging
can. All arguments to the message template are passed to the string.format()
function as keywords. This allows us to transparently provide key-value data in
alongside each history entry. This makes automatic analysis and data retrieval
much easier. It also allows for new data logging features such as downloading a
file and storing the speed history as an array in the activity meta-data. Such
object can be rendered with appropriate care by the front end thus making the
user interface far more rich than we could previously attempt without extreme
hacks and side-band, custom communication channels. Another feature that takes
advantage of this rich meta-data is attachments. Any activity can have a
collection of attachments that can be presented in a web front-end (attachments
also carry explicit MIME type). LAVA Core currently uses this to ensure we
capture full, uncorrupted output of any external programs we run.
The history module offers utility functions that can render any activity (or
the convenience lava.core.
history of the process) as JSON or plain text (in several formats). In addition
both formats can be saved, along with all the attachments, in a history
tarball. This functionality is exploited in the top-level lava command line
program, that allows the user to record the history of any LAVA commands, for
visualization, debugging, rich analysis or other, yet-unknown purpose.
- 9. By Zygmunt Krynicki
-
Fix module docstrings not to refer to lava.core.utils anymore
- 10. By Zygmunt Krynicki
-
Update docstrings of misc logging functions
Michael Hudson-Doyle (mwhudson) wrote : | # |
Zygmunt Krynicki (zyga) wrote : | # |
> [And once more, to the merge proposal as well :(]
>
> Hi. This overall seems reasonably nice. With things like this I always
> wonder if we'll ever get around to building the nice UIs that are
> enabled...
>
> Some general concerns:
>
> 1) Incremental output on the scheduler job page is _massively_ useful
> and losing it even temporarily is not acceptable, IMHO. I don't
> quite see how this meshes with this code. I guess we could still
> define a logging handler that outputs log messages to stdout/stderr
> and that will display nicely in the scheduler view until we get
> around to doing something more sophisticated there.
While I was somewhat skeptical about this, anything involving fast models made me reconsider quickly. I did a small proof of concept that streams data in real time (anything invoked via the agent) while retaining stdout/stderr separation. So I guess that means we'll just keep the verbose output for the current scheduler view. In the future we may replace that with real-time status updates so that we'd get the full hierarchical structure but as you rightfully point out, that may take a while.
> 2) I can see having two sets of debug/info/
> slightly different APIs (% vs .format() interpolation) being trying.
> But maybe this will be a non-issue -- I guess usually it the code
> will be saying self.history.info() or self.logger.info() and the
> logger vs history API will be clear.
We can still reconsider this as it's reasonably easy/fast to replace this with the moderate amount of usage it's seen so far. My motivations for format() were twofold: python deprecates % in favor of .format() and (perhaps I have over-valued this) I wanted to have key-value meta-data in each log message, believing that it could be used by some future smart UI to present the data better. If we were to go with % formatting then we don't really loose key-value formats (they just look different). Perhaps we could do a machine substitution so that we can get consistent logging + % API and keep the true data behind the scenes. In practical terms it would do mean calling self.history.
> 3) I'm just a little worried by memory consumption -- it seems that
> there is the potential to end up with a _lot_ of objects hanging
> around, particularly if you hang on to tracebacks. It might be worth
> stringifying things a little earlier. I guess this won't affect the
> API and so can be done later...
I thought about it too, by demo-2 I made sure we don't keep an Activity record for each stdout line we print. In theory this dispatcher is better as it never keeps an in-memory log file of any test. The amount of 'activities' is pretty much constant across all test runs. Still, unlike logging which never suffers from this problem, activities do add up. When it becomes a problem we can simply record them (in some machine format) as they are being generated. The only issue here is that activities that are in flight (__enter__()) will require seeking + updates to "close" and any exceptions we generate could need special treatment (as ...
Michael Hudson-Doyle (mwhudson) wrote : | # |
On Tue, 08 May 2012 10:11:19 -0000, Zygmunt Krynicki <email address hidden> wrote:
> > [And once more, to the merge proposal as well :(]
> >
> > Hi. This overall seems reasonably nice. With things like this I always
> > wonder if we'll ever get around to building the nice UIs that are
> > enabled...
> >
> > Some general concerns:
> >
> > 1) Incremental output on the scheduler job page is _massively_ useful
> > and losing it even temporarily is not acceptable, IMHO. I don't
> > quite see how this meshes with this code. I guess we could still
> > define a logging handler that outputs log messages to stdout/stderr
> > and that will display nicely in the scheduler view until we get
> > around to doing something more sophisticated there.
>
> While I was somewhat skeptical about this, anything involving fast
> models made me reconsider quickly.
I don't think fast models are fundamentally different here, but
whatever, I'm glad I don't have to convince you :-)
> I did a small proof of concept that streams data in real time
> (anything invoked via the agent) while retaining stdout/stderr
> separation. So I guess that means we'll just keep the verbose output
> for the current scheduler view. In the future we may replace that with
> real-time status updates so that we'd get the full hierarchical
> structure but as you rightfully point out, that may take a while.
Cool.
> > 2) I can see having two sets of debug/info/
> > slightly different APIs (% vs .format() interpolation) being trying.
> > But maybe this will be a non-issue -- I guess usually it the code
> > will be saying self.history.info() or self.logger.info() and the
> > logger vs history API will be clear.
>
> We can still reconsider this as it's reasonably easy/fast to replace
> this with the moderate amount of usage it's seen so far. My
> motivations for format() were twofold: python deprecates % in favor of
> .format() and (perhaps I have over-valued this) I wanted to have
> key-value meta-data in each log message, believing that it could be
> used by some future smart UI to present the data better. If we were to
> go with % formatting then we don't really loose key-value formats
> (they just look different). Perhaps we could do a machine substitution
> so that we can get consistent logging + % API and keep the true data
> behind the scenes. In practical terms it would do mean calling
> self.history.
> think?
I'm not sure that preserving the key/value pairs that are intended to be
part of the formatted log message will be much of a win really (because
its intended to be formatted, you'll likely do things like pass a field
value rather than the whole object, for example, and there's not really
much value in having a simple value both inside and outside the log
message). I can see the potential desire to attach other data to the
Activity objects though.
So, um, I don't know. But also, I don't think it's a very big deal...
> > 3) I'm just a little worried by memory consumption -- it seems that
> > there is the potential to end up with a _lot_ of objects hangi...
- 11. By Zygmunt Krynicki
-
Fix History.last
- 12. By Zygmunt Krynicki
-
Simplify DictProxy
As pointed out by reviewers, DictProxy can be somewhat simple with the correct
usage of __setattr__ in __init__ and usage of __getattr__ instead of
__getattribute__I've also added __eq__ and __repr__ for convenience
- 13. By Zygmunt Krynicki
-
Allow Activity.attach() to work on plain file names
This change is backported from demo-3 branch. It allows one to
attach files to an activity simply by passing their pathname. - 14. By Zygmunt Krynicki
-
Improve and fix _build_json
There are three separate changes here:
Early unicode correctness:
* all strings are converted to unicode with str.decode("UTF-8")
* all dictionary keys are converted to unicode with __unicode__()
* all non-native objects are converted to unicode with __unicode__()Support for datetime and timedelta:
Previously it was a small burden on the caller, now timedelta and datetime
objects are consistently encoded to strings with a well-known representation.Support for tuples:
Previously tuples would not work, now they are treated just like lists. In
addition, both lists, tuples and dictionaries are removed from the (dead)
code path for 'native' objects as they are already handled earlier. - 15. By Zygmunt Krynicki
-
Simplify Activity.as_json()
This change takes advantage of the improved _build_json() that already does the
necessary conversions and 'if obj is None' checks - 16. By Zygmunt Krynicki
-
Add depencency declaration.
This is a small backport from future branches to allow unit tests to work
- 17. By Zygmunt Krynicki
-
Add tests for lava.core.history
Most of the Activity class is tested. Part of the json serialization is tested.
DictProxy and History objects are not tested yet - 18. By Zygmunt Krynicki
-
Fix a bug in Hisstory.last
History.last returned None just after constructing the History object. It
should have returned 'top' (root of the history) instead. - 19. By Zygmunt Krynicki
-
Add unit tests for History class
Preview Diff
1 | === added file 'lava/core/history.py' |
2 | --- lava/core/history.py 1970-01-01 00:00:00 +0000 |
3 | +++ lava/core/history.py 2012-05-16 11:20:24 +0000 |
4 | @@ -0,0 +1,522 @@ |
5 | +# Copyright (C) 2011-2012 Linaro Limited |
6 | +# vim: set fileencoding=utf8 : |
7 | +# |
8 | +# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org> |
9 | +# |
10 | +# This file is part of lava-core |
11 | +# |
12 | +# lava-core is free software: you can redistribute it and/or modify |
13 | +# it under the terms of the GNU Lesser General Public License version 3 |
14 | +# as published by the Free Software Foundation |
15 | +# |
16 | +# lava-core is distributed in the hope that it will be useful, |
17 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
18 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
19 | +# GNU General Public License for more details. |
20 | +# |
21 | +# You should have received a copy of the GNU Lesser General Public License |
22 | +# along with lava-core. If not, see <http://www.gnu.org/licenses/>. |
23 | + |
24 | +from __future__ import absolute_import, print_function |
25 | + |
26 | +""" |
27 | +lava.core.history |
28 | +================= |
29 | + |
30 | +Implementation of hierarhical history |
31 | +""" |
32 | + |
33 | +from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL |
34 | +import collections |
35 | +import datetime |
36 | +import os |
37 | +import sys |
38 | +import tarfile |
39 | +import traceback |
40 | + |
41 | +from json_document.serializers import JSON |
42 | +from json_schema_validator.extensions import ( |
43 | + datetime_extension, timedelta_extension) |
44 | +import simplejson |
45 | + |
46 | + |
47 | +class DictProxy(object): |
48 | + |
49 | + def __init__(self, d): |
50 | + object.__setattr__(self, "_DictProxy__d", d) |
51 | + |
52 | + def as_dict(self): |
53 | + return self.__d |
54 | + |
55 | + def __repr__(self): |
56 | + return "<DictProxy {0!r}>".format(self.__d) |
57 | + |
58 | + def __setattr__(self, name, value): |
59 | + self.__d[name] = value |
60 | + |
61 | + def __getattr__(self, name): |
62 | + try: |
63 | + return self.__d[name] |
64 | + except KeyError: |
65 | + raise AttributeError(name) |
66 | + |
67 | + def __eq__(self, other): |
68 | + if isinstance(other, DictProxy): |
69 | + return self.__d == other.__d |
70 | + else: |
71 | + return False |
72 | + |
73 | + |
74 | +ExceptionInfo = collections.namedtuple( |
75 | + "ExceptionInfo", "filename lineno function text") |
76 | + |
77 | + |
78 | +_name_and_back = { |
79 | + DEBUG: "DEBUG", |
80 | + INFO: "INFO", |
81 | + WARNING: "WARNING", |
82 | + ERROR: "ERROR", |
83 | + CRITICAL: "CRITICAL", |
84 | + "DEBUG": DEBUG, |
85 | + "INFO": INFO, |
86 | + "WARNING": WARNING, |
87 | + "ERROR": ERROR, |
88 | + "CRITICAL": CRITICAL |
89 | +} |
90 | + |
91 | + |
92 | +class Activity(object): |
93 | + |
94 | + def __init__(self, level, message, **meta): |
95 | + """ |
96 | + Create a new activity with the specified message, level and |
97 | + initial meta-data |
98 | + """ |
99 | + # Message that has format() references to meta-data |
100 | + self._level = level |
101 | + self._message = message |
102 | + self._meta = DictProxy(meta) |
103 | + # File attachments |
104 | + self._attachments = [] |
105 | + # Children activities |
106 | + self._sub_activities = [] |
107 | + # Start and end timestamps |
108 | + self._start = datetime.datetime.utcnow() |
109 | + self._end = self._start |
110 | + # Exception + traceback |
111 | + self._exc_value = None |
112 | + self._exc_type = None |
113 | + self._traceback = None |
114 | + |
115 | + @property |
116 | + def raw_message(self): |
117 | + """ |
118 | + the raw, unformatted message |
119 | + """ |
120 | + return self._message |
121 | + |
122 | + @property |
123 | + def message(self): |
124 | + """ |
125 | + rendered message |
126 | + |
127 | + Rendering substitutes str.format() style variables with data from |
128 | + self.meta. |
129 | + """ |
130 | + return self._message.format(**self._meta.as_dict()) |
131 | + |
132 | + @property |
133 | + def level(self): |
134 | + """ |
135 | + level number, 10 (DEBUG) - 50 (CRITICAL) |
136 | + """ |
137 | + return self._level |
138 | + |
139 | + @property |
140 | + def level_name(self): |
141 | + """ |
142 | + name of the level "DEBUG" ... "CRITICAL" |
143 | + """ |
144 | + return _name_and_back[self._level] |
145 | + |
146 | + @property |
147 | + def meta(self): |
148 | + """ |
149 | + collection of meta-data |
150 | + |
151 | + Meta-data serves two purposes: to fill in data to the message |
152 | + template and to store arbitrary auxiliary data. This property |
153 | + returns a dictionary proxy that looks like an object (has normal |
154 | + attribute accessors) but still talks to the real dictionary behind |
155 | + the scenes. You can both read and write/set attributes at will. |
156 | + """ |
157 | + return self._meta |
158 | + |
159 | + def attach(self, stream_or_pathname, name=None, mime_type='text/plain'): |
160 | + """ |
161 | + Add an attachment. |
162 | + |
163 | + Everything is extracted from the file-like object stream. If you care |
164 | + about a particular name of the attachment it an be specified with name. |
165 | + MIME type can help the web front-end to render the content. It defaults |
166 | + to text/plain which is suitable for most log files. |
167 | + """ |
168 | + if isinstance(stream_or_pathname, basestring): |
169 | + orig_name = stream_or_pathname |
170 | + else: |
171 | + orig_name = stream_or_pathname.name |
172 | + if name is None: |
173 | + name = os.path.basename(orig_name) |
174 | + self._attachments.append( |
175 | + DictProxy({ |
176 | + 'name': name, |
177 | + 'orig_name': orig_name, |
178 | + 'mime_type': mime_type})) |
179 | + |
180 | + @property |
181 | + def duration(self): |
182 | + """ |
183 | + Duration of the activity. |
184 | + |
185 | + If the activity has not started the duration is always zero. |
186 | + Activities that are in progress will observe the duration to grow. |
187 | + """ |
188 | + if self._start is None: |
189 | + return datetime.timedelta() |
190 | + if self._end is None: |
191 | + return datetime.datetime.utcnow() - self._start |
192 | + return self._end - self._start |
193 | + |
194 | + def as_json(self): |
195 | + """ |
196 | + Convert this Activity to JSON |
197 | + """ |
198 | + obj = simplejson.OrderedDict(( |
199 | + ("level", self.level_name), |
200 | + ("message", self._message), |
201 | + ("meta", self.meta.as_dict()), |
202 | + ("start", self._start), |
203 | + ("duration", self.duration), |
204 | + ("end", self._end), |
205 | + ("exc_value", self._exc_value), |
206 | + ("exc_type", (self._exc_type.__name__ |
207 | + if self._exc_type else None)), |
208 | + ("traceback", ([ExceptionInfo(*ei) for ei in |
209 | + traceback.extract_tb(self._traceback)] |
210 | + if self._traceback else None)), |
211 | + ("attachments", [ |
212 | + attachment.as_dict() for attachment in self._attachments]), |
213 | + ("sub_activities", [ |
214 | + activity.as_json() for activity in self._sub_activities]))) |
215 | + return _build_json(obj) |
216 | + |
217 | + def __str__(self): |
218 | + """" |
219 | + Returns self.message |
220 | + """ |
221 | + return self.message |
222 | + |
223 | + def save_as_tarball(self, scratch, tarball_stream, mode='w:gz'): |
224 | + """ |
225 | + Save activity history as a tarball. |
226 | + |
227 | + The tarball will have two files 'history.txt' and 'history.json' with |
228 | + appropriate encoding of this activity, as well as all sub-activities. |
229 | + Any attachments are added as well. |
230 | + """ |
231 | + with tarfile.open(tarball_stream.name, mode=mode, |
232 | + fileobj=tarball_stream) as tarball: |
233 | + with scratch.open('history.txt') as stream: |
234 | + self.print_(stream=stream) |
235 | + tarball.add(stream.name, 'history.txt') |
236 | + with scratch.open('history.json') as stream: |
237 | + JSON.dump(stream, self.as_json()) |
238 | + tarball.add(stream.name, 'history.json') |
239 | + self._save_tarball_attachments(tarball) |
240 | + |
241 | + def _save_tarball_attachments(self, tarball): |
242 | + for attachment in self._attachments: |
243 | + if os.path.isfile(attachment.orig_name): |
244 | + tarball.add(attachment.orig_name, attachment.name) |
245 | + for activity in self._sub_activities: |
246 | + activity._save_tarball_attachments(tarball) |
247 | + |
248 | + def __repr__(self): |
249 | + return "<Activity: %r>" % str(self) |
250 | + |
251 | + def __enter__(self): |
252 | + """ |
253 | + Begins a 'long' activity. |
254 | + |
255 | + Long activities have both start and end, and thus, non-zero duration. |
256 | + This is called automatically by History.__enter__ so you won't usually |
257 | + have to use it explicitly. |
258 | + |
259 | + Here we reset self.end to None (it was initialized to 'now' in |
260 | + __init__) |
261 | + """ |
262 | + self._end = None |
263 | + return self |
264 | + |
265 | + def __exit__(self, exc_type, exc_value, traceback): |
266 | + """ |
267 | + Terminates a 'long' activity. |
268 | + |
269 | + Here we set self.end to 'now' and record any exception information. |
270 | + See __enter__() for more information on 'long' activities. |
271 | + """ |
272 | + self._end = datetime.datetime.utcnow() |
273 | + self._exc_value = exc_value |
274 | + self._exc_type = exc_type |
275 | + self._traceback = traceback |
276 | + |
277 | + def print_(self, stream=None, print_meta=False, print_children=True, |
278 | + stack=None): |
279 | + """ |
280 | + Print a nice Unicode tree of activities. |
281 | + |
282 | + This is very useful for tracing and debugging. |
283 | + """ |
284 | + if stack is None: |
285 | + stack = [] |
286 | + if stream is None: |
287 | + stream = sys.stdout |
288 | + fragments = [] |
289 | + if print_meta is False: |
290 | + fragments.append("{0:7}".format(self.level_name)) |
291 | + fragments.append(" | ") |
292 | + fragments.append("{start}.{micro:06}".format( |
293 | + start=self._start.strftime("%Y-%m-%d %T"), |
294 | + micro=self._start.microsecond)) |
295 | + fragments.append(" | ") |
296 | + if self.duration.total_seconds() > 0: |
297 | + fragments.append("{0:14}s".format(self.duration)) |
298 | + else: |
299 | + fragments.append("{0:15}".format("")) |
300 | + fragments.append(" | ") |
301 | + fragments.append(''.join(self._tree_indent(stack))) |
302 | + # Process the message to make it safer |
303 | + message = self.message |
304 | + message = message.replace("\n", " ").replace("\r", " ") |
305 | + if isinstance(message, unicode): |
306 | + message = message.encode(stream.encoding or "UTF-8", 'replace') |
307 | + fragments.append(message) |
308 | + if self._exc_value: |
309 | + fragments.append(", ***crashed*** {0}".format(self._exc_value)) |
310 | + print("".join(fragments), file=stream) |
311 | + if print_meta: |
312 | + print("\tstart: {0}".format(self._start)) |
313 | + if self.duration.total_seconds() > 0: |
314 | + print("\tduration: {0}".format(self.duration)) |
315 | + if self._end is not None and self._end != self._start: |
316 | + print("\tend: {0}".format(self._end)) |
317 | + if self._exc_value is not None: |
318 | + print("\texc_value: {0}".format(self._exc_value)) |
319 | + if self._exc_type is not None: |
320 | + print("\texc_type: {0}".format(self._exc_type)) |
321 | + if self._traceback is not None: |
322 | + for index, exc_info in enumerate(self._traceback): |
323 | + print("\ttraceback.{0}.filename: {1}".format( |
324 | + index, self._traceback.filename)) |
325 | + print("\ttraceback.{0}.lineno: {1}".format( |
326 | + index, self._traceback.lineno)) |
327 | + print("\ttraceback.{0}.function {1}".format( |
328 | + index, self._traceback.function)) |
329 | + print("\ttraceback.{0}.text {1}".format( |
330 | + index, self._traceback.text)) |
331 | + for key, value in self.meta.as_dict().iteritems(): |
332 | + # Skip one special key used by logging integration |
333 | + if key == "__prefmt_msg": |
334 | + continue |
335 | + print("\t{key}: {value!r}".format(key=key, value=value)) |
336 | + if print_children: |
337 | + pos = len(stack) |
338 | + stack.append(len(self._sub_activities)) |
339 | + for sub in self._sub_activities: |
340 | + stack[pos] -= 1 |
341 | + sub.print_(stream, print_meta, print_children, stack) |
342 | + stack.pop() |
343 | + |
344 | + @staticmethod |
345 | + def _tree_indent(stack, use_unicode=False): |
346 | + """ |
347 | + Compute the indentation part of the tree displayed by print_. |
348 | + """ |
349 | + # The code here might be tricky. It's easier to understand if you keep |
350 | + # in mind that stack[n] tells you how many more entries are left in the |
351 | + # tree at that stack depth. The stack is constructed by print_() |
352 | + for items_left in stack[:-1]: |
353 | + # FIXME: this is broekn |
354 | + if items_left == 0: |
355 | + yield " " |
356 | + elif items_left == 1: |
357 | + if use_unicode: |
358 | + yield "└ " |
359 | + else: |
360 | + yield "\ " |
361 | + elif items_left > 1: |
362 | + if use_unicode: |
363 | + yield "│ " |
364 | + else: |
365 | + yield "| " |
366 | + for items_left in stack[-1:]: |
367 | + if items_left == 0: |
368 | + if use_unicode: |
369 | + yield "└── " |
370 | + else: |
371 | + yield "\-- " |
372 | + elif items_left > 0: |
373 | + if use_unicode: |
374 | + yield "├── " |
375 | + else: |
376 | + yield "|-- " |
377 | + |
378 | + |
379 | +def _build_json(obj): |
380 | + """ |
381 | + Helper to construct json that keeps ordering and discards None values |
382 | + """ |
383 | + if isinstance(obj, (dict, simplejson.OrderedDict)): |
384 | + new_obj = simplejson.OrderedDict() |
385 | + for key, value in obj.iteritems(): |
386 | + if value is None or value == [] or value == {}: |
387 | + continue |
388 | + else: |
389 | + new_obj[unicode(key)] = _build_json(value) |
390 | + return new_obj |
391 | + elif isinstance(obj, (tuple, list)): |
392 | + return [_build_json(item) for item in obj] |
393 | + elif (isinstance(obj, (int, float, unicode, bool)) |
394 | + or obj is None): |
395 | + return obj |
396 | + elif isinstance(obj, str): |
397 | + return obj.decode("UTF-8") |
398 | + elif isinstance(obj, datetime.datetime): |
399 | + return datetime_extension.to_json(obj) |
400 | + elif isinstance(obj, datetime.timedelta): |
401 | + return timedelta_extension.to_json(obj) |
402 | + else: |
403 | + try: |
404 | + return unicode(obj) |
405 | + except Exception: |
406 | + raise ValueError("cannot convert {0!r} to JSON".format(obj)) |
407 | + |
408 | + |
409 | +class History(object): |
410 | + """ |
411 | + History class, provides helper methods for hierarchical logging. |
412 | + Maintains a stack of activities that are nested with context |
413 | + management. |
414 | + """ |
415 | + |
416 | + def __init__(self, top): |
417 | + """ |
418 | + Create a History that starts with the specified 'top' activity |
419 | + |
420 | + Usually you don't want to call this. Instead, use the singleton |
421 | + instance 'history'. |
422 | + """ |
423 | + self._nesting = [top] |
424 | + self._last = top |
425 | + |
426 | + @property |
427 | + def top(self): |
428 | + """ |
429 | + the top-level activity |
430 | + |
431 | + Very useful to grab entire history and do something with it, for |
432 | + example history.top.as_json() or history.top.print_() |
433 | + """ |
434 | + return self._nesting[0] |
435 | + |
436 | + @property |
437 | + def bottom(self): |
438 | + """ |
439 | + the bottommost activity |
440 | + |
441 | + This is the most nested activity, as established by: |
442 | + |
443 | + with history.xxx(): |
444 | + ... |
445 | + """ |
446 | + return self._nesting[-1] |
447 | + |
448 | + @property |
449 | + def last(self): |
450 | + """ |
451 | + the most recent activity |
452 | + |
453 | + This is the most recently created activity in this history. |
454 | + """ |
455 | + return self._last |
456 | + |
457 | + def log(self, level, message, **meta): |
458 | + """ |
459 | + Create an activity and append it to the history |
460 | + """ |
461 | + self._last = Activity(level, message, **meta) |
462 | + self.bottom._sub_activities.append(self._last) |
463 | + return self |
464 | + |
465 | + def debug(self, message, **meta): |
466 | + """ |
467 | + Append DEBUG activity to the bottommost activity |
468 | + """ |
469 | + return self.log(DEBUG, message, **meta) |
470 | + |
471 | + def info(self, message, **meta): |
472 | + """ |
473 | + Append INFO activity to the bottommost activity |
474 | + """ |
475 | + return self.log(INFO, message, **meta) |
476 | + |
477 | + def warning(self, message, **meta): |
478 | + """ |
479 | + Append WARNING activity to the bottommost activity |
480 | + """ |
481 | + return self.log(WARNING, message, **meta) |
482 | + |
483 | + def error(self, message, **meta): |
484 | + """ |
485 | + Append ERROR activity to the bottommost activity |
486 | + """ |
487 | + return self.log(ERROR, message, **meta) |
488 | + |
489 | + def critical(self, message, **meta): |
490 | + """ |
491 | + Append CRITICAL activity to the bottommost activity |
492 | + """ |
493 | + return self.log(CRITICAL, message, **meta) |
494 | + |
495 | + def __enter__(self): |
496 | + """ |
497 | + Begins nested activity |
498 | + |
499 | + Nesting is widely useful as it helps to create a structure in the |
500 | + history stream. Typically you will call this method indirectly |
501 | + with code that looks like this: |
502 | + |
503 | + with history.info("Doing something complex"): |
504 | + history.debug("step 1") |
505 | + history.debug("step 2") |
506 | + history.debug("step 3") |
507 | + |
508 | + Here we also call __enter__() on the last activity to convert it to a |
509 | + 'long' activity (one that has both start, end and non-zero duration) |
510 | + """ |
511 | + self._nesting.append(self._last) |
512 | + return self._last.__enter__() |
513 | + |
514 | + def __exit__(self, exc_type, exc_value, traceback): |
515 | + """ |
516 | + Terminates most recently nested activity |
517 | + |
518 | + Here we also call __exit__() on the last activity to allow it to record |
519 | + any exceptions that may have been raised. |
520 | + """ |
521 | + last = self._nesting.pop() |
522 | + last.__exit__(exc_type, exc_value, traceback) |
523 | + |
524 | + |
525 | +# Global history |
526 | +history = History(Activity(DEBUG, "History module loaded")) |
527 | |
528 | === added file 'lava/core/logging.py' |
529 | --- lava/core/logging.py 1970-01-01 00:00:00 +0000 |
530 | +++ lava/core/logging.py 2012-05-16 11:20:24 +0000 |
531 | @@ -0,0 +1,120 @@ |
532 | +# Copyright (C) 2011-2012 Linaro Limited |
533 | +# vim: set fileencoding=utf8 : |
534 | +# |
535 | +# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org> |
536 | +# |
537 | +# This file is part of lava-core |
538 | +# |
539 | +# lava-core is free software: you can redistribute it and/or modify |
540 | +# it under the terms of the GNU Lesser General Public License version 3 |
541 | +# as published by the Free Software Foundation |
542 | +# |
543 | +# lava-core is distributed in the hope that it will be useful, |
544 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
545 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
546 | +# GNU General Public License for more details. |
547 | +# |
548 | +# You should have received a copy of the GNU Lesser General Public License |
549 | +# along with lava-core. If not, see <http://www.gnu.org/licenses/>. |
550 | + |
551 | +from __future__ import absolute_import |
552 | + |
553 | +""" |
554 | +lava.core.logging |
555 | +================= |
556 | + |
557 | +Logging utilities |
558 | +""" |
559 | + |
560 | +import logging |
561 | + |
562 | +from lava.core.history import history as global_history |
563 | + |
564 | + |
565 | +class LoggerToHistoryProxy(object): |
566 | + """ |
567 | + Wrapper that looks like logging.Logger but also creates history records |
568 | + """ |
569 | + |
570 | + _history = global_history |
571 | + |
572 | + def __init__(self, logger): |
573 | + self._logger = logger |
574 | + |
575 | + def log(self, level, msg, *args, **kwargs): |
576 | + self._logger.log(level, msg, *args, **kwargs) |
577 | + self._history.log(level, "{__prefmt_msg}", |
578 | + __prefmt_msg=msg % args, logger=self._logger.name) |
579 | + |
580 | + def debug(self, msg, *args, **kwargs): |
581 | + return self.log(logging.DEBUG, msg, *args, **kwargs) |
582 | + |
583 | + def info(self, msg, *args, **kwargs): |
584 | + return self.log(logging.INFO, msg, *args, **kwargs) |
585 | + |
586 | + def warning(self, msg, *args, **kwargs): |
587 | + return self.log(logging.WARNING, msg, *args, **kwargs) |
588 | + |
589 | + def error(self, msg, *args, **kwargs): |
590 | + return self.log(logging.ERROR, msg, *args, **kwargs) |
591 | + |
592 | + def critical(self, msg, *args, **kwargs): |
593 | + return self.log(logging.CRITICAL, msg, *args, **kwargs) |
594 | + |
595 | + def exception(self, msg, *args): |
596 | + self._logger.exception(msg, *args) |
597 | + |
598 | + |
599 | +class HistoryToLoggerProxy(object): |
600 | + """ |
601 | + Wrapper that looks like History but also logs to logger |
602 | + """ |
603 | + |
604 | + _history = global_history |
605 | + |
606 | + def __init__(self, logger): |
607 | + self._logger = logger |
608 | + |
609 | + def log(self, level, message, **meta): |
610 | + self._logger.log(level, message.format(**meta)) |
611 | + return self._history.log(level, message, **meta) |
612 | + |
613 | + def debug(self, message, **meta): |
614 | + return self.log(logging.DEBUG, message, **meta) |
615 | + |
616 | + def info(self, message, **meta): |
617 | + return self.log(logging.INFO, message, **meta) |
618 | + |
619 | + def warning(self, message, **meta): |
620 | + return self.log(logging.WARNING, message, **meta) |
621 | + |
622 | + def error(self, message, **meta): |
623 | + return self.log(logging.ERROR, message, **meta) |
624 | + |
625 | + def critical(self, message, **meta): |
626 | + return self.log(logging.CRITICAL, message, **meta) |
627 | + |
628 | + |
629 | +class LoggingMixIn(object): |
630 | + """ |
631 | + Mix-in that adds a self.logger and self.history instance attributes |
632 | + """ |
633 | + |
634 | + @property |
635 | + def _logger(self): |
636 | + return logging.getLogger( |
637 | + self.__class__.__module__ + "." + self.__class__.__name__) |
638 | + |
639 | + @property |
640 | + def history(self): |
641 | + """ |
642 | + magic lava.core.history bridge |
643 | + """ |
644 | + return HistoryToLoggerProxy(self._logger) |
645 | + |
646 | + @property |
647 | + def logger(self): |
648 | + """ |
649 | + magic classic python logging bridge |
650 | + """ |
651 | + return LoggerToHistoryProxy(self._logger) |
652 | |
653 | === added directory 'lava/core/tests' |
654 | === added file 'lava/core/tests/__init__.py' |
655 | === added file 'lava/core/tests/test_history.py' |
656 | --- lava/core/tests/test_history.py 1970-01-01 00:00:00 +0000 |
657 | +++ lava/core/tests/test_history.py 2012-05-16 11:20:24 +0000 |
658 | @@ -0,0 +1,278 @@ |
659 | +""" |
660 | +lava.core.tests.test_history |
661 | +============================ |
662 | + |
663 | +Tests for lava.core.history |
664 | +""" |
665 | +import datetime |
666 | + |
667 | +from unittest2 import TestCase |
668 | +from mocker import Mocker |
669 | + |
670 | +from lava.core.history import Activity, DictProxy, ExceptionInfo, History |
671 | +from lava.core.history import DEBUG, INFO, WARNING, ERROR, CRITICAL |
672 | +from lava.core.history import _build_json |
673 | + |
674 | + |
675 | +class ActivityTestCase(TestCase): |
676 | + |
677 | + def test_raw_message(self): |
678 | + act = Activity(INFO, "some {keyword}", keyword='word') |
679 | + self.assertEqual(act.raw_message, "some {keyword}") |
680 | + |
681 | + def test_message(self): |
682 | + act = Activity(INFO, "typical {keyword}", keyword='word') |
683 | + self.assertEqual(act.message, "typical word") |
684 | + act = Activity(INFO, "mistake {bad_keyword}") |
685 | + with self.assertRaises(KeyError): |
686 | + act.message |
687 | + |
688 | + def test_level(self): |
689 | + self.assertEqual(Activity(DEBUG, "").level, DEBUG) |
690 | + self.assertEqual(Activity(INFO, "").level, INFO) |
691 | + self.assertEqual(Activity(WARNING, "").level, WARNING) |
692 | + self.assertEqual(Activity(ERROR, "").level, ERROR) |
693 | + self.assertEqual(Activity(CRITICAL, "").level, CRITICAL) |
694 | + |
695 | + def test_level_name(self): |
696 | + self.assertEqual(Activity(DEBUG, "").level_name, "DEBUG") |
697 | + self.assertEqual(Activity(INFO, "").level_name, "INFO") |
698 | + self.assertEqual(Activity(WARNING, "").level_name, "WARNING") |
699 | + self.assertEqual(Activity(ERROR, "").level_name, "ERROR") |
700 | + self.assertEqual(Activity(CRITICAL, "").level_name, "CRITICAL") |
701 | + |
702 | + def test_meta(self): |
703 | + act = Activity(INFO, "", foo=1) |
704 | + self.assertEqual(act.meta.foo, 1) |
705 | + |
706 | + def test_attach(self): |
707 | + act = Activity(INFO, "") |
708 | + act.attach("foo") |
709 | + self.assertEqual(act._attachments, [ |
710 | + DictProxy({'name': 'foo', |
711 | + 'orig_name': 'foo', |
712 | + 'mime_type': 'text/plain'})]) |
713 | + |
714 | + def test_attach_uses_basename(self): |
715 | + act = Activity(INFO, "") |
716 | + act.attach("/some/place/foo") |
717 | + self.assertEqual(act._attachments, [ |
718 | + DictProxy({'name': 'foo', |
719 | + 'orig_name': '/some/place/foo', |
720 | + 'mime_type': 'text/plain'})]) |
721 | + |
722 | + def test_attach_uses_stream_name(self): |
723 | + act = Activity(INFO, "") |
724 | + mocker = Mocker() |
725 | + stream = mocker.mock() |
726 | + stream.name |
727 | + mocker.result("/some/place/foo") |
728 | + mocker.replay() |
729 | + act.attach(stream) |
730 | + self.assertEqual(act._attachments, [ |
731 | + DictProxy({'name': 'foo', |
732 | + 'orig_name': '/some/place/foo', |
733 | + 'mime_type': 'text/plain'})]) |
734 | + mocker.verify() |
735 | + |
736 | + def test_attach_respects_mime_type(self): |
737 | + act = Activity(INFO, "") |
738 | + act.attach("foo", mime_type="text/html") |
739 | + self.assertEqual(act._attachments, [ |
740 | + DictProxy({'name': 'foo', |
741 | + 'orig_name': 'foo', |
742 | + 'mime_type': 'text/html'})]) |
743 | + |
744 | + def test_attach_respects_name(self): |
745 | + act = Activity(INFO, "") |
746 | + act.attach("foo", name='blarg') |
747 | + self.assertEqual(act._attachments, [ |
748 | + DictProxy({'name': 'blarg', |
749 | + 'orig_name': 'foo', |
750 | + 'mime_type': 'text/plain'})]) |
751 | + |
752 | + def test_duration_for_immediate_activities(self): |
753 | + act = Activity(INFO, "") |
754 | + self.assertEqual(act.duration, datetime.timedelta(0)) |
755 | + |
756 | + def test_duration_for_long_activities(self): |
757 | + act = Activity(INFO, "") |
758 | + with act: |
759 | + pass |
760 | + self.assertNotEqual(act.duration, datetime.timedelta(0)) |
761 | + |
762 | + # TODO: as_json |
763 | + |
764 | + def test__str__(self): |
765 | + act = Activity(INFO, "foo {bar}", bar='BAR') |
766 | + self.assertEqual(str(act), "foo BAR") |
767 | + |
768 | + # TODO: save_as_tarball |
769 | + |
770 | + # TODO: _save_tarball_attachments |
771 | + |
772 | + def test__repr__(self): |
773 | + act = Activity(INFO, "foo {bar}", bar='BAR') |
774 | + self.assertEqual(repr(act), "<Activity: 'foo BAR'>") |
775 | + |
776 | + def test__enter__(self): |
777 | + act = Activity(INFO, "") |
778 | + retval = act.__enter__() |
779 | + self.assertIs(retval, act) |
780 | + self.assertEqual(act._end, None) |
781 | + |
782 | + def test__exit__for_normal_termination(self): |
783 | + act = Activity(INFO, "") |
784 | + act.__enter__() |
785 | + act.__exit__(None, None, None) |
786 | + self.assertNotEqual(act._end, None) |
787 | + self.assertEqual(act._exc_value, None) |
788 | + self.assertEqual(act._exc_type, None) |
789 | + self.assertEqual(act._traceback, None) |
790 | + |
791 | + def test__exit__for_abnormal_termination(self): |
792 | + act = Activity(INFO, "") |
793 | + act.__enter__() |
794 | + exc = Exception() |
795 | + tb = [ExceptionInfo('file', 1, 'foo', 'blarg')] |
796 | + act.__exit__(type(exc), exc, tb) |
797 | + self.assertNotEqual(act._end, None) |
798 | + self.assertEqual(act._exc_value, exc) |
799 | + self.assertEqual(act._exc_type, type(exc)) |
800 | + self.assertEqual(act._traceback, tb) |
801 | + |
802 | + # TODO: print_ |
803 | + |
804 | + # TODO: _tree_indent |
805 | + |
806 | + |
807 | +class test_build_json(TestCase): |
808 | + |
809 | + def assertJSON(self, a, b): |
810 | + self.assertEqual(_build_json(a), b) |
811 | + |
812 | + def test_dict(self): |
813 | + self.assertJSON({'foo': 'bar'}, {u'foo': u'bar'}) |
814 | + |
815 | + def test_dict_skips_empty_values(self): |
816 | + self.assertJSON({'foo': None}, {}) |
817 | + self.assertJSON({'foo': {}}, {}) |
818 | + self.assertJSON({'foo': []}, {}) |
819 | + |
820 | + def test_list_or_tuple(self): |
821 | + self.assertJSON(['foo'], [u'foo']) |
822 | + self.assertJSON(('foo', ), [u'foo']) |
823 | + |
824 | + def test_passthrough(self): |
825 | + self.assertJSON(1, 1) |
826 | + self.assertJSON(1.0, 1.0) |
827 | + self.assertJSON(u"1", u"1") |
828 | + self.assertJSON(True, True) |
829 | + self.assertJSON(None, None) |
830 | + |
831 | + def test_str(self): |
832 | + self.assertJSON("1", u"1") |
833 | + with self.assertRaises(UnicodeDecodeError): |
834 | + self.assertJSON("\xff", u"\xff") |
835 | + |
836 | + def test_datetime(self): |
837 | + self.assertJSON(datetime.datetime(2012, 8, 19, 12, 34, 56), |
838 | + "2012-08-19T12:34:56Z") |
839 | + |
840 | + def test_timedelta(self): |
841 | + self.assertJSON(datetime.timedelta(1, 2, 3), "1d 2s 3us") |
842 | + |
843 | + def test_other(self): |
844 | + class Foo(object): |
845 | + def __unicode__(self): |
846 | + return u"blarg" |
847 | + self.assertJSON(Foo(), u"blarg") |
848 | + |
849 | + |
850 | +class HistoryTest(TestCase): |
851 | + |
852 | + def test_top(self): |
853 | + act = Activity(INFO, "") |
854 | + hist = History(act) |
855 | + self.assertIs(hist.top, act) |
856 | + |
857 | + def test_bottom(self): |
858 | + act = Activity(INFO, "") |
859 | + hist = History(act) |
860 | + self.assertIs(hist.bottom, act) |
861 | + with hist.info("") as act2: |
862 | + self.assertIs(hist.bottom, act2) |
863 | + self.assertIs(hist.bottom, act) |
864 | + |
865 | + def test_last(self): |
866 | + act = Activity(INFO, "") |
867 | + hist = History(act) |
868 | + self.assertIs(hist.last, act) |
869 | + with hist.info("") as act2: |
870 | + pass |
871 | + self.assertIs(hist.last, act2) |
872 | + |
873 | + def test_log_appends_to_bottommost_activity(self): |
874 | + act = Activity(INFO, "") |
875 | + hist = History(act) |
876 | + hist.log(INFO, "foo") |
877 | + self.assertEqual(hist.bottom._sub_activities, [hist.last]) |
878 | + |
879 | + def test_debug(self): |
880 | + act = Activity(INFO, "") |
881 | + hist = History(act) |
882 | + hist.debug("foo") |
883 | + self.assertEqual(hist.last.level, DEBUG) |
884 | + self.assertEqual(hist.last.message, "foo") |
885 | + |
886 | + def test_info(self): |
887 | + act = Activity(INFO, "") |
888 | + hist = History(act) |
889 | + hist.info("foo") |
890 | + self.assertEqual(hist.last.level, INFO) |
891 | + self.assertEqual(hist.last.message, "foo") |
892 | + |
893 | + def test_warning(self): |
894 | + act = Activity(INFO, "") |
895 | + hist = History(act) |
896 | + hist.warning("foo") |
897 | + self.assertEqual(hist.last.level, WARNING) |
898 | + self.assertEqual(hist.last.message, "foo") |
899 | + |
900 | + def test_error(self): |
901 | + act = Activity(INFO, "") |
902 | + hist = History(act) |
903 | + hist.error("foo") |
904 | + self.assertEqual(hist.last.level, ERROR) |
905 | + self.assertEqual(hist.last.message, "foo") |
906 | + |
907 | + def test_critical(self): |
908 | + act = Activity(INFO, "") |
909 | + hist = History(act) |
910 | + hist.critical("foo") |
911 | + self.assertEqual(hist.last.level, CRITICAL) |
912 | + self.assertEqual(hist.last.message, "foo") |
913 | + |
914 | + def test__enter__(self): |
915 | + act = Activity(INFO, "") |
916 | + hist = History(act) |
917 | + ret = hist.__enter__() |
918 | + # Enter will push the last activity on the nesting stack |
919 | + self.assertIs(ret, act) |
920 | + # NOTE: act is now a long activity |
921 | + self.assertIs(act._end, None) |
922 | + # NOTE: top of the history is now twice on the nesting stack |
923 | + self.assertEqual(hist._nesting, [act, act]) |
924 | + hist.info("foo") |
925 | + self.assertEqual(hist.top._sub_activities[0].message, "foo") |
926 | + |
927 | + def test__exit__(self): |
928 | + act = Activity(INFO, "") |
929 | + hist = History(act) |
930 | + hist.__enter__() |
931 | + self.assertEqual(hist._nesting, [act, act]) |
932 | + hist.__exit__(None, None, None) |
933 | + self.assertEqual(hist._nesting, [act]) |
934 | + # NOTE: act is now a long activity |
935 | + self.assertIsNot(act._end, None) |
936 | + self.assertNotEqual(act.duration, datetime.timedelta(0)) |
937 | |
938 | === modified file 'setup.py' |
939 | --- setup.py 2012-05-03 22:42:55 +0000 |
940 | +++ setup.py 2012-05-16 11:20:24 +0000 |
941 | @@ -44,6 +44,13 @@ |
942 | "Topic :: Software Development :: Testing", |
943 | ], |
944 | extras_require={}, |
945 | - install_requires=[], |
946 | - tests_require=['unittest2'], |
947 | + install_requires=[ |
948 | + 'json-document', |
949 | + 'json-schema-validator', |
950 | + 'simplejson', |
951 | + ], |
952 | + tests_require=[ |
953 | + 'unittest2', |
954 | + 'mocker', |
955 | + ], |
956 | zip_safe=True) |
[And once more, to the merge proposal as well :(]
Hi. This overall seems reasonably nice. With things like this I always
wonder if we'll ever get around to building the nice UIs that are
enabled...
Some general concerns:
1) Incremental output on the scheduler job page is _massively_ useful
and losing it even temporarily is not acceptable, IMHO. I don't
quite see how this meshes with this code. I guess we could still
define a logging handler that outputs log messages to stdout/stderr
and that will display nicely in the scheduler view until we get
around to doing something more sophisticated there.
2) I can see having two sets of debug/info/ warning/ error functions with
slightly different APIs (% vs .format() interpolation) being trying.
But maybe this will be a non-issue -- I guess usually it the code
will be saying self.history.info() or self.logger.info() and the
logger vs history API will be clear.
3) I'm just a little worried by memory consumption -- it seems that
there is the potential to end up with a _lot_ of objects hanging
around, particularly if you hang on to tracebacks. It might be worth
stringifying things a little earlier. I guess this won't affect the
API and so can be done later...
On Mon, 07 May 2012 09:49:18 -0000, Zygmunt Krynicki <email address hidden> wrote: /code.launchpad .net/~zkrynicki /lava-core/ history- and-logging/ +merge/ 104882 /code.launchpad .net/~zkrynicki /lava-core/ logging- mixin/+ merge/102339)
> Zygmunt Krynicki has proposed merging lp:~zkrynicki/lava-core/history-and-logging into lp:lava-core.
>
> Requested reviews:
> Linaro Validation Team (linaro-validation)
>
> For more details, see:
> https:/
>
> This is a re-spin of earlier merge proposal (https:/
>
> Again, to quote my extensive commit message:
>
> Add the logging and history modules
>
> Both modules cooperate on centralized logging interface and have largely
> identical APIs. LoggingMixIn class adds self.history and self.logging
> properties to the class it is injected to.
>
> Both properties return proxy objects that ultimately call classic python
> logging and lava-core'esque history. History is much like logging but has two
> advantages over plain logging.
>
> The first advantage is that it was designed up front for being hierarchical.
> This naturally constructs a tree of activities. I believe that such structure
> can be visualized much better than a plain log file ever could. Any composite
> operation also computes and records the duration of all the activities it
> contains. This yields much more transparent duration awareness of entire
> processes that lava performs internally.
>
> The second advantage is that it can carry more data than regular python logging
> can. All arguments to the message template are passed to the string.format()
> function as keywords. This allows us to transparently provide key-value data in
> alongside each history entry. This makes automatic analysis and data retrieval
> much easier. It also allows for new data logging features such as downloading a
> file and storing the speed history as an array in the acti...