Merge ~cjwatson/snapstore-client:better-logging into snapstore-client:master
- Git
- lp:~cjwatson/snapstore-client
- better-logging
- Merge into master
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | c362e62ad2df20b60d39c4dd67b9b8aea7b309b7 |
Proposed branch: | ~cjwatson/snapstore-client:better-logging |
Merge into: | snapstore-client:master |
Prerequisite: | ~cjwatson/snapstore-client:initial-tests |
Diff against target: |
444 lines (+193/-39) 7 files modified
serve-snaps.py (+12/-4) siab_client/cli.py (+66/-0) siab_client/logic/dump.py (+7/-3) siab_client/logic/upload.py (+14/-10) siab_client/tests/test_cli.py (+64/-0) siab_client/webservices.py (+28/-22) snapstore (+2/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Adam Collard (community) | Approve | ||
Review via email: mp+326081@code.launchpad.net |
Commit message
Set up better output logging
Using a logger rather than plain `print` makes it easier to unit-test
things without extensive monkey-patching, and is just generally better
style.
The general approach is borrowed from snapcraft, although I changed a
number of details: in particular, I only use colours for errors to avoid
sensory overload.
Note that we must use %-style rather than {}-style formatting, since the
latter can't safely be used in logging records without considerable
complications.
Description of the change
To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) wrote : | # |
As discussed on IRC, we'll stick with %-formatting since we can't interpolate using {} beforehand in case any the resulting string contains '%' (which would cause problems)
Revision history for this message
Adam Collard (adam-collard) wrote : | # |
+1 but please see inline-comments
review:
Approve
Revision history for this message
Colin Watson (cjwatson) wrote : | # |
Thanks. I've fixed all of those now.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/serve-snaps.py b/serve-snaps.py |
2 | index 2e9133f..7ab8f5d 100755 |
3 | --- a/serve-snaps.py |
4 | +++ b/serve-snaps.py |
5 | @@ -2,9 +2,11 @@ |
6 | # -*- coding: utf-8 -*- |
7 | # Copyright 2017 Canonical Ltd. |
8 | |
9 | -"""serve snaps from a dictory on disk.""" |
10 | -import os |
11 | +"""Serve snaps from a directory on disk.""" |
12 | + |
13 | import http.server |
14 | +import logging |
15 | +import os |
16 | from urllib.error import HTTPError |
17 | from urllib.parse import ( |
18 | urlsplit, |
19 | @@ -12,8 +14,12 @@ from urllib.parse import ( |
20 | ) |
21 | from urllib.request import urlopen |
22 | |
23 | -from siab_client.config import read_config |
24 | from siab_client import get_storage_root |
25 | +from siab_client.cli import configure_logging |
26 | +from siab_client.config import read_config |
27 | + |
28 | + |
29 | +logger = logging.getLogger(__name__) |
30 | |
31 | |
32 | class SometimesProxyingButOtherwisePrettySimpleHTTPRequestHandler( |
33 | @@ -43,6 +49,7 @@ class SometimesProxyingButOtherwisePrettySimpleHTTPRequestHandler( |
34 | |
35 | |
36 | def main(): |
37 | + configure_logging() |
38 | # TODO: perhaps this should come from config instead? |
39 | os.chdir(get_storage_root()) |
40 | config = read_config() |
41 | @@ -52,8 +59,9 @@ def main(): |
42 | httpd = http.server.HTTPServer( |
43 | (listen_host, listen_port), |
44 | SometimesProxyingButOtherwisePrettySimpleHTTPRequestHandler) |
45 | - print("serving at port", listen_port) |
46 | + logger.info('Serving at port %d', listen_port) |
47 | httpd.serve_forever() |
48 | |
49 | + |
50 | if __name__ == '__main__': |
51 | main() |
52 | diff --git a/siab_client/cli.py b/siab_client/cli.py |
53 | new file mode 100644 |
54 | index 0000000..0ee6e66 |
55 | --- /dev/null |
56 | +++ b/siab_client/cli.py |
57 | @@ -0,0 +1,66 @@ |
58 | +# Copyright 2017 Canonical Ltd. |
59 | + |
60 | +"""Command-line interface niceties for this service.""" |
61 | + |
62 | +import logging |
63 | +import os |
64 | +import sys |
65 | + |
66 | + |
67 | +class _StdoutFilter(logging.Filter): |
68 | + |
69 | + def filter(self, record): |
70 | + return record.levelno <= logging.WARNING |
71 | + |
72 | + |
73 | +class _StderrFilter(logging.Filter): |
74 | + |
75 | + def filter(self, record): |
76 | + return record.levelno >= logging.ERROR |
77 | + |
78 | + |
79 | +def _is_dumb_terminal(): |
80 | + """Return True if on a dumb terminal.""" |
81 | + is_stdout_tty = os.isatty(sys.stdout.fileno()) |
82 | + is_term_dumb = os.environ.get('TERM', '') == 'dumb' |
83 | + return not is_stdout_tty or is_term_dumb |
84 | + |
85 | + |
86 | +class _ColouredFormatter(logging.Formatter): |
87 | + |
88 | + _reset = '\033[0m' |
89 | + _level_colours = { |
90 | + 'ERROR': '\033[0;31m', # Dark red |
91 | + } |
92 | + |
93 | + def format(self, record): |
94 | + colour = self._level_colours.get(record.levelname) |
95 | + log_message = super().format(record) |
96 | + if colour is not None: |
97 | + return colour + log_message + self._reset |
98 | + else: |
99 | + return log_message |
100 | + |
101 | + |
102 | +def configure_logging(logger_name=None, log_level=logging.INFO): |
103 | + stdout_handler = logging.StreamHandler(stream=sys.stdout) |
104 | + stdout_handler.addFilter(_StdoutFilter()) |
105 | + stderr_handler = logging.StreamHandler(stream=sys.stderr) |
106 | + stderr_handler.addFilter(_StderrFilter()) |
107 | + handlers = [stdout_handler, stderr_handler] |
108 | + if _is_dumb_terminal(): |
109 | + formatter = logging.Formatter(style='{') |
110 | + else: |
111 | + formatter = _ColouredFormatter(style='{') |
112 | + logger = logging.getLogger(logger_name) |
113 | + for handler in handlers: |
114 | + handler.setFormatter(formatter) |
115 | + logger.addHandler(handler) |
116 | + logger.setLevel(log_level) |
117 | + |
118 | + # The requests library is too noisy at INFO level. |
119 | + if log_level == logging.DEBUG: |
120 | + logging.getLogger('requests').setLevel(log_level) |
121 | + else: |
122 | + logging.getLogger('requests').setLevel( |
123 | + max(logging.WARNING, log_level)) |
124 | diff --git a/siab_client/logic/dump.py b/siab_client/logic/dump.py |
125 | index 14a27ec..554ced5 100644 |
126 | --- a/siab_client/logic/dump.py |
127 | +++ b/siab_client/logic/dump.py |
128 | @@ -5,6 +5,7 @@ import codecs |
129 | import csv |
130 | import itertools |
131 | import json |
132 | +import logging |
133 | import os.path |
134 | |
135 | import yaml |
136 | @@ -16,6 +17,9 @@ from siab_client import ( |
137 | from siab_client import webservices as ws |
138 | |
139 | |
140 | +logger = logging.getLogger(__name__) |
141 | + |
142 | + |
143 | RISK_ORDER = ['stable', 'candidate', 'beta', 'edge'] |
144 | |
145 | |
146 | @@ -29,7 +33,7 @@ def parse_channel(channel): |
147 | |
148 | |
149 | def import_dump(args): |
150 | - print('Importing from {}.'.format(args.root)) |
151 | + logger.info('Importing from %s.', args.root) |
152 | for snap_id in args.snap_id: |
153 | snap_root = os.path.join(args.root, snap_id) |
154 | _import_snap(snap_root) |
155 | @@ -38,7 +42,7 @@ def import_dump(args): |
156 | def _import_snap(dir): |
157 | with open(os.path.join(dir, 'snap.json')) as f: |
158 | snap_meta = json.loads(f.read()) |
159 | - print(' {} -> {}'.format(snap_meta['snap_id'], snap_meta['name'])) |
160 | + logger.info(' %s -> %s', snap_meta['snap_id'], snap_meta['name']) |
161 | |
162 | # XXX: Series and authority hardcoded. |
163 | error = ws.register_snap_name_and_blob( |
164 | @@ -134,7 +138,7 @@ def import_snapsection_dump(args): |
165 | Assumes tsv takes form: |
166 | snap_id\tfeatured\tscore\tsection_name |
167 | """ |
168 | - print('Importing snapsections from {}.'.format(args.tsv_file)) |
169 | + logger.info('Importing snapsections from %s.', args.tsv_file) |
170 | |
171 | sections = {} |
172 | with open(args.tsv_file, 'r') as tsvin: |
173 | diff --git a/siab_client/logic/upload.py b/siab_client/logic/upload.py |
174 | index 6dda1f6..86264c0 100644 |
175 | --- a/siab_client/logic/upload.py |
176 | +++ b/siab_client/logic/upload.py |
177 | @@ -1,6 +1,7 @@ |
178 | # Copyright 2017 Canonical Ltd. |
179 | |
180 | import hashlib |
181 | +import logging |
182 | import os |
183 | import random |
184 | import shutil |
185 | @@ -21,6 +22,9 @@ if sys.version < '3.6': |
186 | import sha3 # noqa |
187 | |
188 | |
189 | +logger = logging.getLogger(__name__) |
190 | + |
191 | + |
192 | def upload_snap(args): |
193 | snap_path = args.snap |
194 | with tempfile.TemporaryDirectory() as workdir: |
195 | @@ -38,15 +42,15 @@ def upload_snap(args): |
196 | |
197 | snap_yaml = _read_snap_yaml(workdir, snap_path) |
198 | |
199 | - print("Name: %s" % name) |
200 | - print("version: %s" % version) |
201 | - print("Summary: %s" % summary) |
202 | - print("description: %s" % description) |
203 | - print("Confinement: %s" % confinement) |
204 | - print("Architectures: %s" % ', '.join(architectures)) |
205 | - print("Sha3_384: %s" % sha3_384) |
206 | - print("Sha512: %s" % sha512) |
207 | - print("Binary filesize: %d" % binary_filesize) |
208 | + logger.info("Name: %s", name) |
209 | + logger.info("Version: %s", version) |
210 | + logger.info("Summary: %s", summary) |
211 | + logger.info("Description: %s", description) |
212 | + logger.info("Confinement: %s", confinement) |
213 | + logger.info("Architectures: %s", ', '.join(architectures)) |
214 | + logger.info("Sha3_384: %s", sha3_384) |
215 | + logger.info("Sha512: %s", sha512) |
216 | + logger.info("Binary filesize: %d", binary_filesize) |
217 | |
218 | series = '16' |
219 | |
220 | @@ -66,7 +70,7 @@ def upload_snap(args): |
221 | latest_revision = ws.get_latest_revision_for_snap_id(snap_id) |
222 | next_revision = latest_revision + 1 if latest_revision else 1 |
223 | binary_path = _store_snap(snap_path, name, next_revision) |
224 | - print("Binary file path: %s" % binary_path) |
225 | + logger.info("Binary file path: %s", binary_path) |
226 | registration_success = ws.create_revision( |
227 | snap_id, |
228 | next_revision, |
229 | diff --git a/siab_client/tests/test_cli.py b/siab_client/tests/test_cli.py |
230 | new file mode 100644 |
231 | index 0000000..18b7b8d |
232 | --- /dev/null |
233 | +++ b/siab_client/tests/test_cli.py |
234 | @@ -0,0 +1,64 @@ |
235 | +# Copyright 2017 Canonical Ltd. |
236 | + |
237 | +import logging |
238 | + |
239 | +import fixtures |
240 | +from testtools import TestCase |
241 | + |
242 | +from siab_client import cli |
243 | + |
244 | + |
245 | +class ConfigureLoggingTests(TestCase): |
246 | + |
247 | + def setUp(self): |
248 | + super().setUp() |
249 | + self.logger = logging.getLogger(__name__) |
250 | + self.addCleanup( |
251 | + self._restoreLogger, |
252 | + self.logger, self.logger.level, list(self.logger.handlers)) |
253 | + self.stdout = self.useFixture(fixtures.StringStream('stdout')).stream |
254 | + self.stdout.fileno = lambda: 1 |
255 | + self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.stdout)) |
256 | + self.stderr = self.useFixture(fixtures.StringStream('stderr')).stream |
257 | + self.useFixture(fixtures.MonkeyPatch('sys.stderr', self.stderr)) |
258 | + |
259 | + @staticmethod |
260 | + def _restoreLogger(logger, level, handlers): |
261 | + logger.setLevel(logger.level) |
262 | + for handler in list(logger.handlers): |
263 | + logger.removeHandler(handler) |
264 | + for handler in handlers: |
265 | + logger.addHandler(handler) |
266 | + |
267 | + def test_log_levels(self): |
268 | + self.useFixture(fixtures.MonkeyPatch('os.isatty', lambda fd: True)) |
269 | + cli.configure_logging(__name__) |
270 | + self.assertEqual(logging.INFO, self.logger.level) |
271 | + self.logger.debug('Debug') |
272 | + self.logger.info('Info') |
273 | + self.logger.warning('Warning: %s', 'smoke') |
274 | + self.logger.error('Error: %s', 'fire') |
275 | + self.stdout.seek(0) |
276 | + self.assertEqual('Info\nWarning: smoke\n', self.stdout.read()) |
277 | + self.stderr.seek(0) |
278 | + self.assertEqual('\033[0;31mError: fire\033[0m\n', self.stderr.read()) |
279 | + |
280 | + def test_requests_log_level_default(self): |
281 | + cli.configure_logging(__name__) |
282 | + self.assertEqual(logging.WARNING, logging.getLogger('requests').level) |
283 | + |
284 | + def test_requests_log_level_debug(self): |
285 | + cli.configure_logging(__name__, logging.DEBUG) |
286 | + self.assertEqual(logging.DEBUG, logging.getLogger('requests').level) |
287 | + |
288 | + def test_requests_log_level_error(self): |
289 | + cli.configure_logging(__name__, logging.ERROR) |
290 | + self.assertEqual(logging.ERROR, logging.getLogger('requests').level) |
291 | + |
292 | + def test_no_tty(self): |
293 | + self.useFixture(fixtures.MonkeyPatch('os.isatty', lambda fd: False)) |
294 | + self.useFixture(fixtures.EnvironmentVariable('TERM', 'xterm')) |
295 | + cli.configure_logging(__name__) |
296 | + self.logger.error('Error: %s', 'fire') |
297 | + self.stderr.seek(0) |
298 | + self.assertEqual('Error: fire\n', self.stderr.read()) |
299 | diff --git a/siab_client/webservices.py b/siab_client/webservices.py |
300 | index 792fd67..addfd0f 100644 |
301 | --- a/siab_client/webservices.py |
302 | +++ b/siab_client/webservices.py |
303 | @@ -4,6 +4,7 @@ import base64 |
304 | import binascii |
305 | import datetime |
306 | import json |
307 | +import logging |
308 | import os.path |
309 | import urllib.parse |
310 | |
311 | @@ -12,8 +13,11 @@ import requests |
312 | from siab_client import config |
313 | |
314 | |
315 | +logger = logging.getLogger(__name__) |
316 | + |
317 | + |
318 | def get_snap_id_for_name(snap_name, series): |
319 | - """Return the snap_name associated with this snam_name. |
320 | + """Return the snap_name associated with this snap_name. |
321 | |
322 | Returns None if the snap_name has not been associated with a snap_id yet. |
323 | """ |
324 | @@ -57,10 +61,11 @@ def register_snap_name_and_blob(snap_id, snap_name, series, snap_blob, |
325 | urllib.parse.urljoin(ident_root, '/snaps/update'), |
326 | json={'snaps': [data]}) |
327 | if resp.status_code == 200: |
328 | - print( |
329 | - "Snap '{}' ({}) registered for series '{}'.".format( |
330 | - snap_name, snap_id, series) |
331 | + logger.info( |
332 | + "Snap '%s' (%s) registered for series '%s'.", |
333 | + snap_name, snap_id, series |
334 | ) |
335 | + return False |
336 | else: |
337 | _print_error_message('register snap', resp) |
338 | return True |
339 | @@ -92,11 +97,11 @@ def release_revision(snap_id, snap_name, series, channel, arches, |
340 | } |
341 | ) |
342 | if resp.status_code == 200: |
343 | - print( |
344 | - "Snap '{}' ({}) revision {} released to series={}, " |
345 | - "channel={}, arches={}".format( |
346 | - snap_name, snap_id, revision, series, channel, |
347 | - ','.join(arches)) |
348 | + logger.info( |
349 | + "Snap '%s' (%s) revision %s released to series=%s, " |
350 | + "channel=%s, arches=%s", |
351 | + snap_name, snap_id, revision, series, channel, |
352 | + ','.join(arches) |
353 | ) |
354 | else: |
355 | _print_error_message('release revision', resp) |
356 | @@ -151,12 +156,12 @@ def create_revision(snap_id, revision, architectures, binary_path, filesize, |
357 | _print_error_message('create revision', resp) |
358 | return False |
359 | |
360 | - print("Revision {} of {} created.".format(revision, snap_id)) |
361 | + logger.info("Revision %s of %s created.", revision, snap_id) |
362 | return True |
363 | |
364 | |
365 | def create_snapsections(payload): |
366 | - print('Updating sections and snapsections...') |
367 | + logger.info('Updating sections and snapsections...') |
368 | |
369 | snapfind_root = config.read_config()['services']['snapfind'] |
370 | snapfind_snapsection_endpoint = urllib.parse.urljoin( |
371 | @@ -164,9 +169,9 @@ def create_snapsections(payload): |
372 | |
373 | response = requests.post(snapfind_snapsection_endpoint, json=payload) |
374 | if response.ok: |
375 | - print('Done.') |
376 | + logger.info('Done.') |
377 | else: |
378 | - print(response.text) |
379 | + logger.error(response.text) |
380 | |
381 | |
382 | def get_assertion(root, type_, key): |
383 | @@ -187,7 +192,7 @@ def create_or_update_assertions( |
384 | |
385 | if get_assertion( |
386 | assertions_root, 'snap-declaration', (series, snap_id)) is None: |
387 | - print("Creating snap-declaration ...") |
388 | + logger.info("Creating snap-declaration ...") |
389 | assertion_headers = { |
390 | 'type': 'snap-declaration', |
391 | 'revision': '0', |
392 | @@ -201,7 +206,7 @@ def create_or_update_assertions( |
393 | assertion = sign_assertion(assertion_headers) |
394 | save_assertion(assertion) |
395 | |
396 | - print("Creating snap-revision ...") |
397 | + logger.info("Creating snap-revision ...") |
398 | snap_sha3_384 = base64.urlsafe_b64encode( |
399 | binascii.a2b_hex(binary_sha3_384)).decode().rstrip("=") |
400 | snap_size = str(binary_filesize) |
401 | @@ -257,16 +262,17 @@ def sign_assertion(assertion_headers): |
402 | |
403 | def _print_error_message(action, response): |
404 | """Print failure messages from other services in a standard way.""" |
405 | - print("Failed to %s:" % action) |
406 | + logger.error("Failed to %s:", action) |
407 | if response.status_code == 500: |
408 | - print("Server sent 500 response.") |
409 | + logger.error("Server sent 500 response.") |
410 | else: |
411 | try: |
412 | json_document = response.json() |
413 | for error in json_document['error_list']: |
414 | - print(error['message']) |
415 | + logger.error(error['message']) |
416 | except json.JSONDecodeError: |
417 | - print("Server sent non-json response, printed verbatim below:") |
418 | - print('=' * 20) |
419 | - print(response.content) |
420 | - print('=' * 20) |
421 | + logger.error( |
422 | + "Server sent non-json response, printed verbatim below:") |
423 | + logger.error('=' * 20) |
424 | + logger.error(response.content) |
425 | + logger.error('=' * 20) |
426 | diff --git a/snapstore b/snapstore |
427 | index b111e94..5d66fe9 100755 |
428 | --- a/snapstore |
429 | +++ b/snapstore |
430 | @@ -7,6 +7,7 @@ |
431 | import argparse |
432 | import sys |
433 | |
434 | +from siab_client.cli import configure_logging |
435 | from siab_client.logic.upload import upload_snap |
436 | from siab_client.logic.dump import ( |
437 | import_dump, |
438 | @@ -15,6 +16,7 @@ from siab_client.logic.dump import ( |
439 | |
440 | |
441 | def main(): |
442 | + configure_logging() |
443 | args = parse_args() |
444 | args.func(args) |
445 |
Some comments inline, awaiting info on the peril you describe about using {}-style formatting (I contend that if we interpolate before passing the string into the logging subsystem, we won't have any complications)