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

Proposed by Robert Bruce Park
Status: Merged
Merged at revision: 1404
Proposed branch: lp:~robru/gwibber/account
Merge into: lp:~barry/gwibber/py3
Diff against target: 193 lines (+158/-1) (has conflicts)
3 files modified
gwibber/gwibber/tests/test_account.py (+97/-0)
gwibber/gwibber/utils/account.py (+57/-0)
gwibber/microblog/dispatcher.py (+4/-1)
Text conflict in gwibber/microblog/dispatcher.py
To merge this branch: bzr merge lp:~robru/gwibber/account
Reviewer Review Type Date Requested Status
Barry Warsaw Approve
Review via email: mp+121503@code.launchpad.net

Description of the change

Rewrite Account class using dict instead of collections.MutableMapping, allowing a significant refactoring, as well as removal from dispatcher.py into it's own file, account.py. Includes test coverage.

Barry, one thing to keep in mind here is that I *still* don't really know what's going on, so the answer to "Why'd you do [something] that way?" is "Because it was like that before, but now the code is less sloppy and has test coverage."

This one makes heavy use of MagicMock, so I'm a bit afraid that the tests I've written are testing too heavily *how* certain things are being done, instead of just testing *that* they are done. It's not clear to me how to make this better though, so I could use your input.

To post a comment you must log in.
lp:~robru/gwibber/account updated
1403. By Robert Bruce Park

Fixes based on discussion with mardy regarding libaccounts API.

1404. By Robert Bruce Park

Update tests to reflect the changes of the previous commit.

Revision history for this message
Robert Bruce Park (robru) wrote :

So barry, the thing to remember about what mardy just told us is this get_settings_iter may not quite be iterable in the way python expects, so the list comprehension in on_account_changed may explode when we give it real access to libaccounts outside of the over-mocked testsuite. I don't know how to test this properly at this point so we'll just have to remember and be aware of this when it comes time to write some integration tests when we're wrapping up the porting job.

Revision history for this message
Robert Bruce Park (robru) wrote :

The best solution of course would be to implement a python override in libaccounts so that it *is* an iterator in the way python expects (needs a __next__ method that raises StopIteration when the first False value is encountered), but I'm not sure how possible that would be to coordinate (I actually have experience writing python overrides for GI code, but I'm uncertain how this would impact feature freeze, and also coordinating them getting it released in time for our needs etc, might have to wait for next cycle).

lp:~robru/gwibber/account updated
1405. By Robert Bruce Park

Drop Account.global_id() method in favor of accessing Account.global_id
attribute directly.

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

Hi Robert,

Thanks again for all the awesome work on the Python 3 port of Gwibber. Here
are some detailed comments on the Accounts branch.

=== added file 'gwibber/gwibber/tests/test_account.py'
--- gwibber/gwibber/tests/test_account.py 1970-01-01 00:00:00 +0000
+++ gwibber/gwibber/tests/test_account.py 2012-08-28 21:31:33 +0000
> @@ -0,0 +1,100 @@
> +# 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 Account class."""
> +
> +__all__ = [
> + 'TestAccount',
> + ]
> +
> +
> +import unittest
> +
> +from threading import Lock
> +
> +from gwibber.utils.account import Account
> +
> +try:
> + # Python 3.3
> + from unittest import mock
> +except ImportError:
> + import mock
> +
> +
> +class TestAccount(unittest.TestCase):
> + """Test Account class."""
> +
> + def test_basic_api(self):
> + fake_account_service = mock.MagicMock()
> + fake_account_service.get_account().get_settings_iter.return_value = iter([(True, 'key', 'val')])

PEP 8 recommends a maximum line length of 79 characters.

http://www.python.org/dev/peps/pep-0008/#maximum-line-length

There's ways to rewrite this code to fit, but I have another suggestion in a
moment..

> + fake_account_service.get_account().id = 'fake_id'
> + fake_account_service.get_service().get_name.return_value = 'fake_service'
> +
> + account = Account(fake_account_service)

I'm not actually sure you need to mock this to get decent tests. Since you're
passing account_service into the constructor, the test module could just
create a simple class that provides the required API. Instantiate that and
pass it into the constructor, then have it respond the way you want it to to
the various calls.

> + fake_account_service.get_auth_data.assert_called_once_with()
> +
> + fake_account_service.connect.assert_called_once_with('changed',
> + account.on_account_changed, fake_account_service.get_account())
> +
> + account['name'] = 'Fred'
> + self.assertEqual(account['name'], 'Fred')
> +
> + self.assertTrue('auth' in account)
> + self.assertTrue('id' in account['auth'])
> + self.assertTrue('method' in account['auth'])
> + self.assertTrue('mechanism' in account['auth'])
> + self.assertTrue('parameters' in account['auth'])
> + self.assertEqual(len(account['auth']), 4)
> +
> + self.assertTrue('service' in account)
> +
> + self.assertTrue('id' in account)

If you do create a fake, unmocked account_service object, then you won't need
to do the assertTrues here. You could actually test...

Revision history for this message
Barry Warsaw (barry) wrote :

On Aug 28, 2012, at 02:35 PM, Robert Bruce Park wrote:

>The best solution of course would be to implement a python override in
>libaccounts so that it *is* an iterator in the way python expects (needs a
>__next__ method that raises StopIteration when the first False value is
>encountered), but I'm not sure how possible that would be to coordinate (I
>actually have experience writing python overrides for GI code, but I'm
>uncertain how this would impact feature freeze, and also coordinating them
>getting it released in time for our needs etc, might have to wait for next
>cycle).

Probably too late to do it for this cycle, but I would definitely suggest
filing a bug for next cycle! If you do, please subscribe me to it; I'd like
to work on the API for libaccounts too.

lp:~robru/gwibber/account updated
1406. By Robert Bruce Park

Minor cleanup based on barry's review.

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

On 12-08-28 05:04 PM, Barry Warsaw wrote:
>> + fake_account_service.get_account().get_settings_iter.return_value = iter([(True, 'key', 'val')])
>
> PEP 8 recommends a maximum line length of 79 characters.
>
> http://www.python.org/dev/peps/pep-0008/#maximum-line-length

Yeah, sorry. Somewhere along the line I fell into the bad habit of
regarding my tests as just "test code" and not "real code" and thus I've
become less stringent about adhering to PEP8 in the tests.

> I'm not actually sure you need to mock this to get decent tests. Since you're
> passing account_service into the constructor, the test module could just
> create a simple class that provides the required API. Instantiate that and
> pass it into the constructor, then have it respond the way you want it to to
> the various calls.

I had actually written a fake class originally, and there were dozens of
lines of stub methods on it and I found it was actually quite
cumbersome. The mock only required a few lines worth of return_value
defintions in order to be serviceable, so I definitely prefer the mock.

>> + fake_account_service.get_enabled.return_value = True
>> + self.assertTrue(account.enabled())
>> + fake_account_service.get_enabled.return_value = False
>> + self.assertFalse(account.enabled())
>
> Also, I typically prefer really tight, focused tests for each bit of code
> you're testing. That way if you get a failure because the
> login_lock.acquire() returned False, you'll know immediately that the test
> didn't fail because, say account['id'] had the wrong value.

But the beauty of unittest is that it tells you precisely which test
failed and why, unlike using bare 'assert' statements. I understand this
test is a bit long, I guess I'll leave it to you to split up.

> You wouldn't need to test each individual key in account['auth'], because
> those are logically the same bit of code. But this test could be split up
> into several smaller tests that each flex a single logical chunk of the
> constructor.

The other reason that it's all crammed into one test is that I didn't
want to have to duplicate the mock creation code needlessly. I guess it
could be promoted to a module global and then referenced from different
tests if you cared to tackle that though.

>> + auth_data = account_service.get_auth_data()
>> + self['auth'] = {
>> + 'id': auth_data.get_credentials_id(),
>> + 'method': auth_data.get_method(),
>> + 'mechanism': auth_data.get_mechanism(),
>> + 'parameters': auth_data.get_parameters(),
>> + }
>
> An alternative style I sometimes like, which works if all the keys are Python
> identifiers (as is the case here), is something like this:
>
> self['auth'] = dict(
> id = auth_data.get_credentials_id(),
> method = auth_data.get_method(),
> mechanism = auth_data.get_mechanism(),
> parameters = auth_data.get_parameters(),
> )

Done.

>> + account = account_service.get_account()
>> + self.global_id = account.id
>> + service_name = account_serv...

Read more...

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

On Aug 28, 2012, at 05:31 PM, Robert Bruce Park wrote:

>Yeah, sorry. Somewhere along the line I fell into the bad habit of regarding
>my tests as just "test code" and not "real code" and thus I've become less
>stringent about adhering to PEP8 in the tests.

No worries!

>> I'm not actually sure you need to mock this to get decent tests. Since
>> you're passing account_service into the constructor, the test module could
>> just create a simple class that provides the required API. Instantiate
>> that and pass it into the constructor, then have it respond the way you
>> want it to to the various calls.
>
>I had actually written a fake class originally, and there were dozens of
>lines of stub methods on it and I found it was actually quite cumbersome. The
>mock only required a few lines worth of return_value defintions in order to
>be serviceable, so I definitely prefer the mock.

Interesting! Okay.

>>> + fake_account_service.get_enabled.return_value = True
>>> + self.assertTrue(account.enabled())
>>> + fake_account_service.get_enabled.return_value = False
>>> + self.assertFalse(account.enabled())
>>
>> Also, I typically prefer really tight, focused tests for each bit of code
>> you're testing. That way if you get a failure because the
>> login_lock.acquire() returned False, you'll know immediately that the test
>> didn't fail because, say account['id'] had the wrong value.
>
>But the beauty of unittest is that it tells you precisely which test failed
>and why, unlike using bare 'assert' statements. I understand this test is a
>bit long, I guess I'll leave it to you to split up.
>
>> You wouldn't need to test each individual key in account['auth'], because
>> those are logically the same bit of code. But this test could be split up
>> into several smaller tests that each flex a single logical chunk of the
>> constructor.
>
>The other reason that it's all crammed into one test is that I didn't want to
>have to duplicate the mock creation code needlessly. I guess it could be
>promoted to a module global and then referenced from different tests if you
>cared to tackle that though.

TestCases can have both a per-test setup and teardown and a per-class setup
and teardown, so all the mock setups could go into TestAccount.setUp(). I'll
give that a shot along with splitting up the tests.

>>> + def on_account_changed(self, account_service, account):
>>> + self.update(
>>> + [(key, value)
>>> + for (success, key, value)
>>> + in account.get_settings_iter('gwibber/')
>>> + if success])
>>
>> You don't need a list comprehension here because a generator comprehension
>> will work just fine. There's no need to create a temporary concrete list
>> that just gets thrown away.
>
>Well, the last time I was profiling code, it turned out that generator
>expressions were actually significantly slower than list comprehensions
>(though admittedly this was in py2.7, I'm not sure if it's changed since
>then). Generators have the benefit of not using up a lot of RAM, but they do
>so by invoking python functions repeatedly, which is a well known way to do
>things very slowly (functi...

Read more...

Revision history for this message
Robert Bruce Park (robru) wrote :

On 12-08-28 06:30 PM, Barry Warsaw wrote:
>> I had actually written a fake class originally, and there were dozens of
>> lines of stub methods on it and I found it was actually quite cumbersome. The
>> mock only required a few lines worth of return_value defintions in order to
>> be serviceable, so I definitely prefer the mock.
>
> Interesting! Okay.

Yeah, part of it was that it actually took a couple different classes to
be faked so I had methods defining other classes to return and it became
quite cumersome. MagicMock just creates everything, well, magically, so
there's very little setup required ;-)

>> Well, the last time I was profiling code, it turned out that generator
>> expressions were actually significantly slower than list comprehensions
>> (though admittedly this was in py2.7, I'm not sure if it's changed since
>> then). Generators have the benefit of not using up a lot of RAM, but they do
>> so by invoking python functions repeatedly, which is a well known way to do
>> things very slowly (function invocation is known to have a high overhead). So
>> here you just have to choose whether you want to be wasteful of CPU time, or
>> of RAM. On my 8GB machine I typically prefer to waste RAM ;-)
>
> Okay, I'm seriously intrigued, so I timed the two alternatives in both 2.7 and
> 3.2. os.environ is just a convenient mapping, but I had to convert it to a
> regular dictionary in the setup, otherwise the different implementations of
> os.environ cloud the results.
>
> % python3 -m timeit -s "import os; d = dict(); d.update(os.environ)" "dict([(key, value) for (key, value) in d.items()])"
> 100000 loops, best of 3: 10.4 usec per loop
>
> % python3 -m timeit -s "import os; d = dict(); d.update(os.environ)" "dict((key, value) for (key, value) in d.items())"
> 100000 loops, best of 3: 10.1 usec per loop

Ok, here's a mind-fuck for you ;-)

$ python3 -m timeit "tuple(x for x in range(100))"
100000 loops, best of 3: 9.37 usec per loop
$ python3 -m timeit "tuple([x for x in range(100)])"
100000 loops, best of 3: 5.94 usec per loop

$ python3 -m timeit "dict((x, x) for x in range(100))"
100000 loops, best of 3: 15.9 usec per loop
$ python3 -m timeit "dict([(x, x) for x in range(100)])"
100000 loops, best of 3: 15.1 usec per loop

I don't know why list comprehensions are 2x faster for tuple creation
but not dict creation, when I tested in the past I was only interested
in tuple creation. I assume the dict instantiation is doing something so
slow that it dwarfs the relative slowness of the generator. Here's
another python curiosity:

$ python3 -m timeit "[1,2,3]"
10000000 loops, best of 3: 0.0979 usec per loop
$ python3 -m timeit "(1,2,3)"
10000000 loops, best of 3: 0.024 usec per loop

Due to the above results I spent a lot of time converting my
performance-critical code to use tuples instead of lists, except that
those "slow" list brackets ended up being faster when I needed to do a
comprehension to generate my tuples ;-)

Don't get me started on performance tuning! I can do this all night!! :-P

Revision history for this message
Barry Warsaw (barry) wrote :

Thanks. I finally merged this, although I did make the tests more granular.

Revision history for this message
Barry Warsaw (barry) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'gwibber/gwibber/tests/test_account.py'
2--- gwibber/gwibber/tests/test_account.py 1970-01-01 00:00:00 +0000
3+++ gwibber/gwibber/tests/test_account.py 2012-08-28 22:32:19 +0000
4@@ -0,0 +1,97 @@
5+# Copyright (C) 2012 Canonical Ltd
6+#
7+# This program is free software: you can redistribute it and/or modify
8+# it under the terms of the GNU General Public License version 2 as
9+# published by the Free Software Foundation.
10+#
11+# This program is distributed in the hope that it will be useful,
12+# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+# GNU General Public License for more details.
15+#
16+# You should have received a copy of the GNU General Public License
17+# along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
19+"""Test the Account class."""
20+
21+__all__ = [
22+ 'TestAccount',
23+ ]
24+
25+
26+import unittest
27+
28+from threading import Lock
29+
30+from gwibber.utils.account import Account
31+
32+try:
33+ # Python 3.3
34+ from unittest import mock
35+except ImportError:
36+ import mock
37+
38+
39+class TestAccount(unittest.TestCase):
40+ """Test Account class."""
41+
42+ def test_basic_api(self):
43+ fake_account_service = mock.MagicMock()
44+ fake_account_service.get_account().get_settings_iter.return_value = iter([(True, 'key', 'val')])
45+ fake_account_service.get_account().id = 'fake_id'
46+ fake_account_service.get_service().get_name.return_value = 'fake_service'
47+
48+ account = Account(fake_account_service)
49+ fake_account_service.get_auth_data.assert_called_once_with()
50+
51+ fake_account_service.connect.assert_called_once_with('changed',
52+ account.on_account_changed, fake_account_service.get_account())
53+
54+ account['name'] = 'Fred'
55+ self.assertEqual(account['name'], 'Fred')
56+
57+ self.assertTrue('auth' in account)
58+ self.assertIsInstance(account['auth'], dict)
59+ self.assertEqual(len(account['auth']), 4)
60+
61+ self.assertTrue('service' in account)
62+
63+ self.assertTrue('id' in account)
64+ self.assertEqual(account['id'], 'fake_id/fake_service')
65+ self.assertEqual(account.global_id, 'fake_id')
66+
67+ self.assertFalse('foobar' in account)
68+ with self.assertRaises(KeyError):
69+ account['foobar']
70+
71+ self.assertTrue(account.login_lock.acquire())
72+
73+ fake_account_service.get_enabled.return_value = True
74+ self.assertTrue(account.enabled())
75+ fake_account_service.get_enabled.return_value = False
76+ self.assertFalse(account.enabled())
77+
78+ def test_account_changed(self):
79+ fake_account_service = mock.MagicMock()
80+ fake_account_service.get_account().get_settings_iter.return_value = iter(
81+ [(True, 'key', 'val'), (True, 'setting', 'foobar')])
82+
83+ account = Account(fake_account_service)
84+ self.assertEqual(account['key'], 'val')
85+ self.assertEqual(account['setting'], 'foobar')
86+
87+ fake_account_service.get_account().get_settings_iter.return_value = iter(
88+ [(True, 'new', 'stuff'), (True, 'gets', 'added'),
89+ (False, 'not_gwibber/wrong', 'stuff is ignored')])
90+
91+ # I know this method has a goofy signature, but it's a signal callback,
92+ # so it never actually gets called like this. It was necessary here
93+ # because the MagicMock isn't magical enough to emit signals for us...
94+ account.on_account_changed(
95+ account.account_service,
96+ account.account_service.get_account())
97+
98+ self.assertEqual(account['new'], 'stuff')
99+ self.assertEqual(account['gets'], 'added')
100+ self.assertFalse('wrong' in account)
101+
102
103=== added file 'gwibber/gwibber/utils/account.py'
104--- gwibber/gwibber/utils/account.py 1970-01-01 00:00:00 +0000
105+++ gwibber/gwibber/utils/account.py 2012-08-28 22:32:19 +0000
106@@ -0,0 +1,57 @@
107+# Copyright (C) 2012 Canonical Ltd
108+#
109+# This program is free software: you can redistribute it and/or modify
110+# it under the terms of the GNU General Public License version 2 as
111+# published by the Free Software Foundation.
112+#
113+# This program is distributed in the hope that it will be useful,
114+# but WITHOUT ANY WARRANTY; without even the implied warranty of
115+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
116+# GNU General Public License for more details.
117+#
118+# You should have received a copy of the GNU General Public License
119+# along with this program. If not, see <http://www.gnu.org/licenses/>.
120+
121+
122+from threading import Lock
123+
124+
125+class Account(dict):
126+ def __init__(self, account_service):
127+ self.account_service = account_service
128+
129+ auth_data = account_service.get_auth_data()
130+ self['auth'] = dict(
131+ id = auth_data.get_credentials_id(),
132+ method = auth_data.get_method(),
133+ mechanism = auth_data.get_mechanism(),
134+ parameters = auth_data.get_parameters(),
135+ )
136+
137+ account = account_service.get_account()
138+ self.global_id = account.id
139+ service_name = account_service.get_service().get_name()
140+ self['id'] = '{}/{}'.format(self.global_id, service_name)
141+
142+ # The provider name in libaccounts should match the name of our plugin.
143+ self['service'] = account.get_provider_name()
144+
145+ account_service.connect('changed', self.on_account_changed, account)
146+ self.on_account_changed(account_service, account)
147+
148+ # This is used to prevent multiple simultaneous login attempts.
149+ self.login_lock = Lock()
150+
151+ def on_account_changed(self, account_service, account):
152+ self.update(
153+ [(key, value)
154+ for (success, key, value)
155+ in account.get_settings_iter('gwibber/')
156+ if success])
157+
158+ def enabled(self):
159+ return self.account_service.get_enabled()
160+
161+ def __eq__(self, other):
162+ return self.account_service == other.account_service
163+
164
165=== modified file 'gwibber/microblog/dispatcher.py'
166--- gwibber/microblog/dispatcher.py 2012-08-27 19:39:58 +0000
167+++ gwibber/microblog/dispatcher.py 2012-08-28 22:32:19 +0000
168@@ -138,6 +138,7 @@
169 logger.debug("%s Finished operation (%s)" % (logtext, str(self.t_runtime)))
170
171
172+<<<<<<< TREE
173 class Account(collections.MutableMapping):
174 def __init__(self, account_service):
175 self._account_service = account_service
176@@ -204,6 +205,8 @@
177 return iter(self._dict.items())
178
179
180+=======
181+>>>>>>> MERGE-SOURCE
182 class OperationCollector:
183 def __init__(self, dispatcher):
184 self.dispatcher = dispatcher
185@@ -476,7 +479,7 @@
186 def on_account_deleted(self, account_manager, account_id):
187 # Delete streams associated with the user that was deleted
188 for account in self._accounts:
189- if account.global_id() != account_id: continue
190+ if account.global_id != account_id: continue
191 logger.debug("Deleting account %s" % account["id"])
192 self._accounts.remove(account)
193 try:

Subscribers

People subscribed via source and target branches