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

Proposed by Robert Bruce Park
Status: Merged
Merged at revision: 1433
Proposed branch: lp:~robru/gwibber/dee
Merge into: lp:~barry/gwibber/py3
Diff against target: 2162 lines (+327/-1550)
21 files modified
gwibber/gwibber/main.py (+3/-13)
gwibber/gwibber/service/dispatcher.py (+2/-2)
gwibber/gwibber/service/messages.py (+0/-47)
gwibber/gwibber/service/searches.py (+0/-87)
gwibber/gwibber/service/streams.py (+0/-93)
gwibber/gwibber/storage/messages.py (+0/-172)
gwibber/gwibber/storage/searches.py (+0/-63)
gwibber/gwibber/storage/streams.py (+0/-68)
gwibber/gwibber/testing/com.Gwibber.Messages.service.in (+0/-3)
gwibber/gwibber/testing/com.Gwibber.Searches.service.in (+0/-3)
gwibber/gwibber/testing/com.Gwibber.Streams.service.in (+0/-3)
gwibber/gwibber/testing/dbus.py (+0/-3)
gwibber/gwibber/testing/service.py (+1/-48)
gwibber/gwibber/tests/test_account.py (+5/-12)
gwibber/gwibber/tests/test_dbus.py (+0/-237)
gwibber/gwibber/tests/test_model.py (+39/-0)
gwibber/gwibber/tests/test_protocols.py (+95/-0)
gwibber/gwibber/tests/test_storage.py (+0/-688)
gwibber/gwibber/utils/account.py (+7/-6)
gwibber/gwibber/utils/base.py (+66/-2)
gwibber/gwibber/utils/model.py (+109/-0)
To merge this branch: bzr merge lp:~robru/gwibber/dee
Reviewer Review Type Date Requested Status
Ken VanDine Pending
Barry Warsaw Pending
Review via email: mp+124827@code.launchpad.net

Description of the change

Implement a Dee.SharedModel with a libgwibber-compatible schema that we can use for passing messages to libgwibber for display, with tests.

Note: Before I wrote this code, I had two terminals open running two interactive python sessions, and I was toying around with Dee.SharedModel. I was not actually able to succeed in getting the two different models to share their data... they never synced up and adding a row from one terminal never resulted in the 'row-added' signal being emitted on the other terminal. None of the documentaton I was able to find offered any help. I was thinking there might need to be some kind of 'flush' method to indicate that the data was ready to be sent over dbus, but I couldn't find any such thing.

So, with that utter failure in mind, I set out to write the simplest possible API that would allow our plugins to "publish" the sanitized json into the model, and this is what I came up with. It works internally, but there is probably something missing before libgwibber is actually able to access this over dbus. Gotta check with kenvandine about this before you approve the merge.

The idea behind my API choice is that all protocol actions will call something like "self._publish(key='word', args=True)" at the end, instead of returning any values. When converting the protocols to use this API, it should be straightforward to mock out _publish with something like "lambda **kwargs: kwargs" just to be able to check the results easily.

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

Fix up a copy&paste error. Oops ;-)

1435. By Robert Bruce Park

Stop hard-coding the number of columns in case the schema changes later.

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

Also, is Dee threadsafe? Because if it isn't, then we're going to have to give it a threading.Lock instance so that no two threads try to publish at the same time.

lp:~robru/gwibber/dee updated
1436. By Robert Bruce Park

Rename our SharedModel bus name from com.Gwibber.SharedModel to com.Gwibber.Streams.

This name is more semantically meaningful, rather than being named
after an implementation detail.

1437. By Robert Bruce Park

By my estimation, this is all the code that is obsoleted by Dee.SharedModel.

1438. By Robert Bruce Park

Test that we can get the message back from the SharedModel, too.

1439. By Robert Bruce Park

Schema fixup, plus working de-duplication logic.

The de-dup logic is modelled very closely on the way libgwibber does
it in Vala, but now we're doing it right at the source before
libgwibber even gets there. Three cheers for efficiency!

1440. By Robert Bruce Park

Tweak the return value logic in Base._publish.

Also stop hard-coding column index numbers.

1441. By Robert Bruce Park

Add a publish_lock to avoid race conditions.

We are checking for duplicates first, and then appending new rows later,
which creates a race condition in between (eg, some other thread can easily
insert a duplicate row after we find that there are no duplicates present, but
before we get around to appending our own row).

This makes duplicate checking / row appends atomic.

1442. By Robert Bruce Park

Looks like I didn't rip out enough old crufty stuff last time.

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

I'm going to review the latest and greatest version of the branch.

First of all, this is fantastic! You've done a great job at removing tons of
crufty, legacy, mysterious, horrible old code and replaced it with just a
little bit of good, new, readable code. This is a huge win. Just to show you
how big, here's the diffstat:

 service/messages.py | 47 --
 service/searches.py | 87 ---
 service/streams.py | 93 ----
 storage/messages.py | 172 -------
 storage/searches.py | 63 --
 storage/streams.py | 68 ---
 testing/com.Gwibber.Messages.service.in | 3
 testing/com.Gwibber.Searches.service.in | 3
 testing/com.Gwibber.Streams.service.in | 3
 testing/dbus.py | 3
 tests/test_model.py | 39 +
 tests/test_protocols.py | 37 +
 tests/test_storage.py | 688 -------------------------------
 utils/base.py | 57 ++
 utils/model.py | 100 ++++
 15 files changed, 231 insertions(+), 1232 deletions(-)

You gotta love it when you remove 5x more code than you add, *and* make it so
much more understandable.

One thing I noticed was lots of skipped tests when running under `make
check_all` or a virtualenv. As we discussed on IRC, you're going to fix
those, and I'll review what you've got so far.

I'll skip the deletions and any uncontroversial new code.

=== modified file 'gwibber/gwibber/tests/test_protocols.py'
--- gwibber/gwibber/tests/test_protocols.py 2012-09-13 17:50:35 +0000
+++ gwibber/gwibber/tests/test_protocols.py 2012-09-18 20:37:18 +0000
> @@ -27,6 +27,7 @@
> from gwibber.protocols.twitter import Twitter
> from gwibber.utils.base import Base
> from gwibber.utils.manager import ProtocolManager
> +from gwibber.utils.model import SharedModel
>
>
> class TestProtocolManager(unittest.TestCase):
> @@ -126,6 +127,42 @@
> thread.join()
> self.assertEqual(my_protocol.result, 'one:two')
>
> + def test_publish(self):
> + base = Base(None)
> + with self.assertRaises(TypeError):
> + base._publish(invalid_argument='not good')

I don't think you need the context manager version of this, since you're not
checking anything about the raised exception. You could possibly check
details about the exception, e.g. the bad column name (more on this later),
but if not, then I think this would be fine:

       self.assertRaises(TypeError, base._publish, invalid_argument='not good')

One other thing we discussed on IRC is that these tests are not well isolated
from the environment. Because these are not running inside TestDBus, they
actually pollute the user's session. Other than the problem that running the
test would show bogus data in the Gwibber ui, the other problem is that the
model doesn't get reset after every test, so I bet it persists between test
runs. Which means it's not clear this is actually testing what you think it's
testing.

The solution is to move all the _publish() tests to TestDBus, since that
ensures it's ru...

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

On 12-09-18 05:16 PM, Barry Warsaw wrote:
> First of all, this is fantastic! You've done a great job at removing tons of
> crufty, legacy, mysterious, horrible old code and replaced it with just a
> little bit of good, new, readable code. This is a huge win.

Thanks. I can't take all the credit though... somebody had to write all
this terrible code in the first place! Lots of low-hanging fruit on this
project ;-)

> Just to show you how big, here's the diffstat:
[snip]
> 15 files changed, 231 insertions(+), 1232 deletions(-)

This is actually an old diffstat! The final total was actually 1550
lines deleted ;-)

> You gotta love it when you remove 5x more code than you add, *and* make it so
> much more understandable.

Heh. It was 10x more, but then I expanded the tests... ;-)

> One thing I noticed was lots of skipped tests when running under `make
> check_all` or a virtualenv. As we discussed on IRC, you're going to fix
> those, and I'll review what you've got so far.

Yep, that's already fixed and pushed.

>> + def test_publish(self):
>> + base = Base(None)
>> + with self.assertRaises(TypeError):
>> + base._publish(invalid_argument='not good')
>
> I don't think you need the context manager version of this, since you're not
> checking anything about the raised exception. You could possibly check
> details about the exception, e.g. the bad column name (more on this later),
> but if not, then I think this would be fine:
>
> self.assertRaises(TypeError, base._publish, invalid_argument='not good')

Fair. I had forgotten about your proposed syntax and was thinking that
the with statement was the only way to test for exceptions. You can
change this if you feel strongly about it, but I'm indifferent on this
point.

> One other thing we discussed on IRC is that these tests are not well isolated
> from the environment. Because these are not running inside TestDBus, they
> actually pollute the user's session. Other than the problem that running the
> test would show bogus data in the Gwibber ui, the other problem is that the
> model doesn't get reset after every test, so I bet it persists between test
> runs. Which means it's not clear this is actually testing what you think it's
> testing.

Yes, I know. It's not clear to me exactly how this will fit into
TestDBus though, because SharedModel is not like our other DBus classes
where we are defining the classes ourselves and have a lot of control
over them. I have no idea if SharedModel will respect the custom
sessionbus that TestDBus creates, or how to ensure that that is
happening. I was hoping you'd offer a bit more guidance in this aspect.

Also, on some level we need to understand that we don't need to test
SharedModel itself, but just the code that we use to interface with it.
To that end, we might almost be better off leaving it outside of
TestDBus, and simply create a new SharedModel that uses a different bus
name, the mock that in place of the 'real' sharedmodel. At least as far
as testing Base._publish goes, anyway.

I'll play with this some more tonight I suppose.

> The solution is to move all the _publish() tests to TestDBus, sinc...

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

On 12-09-18 05:16 PM, Barry Warsaw wrote:
> First of all, this is fantastic! You've done a great job at removing tons of
> crufty, legacy, mysterious, horrible old code and replaced it with just a
> little bit of good, new, readable code. This is a huge win.

Thanks. I can't take all the credit though... somebody had to write all
this terrible code in the first place! Lots of low-hanging fruit on this
project ;-)

> Just to show you how big, here's the diffstat:
[snip]
> 15 files changed, 231 insertions(+), 1232 deletions(-)

This is actually an old diffstat! The final total was actually 1550
lines deleted ;-)

> You gotta love it when you remove 5x more code than you add, *and* make it so
> much more understandable.

Heh. It was 10x more, but then I expanded the tests... ;-)

> One thing I noticed was lots of skipped tests when running under `make
> check_all` or a virtualenv. As we discussed on IRC, you're going to fix
> those, and I'll review what you've got so far.

Yep, that's already fixed and pushed.

>> + def test_publish(self):
>> + base = Base(None)
>> + with self.assertRaises(TypeError):
>> + base._publish(invalid_argument='not good')
>
> I don't think you need the context manager version of this, since you're not
> checking anything about the raised exception. You could possibly check
> details about the exception, e.g. the bad column name (more on this later),
> but if not, then I think this would be fine:
>
> self.assertRaises(TypeError, base._publish, invalid_argument='not good')

Fair. I had forgotten about your proposed syntax and was thinking that
the with statement was the only way to test for exceptions. You can
change this if you feel strongly about it, but I'm indifferent on this
point.

> One other thing we discussed on IRC is that these tests are not well isolated
> from the environment. Because these are not running inside TestDBus, they
> actually pollute the user's session. Other than the problem that running the
> test would show bogus data in the Gwibber ui, the other problem is that the
> model doesn't get reset after every test, so I bet it persists between test
> runs. Which means it's not clear this is actually testing what you think it's
> testing.

Yes, I know. It's not clear to me exactly how this will fit into
TestDBus though, because SharedModel is not like our other DBus classes
where we are defining the classes ourselves and have a lot of control
over them. I have no idea if SharedModel will respect the custom
sessionbus that TestDBus creates, or how to ensure that that is
happening. I was hoping you'd offer a bit more guidance in this aspect.

Also, on some level we need to understand that we don't need to test
SharedModel itself, but just the code that we use to interface with it.
To that end, we might almost be better off leaving it outside of
TestDBus, and simply create a new SharedModel that uses a different bus
name, the mock that in place of the 'real' sharedmodel. At least as far
as testing Base._publish goes, anyway.

I'll play with this some more tonight I suppose.

> The solution is to move all the _publish() tests to TestDBus, sinc...

lp:~robru/gwibber/dee updated
1443. By Robert Bruce Park

Part one of cleanup based on barry's review.

a) Stop iterating over the SharedModel, because that's stupidly slow
in practise.

b) cache the output of strip(), making the performance of _publish
O(1) instead of O(n)

1444. By Robert Bruce Park

Part two of the cleanup based on barry's review.

a) SharedModel is now mocked in the tests so that we are not dumping
   test data into the live SharedModel.

b) Each test is broken down into more bitesize units for easier
   understanding when something goes wrong.

1445. By Robert Bruce Park

Avoid regex in order to improve readability.

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

On Sep 18, 2012, at 11:48 PM, Robert Bruce Park wrote:

>This is actually an old diffstat! The final total was actually 1550
>lines deleted ;-)

Happy happy, joy joy!

>> I don't think you need the context manager version of this, since you're not
>> checking anything about the raised exception. You could possibly check
>> details about the exception, e.g. the bad column name (more on this later),
>> but if not, then I think this would be fine:
>>
>> self.assertRaises(TypeError, base._publish, invalid_argument='not good')
>
>Fair. I had forgotten about your proposed syntax and was thinking that
>the with statement was the only way to test for exceptions. You can
>change this if you feel strongly about it, but I'm indifferent on this
>point.

Right, the context manager is really useful if you want to make additional
assertions about the exception that was raised.

For example, let's say you raised some Gwibber-specific exception, and you
stored the set of invalid arguments on an attribute of that exception. You
could add an assertion that the attribute had the set of strings you expected
it to have, and you'd have to use the context manager syntax to do that. I'm
not suggesting this as a change, just illustrating an example (which I know
you understand, just for anyone else following along).

>> One other thing we discussed on IRC is that these tests are not well
>> isolated from the environment. Because these are not running inside
>> TestDBus, they actually pollute the user's session. Other than the problem
>> that running the test would show bogus data in the Gwibber ui, the other
>> problem is that the model doesn't get reset after every test, so I bet it
>> persists between test runs. Which means it's not clear this is actually
>> testing what you think it's testing.
>
>Yes, I know. It's not clear to me exactly how this will fit into
>TestDBus though, because SharedModel is not like our other DBus classes
>where we are defining the classes ourselves and have a lot of control
>over them. I have no idea if SharedModel will respect the custom
>sessionbus that TestDBus creates, or how to ensure that that is
>happening. I was hoping you'd offer a bit more guidance in this aspect.

I don't know a lot about SharedModel yet, but Ken mentioned on IRC that using
a temporary session bus will isolate the model changes. This should be fairly
easy to verify, though the minimal documentation I've found on Dee doesn't
really say. I'd be more worried about any caching polluting the user's
environment. We'll just have to hack on this to make sure.

>Also, on some level we need to understand that we don't need to test
>SharedModel itself, but just the code that we use to interface with it.
>To that end, we might almost be better off leaving it outside of
>TestDBus, and simply create a new SharedModel that uses a different bus
>name, the mock that in place of the 'real' sharedmodel. At least as far
>as testing Base._publish goes, anyway.

It probably does make sense to have two types of tests, one for outside-dbus
which mocks the SharedModel and one inside-dbus which uses the real shared
model on a temporary bus (which TestDBus...

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

On 12-09-19 10:52 AM, Barry Warsaw wrote:
>> Yes, I know. It's not clear to me exactly how this will fit into
>> TestDBus though, because SharedModel is not like our other DBus classes
>> where we are defining the classes ourselves and have a lot of control
>> over them. I have no idea if SharedModel will respect the custom
>> sessionbus that TestDBus creates, or how to ensure that that is
>> happening. I was hoping you'd offer a bit more guidance in this aspect.
>
> I don't know a lot about SharedModel yet, but Ken mentioned on IRC that using
> a temporary session bus will isolate the model changes. This should be fairly
> easy to verify, though the minimal documentation I've found on Dee doesn't
> really say. I'd be more worried about any caching polluting the user's
> environment. We'll just have to hack on this to make sure.

The testsuite, as it currently stands in the mp, does not pollute the
users environment. The tests that test Base._publish are using a mocked
SharedModel using a different bus name, and there's even a test that
confirms the user's environ is not polluted.

The other test, that tests SharedModel more directly, does operate on
the user's live environment, but it's a read-only test that doesn't do
any polluting. It basically just tests that the right schema is in
place, which is probably good enough, because anything more than that
would become a test against Dee, rather than a test against Gwibber.

>> Is it done in such a way that importing SharedModel will force it to use the
>> custom bus and not the real bus?
>
> Only in the gwibber-service executed by dbus-daemon described above.
>
>> Because SharedModel handles it's own dbus logic internally, I don't see any
>> way to tell SharedModel 'hey, use this other bus instead' unless your
>> TestDBus magic is somehow affecting the dbus module itself in a
>> process-global way.
>
> Yep, it is. `M-x man RET dbus-daemon RET` will provide additional
> information. Essentially, it's dbus-daemon that's doing all the magic that
> the dbus library needs to run gwibber-service on an isolated session bus.

Ok, so importing our SharedModel instance in main.py would result in an
isolated, testable SharedModel. good to know.

> We can keep the regexp.

Funny, because I actually did replace it with a string comprehension,
and the readability did improve incredibly. My performance concerns were
mitigated by a) no longer iterating over the sharedmodel, calling
strip() each time, and b) caching the results of strip(), such that it's
only necessary to call it once for each new message addition. That
effectively upgrades it from O(n) to O(1), which means the slower
implementation won't really be noticable.

>> It turns out that concatenating the sender's name and message text
>> *only* is the correct approach for fuzzy equivalence testing ;-)
>
> No, because it's just that strip() or whatever would pull out row[sender] and
> row[message] and do the concatenation itself. It just moves that logic into
> the method that calculates the unique key from the row data.

Oh, now i see. You can make that change if you insist ;-)

>>> Also, is there no more efficient way of asking ...

Read more...

lp:~robru/gwibber/dee updated
1446. By Robert Bruce Park

Rename some things to indicate that they are private members.

1447. By Robert Bruce Park

Add a helpful comment.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'gwibber/gwibber/main.py'
2--- gwibber/gwibber/main.py 2012-09-13 18:04:03 +0000
3+++ gwibber/gwibber/main.py 2012-09-19 17:22:19 +0000
4@@ -31,10 +31,7 @@
5
6 from gwibber.service.connection import ConnectionMonitor
7 from gwibber.service.dispatcher import Dispatcher
8-from gwibber.service.messages import MessageManager
9-from gwibber.service.searches import SearchManager
10 from gwibber.service.shortener import URLShorten
11-from gwibber.service.streams import StreamManager
12 from gwibber.testing.service import TestService
13 from gwibber.utils.logging import initialize
14 from gwibber.utils.menus import MenuManager
15@@ -66,11 +63,7 @@
16 debug=args.debug or gsettings.get_boolean('debug'))
17 log = logging.getLogger('gwibber.service')
18 log.info('Gwibber backend service starting')
19- # Where is the SQLite database?
20- sqlite_file = (DEFAULT_SQLITE_FILE
21- if args.database is None
22- else args.database)
23- database = sqlite3.connect(sqlite_file)
24+
25 # Set up the DBus main loop.
26 DBusGMainLoop(set_as_default=True)
27 loop = GLib.MainLoop()
28@@ -82,15 +75,12 @@
29 # about unused local variables.
30 class services:
31 connection = ConnectionMonitor()
32- streams = StreamManager(database)
33- dispatcher = Dispatcher(loop, streams, refresh_interval)
34+ dispatcher = Dispatcher(loop, refresh_interval)
35 menus = MenuManager(dispatcher.Refresh, loop.quit)
36- messages = MessageManager(database)
37- searches = SearchManager(database)
38 shorten = URLShorten(gsettings)
39
40 if args.test:
41- services.test = TestService(database, services.connection)
42+ services.test = TestService(services.connection)
43 try:
44 log.info('Starting gwibber-service main loop')
45 loop.run()
46
47=== modified file 'gwibber/gwibber/service/dispatcher.py'
48--- gwibber/gwibber/service/dispatcher.py 2012-09-13 18:04:03 +0000
49+++ gwibber/gwibber/service/dispatcher.py 2012-09-19 17:22:19 +0000
50@@ -40,13 +40,13 @@
51 """This is the primary handler of dbus method calls."""
52 __dbus_object_path__ = '/com/gwibber/Service'
53
54- def __init__(self, mainloop, streams, interval):
55+ def __init__(self, mainloop, interval):
56 self.bus = dbus.SessionBus()
57 bus_name = dbus.service.BusName('com.Gwibber.Service', bus=self.bus)
58 super().__init__(bus_name, self.__dbus_object_path__)
59 self.mainloop = mainloop
60 self._interval = interval
61- self.account_manager = AccountManager(self.Refresh, streams)
62+ self.account_manager = AccountManager(self.Refresh)
63 self._timer_id = None
64 signaler.add_signal('ConnectionOnline', self._on_connection_online)
65 signaler.add_signal('ConnectionOffline', self._on_connection_offline)
66
67=== removed file 'gwibber/gwibber/service/messages.py'
68--- gwibber/gwibber/service/messages.py 2012-08-21 14:51:01 +0000
69+++ gwibber/gwibber/service/messages.py 1970-01-01 00:00:00 +0000
70@@ -1,47 +0,0 @@
71-# Copyright (C) 2012 Canonical Ltd
72-#
73-# This program is free software: you can redistribute it and/or modify
74-# it under the terms of the GNU General Public License version 2 as
75-# published by the Free Software Foundation.
76-#
77-# This program is distributed in the hope that it will be useful,
78-# but WITHOUT ANY WARRANTY; without even the implied warranty of
79-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
80-# GNU General Public License for more details.
81-#
82-# You should have received a copy of the GNU General Public License
83-# along with this program. If not, see <http://www.gnu.org/licenses/>.
84-
85-"""DBus service object for message manager."""
86-
87-__all__ = [
88- 'MessageManager',
89- ]
90-
91-
92-import dbus
93-import dbus.service
94-
95-from gwibber.storage.messages import MessageStorage
96-
97-
98-class MessageManager(dbus.service.Object):
99- __dbus_object_path__ = '/com/gwibber/Messages'
100-
101- def __init__(self, db):
102- self.bus = dbus.SessionBus()
103- bus_name = dbus.service.BusName('com.Gwibber.Messages', bus=self.bus)
104- super().__init__(bus_name, self.__dbus_object_path__)
105- self.db = db
106- # Initialize the table.
107- MessageStorage(self.db)
108-
109- @dbus.service.signal('com.Gwibber.Messages', signature='ss')
110- def Message(self, change, data):
111- pass
112-
113- @dbus.service.method('com.Gwibber.Messages',
114- in_signature='s',
115- out_signature='s')
116- def Get(self, id):
117- return MessageStorage(self.db).get_data(id)
118
119=== removed file 'gwibber/gwibber/service/searches.py'
120--- gwibber/gwibber/service/searches.py 2012-08-21 22:42:24 +0000
121+++ gwibber/gwibber/service/searches.py 1970-01-01 00:00:00 +0000
122@@ -1,87 +0,0 @@
123-# Copyright (C) 2012 Canonical Ltd
124-#
125-# This program is free software: you can redistribute it and/or modify
126-# it under the terms of the GNU General Public License version 2 as
127-# published by the Free Software Foundation.
128-#
129-# This program is distributed in the hope that it will be useful,
130-# but WITHOUT ANY WARRANTY; without even the implied warranty of
131-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
132-# GNU General Public License for more details.
133-#
134-# You should have received a copy of the GNU General Public License
135-# along with this program. If not, see <http://www.gnu.org/licenses/>.
136-
137-"""DBus service object for searches."""
138-
139-__all__ = [
140- 'SearchManager',
141- ]
142-
143-
144-import json
145-import uuid
146-import logging
147-
148-import dbus
149-import dbus.service
150-
151-from gwibber.storage.messages import MessageStorage
152-from gwibber.storage.searches import SearchStorage
153-
154-
155-log = logging.getLogger('gwibber.service')
156-
157-
158-class SearchManager(dbus.service.Object):
159- __dbus_object_path__ = '/com/gwibber/Searches'
160-
161- def __init__(self, db):
162- self.bus = dbus.SessionBus()
163- bus_name = dbus.service.BusName('com.Gwibber.Searches', bus=self.bus)
164- super().__init__(bus_name, self.__dbus_object_path__)
165- self.db = db
166- # Initialize the table.
167- SearchStorage(self.db)
168-
169- @dbus.service.signal('com.Gwibber.Searches', signature='s')
170- def Updated(self, data):
171- log.debug('Search Changed: {}'.format(data))
172-
173- @dbus.service.signal('com.Gwibber.Searches', signature='s')
174- def Deleted(self, data):
175- log.debug('Search Deleted: {}'.format(data))
176-
177- @dbus.service.signal('com.Gwibber.Searches', signature='s')
178- def Created(self, data):
179- log.debug('Search Created: {}'.format(data))
180-
181- @dbus.service.method('com.Gwibber.Searches',
182- in_signature='s',
183- out_signature='s')
184- def Get(self, id):
185- return SearchStorage(self.db).get_data(id)
186-
187- @dbus.service.method('com.Gwibber.Searches', out_signature='s')
188- def List(self):
189- return SearchStorage(self.db).list_all_data()
190-
191- @dbus.service.method('com.Gwibber.Searches',
192- in_signature='s',
193- out_signature='s')
194- def Create(self, search):
195- data = json.loads(search)
196- id = data['id'] = uuid.uuid1().hex
197- encoded = json.dumps(data)
198- SearchStorage(self.db).add(**data)
199- self.Created(encoded)
200- return id
201-
202- @dbus.service.method('com.Gwibber.Searches', in_signature='s')
203- def Delete(self, id):
204- data = self.Get(id)
205- if data:
206- with self.db:
207- SearchStorage(self.db).remove(id)
208- MessageStorage(self.db).remove_transient(id)
209- self.Deleted(data)
210
211=== removed file 'gwibber/gwibber/service/streams.py'
212--- gwibber/gwibber/service/streams.py 2012-08-17 22:30:54 +0000
213+++ gwibber/gwibber/service/streams.py 1970-01-01 00:00:00 +0000
214@@ -1,93 +0,0 @@
215-# Copyright (C) 2012 Canonical Ltd
216-#
217-# This program is free software: you can redistribute it and/or modify
218-# it under the terms of the GNU General Public License version 2 as
219-# published by the Free Software Foundation.
220-#
221-# This program is distributed in the hope that it will be useful,
222-# but WITHOUT ANY WARRANTY; without even the implied warranty of
223-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
224-# GNU General Public License for more details.
225-#
226-# You should have received a copy of the GNU General Public License
227-# along with this program. If not, see <http://www.gnu.org/licenses/>.
228-
229-"""DBus service object for the streams manager."""
230-
231-__all__ = [
232- 'StreamManager',
233- ]
234-
235-
236-import json
237-import uuid
238-import logging
239-
240-import dbus
241-import dbus.service
242-
243-from gwibber.storage.messages import MessageStorage
244-from gwibber.storage.streams import StreamStorage
245-
246-
247-log = logging.getLogger('gwibber.service')
248-
249-
250-class StreamManager(dbus.service.Object):
251- __dbus_object_path__ = '/com/gwibber/Streams'
252-
253- def __init__(self, db):
254- self.bus = dbus.SessionBus()
255- bus_name = dbus.service.BusName('com.Gwibber.Streams', bus=self.bus)
256- super().__init__(bus_name, self.__dbus_object_path__)
257- self.db = db
258- # Initialize the table.
259- StreamStorage(self.db)
260-
261- @dbus.service.signal('com.Gwibber.Streams', signature='s')
262- def Updated(self, data):
263- log.debug('Stream Changed: {}'.format(data))
264-
265- @dbus.service.signal('com.Gwibber.Streams', signature='s')
266- def Deleted(self, data):
267- log.debug('Stream Deleted: {}'.format(data))
268-
269- @dbus.service.signal('com.Gwibber.Streams', signature='s')
270- def Created(self, data):
271- log.debug('Stream Created: {}'.format(data))
272-
273- @dbus.service.method('com.Gwibber.Streams',
274- in_signature='s',
275- out_signature='s')
276- def Get(self, id):
277- return StreamStorage(self.db).get_data(id)
278-
279- @dbus.service.method('com.Gwibber.Streams', out_signature='s')
280- def List(self):
281- return StreamStorage(self.db).list_all_data()
282-
283- @dbus.service.method('com.Gwibber.Streams',
284- in_signature='ssdssssd',
285- out_signature='s')
286- def Messages(self, stream, account, time, transient, recipient,
287- orderby, order, limit):
288- return MessageStorage(self.db).get_messages(
289- stream, account, time, transient, recipient, orderby, order, limit)
290-
291- @dbus.service.method('com.Gwibber.Streams',
292- in_signature='s',
293- out_signature='s')
294- def Create(self, search):
295- data = json.loads(search)
296- id = data['id'] = uuid.uuid1().hex
297- encoded = json.dumps(data)
298- StreamStorage(self.db).add(**data)
299- self.Created(encoded)
300- return id
301-
302- @dbus.service.method('com.Gwibber.Streams', in_signature='s')
303- def Delete(self, id):
304- data = self.Get(id)
305- if data:
306- StreamStorage(self.db).remove(id)
307- self.Deleted(data)
308
309=== removed directory 'gwibber/gwibber/storage'
310=== removed file 'gwibber/gwibber/storage/__init__.py'
311=== removed file 'gwibber/gwibber/storage/messages.py'
312--- gwibber/gwibber/storage/messages.py 2012-08-21 22:42:24 +0000
313+++ gwibber/gwibber/storage/messages.py 1970-01-01 00:00:00 +0000
314@@ -1,172 +0,0 @@
315-# Copyright (C) 2012 Canonical Ltd
316-#
317-# This program is free software: you can redistribute it and/or modify
318-# it under the terms of the GNU General Public License version 2 as
319-# published by the Free Software Foundation.
320-#
321-# This program is distributed in the hope that it will be useful,
322-# but WITHOUT ANY WARRANTY; without even the implied warranty of
323-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
324-# GNU General Public License for more details.
325-#
326-# You should have received a copy of the GNU General Public License
327-# along with this program. If not, see <http://www.gnu.org/licenses/>.
328-
329-"""Message storage."""
330-
331-__all__ = [
332- 'MessageStorage',
333- ]
334-
335-
336-import logging
337-
338-log = logging.getLogger('gwibber.service')
339-
340-
341-COMMASPACE = ', '
342-MAX_COUNT = 2000
343-
344-
345-class MessageStorage:
346- """Message storage."""
347-
348- def __init__(self, db):
349- self.db = db
350- # Create the table if it does not yet exist.
351- if not self.db.execute('PRAGMA table_info(messages)').fetchall():
352- with self.db:
353- self.db.execute("""
354- CREATE TABLE messages (
355- id text,
356- mid text,
357- account text,
358- service text,
359- operation text,
360- transient text,
361- stream text,
362- time integer,
363- text text,
364- from_me integer,
365- to_me integer,
366- sender text,
367- recipient text,
368- data text)
369- """)
370- self.db.execute("""
371- CREATE UNIQUE INDEX idx1 ON messages (
372- mid, account, operation, transient)
373- """)
374- self.db.execute("""
375- CREATE INDEX IF NOT EXISTS idx2 on messages (
376- stream, time, transient)
377- """)
378-
379- def get_data(self, id):
380- results = self.db.execute(
381- 'SELECT data FROM messages WHERE id = ?', (id,)).fetchone()
382- if results:
383- return results[0]
384- return ''
385-
386- def get_messages(self, stream, account, time, transient, recipient,
387- orderby, order, limit):
388- # The home stream is an alias for all streams in all accounts.
389- if stream == 'home':
390- stream = 'all'
391- account = 'all'
392-
393- with self.db:
394- if stream == 'sent' and account != 'all':
395- # Get all messages I've sent from a specific account.
396- where = """
397- WHERE from_me = 1 AND account = ?
398- AND time >= ? AND transient = ?
399- """
400- args = account, time, transient
401- elif stream == 'sent' and account == 'all':
402- # Get all messages I've sent from any account.
403- where = 'WHERE from_me = 1 AND time >= ? AND transient = ?'
404- args = time, transient
405- elif transient != '0' and account != 'all' and recipient != '0':
406- where = """
407- WHERE time >= ? AND transient = ?
408- OR (time >= ? AND account = ? AND recipient = ?)
409- """
410- args = time, transient, time, account, recipient
411- elif stream != 'all' and account != 'all':
412- where = """
413- WHERE stream = ? AND account = ? AND time >= ?
414- AND transient = ?
415- """
416- args = stream, account, time, transient
417- elif stream == 'all' and account != 'all':
418- where = 'WHERE account = ? AND time >= ? AND transient = ?'
419- args = account, time, transient
420- elif stream != 'all' and account == 'all':
421- where = 'WHERE stream = ? AND time >= ? AND transient = ?'
422- args = stream, time, transient
423- else:
424- where = 'WHERE time >= ? AND transient = ?'
425- args = time, transient
426- query = 'SELECT data FROM messages '
427- query += where
428- query += ' ORDER BY {} {}'.format(orderby, order)
429- if limit > 0:
430- query += ' LIMIT {:d}'.format(limit)
431- results = self.db.execute(query, args)
432- # Collating happens outside of the database transaction.
433- return '[{}]'.format(COMMASPACE.join(row[0] for row in results))
434-
435- def maintenance(self, account_manager):
436- log.info('DB maintenance: cleaning up messages table...')
437- accounts = self.db.execute(
438- 'SELECT distinct(account) FROM messages').fetchall()
439- for account in accounts:
440- # Adjust for the query returning a tuple.
441- account = account[0]
442- # Does the account exist?
443- global_id, slash, rest = account.partition('/')
444- if slash == '':
445- # There is no global id, thus the account does not exist.
446- exists = False
447- else:
448- exists = account_manager.get_account(global_id)
449- if not exists:
450- log.info('DB maintenance: Removing data for '
451- 'unknown account: {}'.format(account))
452- self.db.execute('DELETE FROM messages WHERE account = ?',
453- (account,))
454- count = self.db.execute("""
455- SELECT count(data) FROM messages
456- WHERE operation = 'receive'
457- AND stream = 'messages'
458- AND account = ?
459- AND time != 0
460- """, (account,)).fetchone()[0]
461- log.info('DB maintenance: found {} records in messages '
462- 'stream for account {}'.format(count, account))
463- log.info('DB maintenance: purging old data for {}'.format(
464- account))
465- delete_limit = count - MAX_COUNT
466- if delete_limit > 0:
467- self.db.execute("""
468- DELETE FROM messages
469- WHERE account = ?
470- AND operation = 'receive'
471- AND stream = 'messages'
472- AND time IN (
473- SELECT CAST (time AS int) FROM (
474- SELECT time FROM messages
475- WHERE account = ?
476- AND operation = 'receive'
477- AND stream = 'messages'
478- AND time != 0 ORDER BY time ASC LIMIT ?)
479- ORDER BY time ASC)
480- """, (account, account, delete_limit))
481- self.db.execute('VACUUM')
482-
483- def remove_transient(self, transient_id):
484- with self.db:
485- self.db.execute('DELETE FROM messages WHERE transient = ?',
486- (transient_id,))
487
488=== removed file 'gwibber/gwibber/storage/searches.py'
489--- gwibber/gwibber/storage/searches.py 2012-08-21 21:35:08 +0000
490+++ gwibber/gwibber/storage/searches.py 1970-01-01 00:00:00 +0000
491@@ -1,63 +0,0 @@
492-# Copyright (C) 2012 Canonical Ltd
493-#
494-# This program is free software: you can redistribute it and/or modify
495-# it under the terms of the GNU General Public License version 2 as
496-# published by the Free Software Foundation.
497-#
498-# This program is distributed in the hope that it will be useful,
499-# but WITHOUT ANY WARRANTY; without even the implied warranty of
500-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
501-# GNU General Public License for more details.
502-#
503-# You should have received a copy of the GNU General Public License
504-# along with this program. If not, see <http://www.gnu.org/licenses/>.
505-
506-"""Search storage."""
507-
508-__all__ = [
509- 'SearchStorage',
510- ]
511-
512-
513-COMMASPACE = ', '
514-
515-
516-class SearchStorage:
517- """Search storage."""
518-
519- def __init__(self, db):
520- self.db = db
521- # Create the table if it does not yet exist.
522- if not self.db.execute('PRAGMA table_info(searches)').fetchall():
523- with self.db:
524- self.db.execute("""
525- CREATE TABLE searches (
526- id text,
527- name text,
528- query text,
529- data text)
530- """)
531-
532- def get_data(self, id):
533- """Get the row's data."""
534- results = self.db.execute('SELECT data FROM searches WHERE id = ?',
535- (id,)).fetchone()
536- if results:
537- return results[0]
538- return ''
539-
540- def list_all_data(self):
541- """Get a string listing all data in the table."""
542- results = self.db.execute('SELECT data FROM searches')
543- return '[{}]'.format(COMMASPACE.join(row[0] for row in results))
544-
545- def add(self, id, name, query, data):
546- """Add a row to the table."""
547- with self.db:
548- self.db.execute('INSERT INTO searches VALUES (?, ?, ?, ?)',
549- (id, name, query, data))
550-
551- def remove(self, id):
552- """Remove a row from the table."""
553- with self.db:
554- self.db.execute('DELETE FROM searches WHERE id = ?', (id,))
555
556=== removed file 'gwibber/gwibber/storage/streams.py'
557--- gwibber/gwibber/storage/streams.py 2012-08-17 22:30:54 +0000
558+++ gwibber/gwibber/storage/streams.py 1970-01-01 00:00:00 +0000
559@@ -1,68 +0,0 @@
560-# Copyright (C) 2012 Canonical Ltd
561-#
562-# This program is free software: you can redistribute it and/or modify
563-# it under the terms of the GNU General Public License version 2 as
564-# published by the Free Software Foundation.
565-#
566-# This program is distributed in the hope that it will be useful,
567-# but WITHOUT ANY WARRANTY; without even the implied warranty of
568-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
569-# GNU General Public License for more details.
570-#
571-# You should have received a copy of the GNU General Public License
572-# along with this program. If not, see <http://www.gnu.org/licenses/>.
573-
574-"""Streams storage."""
575-
576-__all__ = [
577- 'StreamStorage',
578- ]
579-
580-
581-COMMASPACE = ', '
582-
583-
584-class StreamStorage:
585- """Stream storage."""
586-
587- def __init__(self, db):
588- self.db = db
589- # Create the table if it does not yet exist.
590- if not self.db.execute('PRAGMA table_info(streams)').fetchall():
591- with self.db:
592- self.db.execute("""
593- CREATE TABLE streams (
594- id text,
595- name text,
596- account text,
597- operation text,
598- data text)
599- """)
600-
601- def get_data(self, id):
602- """Get the row's data."""
603- with self.db:
604- results = self.db.execute(
605- 'SELECT data FROM streams WHERE id = ?',
606- (id,)).fetchone()
607- if results:
608- return results[0]
609- return ''
610-
611- def list_all_data(self):
612- """Get a string listing all data in the table."""
613- with self.db:
614- results = self.db.execute('SELECT data FROM streams')
615- return '[{}]'.format(
616- COMMASPACE.join(row[0] for row in results.fetchall()))
617-
618- def add(self, id, name, account, operation, data):
619- """Add a row to the table."""
620- with self.db:
621- self.db.execute('INSERT INTO streams VALUES (?, ?, ?, ?, ?)',
622- (id, name, account, operation, data))
623-
624- def remove(self, id):
625- """Remove a row from the table."""
626- with self.db:
627- self.db.execute('DELETE FROM streams WHERE id = ?', (id,))
628
629=== removed file 'gwibber/gwibber/testing/com.Gwibber.Messages.service.in'
630--- gwibber/gwibber/testing/com.Gwibber.Messages.service.in 2012-08-21 14:51:01 +0000
631+++ gwibber/gwibber/testing/com.Gwibber.Messages.service.in 1970-01-01 00:00:00 +0000
632@@ -1,3 +0,0 @@
633-[D-BUS Service]
634-Name=com.Gwibber.Messages
635-Exec={BINDIR}/gwibber-service --database {TMPDIR}/gwibber.db --test
636
637=== removed file 'gwibber/gwibber/testing/com.Gwibber.Searches.service.in'
638--- gwibber/gwibber/testing/com.Gwibber.Searches.service.in 2012-08-21 21:05:20 +0000
639+++ gwibber/gwibber/testing/com.Gwibber.Searches.service.in 1970-01-01 00:00:00 +0000
640@@ -1,3 +0,0 @@
641-[D-BUS Service]
642-Name=com.Gwibber.Searches
643-Exec={BINDIR}/gwibber-service --database {TMPDIR}/gwibber.db --test
644
645=== removed file 'gwibber/gwibber/testing/com.Gwibber.Streams.service.in'
646--- gwibber/gwibber/testing/com.Gwibber.Streams.service.in 2012-08-21 14:51:01 +0000
647+++ gwibber/gwibber/testing/com.Gwibber.Streams.service.in 1970-01-01 00:00:00 +0000
648@@ -1,3 +0,0 @@
649-[D-BUS Service]
650-Name=com.Gwibber.Streams
651-Exec={BINDIR}/gwibber-service --database {TMPDIR}/gwibber.db --test
652
653=== modified file 'gwibber/gwibber/testing/dbus.py'
654--- gwibber/gwibber/testing/dbus.py 2012-09-11 22:32:44 +0000
655+++ gwibber/gwibber/testing/dbus.py 2012-09-19 17:22:19 +0000
656@@ -49,10 +49,7 @@
657
658 SERVICES = [
659 'com.Gwibber.Connection',
660- 'com.Gwibber.Messages',
661- 'com.Gwibber.Searches',
662 'com.Gwibber.Service',
663- 'com.Gwibber.Streams',
664 'com.Gwibber.Test',
665 'com.Gwibber.URLShorten',
666 ]
667
668=== modified file 'gwibber/gwibber/testing/service.py'
669--- gwibber/gwibber/testing/service.py 2012-09-07 15:20:41 +0000
670+++ gwibber/gwibber/testing/service.py 2012-09-19 17:22:19 +0000
671@@ -35,11 +35,10 @@
672 class TestService(dbus.service.Object):
673 __dbus_object_path__ = '/com/gwibber/Test'
674
675- def __init__(self, db, monitor):
676+ def __init__(self, monitor):
677 self.bus = dbus.SessionBus()
678 bus_name = dbus.service.BusName('com.Gwibber.Test', bus=self.bus)
679 super().__init__(bus_name, self.__dbus_object_path__)
680- self.db = db
681 self.id = 0
682 self.connection_monitor = monitor
683 # Connect signals
684@@ -51,52 +50,6 @@
685 signaler.add_signal('ConnectionOnline', online_callback)
686 signaler.add_signal('ConnectionOffline', offline_callback)
687
688- @dbus.service.method('com.Gwibber.Test',
689- in_signature='s',
690- out_signature='s')
691- def InsertDataIntoMessages(self, data):
692- # Initialize the database if not already created.
693- MessageStorage(self.db)
694- stored_id = str(self.id)
695- self.db.execute("""
696- INSERT INTO messages VALUES
697- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
698- """, (stored_id, stored_id, '', '', '', '', '', 0, '', 0, 0, '', '',
699- data))
700- self.id += 1
701- return stored_id
702-
703- @dbus.service.method('com.Gwibber.Test',
704- in_signature='sss',
705- out_signature='s')
706- def InsertSearchTransientData(self, name, query, data):
707- # Set things up to test the com.Gwibber.Searches.Delete() method.
708- # Start by initializing the tables.
709- MessageStorage(self.db)
710- SearchStorage(self.db)
711- stored_id = str(self.id)
712- self.db.execute("""
713- INSERT INTO searches VALUES (?, ?, ?, ?)
714- """, (stored_id, name, query, data))
715- # The key thing about adding a row to messages is that the
716- # messages.transient column must be equal to the searches.id column.
717- # It's kind of like a foreign reference.
718- self.db.execute("""
719- INSERT INTO messages VALUES
720- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
721- """, (stored_id, stored_id, '', '', '',
722- stored_id, '', 0, '', 0, 0, '', '', data))
723- self.id += 1
724- return stored_id
725-
726- @dbus.service.method('com.Gwibber.Test', in_signature='s')
727- def DeleteDataFromMessages(self, id):
728- # Initialize the database if not already created.
729- MessageStorage(self.db)
730- self.db.execute("""
731- DELETE FROM messages WHERE id = ?
732- """, id)
733-
734 @dbus.service.method('com.Gwibber.Test', out_signature='i')
735 def SignalTestOn(self):
736 self.connection_monitor.ConnectionOnline()
737
738=== modified file 'gwibber/gwibber/tests/test_account.py'
739--- gwibber/gwibber/tests/test_account.py 2012-09-13 17:01:59 +0000
740+++ gwibber/gwibber/tests/test_account.py 2012-09-19 17:22:19 +0000
741@@ -171,11 +171,6 @@
742 """Test the AccountManager API."""
743
744 def setUp(self):
745- self.stream = mock.Mock()
746- self.stream.List.return_value = json.dumps(
747- [dict(account='faker/than fake',
748- id='foo'),
749- ])
750 self.account_service = mock.Mock()
751
752 def test_account_manager(self):
753@@ -183,14 +178,14 @@
754 # internal mapping.
755 def refresh():
756 pass
757- AccountManager(refresh, self.stream)
758+ AccountManager(refresh)
759
760 def test_account_manager_add_new_account(self):
761 # Explicitly adding a new account puts the account's global_id into
762 # the account manager's mapping.
763 def refresh():
764 pass
765- manager = AccountManager(refresh, self.stream)
766+ manager = AccountManager(refresh)
767 manager.add_new_account(self.account_service)
768 self.assertIn('fake global id', manager._accounts)
769
770@@ -201,7 +196,7 @@
771 nonlocal refreshed
772 refreshed = True
773 accounts_manager.get_account().list_services.return_value = []
774- manager = AccountManager(refresh, self.stream)
775+ manager = AccountManager(refresh)
776 manager._on_enabled_event(accounts_manager, 'fake global id')
777 self.assertTrue(refreshed)
778
779@@ -213,7 +208,7 @@
780 def refresh():
781 nonlocal refreshed
782 refreshed = True
783- manager = AccountManager(refresh, self.stream)
784+ manager = AccountManager(refresh)
785 self.assertNotIn('fake global id', manager._accounts)
786 manager._on_account_deleted(accounts_manager, 'fake global id')
787 self.assertNotIn('fake global id', manager._accounts)
788@@ -226,11 +221,9 @@
789 def refresh():
790 nonlocal refreshed
791 refreshed = True
792- manager = AccountManager(refresh, self.stream)
793+ manager = AccountManager(refresh)
794 manager.add_new_account(self.account_service)
795 # Now manager._accounts has a 'fake global id' key.
796 manager._on_account_deleted(accounts_manager, 'fake global id')
797- self.stream.List.assert_called_once_with()
798- self.stream.Delete.assert_called_once_with('foo')
799 self.assertNotIn('fake global id', manager._accounts)
800 self.assertTrue(refreshed)
801
802=== modified file 'gwibber/gwibber/tests/test_dbus.py'
803--- gwibber/gwibber/tests/test_dbus.py 2012-09-12 22:15:43 +0000
804+++ gwibber/gwibber/tests/test_dbus.py 2012-09-19 17:22:19 +0000
805@@ -67,24 +67,6 @@
806
807 def setUp(self):
808 self.session_bus = dbus.SessionBus()
809- # We have to make sure to remove any rows that the individual tests
810- # may have added, otherwise there are order dependencies.
811- self.stream_ids = []
812- self.search_ids = []
813-
814- def tearDown(self):
815- obj = self.session_bus.get_object(
816- 'com.Gwibber.Streams',
817- '/com/gwibber/Streams')
818- iface = dbus.Interface(obj, 'com.Gwibber.Streams')
819- for id in self.stream_ids:
820- iface.Delete(id)
821- obj = self.session_bus.get_object(
822- 'com.Gwibber.Searches',
823- '/com/gwibber/Searches')
824- iface = dbus.Interface(obj, 'com.Gwibber.Searches')
825- for id in self.search_ids:
826- iface.Delete(id)
827
828 def test_shorten(self):
829 obj = self.session_bus.get_object(
830@@ -94,125 +76,6 @@
831 short_url = iface.Shorten('http://www.python.org')
832 self.assertIsInstance(short_url, dbus.String)
833
834- def test_streams_list_empty(self):
835- obj = self.session_bus.get_object(
836- 'com.Gwibber.Streams',
837- '/com/gwibber/Streams')
838- iface = dbus.Interface(obj, 'com.Gwibber.Streams')
839- self.assertEqual(iface.List(), '[]')
840-
841- def test_streams_create(self):
842- obj = self.session_bus.get_object(
843- 'com.Gwibber.Streams',
844- '/com/gwibber/Streams')
845- iface = dbus.Interface(obj, 'com.Gwibber.Streams')
846- name = 'bob'
847- account = 'twitter'
848- operation = 'tweet'
849- data = 'payload'
850- search = json.dumps(dict(name=name, account=account,
851- operation=operation, data=data))
852- id = iface.Create(search)
853- self.stream_ids.append(id)
854- self.assertEqual(iface.List(), '[payload]')
855-
856- def test_streams_create_two(self):
857- obj = self.session_bus.get_object(
858- 'com.Gwibber.Streams',
859- '/com/gwibber/Streams')
860- iface = dbus.Interface(obj, 'com.Gwibber.Streams')
861- name = 'bob'
862- account = 'twitter'
863- operation = 'tweet'
864- search = json.dumps(dict(name=name, account=account,
865- operation=operation, data='one'))
866- id = iface.Create(search)
867- self.stream_ids.append(id)
868- search = json.dumps(dict(name=name, account=account,
869- operation=operation, data='two'))
870- id = iface.Create(search)
871- self.stream_ids.append(id)
872- self.assertEqual(iface.List(), '[one, two]')
873-
874- def test_streams_delete(self):
875- obj = self.session_bus.get_object(
876- 'com.Gwibber.Streams',
877- '/com/gwibber/Streams')
878- iface = dbus.Interface(obj, 'com.Gwibber.Streams')
879- name = 'bob'
880- account = 'twitter'
881- operation = 'tweet'
882- data = 'payload'
883- search = json.dumps(dict(name=name, account=account,
884- operation=operation, data=data))
885- id = iface.Create(search)
886- self.assertEqual(iface.List(), '[payload]')
887- iface.Delete(id)
888- self.assertEqual(iface.List(), '[]')
889-
890- def test_streams_get(self):
891- obj = self.session_bus.get_object(
892- 'com.Gwibber.Streams',
893- '/com/gwibber/Streams')
894- iface = dbus.Interface(obj, 'com.Gwibber.Streams')
895- name = 'bob'
896- account = 'twitter'
897- operation = 'tweet'
898- data = 'getme'
899- search = json.dumps(dict(name=name, account=account,
900- operation=operation, data=data))
901- id = iface.Create(search)
902- self.assertEqual(iface.Get(id), 'getme')
903- self.stream_ids.append(id)
904-
905- # XXX Need tests for the Updated(), Deleted(), and Created() signals in
906- # com.Gwibber.Streams.
907-
908- def test_messages_get(self):
909- # First, insert some data into the messages table. We do this through
910- # the dbus testing interface so that we don't have to worry about
911- # SQLite lock contention.
912- obj = self.session_bus.get_object(
913- 'com.Gwibber.Test',
914- '/com/gwibber/Test')
915- test_iface = dbus.Interface(obj, 'com.Gwibber.Test')
916- stored_ids = {}
917- stored_ids[test_iface.InsertDataIntoMessages('one')] = 'one'
918- stored_ids[test_iface.InsertDataIntoMessages('two')] = 'two'
919- # Now get the data back through the non-testing API.
920- obj = self.session_bus.get_object(
921- 'com.Gwibber.Messages',
922- '/com/gwibber/Messages')
923- iface = dbus.Interface(obj, 'com.Gwibber.Messages')
924- for key, value in stored_ids.items():
925- self.assertEqual(iface.Get(key), value)
926- # Clean up.
927- for id in stored_ids:
928- test_iface.DeleteDataFromMessages(id)
929-
930- def test_messages_get_missing(self):
931- # First, insert some data into the messages table. We do this through
932- # the dbus testing interface so that we don't have to worry about
933- # SQLite lock contention.
934- obj = self.session_bus.get_object(
935- 'com.Gwibber.Test',
936- '/com/gwibber/Test')
937- test_iface = dbus.Interface(obj, 'com.Gwibber.Test')
938- stored_ids = []
939- stored_ids.append(test_iface.InsertDataIntoMessages('one'))
940- stored_ids.append(test_iface.InsertDataIntoMessages('two'))
941- # Now get the data back through the non-testing API.
942- obj = self.session_bus.get_object(
943- 'com.Gwibber.Messages',
944- '/com/gwibber/Messages')
945- iface = dbus.Interface(obj, 'com.Gwibber.Messages')
946- self.assertEqual(iface.Get('9999'), '')
947- # Clean up.
948- for id in stored_ids:
949- test_iface.DeleteDataFromMessages(id)
950-
951- # XXX Need test for the Message() signal in com.Gwibber.Messages.
952-
953 def test_connection_is_connected(self):
954 # Test whether the network manager is connected or not. This uses the
955 # standard system service, and we can't guarantee its state will be
956@@ -242,106 +105,6 @@
957 # tests for various failure modes in ConnectionMonitor.__init__() and
958 # .isConnected().
959
960- def test_searches_list_empty(self):
961- obj = self.session_bus.get_object(
962- 'com.Gwibber.Searches',
963- '/com/gwibber/Searches')
964- iface = dbus.Interface(obj, 'com.Gwibber.Searches')
965- self.assertEqual(iface.List(), '[]')
966-
967- def test_searches_create(self):
968- obj = self.session_bus.get_object(
969- 'com.Gwibber.Searches',
970- '/com/gwibber/Searches')
971- iface = dbus.Interface(obj, 'com.Gwibber.Searches')
972- name = 'bob'
973- query = 'myquery'
974- data = 'payload'
975- search = json.dumps(dict(name=name, query=query, data=data))
976- id = iface.Create(search)
977- self.search_ids.append(id)
978- self.assertEqual(iface.List(), '[payload]')
979-
980- def test_searches_create_two(self):
981- obj = self.session_bus.get_object(
982- 'com.Gwibber.Searches',
983- '/com/gwibber/Searches')
984- iface = dbus.Interface(obj, 'com.Gwibber.Searches')
985- name = 'bob'
986- query = 'myquery'
987- search = json.dumps(dict(name=name, query=query, data='one'))
988- id = iface.Create(search)
989- self.search_ids.append(id)
990- search = json.dumps(dict(name=name, query=query, data='two'))
991- id = iface.Create(search)
992- self.search_ids.append(id)
993- self.assertEqual(iface.List(), '[one, two]')
994-
995- def test_searches_delete(self):
996- obj = self.session_bus.get_object(
997- 'com.Gwibber.Searches',
998- '/com/gwibber/Searches')
999- iface = dbus.Interface(obj, 'com.Gwibber.Searches')
1000- name = 'bob'
1001- query = 'myquery'
1002- data = 'payload'
1003- search = json.dumps(dict(name=name, query=query, data=data))
1004- id = iface.Create(search)
1005- self.assertEqual(iface.List(), '[payload]')
1006- iface.Delete(id)
1007- self.assertEqual(iface.List(), '[]')
1008-
1009- def test_searches_delete_transients(self):
1010- # Deleting search data also deletes rows from the messages table where
1011- # the transients column matches the id in the storage table. Call
1012- # into the test service to set up the data for this.
1013- obj = self.session_bus.get_object(
1014- 'com.Gwibber.Test',
1015- '/com/gwibber/Test')
1016- test_iface = dbus.Interface(obj, 'com.Gwibber.Test')
1017- stored_id_1 = test_iface.InsertSearchTransientData(
1018- 'anne', 'query1', 'one')
1019- stored_id_2 = test_iface.InsertSearchTransientData(
1020- 'bart', 'query2', 'two')
1021- # Now there should be two rows in both tables.
1022- obj = self.session_bus.get_object(
1023- 'com.Gwibber.Searches',
1024- '/com/gwibber/Searches')
1025- searches_iface = dbus.Interface(obj, 'com.Gwibber.Searches')
1026- self.assertEqual(searches_iface.List(), '[one, two]')
1027- obj = self.session_bus.get_object(
1028- 'com.Gwibber.Messages',
1029- '/com/gwibber/Messages')
1030- messages_iface = dbus.Interface(obj, 'com.Gwibber.Messages')
1031- self.assertEqual(messages_iface.Get(stored_id_1), 'one')
1032- self.assertEqual(messages_iface.Get(stored_id_2), 'two')
1033- # Now, delete just the first id.
1034- searches_iface.Delete(stored_id_1)
1035- # And verify that only 'two' exists in both tables.
1036- self.assertEqual(searches_iface.List(), '[two]')
1037- self.assertEqual(messages_iface.Get(stored_id_1), '')
1038- self.assertEqual(messages_iface.Get(stored_id_2), 'two')
1039- # Deleting the second one should clean everything back up.
1040- searches_iface.Delete(stored_id_2)
1041- self.assertEqual(searches_iface.List(), '[]')
1042- self.assertEqual(messages_iface.Get(stored_id_1), '')
1043- self.assertEqual(messages_iface.Get(stored_id_2), '')
1044-
1045- def test_searches_get(self):
1046- obj = self.session_bus.get_object(
1047- 'com.Gwibber.Searches',
1048- '/com/gwibber/Searches')
1049- iface = dbus.Interface(obj, 'com.Gwibber.Searches')
1050- name = 'bob'
1051- query = 'myquery'
1052- data = 'getme'
1053- search = json.dumps(dict(name=name, query=query, data=data))
1054- id = iface.Create(search)
1055- self.assertEqual(iface.Get(id), 'getme')
1056- self.search_ids.append(id)
1057-
1058- # XXX Need tests for the Updated(), Deleted(), and Created() signals.
1059-
1060 def test_get_features(self):
1061 obj = self.session_bus.get_object(
1062 'com.Gwibber.Service',
1063
1064=== added file 'gwibber/gwibber/tests/test_model.py'
1065--- gwibber/gwibber/tests/test_model.py 1970-01-01 00:00:00 +0000
1066+++ gwibber/gwibber/tests/test_model.py 2012-09-19 17:22:19 +0000
1067@@ -0,0 +1,39 @@
1068+# Copyright (C) 2012 Canonical Ltd
1069+#
1070+# This program is free software: you can redistribute it and/or modify
1071+# it under the terms of the GNU General Public License version 2 as
1072+# published by the Free Software Foundation.
1073+#
1074+# This program is distributed in the hope that it will be useful,
1075+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1076+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1077+# GNU General Public License for more details.
1078+#
1079+# You should have received a copy of the GNU General Public License
1080+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1081+
1082+"""Test the Dee.SharedModel that we use for communicating with our frontend."""
1083+
1084+__all__ = [
1085+ 'TestSharedModel',
1086+ ]
1087+
1088+
1089+import unittest
1090+
1091+from gi.repository import Dee
1092+from gwibber.utils.model import SharedModel
1093+
1094+
1095+class TestSharedModel(unittest.TestCase):
1096+ """Test our Dee.SharedModel instance."""
1097+
1098+ def test_basic_properties(self):
1099+ self.assertIsInstance(SharedModel, Dee.SharedModel)
1100+ self.assertEqual(SharedModel.get_n_columns(), 39)
1101+ self.assertEqual(SharedModel.get_n_rows(), 0)
1102+ self.assertEqual(SharedModel.get_schema(),
1103+ ['aas', 's', 's', 's', 's', 'b', 's', 's', 's', 's',
1104+ 's', 's', 's', 's', 's', 's', 'd', 'b', 's', 's',
1105+ 's', 's', 's', 's', 's', 's', 's', 's', 's', 's',
1106+ 's', 's', 's', 's', 's', 's', 's', 's', 's'])
1107
1108=== modified file 'gwibber/gwibber/tests/test_protocols.py'
1109--- gwibber/gwibber/tests/test_protocols.py 2012-09-13 17:50:35 +0000
1110+++ gwibber/gwibber/tests/test_protocols.py 2012-09-19 17:22:19 +0000
1111@@ -23,10 +23,24 @@
1112 import unittest
1113 import threading
1114
1115+from gi.repository import Dee
1116+
1117 from gwibber.protocols.flickr import Flickr
1118 from gwibber.protocols.twitter import Twitter
1119 from gwibber.utils.base import Base
1120 from gwibber.utils.manager import ProtocolManager
1121+from gwibber.utils.model import SharedModel, COLUMN_TYPES
1122+
1123+
1124+try:
1125+ # Python 3.3
1126+ from unittest import mock
1127+except ImportError:
1128+ import mock
1129+
1130+
1131+TestModel = Dee.SharedModel.new('com.Gwibber.TestSharedModel')
1132+TestModel.set_schema_full(COLUMN_TYPES)
1133
1134
1135 class TestProtocolManager(unittest.TestCase):
1136@@ -73,6 +87,9 @@
1137 class TestProtocols(unittest.TestCase):
1138 """Test protocol implementations."""
1139
1140+ def setUp(self):
1141+ TestModel.clear()
1142+
1143 def test_protocol_info(self):
1144 # Each protocol carries with it a number of protocol variables. These
1145 # are initialized to None in the base class.
1146@@ -126,6 +143,84 @@
1147 thread.join()
1148 self.assertEqual(my_protocol.result, 'one:two')
1149
1150+ @mock.patch('gwibber.utils.base.SharedModel', TestModel)
1151+ def test_shared_model_successfully_mocked(self):
1152+ self.assertEqual(SharedModel.get_n_rows(), 0)
1153+ self.assertEqual(TestModel.get_n_rows(), 0)
1154+ base = Base(None)
1155+ base._publish('a', 'b', 'c', message='a',
1156+ from_me=True, likes=1, liked=True)
1157+ base._publish('a', 'b', 'c', message='b',
1158+ from_me=True, likes=1, liked=True)
1159+ base._publish('a', 'b', 'c', message='c',
1160+ from_me=True, likes=1, liked=True)
1161+ self.assertEqual(SharedModel.get_n_rows(), 0)
1162+ self.assertEqual(TestModel.get_n_rows(), 3)
1163+
1164+ @mock.patch('gwibber.utils.base.SharedModel', TestModel)
1165+ def test_invalid_argument(self):
1166+ base = Base(None)
1167+ self.assertEqual(0, TestModel.get_n_rows())
1168+ self.assertRaises(TypeError, base._publish, invalid_argument='not good')
1169+
1170+ @mock.patch('gwibber.utils.base.SharedModel', TestModel)
1171+ @mock.patch('gwibber.utils.base._seen_messages', {})
1172+ def test_one_message(self):
1173+ base = Base(None)
1174+ self.assertEqual(0, TestModel.get_n_rows())
1175+ self.assertTrue(base._publish(
1176+ service='facebook', account_id='1/facebook',
1177+ message_id='1234', stream='messages', sender='fred',
1178+ sender_nick='freddy', from_me=True, timestamp='today',
1179+ message='hello, @jimmy', likes=10, liked=True))
1180+ self.assertEqual(1, TestModel.get_n_rows())
1181+
1182+ @mock.patch('gwibber.utils.base.SharedModel', TestModel)
1183+ @mock.patch('gwibber.utils.base._seen_messages', {})
1184+ def test_duplicate_messages_identified(self):
1185+ base = Base(None)
1186+ self.assertEqual(0, TestModel.get_n_rows())
1187+ self.assertTrue(base._publish(
1188+ service='facebook', account_id='1/facebook',
1189+ message_id='1234', stream='messages', sender='fred',
1190+ sender_nick='freddy', from_me=True, timestamp='today',
1191+ message='hello, @jimmy', likes=10, liked=True))
1192+
1193+ self.assertTrue(base._publish(
1194+ service='twitter', account_id='2/twitter',
1195+ message_id='5678', stream='messages', sender='fred',
1196+ sender_nick='freddy', from_me=True, timestamp='today',
1197+ message='hello jimmy', likes=10, liked=False))
1198+
1199+ self.assertEqual(1, TestModel.get_n_rows())
1200+ self.assertEqual(TestModel.get_row(TestModel.get_first_iter())[7],
1201+ 'hello, @jimmy')
1202+ self.assertEqual(TestModel.get_row(TestModel.get_first_iter())[0],
1203+ [['facebook', '1/facebook', '1234'],
1204+ ['twitter', '2/twitter', '5678']])
1205+
1206+ @mock.patch('gwibber.utils.base.SharedModel', TestModel)
1207+ @mock.patch('gwibber.utils.base._seen_messages', {})
1208+ def test_similar_messages_allowed(self):
1209+ base = Base(None)
1210+ self.assertEqual(0, TestModel.get_n_rows())
1211+
1212+ self.assertTrue(base._publish(
1213+ service='facebook', account_id='1/facebook',
1214+ message_id='1234', stream='messages', sender='fred',
1215+ sender_nick='freddy', from_me=True, timestamp='today',
1216+ message='hello, @jimmy', likes=10, liked=True))
1217+
1218+ self.assertEqual(1, TestModel.get_n_rows())
1219+
1220+ self.assertTrue(base._publish(
1221+ service='facebook', account_id='1/facebook',
1222+ message_id='34567', stream='messages', sender='tedtholomew',
1223+ sender_nick='teddy', from_me=False, timestamp='today',
1224+ message='hello, @jimmy', likes=10, liked=True))
1225+
1226+ self.assertEqual(2, TestModel.get_n_rows())
1227+
1228 def test_basic_login(self):
1229 # Try to log in twice. The second login attempt returns False because
1230 # it's already logged in.
1231
1232=== removed file 'gwibber/gwibber/tests/test_storage.py'
1233--- gwibber/gwibber/tests/test_storage.py 2012-08-21 21:35:08 +0000
1234+++ gwibber/gwibber/tests/test_storage.py 1970-01-01 00:00:00 +0000
1235@@ -1,688 +0,0 @@
1236-# Copyright (C) 2012 Canonical Ltd
1237-#
1238-# This program is free software: you can redistribute it and/or modify
1239-# it under the terms of the GNU General Public License version 2 as
1240-# published by the Free Software Foundation.
1241-#
1242-# This program is distributed in the hope that it will be useful,
1243-# but WITHOUT ANY WARRANTY; without even the implied warranty of
1244-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1245-# GNU General Public License for more details.
1246-#
1247-# You should have received a copy of the GNU General Public License
1248-# along with this program. If not, see <http://www.gnu.org/licenses/>.
1249-
1250-"""Test stream storage."""
1251-
1252-__all__ = [
1253- 'TestMessageStorage',
1254- 'TestSearchStorage',
1255- 'TestStreamStorage',
1256- ]
1257-
1258-
1259-import sqlite3
1260-import unittest
1261-
1262-from gwibber.storage.messages import MessageStorage
1263-from gwibber.storage.searches import SearchStorage
1264-from gwibber.storage.streams import StreamStorage
1265-
1266-import gwibber.storage.messages
1267-
1268-try:
1269- # Python 3.3
1270- from unittest import mock
1271-except ImportError:
1272- import mock
1273-
1274-
1275-class TestStreamStorage(unittest.TestCase):
1276- """Test stream storage."""
1277-
1278- def setUp(self):
1279- self.db = sqlite3.connect(':memory:')
1280-
1281- def test_table_creation(self):
1282- rows = self.db.execute('PRAGMA table_info(streams)').fetchall()
1283- self.assertEqual(len(rows), 0)
1284- StreamStorage(self.db)
1285- rows = self.db.execute('PRAGMA table_info(streams)').fetchall()
1286- # 5 rows because there are 5 columns in the table.
1287- self.assertEqual(len(rows), 5)
1288-
1289- def test_table_already_exists(self):
1290- StreamStorage(self.db)
1291- StreamStorage(self.db)
1292- rows = self.db.execute('PRAGMA table_info(streams)').fetchall()
1293- self.assertEqual(len(rows), 5)
1294-
1295- def test_get_data(self):
1296- storage = StreamStorage(self.db)
1297- self.db.execute('INSERT INTO streams VALUES (?, ?, ?, ?, ?)',
1298- (1, 'myname', 'myaccount', 'operation', 'somedata'))
1299- data = storage.get_data(1)
1300- self.assertEqual(data, 'somedata')
1301-
1302- def test_get_data_missing(self):
1303- storage = StreamStorage(self.db)
1304- self.db.execute('INSERT INTO streams VALUES (?, ?, ?, ?, ?)',
1305- (1, 'myname', 'myaccount', 'operation', 'somedata'))
1306- data = storage.get_data(2)
1307- self.assertEqual(data, '')
1308-
1309- def test_list_all_data(self):
1310- storage = StreamStorage(self.db)
1311- with self.db:
1312- for sample_data in [
1313- (1, 'myname', 'myaccount', 'operation', 'somedata'),
1314- (2, 'myname', 'myaccount', 'operation', 'moredata'),
1315- (3, 'myname', 'myaccount', 'operation', 'anotherdata'),
1316- ]:
1317- self.db.execute(
1318- 'INSERT INTO streams VALUES (?, ?, ?, ?, ?)',
1319- sample_data)
1320- data = storage.list_all_data()
1321- self.assertEqual(data, '[somedata, moredata, anotherdata]')
1322-
1323- def test_add(self):
1324- storage = StreamStorage(self.db)
1325- storage.add(1, 'bob', 'ant', 'noop', 'data')
1326- rows = self.db.execute('SELECT * FROM streams').fetchall()
1327- self.assertEqual(len(rows), 1)
1328- self.assertEqual(rows[0], ('1', 'bob', 'ant', 'noop', 'data'))
1329-
1330- def test_remove(self):
1331- storage = StreamStorage(self.db)
1332- with self.db:
1333- for sample_data in [
1334- (1, 'myname', 'myaccount', 'operation', 'somedata'),
1335- (2, 'myname', 'myaccount', 'operation', 'moredata'),
1336- (3, 'myname', 'myaccount', 'operation', 'anotherdata'),
1337- ]:
1338- self.db.execute(
1339- 'INSERT INTO streams VALUES (?, ?, ?, ?, ?)',
1340- sample_data)
1341- storage.remove(2)
1342- rows = self.db.execute('SELECT * FROM streams').fetchall()
1343- self.assertEqual(len(rows), 2)
1344- self.assertEqual([row[0] for row in rows], ['1', '3'])
1345-
1346- def test_remove_missing(self):
1347- storage = StreamStorage(self.db)
1348- with self.db:
1349- for sample_data in [
1350- (1, 'myname', 'myaccount', 'operation', 'somedata'),
1351- (2, 'myname', 'myaccount', 'operation', 'moredata'),
1352- (3, 'myname', 'myaccount', 'operation', 'anotherdata'),
1353- ]:
1354- self.db.execute(
1355- 'INSERT INTO streams VALUES (?, ?, ?, ?, ?)',
1356- sample_data)
1357- storage.remove(5)
1358- rows = self.db.execute('SELECT * FROM streams').fetchall()
1359- self.assertEqual(len(rows), 3)
1360- self.assertEqual([row[0] for row in rows], ['1', '2', '3'])
1361-
1362-
1363-class TestMessageStorage(unittest.TestCase):
1364- """Test message storage."""
1365-
1366- def setUp(self):
1367- self.db = sqlite3.connect(':memory:')
1368-
1369- def test_table_creation(self):
1370- rows = self.db.execute('PRAGMA table_info(messages)').fetchall()
1371- self.assertEqual(len(rows), 0)
1372- MessageStorage(self.db)
1373- rows = self.db.execute('PRAGMA table_info(messages)').fetchall()
1374- # 14 rows because there are 14 columns in the table.
1375- self.assertEqual(len(rows), 14)
1376-
1377- def test_table_already_exists(self):
1378- MessageStorage(self.db)
1379- MessageStorage(self.db)
1380- rows = self.db.execute('PRAGMA table_info(messages)').fetchall()
1381- self.assertEqual(len(rows), 14)
1382-
1383- def test_get_messages_sent_not_all(self):
1384- # Test conditional arm 1.
1385- storage = MessageStorage(self.db)
1386- with self.db:
1387- for sample_data in [
1388- # id, mid, account, service, operation, transient
1389- # stream, time, text, from_me, to_me, sender, recipient, data
1390- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1391- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1392- (3, 3, 'ant', '', '', '0', '', 99, '', 1, 0, '', '', 'row3'),
1393- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1394- (5, 5, 'ant', '', '', '0', '', 100, '', 0, 0, '', '', 'row5'),
1395- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1396- ]:
1397- self.db.execute("""
1398- INSERT INTO messages VALUES
1399- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1400- """, sample_data)
1401- # This query should return rows 1 and 2 only. It will not return row
1402- # 3 because its time is < 100. It will not return row4 because its
1403- # transient != '0'. It will not return row5 because its from_me !=
1404- # 1. It will not return row6 because its account != 'ant'.
1405- answer = storage.get_messages(
1406- 'sent', 'ant', 100, '0', '', 'time', 'ASC', 0)
1407- self.assertEqual(answer, '[row1, row2]')
1408-
1409- def test_get_messages_sent_not_all_order_desc(self):
1410- # Test conditional arm 1 with descending order.
1411- storage = MessageStorage(self.db)
1412- with self.db:
1413- for sample_data in [
1414- # id, mid, account, service, operation, transient
1415- # stream, time, text, from_me, to_me, sender, recipient, data
1416- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1417- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1418- (3, 3, 'ant', '', '', '0', '', 99, '', 1, 0, '', '', 'row3'),
1419- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1420- (5, 5, 'ant', '', '', '0', '', 100, '', 0, 0, '', '', 'row5'),
1421- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1422- ]:
1423- self.db.execute("""
1424- INSERT INTO messages VALUES
1425- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1426- """, sample_data)
1427- # This query should return rows 1 and 2 only. It will not return row
1428- # 3 because its time is < 100. It will not return row4 because its
1429- # transient != '0'. It will not return row5 because its from_me !=
1430- # 1. It will not return row6 because its account != 'ant'.
1431- answer = storage.get_messages(
1432- 'sent', 'ant', 100, '0', '', 'time', 'DESC', 0)
1433- self.assertEqual(answer, '[row2, row1]')
1434-
1435- def test_get_messages_sent_not_all_with_limit(self):
1436- # Test conditional arm 1 with a limit.
1437- storage = MessageStorage(self.db)
1438- with self.db:
1439- for sample_data in [
1440- # id, mid, account, service, operation, transient
1441- # stream, time, text, from_me, to_me, sender, recipient, data
1442- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1443- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1444- (3, 3, 'ant', '', '', '0', '', 99, '', 1, 0, '', '', 'row3'),
1445- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1446- (5, 5, 'ant', '', '', '0', '', 100, '', 0, 0, '', '', 'row5'),
1447- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1448- ]:
1449- self.db.execute("""
1450- INSERT INTO messages VALUES
1451- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1452- """, sample_data)
1453- # This query should return row 1. It will not return row 3 because
1454- # its time is < 100. It will not return row4 because its transient !=
1455- # '0'. It will not return row5 because its from_me != 1. It will not
1456- # return row6 because its account != 'ant'.
1457- #
1458- # In addition to all that, we'll get just row 1 due to LIMIT 1.
1459- answer = storage.get_messages(
1460- 'sent', 'ant', 100, '0', '', 'time', 'ASC', 1)
1461- self.assertEqual(answer, '[row1]')
1462-
1463- def test_get_messages_sent_all(self):
1464- # Test conditional arm 2.
1465- storage = MessageStorage(self.db)
1466- with self.db:
1467- for sample_data in [
1468- # id, mid, account, service, operation, transient
1469- # stream, time, text, from_me, to_me, sender, recipient, data
1470- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1471- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1472- (3, 3, 'ant', '', '', '0', '', 99, '', 1, 0, '', '', 'row3'),
1473- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1474- (5, 5, 'ant', '', '', '0', '', 100, '', 0, 0, '', '', 'row5'),
1475- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1476- ]:
1477- self.db.execute("""
1478- INSERT INTO messages VALUES
1479- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1480- """, sample_data)
1481- # This query should return rows 1, 2, and 6. It will not return row 3
1482- # because its time is < 100. It will not return row4 because its
1483- # transient != '0'. It will not return row5 because its from_me != 1.
1484- answer = storage.get_messages(
1485- 'sent', 'all', 100, '0', '', 'data', 'ASC', 0)
1486- self.assertEqual(answer, '[row1, row2, row6]')
1487-
1488- def test_get_messages_not_transient_not_all_not_recipient(self):
1489- # Test conditional arm 3, both legs of the OR clause.
1490- storage = MessageStorage(self.db)
1491- with self.db:
1492- for sample_data in [
1493- # id, mid, account, service, operation, transient
1494- # stream, time, text, from_me, to_me, sender, recipient, data
1495- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1496- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1497- (3, 3, 'ant', '', '', '1', '', 99, '', 1, 0, '', '', 'row3'),
1498- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1499- (5, 5, 'ant', '', '', '0', '', 100, '', 0, 0, '', '', 'row5'),
1500- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1501- (7, 7, 'twitter', '', '', '0', '', 100, '', 1, 0, '', 'bob',
1502- 'row7'),
1503- ]:
1504- self.db.execute("""
1505- INSERT INTO messages VALUES
1506- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1507- """, sample_data)
1508- # This query should return rows 4 and 7. It will not return row3
1509- # because its time is < 100. It will not return any other rows
1510- # because their transients are not '1' or their recipients are not
1511- # 'bob'.
1512- answer = storage.get_messages(
1513- 'stream', 'twitter', 100, '1', 'bob', 'data', 'ASC', 0)
1514- self.assertEqual(answer, '[row4, row7]')
1515-
1516- def test_get_messages_not_all_not_all(self):
1517- # Test conditional arm 4.
1518- storage = MessageStorage(self.db)
1519- with self.db:
1520- for sample_data in [
1521- # id, mid, account, service, operation, transient,
1522- # stream, time, text, from_me, to_me, sender, recipient, data
1523- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1524- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1525- (3, 3, 'ant', '', '', '1', '', 99, '', 1, 0, '', '', 'row3'),
1526- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1527- (5, 5, 'ant', '', '', '0', 'zee', 100, '', 0, 0, '', '',
1528- 'row5'),
1529- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1530- ]:
1531- self.db.execute("""
1532- INSERT INTO messages VALUES
1533- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1534- """, sample_data)
1535- # This query should return row 5. That is the only row where the
1536- # stream, account, time, and transient match the query.
1537- answer = storage.get_messages(
1538- 'zee', 'ant', 100, '0', '0', 'data', 'ASC', 0)
1539- self.assertEqual(answer, '[row5]')
1540-
1541- def test_get_messages_all_not_all(self):
1542- # Test conditional arm 5.
1543- storage = MessageStorage(self.db)
1544- with self.db:
1545- for sample_data in [
1546- # id, mid, account, service, operation, transient,
1547- # stream, time, text, from_me, to_me, sender, recipient, data
1548- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1549- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1550- (3, 3, 'ant', '', '', '1', '', 99, '', 1, 0, '', '', 'row3'),
1551- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1552- (5, 5, 'ant', '', '', '0', 'zee', 100, '', 0, 0, '', '',
1553- 'row5'),
1554- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1555- ]:
1556- self.db.execute("""
1557- INSERT INTO messages VALUES
1558- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1559- """, sample_data)
1560- # This query should return rows 1, 2 and 5. All three rows match
1561- # account ant, as well as time and transient. Row 3 does not match
1562- # the time, row 4 does not match the transient, row 6 does not match
1563- # the account.
1564- answer = storage.get_messages(
1565- 'all', 'ant', 100, '0', '0', 'data', 'ASC', 0)
1566- self.assertEqual(answer, '[row1, row2, row5]')
1567-
1568- def test_get_messages_not_all_all(self):
1569- # Test conditional arm 6.
1570- storage = MessageStorage(self.db)
1571- with self.db:
1572- for sample_data in [
1573- # id, mid, account, service, operation, transient,
1574- # stream, time, text, from_me, to_me, sender, recipient, data
1575- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1576- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1577- (3, 3, 'ant', '', '', '1', '', 99, '', 1, 0, '', '', 'row3'),
1578- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1579- (5, 5, 'ant', '', '', '0', 'zee', 100, '', 0, 0, '', '',
1580- 'row5'),
1581- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1582- ]:
1583- self.db.execute("""
1584- INSERT INTO messages VALUES
1585- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1586- """, sample_data)
1587- # This query should return row 5. No other row has a matching stream.
1588- answer = storage.get_messages(
1589- 'zee', 'all', 100, '0', '0', 'data', 'ASC', 0)
1590- self.assertEqual(answer, '[row5]')
1591-
1592- def test_get_messages_else(self):
1593- # Test conditional arm 7.
1594- storage = MessageStorage(self.db)
1595- with self.db:
1596- for sample_data in [
1597- # id, mid, account, service, operation, transient,
1598- # stream, time, text, from_me, to_me, sender, recipient, data
1599- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1600- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1601- (3, 3, 'ant', '', '', '1', '', 99, '', 1, 0, '', '', 'row3'),
1602- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1603- (5, 5, 'ant', '', '', '0', 'zee', 100, '', 0, 0, '', '',
1604- 'row5'),
1605- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1606- (7, 7, 'bee', '', '', '0', '', 99, '', 1, 0, '', '', 'row7'),
1607- ]:
1608- self.db.execute("""
1609- INSERT INTO messages VALUES
1610- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1611- """, sample_data)
1612- # This query should return rows 1, 2, 5, and 6. Those rows have a
1613- # matching time and transient. Rows 3 and 4 don't match transient,
1614- # and row 7 doesn't match time.
1615- answer = storage.get_messages(
1616- 'all', 'all', 100, '0', '0', 'data', 'ASC', 0)
1617- self.assertEqual(answer, '[row1, row2, row5, row6]')
1618-
1619- def test_get_messages_home(self):
1620- # Test conditional arm 7, but by using the alias of stream == 'home'
1621- # to mean stream == 'all' and account == 'all. IOW, the account value
1622- # is ignored.
1623- storage = MessageStorage(self.db)
1624- with self.db:
1625- for sample_data in [
1626- # id, mid, account, service, operation, transient,
1627- # stream, time, text, from_me, to_me, sender, recipient, data
1628- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1629- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1630- (3, 3, 'ant', '', '', '1', '', 99, '', 1, 0, '', '', 'row3'),
1631- (4, 4, 'ant', '', '', '1', '', 100, '', 1, 0, '', '', 'row4'),
1632- (5, 5, 'ant', '', '', '0', 'zee', 100, '', 0, 0, '', '',
1633- 'row5'),
1634- (6, 6, 'bee', '', '', '0', '', 100, '', 1, 0, '', '', 'row6'),
1635- (7, 7, 'bee', '', '', '0', '', 99, '', 1, 0, '', '', 'row7'),
1636- ]:
1637- self.db.execute("""
1638- INSERT INTO messages VALUES
1639- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1640- """, sample_data)
1641- # This query should return rows 1, 2, 5, and 6. Those rows have a
1642- # matching time and transient. Rows 3 and 4 don't match transient,
1643- # and row 7 doesn't match time.
1644- answer = storage.get_messages(
1645- 'home', 'cat', 100, '0', '0', 'data', 'ASC', 0)
1646- self.assertEqual(answer, '[row1, row2, row5, row6]')
1647-
1648- def test_get_data(self):
1649- storage = MessageStorage(self.db)
1650- with self.db:
1651- for sample_data in [
1652- # id, mid, account, service, operation, transient,
1653- # stream, time, text, from_me, to_me, sender, recipient, data
1654- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1655- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1656- ]:
1657- self.db.execute("""
1658- INSERT INTO messages VALUES
1659- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1660- """, sample_data)
1661- self.assertEqual(storage.get_data(2), 'row2')
1662-
1663- def test_get_data_missing(self):
1664- storage = MessageStorage(self.db)
1665- with self.db:
1666- for sample_data in [
1667- # id, mid, account, service, operation, transient,
1668- # stream, time, text, from_me, to_me, sender, recipient, data
1669- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1670- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1671- ]:
1672- self.db.execute("""
1673- INSERT INTO messages VALUES
1674- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1675- """, sample_data)
1676- self.assertEqual(storage.get_data(3), '')
1677-
1678- def test_maintenance_no_accounts(self):
1679- # Initialize the table.
1680- MessageStorage(self.db)
1681- # Calling maintenance() with no accounts should do nothing.
1682- class FakeAccountManager:
1683- def get_account(self, global_id):
1684- return False
1685- with self.db:
1686- MessageStorage(self.db).maintenance(FakeAccountManager())
1687- results = self.db.execute('SELECT account FROM messages').fetchall()
1688- self.assertEqual(len(results), 0)
1689-
1690- def test_maintenance_on_non_account(self):
1691- # Initialize the table.
1692- MessageStorage(self.db)
1693- # Put some sample data into the messages table, but with an account
1694- # that does not match the format expected by the maintenance()
1695- # function, which it interprets as a missing account. Every row gets
1696- # deleted.
1697- with self.db:
1698- for sample_data in [
1699- # id, mid, account, service, operation, transient,
1700- # stream, time, text, from_me, to_me, sender, recipient, data
1701- (1, 1, 'ant', '', '', '0', '', 100, '', 1, 0, '', '', 'row1'),
1702- (2, 2, 'ant', '', '', '0', '', 101, '', 1, 0, '', '', 'row2'),
1703- ]:
1704- self.db.execute("""
1705- INSERT INTO messages VALUES
1706- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1707- """, sample_data)
1708- class FakeAccountManager:
1709- def get_account(self, global_id):
1710- return False
1711- with self.db:
1712- MessageStorage(self.db).maintenance(FakeAccountManager())
1713- results = self.db.execute('SELECT id FROM messages').fetchall()
1714- self.assertEqual(len(results), 0)
1715-
1716- def test_maintenance_on_missing_account(self):
1717- # Initialize the table.
1718- MessageStorage(self.db)
1719- # Put some sample data into the messages table, but with accounts
1720- # we'll treat as both existing, and missing.
1721- # deleted.
1722- with self.db:
1723- for sample_data in [
1724- # id, mid, account, service, operation, transient,
1725- # stream, time, text, from_me, to_me, sender, recipient, data
1726- (1, 1, 'ant/foo', '', '', '0', '', 100, '', 1, 0, '', '', ''),
1727- (2, 2, 'ant/foo', '', '', '0', '', 101, '', 1, 0, '', '', ''),
1728- (3, 3, 'bee/foo', '', '', '0', '', 100, '', 1, 0, '', '', ''),
1729- (4, 4, 'bee/foo', '', '', '0', '', 101, '', 1, 0, '', '', ''),
1730- ]:
1731- self.db.execute("""
1732- INSERT INTO messages VALUES
1733- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1734- """, sample_data)
1735- class FakeAccountManager:
1736- def get_account(self, global_id):
1737- return global_id == 'bee'
1738- with self.db:
1739- MessageStorage(self.db).maintenance(FakeAccountManager())
1740- results = self.db.execute('SELECT id FROM messages').fetchall()
1741- self.assertEqual(sorted(row[0] for row in results), ['3', '4'])
1742-
1743- @mock.patch.dict(gwibber.storage.messages.__dict__, dict(MAX_COUNT=3))
1744- def test_maintenance(self):
1745- # Initialize the table.
1746- MessageStorage(self.db)
1747- # A bunch of messages in various accounts are in the table. We're
1748- # maxed out at three entries per account. The 'ant' account has two
1749- # entries, the 'bee' account has three, the 'cat' account has four,
1750- # and the 'dog' account has five. After maintenance() is run, no
1751- # account has more than 3 entries, and all of them are the latest
1752- # ones added.
1753- with self.db:
1754- for sample_data in [
1755- # id, mid, account, service, operation, transient,
1756- # stream, time, text, from_me, to_me, sender, recipient, data
1757- (1, 1, 'ant/foo', '', 'receive', '0', 'messages',
1758- 100, '', 1, 0, '', '', ''),
1759- (2, 2, 'ant/foo', '', 'receive', '0', 'messages',
1760- 101, '', 1, 0, '', '', ''),
1761- (3, 3, 'bee/foo', '', 'receive', '0', 'messages',
1762- 100, '', 1, 0, '', '', ''),
1763- (4, 4, 'bee/foo', '', 'receive', '0', 'messages',
1764- 101, '', 1, 0, '', '', ''),
1765- (5, 5, 'bee/foo', '', 'receive', '0', 'messages',
1766- 102, '', 1, 0, '', '', ''),
1767- (6, 6, 'cat/foo', '', 'receive', '0', 'messages',
1768- 100, '', 1, 0, '', '', ''),
1769- (7, 7, 'cat/foo', '', 'receive', '0', 'messages',
1770- 101, '', 1, 0, '', '', ''),
1771- (8, 8, 'cat/foo', '', 'receive', '0', 'messages',
1772- 102, '', 1, 0, '', '', ''),
1773- (9, 9, 'cat/foo', '', 'receive', '0', 'messages',
1774- 103, '', 1, 0, '', '', ''),
1775- (10, 10, 'dog/foo', '', 'receive', '0', 'messages',
1776- 100, '', 1, 0, '', '', ''),
1777- (11, 11, 'dog/foo', '', 'receive', '0', 'messages',
1778- 101, '', 1, 0, '', '', ''),
1779- (12, 12, 'dog/foo', '', 'receive', '0', 'messages',
1780- 102, '', 1, 0, '', '', ''),
1781- (13, 13, 'dog/foo', '', 'receive', '0', 'messages',
1782- 103, '', 1, 0, '', '', ''),
1783- (14, 14, 'dog/foo', '', 'receive', '0', 'messages',
1784- 104, '', 1, 0, '', '', ''),
1785- ]:
1786- self.db.execute("""
1787- INSERT INTO messages VALUES
1788- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1789- """, sample_data)
1790- class FakeAccountManager:
1791- def get_account(self, global_id):
1792- return True
1793- with self.db:
1794- MessageStorage(self.db).maintenance(FakeAccountManager())
1795- results = self.db.execute(
1796- 'SELECT id, account FROM messages').fetchall()
1797- ids_by_account = {}
1798- for id, account in results:
1799- global_id, slash, rest = account.partition('/')
1800- ids_by_account.setdefault(global_id, []).append(int(id))
1801- # There are fewer 'ant' messages than the maximum, so all are kept.
1802- self.assertEqual(ids_by_account['ant'], [1, 2])
1803- # There exactly the max number of 'bee' messages, so all are kept.
1804- self.assertEqual(ids_by_account['bee'], [3, 4, 5])
1805- # There is one too many 'cat' messages, so the earliest one (i.e. with
1806- # id = 6, time = 100) is deleted. The later ones are kept.
1807- self.assertEqual(ids_by_account['cat'], [7, 8, 9])
1808- # There are two too many 'dog' messages, so the earliest ones
1809- # (i.e. with ids 10 and 11, times 100 and 101) are deleted. The later
1810- # ones are kept.
1811- self.assertEqual(ids_by_account['dog'], [12, 13, 14])
1812-
1813-
1814-class TestSearchStorage(unittest.TestCase):
1815- """Test the search storage."""
1816-
1817- def setUp(self):
1818- self.db = sqlite3.connect(':memory:')
1819-
1820- def test_table_creation(self):
1821- rows = self.db.execute('PRAGMA table_info(searches)').fetchall()
1822- self.assertEqual(len(rows), 0)
1823- SearchStorage(self.db)
1824- rows = self.db.execute('PRAGMA table_info(searches)').fetchall()
1825- # 4 rows because there are 4 columns in the table.
1826- self.assertEqual(len(rows), 4)
1827-
1828- def test_table_already_exists(self):
1829- SearchStorage(self.db)
1830- SearchStorage(self.db)
1831- rows = self.db.execute('PRAGMA table_info(searches)').fetchall()
1832- self.assertEqual(len(rows), 4)
1833-
1834- def test_get_data(self):
1835- storage = SearchStorage(self.db)
1836- with self.db:
1837- for sample_data in [
1838- # id, name, query, data
1839- (1, 'anne', '', 'one'),
1840- (2, 'bart', '', 'two'),
1841- (3, 'cate', '', 'three'),
1842- (4, 'dave', '', 'four'),
1843- ]:
1844- self.db.execute("""
1845- INSERT INTO searches VALUES (?, ?, ?, ?)
1846- """, sample_data)
1847- data = storage.get_data(3)
1848- self.assertEqual(data, 'three')
1849-
1850- def test_get_data_missing(self):
1851- storage = SearchStorage(self.db)
1852- with self.db:
1853- for sample_data in [
1854- # id, name, query, data
1855- (1, 'anne', '', 'one'),
1856- (2, 'bart', '', 'two'),
1857- (3, 'cate', '', 'three'),
1858- (4, 'dave', '', 'four'),
1859- ]:
1860- self.db.execute("""
1861- INSERT INTO searches VALUES (?, ?, ?, ?)
1862- """, sample_data)
1863- data = storage.get_data(10)
1864- self.assertEqual(data, '')
1865-
1866- def test_list_all_data(self):
1867- storage = SearchStorage(self.db)
1868- with self.db:
1869- for sample_data in [
1870- # id, name, query, data
1871- (1, 'anne', '', 'one'),
1872- (2, 'bart', '', 'two'),
1873- (3, 'cate', '', 'three'),
1874- (4, 'dave', '', 'four'),
1875- ]:
1876- self.db.execute("""
1877- INSERT INTO searches VALUES (?, ?, ?, ?)
1878- """, sample_data)
1879- data = storage.list_all_data()
1880- self.assertEqual(data, '[one, two, three, four]')
1881-
1882- def test_add(self):
1883- storage = SearchStorage(self.db)
1884- storage.add(1, 'bob', 'myquery', 'mydata')
1885- rows = self.db.execute('SELECT * FROM searches').fetchall()
1886- self.assertEqual(len(rows), 1)
1887- id, name, query, data = rows[0]
1888- self.assertEqual(id, '1')
1889- self.assertEqual(name, 'bob')
1890- self.assertEqual(query, 'myquery')
1891- self.assertEqual(data, 'mydata')
1892-
1893- def test_remove(self):
1894- storage = SearchStorage(self.db)
1895- with self.db:
1896- for sample_data in [
1897- (1, 'anne', 'myquery', 'one'),
1898- (2, 'bart', 'myquery', 'two'),
1899- (3, 'cate', 'myquery', 'three'),
1900- ]:
1901- self.db.execute(
1902- 'INSERT INTO searches VALUES (?, ?, ?, ?)',
1903- sample_data)
1904- storage.remove(2)
1905- rows = self.db.execute('SELECT * FROM searches').fetchall()
1906- self.assertEqual(len(rows), 2)
1907- self.assertEqual([row[0] for row in rows], ['1', '3'])
1908-
1909- def test_remove_missing(self):
1910- storage = SearchStorage(self.db)
1911- with self.db:
1912- for sample_data in [
1913- (1, 'anne', 'myquery', 'one'),
1914- (2, 'bart', 'myquery', 'two'),
1915- (3, 'cate', 'myquery', 'three'),
1916- ]:
1917- self.db.execute(
1918- 'INSERT INTO searches VALUES (?, ?, ?, ?)',
1919- sample_data)
1920- storage.remove(5)
1921- rows = self.db.execute('SELECT * FROM searches').fetchall()
1922- self.assertEqual(len(rows), 3)
1923- self.assertEqual([row[0] for row in rows], ['1', '2', '3'])
1924
1925=== modified file 'gwibber/gwibber/utils/account.py'
1926--- gwibber/gwibber/utils/account.py 2012-09-13 17:50:35 +0000
1927+++ gwibber/gwibber/utils/account.py 2012-09-19 17:22:19 +0000
1928@@ -29,7 +29,7 @@
1929
1930 from gwibber.errors import UnsupportedProtocolError
1931 from gwibber.utils.manager import protocol_manager
1932-
1933+from gwibber.utils.model import SharedModel, COLUMN_NAMES
1934
1935 log = logging.getLogger('gwibber.accounts')
1936
1937@@ -37,9 +37,8 @@
1938 class AccountManager:
1939 """Manage the accounts that we know about."""
1940
1941- def __init__(self, refresh_callback, streams):
1942+ def __init__(self, refresh_callback):
1943 self._callback = refresh_callback
1944- self._streams = streams
1945 self._accounts = {}
1946 # Ask libaccounts for a manager of the microblogging services.
1947 # Connect callbacks to the manager so that we can react when accounts
1948@@ -71,9 +70,11 @@
1949 except KeyError:
1950 log.debug('Attempting to delete unknown account: {}', account_id)
1951 return
1952- for stream in json.loads(self._streams.List()):
1953- if stream['account'] == account['id']:
1954- self._streams.Delete(stream['id'])
1955+
1956+ for row in SharedModel:
1957+ if account_id in row[COLUMN_NAMES.index('message_ids')]:
1958+ SharedModel.remove(row)
1959+
1960 self._callback()
1961
1962 def add_new_account(self, account_service):
1963
1964=== modified file 'gwibber/gwibber/utils/base.py'
1965--- gwibber/gwibber/utils/base.py 2012-09-13 17:50:35 +0000
1966+++ gwibber/gwibber/utils/base.py 2012-09-19 17:22:19 +0000
1967@@ -20,8 +20,34 @@
1968 ]
1969
1970
1971-from threading import Thread
1972-
1973+import string
1974+
1975+from threading import Thread, Lock
1976+
1977+from gwibber.utils.model import SharedModel, COLUMN_INDICES
1978+from gwibber.utils.model import SENDER, MESSAGE, IDS
1979+
1980+
1981+IGNORED = string.punctuation + ' '
1982+
1983+def strip(message):
1984+ """Return a string suitable for fuzzy comparisons.
1985+
1986+ This is used to identify highly similar but not quite identical duplicate
1987+ messages, so that we don't display those duplicates to the user.
1988+ """
1989+ message = message.replace('http://', '')
1990+ message = message.replace('https://', '')
1991+ message = message.replace('gwibber:/', '')
1992+ return ''.join(char for char in message if char not in IGNORED)
1993+
1994+
1995+# This dict maps messages stripped by the above function to the
1996+# DeeModelIters that represent their rows in the Dee.SharedModel.
1997+# It's used for convenience in finding duplicates.
1998+_seen_messages = {}
1999+
2000+_publish_lock = Lock()
2001
2002 class Base:
2003 class info:
2004@@ -53,6 +79,44 @@
2005 thread.daemon = True
2006 thread.start()
2007
2008+ def _publish(self, service, account_id, message_id, **kwargs):
2009+ """Publish fresh data into the stream, without duplicating anything.
2010+
2011+ Returns True if the message was appended, or if the message was
2012+ already present, returns False if unable to append new messagee.
2013+ """
2014+ args = [''] * len(COLUMN_INDICES)
2015+ # See gwibber/utils/model.py for an explanation of this unique column.
2016+ args[IDS] = [[service, account_id, message_id]]
2017+
2018+ # The basic idea is that you call _publish() with keyword
2019+ # arguments only, making the calling code much more explicit,
2020+ # and then this converts it from an unordered dict into the
2021+ # ordered list required by SharedModel.append().
2022+ bad_kwargs = []
2023+ for column_name, value in kwargs.items():
2024+ index = COLUMN_INDICES.get(column_name)
2025+ if index is None:
2026+ bad_kwargs.append(column_name)
2027+ else:
2028+ args[index] = value
2029+ if len(bad_kwargs) > 0:
2030+ raise TypeError('Unexpected keyword arguments: {}'.format(
2031+ ', '.join(bad_kwargs)))
2032+
2033+ with _publish_lock:
2034+ # Don't let duplicate messages into the model, but do
2035+ # record the unique message ids of each duplicate message.
2036+ stripped = strip(args[SENDER] + args[MESSAGE])
2037+ row_iter = _seen_messages.get(stripped)
2038+ if row_iter is None:
2039+ _seen_messages[stripped] = SharedModel.append(*args)
2040+ else:
2041+ row = SharedModel.get_row(row_iter)
2042+ row[IDS] = row[IDS] + args[IDS]
2043+
2044+ return stripped in _seen_messages
2045+
2046 def _login(self):
2047 """Prevent redundant login attempts."""
2048 # The first time the user logs in, we expect old_token to be None.
2049
2050=== added file 'gwibber/gwibber/utils/model.py'
2051--- gwibber/gwibber/utils/model.py 1970-01-01 00:00:00 +0000
2052+++ gwibber/gwibber/utils/model.py 2012-09-19 17:22:19 +0000
2053@@ -0,0 +1,109 @@
2054+# Copyright (C) 2012 Canonical Ltd
2055+#
2056+# This program is free software: you can redistribute it and/or modify
2057+# it under the terms of the GNU General Public License version 2 as
2058+# published by the Free Software Foundation.
2059+#
2060+# This program is distributed in the hope that it will be useful,
2061+# but WITHOUT ANY WARRANTY; without even the implied warranty of
2062+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2063+# GNU General Public License for more details.
2064+#
2065+# You should have received a copy of the GNU General Public License
2066+# along with this program. If not, see <http://www.gnu.org/licenses/>.
2067+
2068+"""The Dee.SharedModel interface layer.
2069+
2070+Dee.SharedModel is comparable to a Gtk.ListStore, except that it
2071+shares its state between processes using DBus. When gwibber-service
2072+downloads new messages from a website, it inserts those messages into
2073+this SharedModel instance, which then triggers a callback in the Vala
2074+frontend, which knows to display the new messages there.
2075+"""
2076+
2077+
2078+__all__ = [
2079+ 'SharedModel',
2080+ 'COLUMN_NAMES',
2081+ 'COLUMN_TYPES',
2082+ 'COLUMN_INDICES',
2083+ 'SENDER',
2084+ 'MESSAGE',
2085+ 'IDS',
2086+ ]
2087+
2088+
2089+from gi.repository import Dee
2090+
2091+
2092+# Most of this schema is very straightforward, but the 'message_ids'
2093+# column needs a bit of explanation:
2094+#
2095+# It is a two-dimensional array (ie, an array of arrays). Each inner
2096+# array contains three elements: the name of the protocol, the
2097+# account_id (like '6/flickr' or '3/facebook'), followed by the
2098+# message_id for that particular service.
2099+#
2100+# Then, there will be one of these triples present for every service
2101+# on which the message exists. So for example, if the user posts the
2102+# same message to both facebook and twitter, that message will appear
2103+# as a single row in this schema, and the 'service' column will look
2104+# something like this:
2105+#
2106+# [
2107+# ['facebook', '2/facebook', '12345'],
2108+# ['twitter', '3/twitter', '987654'],
2109+# ]
2110+SCHEMA = (
2111+ ('message_ids', 'aas'),
2112+ ('stream', 's'),
2113+ ('transient', 's'),
2114+ ('sender', 's'),
2115+ ('sender_nick', 's'),
2116+ ('from_me', 'b'),
2117+ ('timestamp', 's'),
2118+ ('message', 's'),
2119+ ('html', 's'),
2120+ ('icon_uri', 's'),
2121+ ('url', 's'),
2122+ ('source', 's'),
2123+ ('timestring', 's'),
2124+ ('reply_nick', 's'),
2125+ ('reply_name', 's'),
2126+ ('reply_url', 's'),
2127+ ('likes', 'd'),
2128+ ('liked', 'b'),
2129+ ('retweet_nick', 's'),
2130+ ('retweet_name', 's'),
2131+ ('retweet_id', 's'),
2132+ ('link_picture', 's'),
2133+ ('link_name', 's'),
2134+ ('link_url', 's'),
2135+ ('link_desc', 's'),
2136+ ('link_caption', 's'),
2137+ ('link_icon', 's'),
2138+ ('img_url', 's'),
2139+ ('img_src', 's'),
2140+ ('img_thumb', 's'),
2141+ ('img_name', 's'),
2142+ ('video_pic', 's'),
2143+ ('video_src', 's'),
2144+ ('video_url', 's'),
2145+ ('video_name', 's'),
2146+ ('comments', 's'),
2147+ ('recipient', 's'),
2148+ ('recipient_nick', 's'),
2149+ ('recipient_icon', 's'),
2150+ )
2151+
2152+
2153+COLUMN_NAMES, COLUMN_TYPES = zip(*SCHEMA)
2154+
2155+COLUMN_INDICES = {name: i for i, name in enumerate(COLUMN_NAMES)}
2156+SENDER = COLUMN_INDICES['sender']
2157+MESSAGE = COLUMN_INDICES['message']
2158+IDS = COLUMN_INDICES['message_ids']
2159+
2160+
2161+SharedModel = Dee.SharedModel.new('com.Gwibber.Streams')
2162+SharedModel.set_schema_full(COLUMN_TYPES)

Subscribers

People subscribed via source and target branches