Merge lp:~robru/gwibber/avatars into lp:~barry/gwibber/py3
- avatars
- Merge into py3
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Barry Warsaw | Pending | ||
Review via email: mp+123634@code.launchpad.net |
Commit message
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.
Barry Warsaw (barry) wrote : | # |
Robert Bruce Park (robru) wrote : | # |
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/
>> + 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.assertEqua
>
> 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...
Barry Warsaw (barry) wrote : | # |
On Sep 10, 2012, at 10:01 PM, Robert Bruce Park wrote:
>>> + self.assertEqual(
>>> + '.cache/
>>> + 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.
>>> + os.makedirs(
>>> + return os.path.jo...
Preview Diff
1 | === added file 'gwibber/gwibber/tests/data/ubuntu.png' |
2 | Binary 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 |
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/ tests/test_ avatars. py 1970-01-01 00:00:00 +0000 gwibber/ tests/test_ avatars. py 2012-09-10 20:08:21 +0000 www.gnu. org/licenses/>. utils.avatar import Avatar testing. mocks import FakeData unittest. TestCase) : get_path( 'fake_url' ) gwibber/ avatars/ 4f37e5dc9d38391 db1728048344c3a b5ff8cecb2' ,
--- gwibber/
+++ gwibber/
> @@ -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://
> +
> +"""Test the Avatar cacher."""
> +
> +__all__ = [
> + 'TestAvatars',
> + ]
> +
> +
> +import unittest
> +
> +from gwibber.
> +from gwibber.
> +
> +from gi.repository import GdkPixbuf
> +
> +try:
> + # Python 3.3
> + from unittest import mock
> +except ImportError:
> + import mock
> +
> +
> +class TestAvatars(
> + """Test Avatar logic."""
> +
> + def test_hashing(self):
> + path = Avatar.
> +
> + self.assertEqual(
> + '.cache/
> + 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.
> + 'gwibber. utils.download. urlopen' , 'gwibber. tests.data' , 'ubuntu.png')) fill(self) :
> + @mock.patch(
> + FakeData(
> + def test_cache_
> + p...