Merge lp:~robru/gwibber/avatars into lp:~barry/gwibber/py3

Proposed by Robert Bruce Park
Status: Merged
Merged at revision: 1424
Proposed branch: lp:~robru/gwibber/avatars
Merge into: lp:~barry/gwibber/py3
Diff against target: 199 lines (+113/-35)
3 files modified
gwibber/gwibber/tests/test_avatars.py (+56/-0)
gwibber/gwibber/utils/avatar.py (+52/-0)
gwibber/microblog/util/resources.py (+5/-35)
To merge this branch: bzr merge lp:~robru/gwibber/avatars
Reviewer Review Type Date Requested Status
Barry Warsaw Pending
Review via email: mp+123634@code.launchpad.net

Description of the change

New Avatar class handles the downloading and caching of any arbitrary avatar URL online.

Thanks to GdkPixbuf, I was able to slash out a lot of code related to file IO and resizing/saving of images. Also all images are now converted to PNG before being stored in the cache. I didn't profile the code but the 75% reduction in file IO should see some good performance enhancements.

To post a comment you must log in.
Revision history for this message
Barry Warsaw (barry) wrote :
Download full text (8.5 KiB)

On Sep 10, 2012, at 08:09 PM, Robert Bruce Park wrote:

>New Avatar class handles the downloading and caching of any arbitrary avatar
>URL online.

Thanks again for another fine refactoring and code deletion branch.
Eventually this sweater will look lean and mean! :)

>Thanks to GdkPixbuf, I was able to slash out a lot of code related to file IO
>and resizing/saving of images. Also all images are now converted to PNG
>before being stored in the cache. I didn't profile the code but the 75%
>reduction in file IO should see some good performance enhancements.

Of course, right now, we're much happier about the 200% improvement in code
readability. We can worry about performance later. :)

I'll clean up a few of the little style nits when I check things in, but let's
look at the code substantively.

=== added file 'gwibber/gwibber/tests/test_avatars.py'
--- gwibber/gwibber/tests/test_avatars.py 1970-01-01 00:00:00 +0000
+++ gwibber/gwibber/tests/test_avatars.py 2012-09-10 20:08:21 +0000
> @@ -0,0 +1,56 @@
> +# Copyright (C) 2012 Canonical Ltd
> +#
> +# This program is free software: you can redistribute it and/or modify
> +# it under the terms of the GNU General Public License version 2 as
> +# published by the Free Software Foundation.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU General Public License for more details.
> +#
> +# You should have received a copy of the GNU General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +
> +"""Test the Avatar cacher."""
> +
> +__all__ = [
> + 'TestAvatars',
> + ]
> +
> +
> +import unittest
> +
> +from gwibber.utils.avatar import Avatar
> +from gwibber.testing.mocks import FakeData
> +
> +from gi.repository import GdkPixbuf
> +
> +try:
> + # Python 3.3
> + from unittest import mock
> +except ImportError:
> + import mock
> +
> +
> +class TestAvatars(unittest.TestCase):
> + """Test Avatar logic."""
> +
> + def test_hashing(self):
> + path = Avatar.get_path('fake_url')
> +
> + self.assertEqual(
> + '.cache/gwibber/avatars/4f37e5dc9d38391db1728048344c3ab5ff8cecb2',
> + path[-63:])

There are a few problems with this test, which I am happy to fix so that you
can continue with the more important work of working on the dispatcher.

The first is that because this test uses the user's actual cache, it's success
can be affected by previous runs, or even the operational environment of the
user. There are a couple of ways to fix this, but what I like best is to use
a temporary directory to store the cache for the test run. This can be
accomplished by mocking the base path to the cache, setting up the tempdir in
a setUp() and removing it in a tearDown().

Probably also a better way to test the expected path is to do an
os.path.split() and check just the components you care about.

> +
> + @mock.patch('gwibber.utils.download.urlopen',
> + FakeData('gwibber.tests.data', 'ubuntu.png'))
> + def test_cache_fill(self):
> + p...

Read more...

Revision history for this message
Robert Bruce Park (robru) wrote :
Download full text (7.7 KiB)

On 12-09-10 03:55 PM, Barry Warsaw wrote:
> Thanks again for another fine refactoring and code deletion branch.
> Eventually this sweater will look lean and mean! :)

Thanks for reviewing so promptly. If it wasn't for your timely advice I
wouldn't be able to move so swiftly ;-)

>> + self.assertEqual(
>> + '.cache/gwibber/avatars/4f37e5dc9d38391db1728048344c3ab5ff8cecb2',
>> + path[-63:])
>
> There are a few problems with this test, which I am happy to fix so that you
> can continue with the more important work of working on the dispatcher.
>
> The first is that because this test uses the user's actual cache, it's success
> can be affected by previous runs, or even the operational environment of the
> user. There are a couple of ways to fix this, but what I like best is to use
> a temporary directory to store the cache for the test run. This can be
> accomplished by mocking the base path to the cache, setting up the tempdir in
> a setUp() and removing it in a tearDown().

Surely this critique refers to the *following* code block and not to
what is immediately quoted above? The get_path test specifically avoids
testing the specific user's home dir, and doesn't do anything but hash a
string.

> Probably also a better way to test the expected path is to do an
> os.path.split() and check just the components you care about.

That would be cleaner, yeah.

>> + # Confirm that the resulting cache image is actually a PNG.
>> + with open(path, 'rb') as raw:
>> + self.assertEqual(raw.read(8), bytes.fromhex('89504E470D0A1A0A'))
>
> I want to add another test, which is that if the cache is already filled,
> another download does not happen.

Yes, and I see you already mocked out the users actual cache dir,
allowing the test to avoid stale results from previous test runs, which
is a problem that hadn't occurred to me until after submitting the mp.

>> +class Avatar:
>> + @staticmethod
>> + def get_path(url):
>
> Of course, this could also be a module global function. It's a tough call,
> with arguments on either side, but since the function is probably not needed
> outside this module (tests don't count), I'd personally make it a non-public
> module global.

I suspected you might have concerns about a class that contains only two
staticmethods. There is still a del_avatar method left to port (was
going to leave it for later since it didn't seem immediately critical),
and I was thinking that Avatar class could grow into a more
general-purpose 'AvatarManager' kind of thing, but for now I just wanted
it to be a class for the sake of being an easy little bundle for
importing (eg, import Avatar and you get both methods easily).

> The entire reason is as a useful, Pythonic, recognizable clue for future
> readers and developers. Think about it this way: you have to port some
> unfamiliar code base (oh, I don't know, take a guess :) and you're trying to
> decide whether some functionality must be preserved or not. You don't really
> know if there are any third party clients out there of the code, so you don't
> know whether a change of yours will break something or not.
>
> Even in a la...

Read more...

Revision history for this message
Barry Warsaw (barry) wrote :
Download full text (7.8 KiB)

On Sep 10, 2012, at 10:01 PM, Robert Bruce Park wrote:

>>> + self.assertEqual(
>>> + '.cache/gwibber/avatars/4f37e5dc9d38391db1728048344c3ab5ff8cecb2',
>>> + path[-63:])
>>
>> There are a few problems with this test, which I am happy to fix so that
>> you can continue with the more important work of working on the dispatcher.
>>
>> The first is that because this test uses the user's actual cache, it's
>> success can be affected by previous runs, or even the operational
>> environment of the user. There are a couple of ways to fix this, but what
>> I like best is to use a temporary directory to store the cache for the test
>> run. This can be accomplished by mocking the base path to the cache,
>> setting up the tempdir in a setUp() and removing it in a tearDown().
>
>Surely this critique refers to the *following* code block and not to
>what is immediately quoted above? The get_path test specifically avoids
>testing the specific user's home dir, and doesn't do anything but hash a
>string.

Ah, but it has the side-effect of creating a directory path in ~/.cache so it
does pollute the user's environment.

>>> +class Avatar:
>>> + @staticmethod
>>> + def get_path(url):
>>
>> Of course, this could also be a module global function. It's a tough call,
>> with arguments on either side, but since the function is probably not needed
>> outside this module (tests don't count), I'd personally make it a non-public
>> module global.
>
>I suspected you might have concerns about a class that contains only two
>staticmethods. There is still a del_avatar method left to port (was
>going to leave it for later since it didn't seem immediately critical),
>and I was thinking that Avatar class could grow into a more
>general-purpose 'AvatarManager' kind of thing, but for now I just wanted
>it to be a class for the sake of being an easy little bundle for
>importing (eg, import Avatar and you get both methods easily).

Yep, no problem.

>> The entire reason is as a useful, Pythonic, recognizable clue for future
>> readers and developers. Think about it this way: you have to port some
>> unfamiliar code base (oh, I don't know, take a guess :) and you're trying
>> to decide whether some functionality must be preserved or not. You don't
>> really know if there are any third party clients out there of the code, so
>> you don't know whether a change of yours will break something or not.
>>
>> Even in a language like Python, marking non-public names with a single
>> leading underscore tells clients "You can use this if you must, but the
>> risk is yours. If I change it in a future version, and it breaks your
>> code, tough luck for you." When you use public methods (e.g. no leading
>> underscore), you're implicitly agreeing not to break the API.
>
>Hmmmm, that point is slowly starting to sink in. I'll fully appreciate
>it one day soon I'm sure ;-)

Just keep repeating the Zen of Python every night before bed.

`import this`

Contemplate the 20th Zen until you achieve Pythonista (or Pythoneer)
enlightenment.

>>> + if not os.path.isdir(CACHE_DIR):
>>> + os.makedirs(CACHE_DIR)
>>> + return os.path.jo...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'gwibber/gwibber/tests/data/ubuntu.png'
2Binary files gwibber/gwibber/tests/data/ubuntu.png 1970-01-01 00:00:00 +0000 and gwibber/gwibber/tests/data/ubuntu.png 2012-09-10 20:08:21 +0000 differ
3=== added file 'gwibber/gwibber/tests/test_avatars.py'
4--- gwibber/gwibber/tests/test_avatars.py 1970-01-01 00:00:00 +0000
5+++ gwibber/gwibber/tests/test_avatars.py 2012-09-10 20:08:21 +0000
6@@ -0,0 +1,56 @@
7+# Copyright (C) 2012 Canonical Ltd
8+#
9+# This program is free software: you can redistribute it and/or modify
10+# it under the terms of the GNU General Public License version 2 as
11+# published by the Free Software Foundation.
12+#
13+# This program is distributed in the hope that it will be useful,
14+# but WITHOUT ANY WARRANTY; without even the implied warranty of
15+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+# GNU General Public License for more details.
17+#
18+# You should have received a copy of the GNU General Public License
19+# along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
21+"""Test the Avatar cacher."""
22+
23+__all__ = [
24+ 'TestAvatars',
25+ ]
26+
27+
28+import unittest
29+
30+from gwibber.utils.avatar import Avatar
31+from gwibber.testing.mocks import FakeData
32+
33+from gi.repository import GdkPixbuf
34+
35+try:
36+ # Python 3.3
37+ from unittest import mock
38+except ImportError:
39+ import mock
40+
41+
42+class TestAvatars(unittest.TestCase):
43+ """Test Avatar logic."""
44+
45+ def test_hashing(self):
46+ path = Avatar.get_path('fake_url')
47+
48+ self.assertEqual(
49+ '.cache/gwibber/avatars/4f37e5dc9d38391db1728048344c3ab5ff8cecb2',
50+ path[-63:])
51+
52+ @mock.patch('gwibber.utils.download.urlopen',
53+ FakeData('gwibber.tests.data', 'ubuntu.png'))
54+ def test_cache_fill(self):
55+ path = Avatar.get_image('http://example.com')
56+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(path)
57+ self.assertEqual(pixbuf.get_height(), 48)
58+ self.assertEqual(pixbuf.get_width(), 48)
59+
60+ # Confirm that the resulting cache image is actually a PNG.
61+ with open(path, 'rb') as raw:
62+ self.assertEqual(raw.read(8), bytes.fromhex('89504E470D0A1A0A'))
63
64=== added file 'gwibber/gwibber/utils/avatar.py'
65--- gwibber/gwibber/utils/avatar.py 1970-01-01 00:00:00 +0000
66+++ gwibber/gwibber/utils/avatar.py 2012-09-10 20:08:21 +0000
67@@ -0,0 +1,52 @@
68+# Copyright (C) 2012 Canonical Ltd
69+#
70+# This program is free software: you can redistribute it and/or modify
71+# it under the terms of the GNU General Public License version 2 as
72+# published by the Free Software Foundation.
73+#
74+# This program is distributed in the hope that it will be useful,
75+# but WITHOUT ANY WARRANTY; without even the implied warranty of
76+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
77+# GNU General Public License for more details.
78+#
79+# You should have received a copy of the GNU General Public License
80+# along with this program. If not, see <http://www.gnu.org/licenses/>.
81+
82+"""Utils for downloading, sizing, and caching of avatar images."""
83+
84+__all__ = [
85+ 'Avatar',
86+ ]
87+
88+from gi.repository import Gio, GLib, GdkPixbuf
89+from hashlib import sha1
90+
91+import os
92+import logging
93+log = logging.getLogger('gwibber.avatars')
94+
95+from gwibber.utils.download import Downloader
96+
97+
98+CACHE_DIR = os.path.realpath(os.path.join(
99+ GLib.get_user_cache_dir(), 'gwibber', 'avatars'))
100+
101+
102+class Avatar:
103+ @staticmethod
104+ def get_path(url):
105+ if not os.path.isdir(CACHE_DIR):
106+ os.makedirs(CACHE_DIR)
107+ return os.path.join(CACHE_DIR, sha1(url.encode('utf-8')).hexdigest())
108+
109+ @staticmethod
110+ def get_image(url):
111+ local_path = Avatar.get_path(url)
112+ if not os.path.exists(local_path) or os.stat(local_path).st_size == 0:
113+ log.debug('Avatar {} empty or missing, download triggered...', url)
114+ image_data = Downloader(url).get_bytes()
115+ input_stream = Gio.MemoryInputStream.new_from_data(image_data, None)
116+ pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale(
117+ input_stream, 48, 48, True, None)
118+ pixbuf.savev(local_path, 'png', [], [])
119+ return local_path
120
121=== modified file 'gwibber/microblog/util/resources.py'
122--- gwibber/microblog/util/resources.py 2012-08-21 22:53:27 +0000
123+++ gwibber/microblog/util/resources.py 2012-09-10 20:08:21 +0000
124@@ -17,7 +17,7 @@
125 import logging
126 logger = logging.getLogger("Resources")
127
128-# Try to import * from custom, install custom.py to include packaging
129+# Try to import * from custom, install custom.py to include packaging
130 # customizations like distro API keys, etc
131 try:
132 from custom import *
133@@ -32,8 +32,8 @@
134 THEME_NAME = "default"
135
136 # Minimum theme version, this is a serial to ensure themes are compatible
137-# with current version of the client. This serial is set in the theme
138-# dir in a file named theme.version
139+# with current version of the client. This serial is set in the theme
140+# dir in a file named theme.version
141 THEME_MIN_VERSION = 2
142
143 try:
144@@ -77,30 +77,12 @@
145 if os.path.isdir(os.path.join(p, d)):
146 if d not in PLUGINS:
147 PLUGINS.append(d)
148- return [PLUGINS,PLUGINS_DIRS]
149+ return [PLUGINS,PLUGINS_DIRS]
150
151 def get_twitter_keys():
152 # Distros should register their own keys and not rely on the defaults
153 return TWITTER_OAUTH_KEY, TWITTER_OAUTH_SECRET
154
155-def get_avatar_path(url):
156- avatar_cache_dir = realpath(join(CACHE_BASE_DIR, "gwibber", "avatars"))
157- if not isdir(avatar_cache_dir):
158- makedirs(avatar_cache_dir)
159- avatar_cache_image = join(avatar_cache_dir, sha1(url).hexdigest())
160-
161- if not exists(avatar_cache_image) or len(open(avatar_cache_image, "r").read()) < 1:
162- logger.debug("Downloading avatar %s", url)
163- f = file(avatar_cache_image, "w")
164- data = network.Download(url)
165- f.write(data.get_string())
166- f.close()
167- img_resize(avatar_cache_image, 48)
168-
169- if len(open(avatar_cache_image, "r").read()) > 0:
170- return avatar_cache_image
171- return None
172-
173 def del_avatar(avatar):
174 if exists(avatar):
175 try:
176@@ -108,22 +90,10 @@
177 except:
178 logger.error("Failed to remove avatar from cache: %s", avatar)
179
180-def img_resize(img_path, size):
181- try:
182- image = Image.open(img_path)
183- x, y = image.size
184- if x != size or y != size:
185- # need to upsample limited palette images before resizing
186- if image.mode == 'P': image = image.convert('RGBA')
187- image.resize((size, size), Image.ANTIALIAS).save(img_path, format="jpeg")
188- except Exception as e:
189- from traceback import format_exc
190- logger.error("Image resizing failed:\n%s", format_exc())
191-
192 def get_desktop_file():
193 p = os.path.join(LAUNCH_DIR, "gwibber.desktop")
194 if os.path.exists(p): return p
195-
196+
197 for base in DATA_BASE_DIRS:
198 p = os.path.join(base, "applications", "gwibber.desktop")
199 if os.path.exists(p): return p

Subscribers

People subscribed via source and target branches