Merge ~cjwatson/snapstore-client:better-logging into snapstore-client: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)
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.

To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) wrote :

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)

review: Needs Information
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
1diff --git a/serve-snaps.py b/serve-snaps.py
2index 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()
52diff --git a/siab_client/cli.py b/siab_client/cli.py
53new file mode 100644
54index 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))
124diff --git a/siab_client/logic/dump.py b/siab_client/logic/dump.py
125index 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:
173diff --git a/siab_client/logic/upload.py b/siab_client/logic/upload.py
174index 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,
229diff --git a/siab_client/tests/test_cli.py b/siab_client/tests/test_cli.py
230new file mode 100644
231index 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())
299diff --git a/siab_client/webservices.py b/siab_client/webservices.py
300index 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)
426diff --git a/snapstore b/snapstore
427index 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

Subscribers

People subscribed via source and target branches

to all changes: