Merge lp:~robru/friends/since_id into lp:friends

Proposed by Robert Bruce Park
Status: Merged
Approved by: Ken VanDine
Approved revision: 158
Merged at revision: 160
Proposed branch: lp:~robru/friends/since_id
Merge into: lp:friends
Diff against target: 505 lines (+250/-34)
6 files modified
friends/protocols/twitter.py (+48/-25)
friends/tests/test_cache.py (+69/-0)
friends/tests/test_identica.py (+12/-3)
friends/tests/test_twitter.py (+44/-5)
friends/utils/base.py (+1/-1)
friends/utils/cache.py (+76/-0)
To merge this branch: bzr merge lp:~robru/friends/since_id
Reviewer Review Type Date Requested Status
Ken VanDine Approve
PS Jenkins bot (community) continuous-integration Approve
Review via email: mp+152315@code.launchpad.net

Commit message

    Start using since_id= on Twitter API requests. (LP: #1152417)

    This was accomplished by implementing two new classes, and ended up
    simplifying some of the RateLimiter code as a side effect.

    The first new class is called JsonCache. It is a subclass of dict,
    which attempts to populate it's initial state by reading in a json
    text file at a configurable location, and also adds a new "write()"
    method that dumps the json back out to the same location. This class
    was a generalization of what we were already doing inside the
    RateLimiter, so it should not be considered a "new feature" if we are
    going to butt heads with today's feature freeze.

    The second new class is a subclass of JsonCache, which enforces that:

    A) keys may not contain slashes, to avoid it getting polluted with
    every search term ever searched for, or every message that's ever been
    replied to ever, it only observes the values of the "main" streams,
    such as "messages", "mentions" and "private", although those values
    are not hardcoded so it's flexible to adapt to new streams in the
    future.

    B) values must be ints (tweet_ids), and values can only be
    incremented. This is so that we can easily just throw every observed
    tweet_id into the cache, and it only records the largest (newest) one.

    The end result is that we now have two new files located at
    ~/.cache/friends/twitter_ids.json and
    ~/.cache/friends/identica_ids.json which track the newest tweet_id
    that we have ever seen for each of the streams that we publish to.
    These values are then consulted to form the since_id= argument to
    several of Twitter's API endpoints, which solves bug #1152417.

    As an added bonus, this also greatly reduces our network usage because
    we are no longer redownloading duplicate messages over and over, so if
    there are no new messages, Twitter is now returning an empty list of
    Tweets rather than a large list of stale tweets.

    This commit includes full test coverage for all new code.

To post a comment you must log in.
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :

PASSED: Continuous integration, rev:158
http://jenkins.qa.ubuntu.com/job/friends-ci/3/
Executed test runs:
    SUCCESS: http://jenkins.qa.ubuntu.com/job/friends-raring-amd64-ci/3//console

Click here to trigger a rebuild:
http://jenkins.qa.ubuntu.com/job/friends-ci/3//rebuild/?

review: Approve (continuous-integration)
Revision history for this message
Ken VanDine (ken-vandine) wrote :

Looks great, works well and i love the tests!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'friends/protocols/twitter.py'
--- friends/protocols/twitter.py 2013-03-01 19:52:25 +0000
+++ friends/protocols/twitter.py 2013-03-08 02:36:21 +0000
@@ -22,10 +22,7 @@
22 ]22 ]
2323
2424
25import os
26import time25import time
27import json
28import errno
29import logging26import logging
3027
31from urllib.parse import quote28from urllib.parse import quote
@@ -33,14 +30,14 @@
3330
34from friends.utils.avatar import Avatar31from friends.utils.avatar import Avatar
35from friends.utils.base import Base, feature32from friends.utils.base import Base, feature
33from friends.utils.cache import JsonCache
34from friends.utils.model import Model
36from friends.utils.http import BaseRateLimiter, Downloader35from friends.utils.http import BaseRateLimiter, Downloader
37from friends.utils.time import parsetime, iso8601utc36from friends.utils.time import parsetime, iso8601utc
38from friends.errors import FriendsError37from friends.errors import FriendsError
3938
4039
41TWITTER_ADDRESS_BOOK = 'friends-twitter-contacts'40TWITTER_ADDRESS_BOOK = 'friends-twitter-contacts'
42TWITTER_RATELIMITER_CACHE = os.path.join(
43 GLib.get_user_cache_dir(), 'friends', 'twitter.rates')
4441
4542
46log = logging.getLogger(__name__)43log = logging.getLogger(__name__)
@@ -72,6 +69,8 @@
72 def __init__(self, account):69 def __init__(self, account):
73 super().__init__(account)70 super().__init__(account)
74 self._rate_limiter = RateLimiter()71 self._rate_limiter = RateLimiter()
72 # Can be 'twitter_ids' or 'identica_ids'
73 self._tweet_ids = TweetIdCache(self.__class__.__name__.lower() + '_ids')
7574
76 def _whoami(self, authdata):75 def _whoami(self, authdata):
77 """Identify the authenticating user."""76 """Identify the authenticating user."""
@@ -103,6 +102,13 @@
103 log.info('Ignoring tweet with no id_str value')102 log.info('Ignoring tweet with no id_str value')
104 return103 return
105104
105 # We need to record tweet_ids for use with since_id. Note that
106 # _tweet_ids is a special dict subclass that only accepts
107 # tweet_ids that are larger than the existing value, so at any
108 # given time it will map the stream to the largest (most
109 # recent) tweet_id we've seen for that stream.
110 self._tweet_ids[stream] = tweet_id
111
106 # 'user' for tweets, 'sender' for direct messages.112 # 'user' for tweets, 'sender' for direct messages.
107 user = tweet.get('user', {}) or tweet.get('sender', {})113 user = tweet.get('user', {}) or tweet.get('sender', {})
108 screen_name = user.get('screen_name', '')114 screen_name = user.get('screen_name', '')
@@ -129,12 +135,20 @@
129 )135 )
130 return permalink136 return permalink
131137
138 def _append_since(self, url, stream='messages'):
139 since = self._tweet_ids.get(stream)
140 if since is not None:
141 return '{}&since_id={}'.format(url, since)
142 return url
143
132# https://dev.twitter.com/docs/api/1.1/get/statuses/home_timeline144# https://dev.twitter.com/docs/api/1.1/get/statuses/home_timeline
133 @feature145 @feature
134 def home(self):146 def home(self):
135 """Gather the user's home timeline."""147 """Gather the user's home timeline."""
136 url = self._timeline.format(148 url = '{}?count={}'.format(
137 'home') + '?count={}'.format(self._DOWNLOAD_LIMIT)149 self._timeline.format('home'),
150 self._DOWNLOAD_LIMIT)
151 url = self._append_since(url)
138 for tweet in self._get_url(url):152 for tweet in self._get_url(url):
139 self._publish_tweet(tweet)153 self._publish_tweet(tweet)
140 return self._get_n_rows()154 return self._get_n_rows()
@@ -143,7 +157,10 @@
143 @feature157 @feature
144 def mentions(self):158 def mentions(self):
145 """Gather the tweets that mention us."""159 """Gather the tweets that mention us."""
146 url = self._mentions_timeline160 url = '{}?count={}'.format(
161 self._mentions_timeline,
162 self._DOWNLOAD_LIMIT)
163 url = self._append_since(url, 'mentions')
147 for tweet in self._get_url(url):164 for tweet in self._get_url(url):
148 self._publish_tweet(tweet, stream='mentions')165 self._publish_tweet(tweet, stream='mentions')
149 return self._get_n_rows()166 return self._get_n_rows()
@@ -185,11 +202,17 @@
185 @feature202 @feature
186 def private(self):203 def private(self):
187 """Gather the direct messages sent to/from us."""204 """Gather the direct messages sent to/from us."""
188 url = self._api_base.format(endpoint='direct_messages')205 url = '{}?count={}'.format(
206 self._api_base.format(endpoint='direct_messages'),
207 self._DOWNLOAD_LIMIT)
208 url = self._append_since(url, 'private')
189 for tweet in self._get_url(url):209 for tweet in self._get_url(url):
190 self._publish_tweet(tweet, stream='private')210 self._publish_tweet(tweet, stream='private')
191211
192 url = self._api_base.format(endpoint='direct_messages/sent')212 url = '{}?count={}'.format(
213 self._api_base.format(endpoint='direct_messages/sent'),
214 self._DOWNLOAD_LIMIT)
215 url = self._append_since(url, 'private')
193 for tweet in self._get_url(url):216 for tweet in self._get_url(url):
194 self._publish_tweet(tweet, stream='private')217 self._publish_tweet(tweet, stream='private')
195 return self._get_n_rows()218 return self._get_n_rows()
@@ -374,35 +397,36 @@
374 return self._delete_service_contacts(source)397 return self._delete_service_contacts(source)
375398
376399
400class TweetIdCache(JsonCache):
401 """Persist most-recent tweet_ids as JSON."""
402
403 def __setitem__(self, key, value):
404 if key.find('/') >= 0:
405 # Don't flood the cache with irrelevant "reply_to/..." and
406 # "search/..." streams, we only need the main streams.
407 return
408 value = int(value)
409 if value > self.get(key, 0):
410 JsonCache.__setitem__(self, key, value)
411
412
377class RateLimiter(BaseRateLimiter):413class RateLimiter(BaseRateLimiter):
378 """Twitter rate limiter."""414 """Twitter rate limiter."""
379415
380 def __init__(self):416 def __init__(self):
381 try:417 self._limits = JsonCache('twitter-ratelimiter')
382 with open(TWITTER_RATELIMITER_CACHE, 'r') as cache:
383 self._limits = json.loads(cache.read())
384 except IOError as error:
385 if error.errno != errno.ENOENT:
386 raise
387 # File not found, so create it:
388 self._limits = {}
389 self._persist_data()
390418
391 def _sanitize_url(self, uri):419 def _sanitize_url(self, uri):
392 # Cache the URL sans any query parameters.420 # Cache the URL sans any query parameters.
393 return uri.host + uri.path421 return uri.host + uri.path
394422
395 def _persist_data(self):
396 with open(TWITTER_RATELIMITER_CACHE, 'w') as cache:
397 cache.write(json.dumps(self._limits))
398
399 def wait(self, message):423 def wait(self, message):
400 # If we haven't seen this URL, default to no wait.424 # If we haven't seen this URL, default to no wait.
401 seconds = self._limits.pop(self._sanitize_url(message.get_uri()), 0)425 seconds = self._limits.pop(self._sanitize_url(message.get_uri()), 0)
402 log.debug('Sleeping for {} seconds!'.format(seconds))426 log.debug('Sleeping for {} seconds!'.format(seconds))
403 time.sleep(seconds)427 time.sleep(seconds)
404 # Don't sleep the same length of time more than once!428 # Don't sleep the same length of time more than once!
405 self._persist_data()429 self._limits.write()
406430
407 def update(self, message):431 def update(self, message):
408 info = message.response_headers432 info = message.response_headers
@@ -427,7 +451,6 @@
427 else:451 else:
428 wait_secs = rate_delta / rate_count452 wait_secs = rate_delta / rate_count
429 self._limits[url] = wait_secs453 self._limits[url] = wait_secs
430 self._persist_data()
431 log.debug(454 log.debug(
432 'Next access to {} must wait {} seconds!'.format(455 'Next access to {} must wait {} seconds!'.format(
433 url, self._limits.get(url, 0)))456 url, self._limits.get(url, 0)))
434457
=== added file 'friends/tests/test_cache.py'
--- friends/tests/test_cache.py 1970-01-01 00:00:00 +0000
+++ friends/tests/test_cache.py 2013-03-08 02:36:21 +0000
@@ -0,0 +1,69 @@
1# friends-dispatcher -- send & receive messages from any social network
2# Copyright (C) 2012 Canonical Ltd
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, version 3 of the License.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program. If not, see <http://www.gnu.org/licenses/>.
15
16"""Test the JSON cacher."""
17
18__all__ = [
19 'TestJsonCache',
20 ]
21
22
23import os
24import time
25import shutil
26import tempfile
27import unittest
28
29from datetime import date, timedelta
30from pkg_resources import resource_filename
31
32from friends.utils.cache import JsonCache
33
34
35class TestJsonCache(unittest.TestCase):
36 """Test JsonCache logic."""
37
38 def setUp(self):
39 self._temp_cache = tempfile.mkdtemp()
40 self._root = JsonCache._root = os.path.join(
41 self._temp_cache, '{}.json')
42
43 def tearDown(self):
44 # Clean up the temporary cache directory.
45 shutil.rmtree(self._temp_cache)
46
47 def test_creation(self):
48 cache = JsonCache('foo')
49 with open(self._root.format('foo'), 'r') as fd:
50 empty = fd.read()
51 self.assertEqual(empty, '{}')
52
53 def test_values(self):
54 cache = JsonCache('bar')
55 cache['hello'] = 'world'
56 with open(self._root.format('bar'), 'r') as fd:
57 result = fd.read()
58 self.assertEqual(result, '{"hello": "world"}')
59
60 def test_writes(self):
61 cache = JsonCache('stuff')
62 cache.update(dict(pi=289/92))
63 with open(self._root.format('stuff'), 'r') as fd:
64 empty = fd.read()
65 self.assertEqual(empty, '{}')
66 cache.write()
67 with open(self._root.format('stuff'), 'r') as fd:
68 result = fd.read()
69 self.assertEqual(result, '{"pi": 3.141304347826087}')
070
=== modified file 'friends/tests/test_identica.py'
--- friends/tests/test_identica.py 2013-02-05 01:11:35 +0000
+++ friends/tests/test_identica.py 2013-03-08 02:36:21 +0000
@@ -21,12 +21,16 @@
21 ]21 ]
2222
2323
24import os
25import tempfile
24import unittest26import unittest
27import shutil
2528
26from gi.repository import Dee29from gi.repository import Dee
2730
28from friends.protocols.identica import Identica31from friends.protocols.identica import Identica
29from friends.tests.mocks import FakeAccount, LogMock, mock32from friends.tests.mocks import FakeAccount, LogMock, mock
33from friends.utils.cache import JsonCache
30from friends.utils.model import COLUMN_TYPES34from friends.utils.model import COLUMN_TYPES
31from friends.errors import AuthorizationError35from friends.errors import AuthorizationError
3236
@@ -43,6 +47,9 @@
43 """Test the Identica API."""47 """Test the Identica API."""
4448
45 def setUp(self):49 def setUp(self):
50 self._temp_cache = tempfile.mkdtemp()
51 self._root = JsonCache._root = os.path.join(
52 self._temp_cache, '{}.json')
46 self.account = FakeAccount()53 self.account = FakeAccount()
47 self.protocol = Identica(self.account)54 self.protocol = Identica(self.account)
48 self.log_mock = LogMock('friends.utils.base',55 self.log_mock = LogMock('friends.utils.base',
@@ -52,6 +59,7 @@
52 # Ensure that any log entries we haven't tested just get consumed so59 # Ensure that any log entries we haven't tested just get consumed so
53 # as to isolate out test logger from other tests.60 # as to isolate out test logger from other tests.
54 self.log_mock.stop()61 self.log_mock.stop()
62 shutil.rmtree(self._temp_cache)
5563
56 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)64 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
57 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')65 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
@@ -83,7 +91,7 @@
8391
84 publish.assert_called_with('tweet', stream='mentions')92 publish.assert_called_with('tweet', stream='mentions')
85 get_url.assert_called_with(93 get_url.assert_called_with(
86 'http://identi.ca/api/statuses/mentions.json')94 'http://identi.ca/api/statuses/mentions.json?count=50')
8795
88 def test_user(self):96 def test_user(self):
89 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])97 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])
@@ -116,8 +124,9 @@
116 publish.assert_called_with('tweet', stream='private')124 publish.assert_called_with('tweet', stream='private')
117 self.assertEqual(125 self.assertEqual(
118 get_url.mock_calls,126 get_url.mock_calls,
119 [mock.call('http://identi.ca/api/direct_messages.json'),127 [mock.call('http://identi.ca/api/direct_messages.json?count=50'),
120 mock.call('http://identi.ca/api/direct_messages/sent.json')])128 mock.call('http://identi.ca/api/direct_messages' +
129 '/sent.json?count=50')])
121130
122 def test_send_private(self):131 def test_send_private(self):
123 get_url = self.protocol._get_url = mock.Mock(return_value='tweet')132 get_url = self.protocol._get_url = mock.Mock(return_value='tweet')
124133
=== modified file 'friends/tests/test_twitter.py'
--- friends/tests/test_twitter.py 2013-02-27 22:22:38 +0000
+++ friends/tests/test_twitter.py 2013-03-08 02:36:21 +0000
@@ -21,13 +21,17 @@
21 ]21 ]
2222
2323
24import os
25import tempfile
24import unittest26import unittest
27import shutil
2528
26from gi.repository import GLib, Dee29from gi.repository import GLib, Dee
27from urllib.error import HTTPError30from urllib.error import HTTPError
2831
29from friends.protocols.twitter import RateLimiter, Twitter32from friends.protocols.twitter import RateLimiter, Twitter
30from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock, mock33from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock, mock
34from friends.utils.cache import JsonCache
31from friends.utils.model import COLUMN_TYPES35from friends.utils.model import COLUMN_TYPES
32from friends.errors import AuthorizationError36from friends.errors import AuthorizationError
3337
@@ -44,6 +48,9 @@
44 """Test the Twitter API."""48 """Test the Twitter API."""
4549
46 def setUp(self):50 def setUp(self):
51 self._temp_cache = tempfile.mkdtemp()
52 self._root = JsonCache._root = os.path.join(
53 self._temp_cache, '{}.json')
47 TestModel.clear()54 TestModel.clear()
48 self.account = FakeAccount()55 self.account = FakeAccount()
49 self.protocol = Twitter(self.account)56 self.protocol = Twitter(self.account)
@@ -54,6 +61,7 @@
54 # Ensure that any log entries we haven't tested just get consumed so61 # Ensure that any log entries we haven't tested just get consumed so
55 # as to isolate out test logger from other tests.62 # as to isolate out test logger from other tests.
56 self.log_mock.stop()63 self.log_mock.stop()
64 shutil.rmtree(self._temp_cache)
5765
58 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)66 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
59 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')67 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
@@ -162,6 +170,32 @@
162170
163 @mock.patch('friends.utils.base.Model', TestModel)171 @mock.patch('friends.utils.base.Model', TestModel)
164 @mock.patch('friends.utils.http.Soup.Message',172 @mock.patch('friends.utils.http.Soup.Message',
173 FakeSoupMessage('friends.tests.data', 'twitter-home.dat'))
174 @mock.patch('friends.protocols.twitter.Twitter._login',
175 return_value=True)
176 @mock.patch('friends.utils.base._seen_messages', {})
177 @mock.patch('friends.utils.base._seen_ids', {})
178 def test_home_since_id(self, *mocks):
179 self.account.access_token = 'access'
180 self.account.secret_token = 'secret'
181 self.account.auth.parameters = dict(
182 ConsumerKey='key',
183 ConsumerSecret='secret')
184 self.assertEqual(self.protocol.home(), 3)
185
186 with open(self._root.format('twitter_ids'), 'r') as fd:
187 self.assertEqual(fd.read(), '{"messages": 240558470661799936}')
188
189 get_url = self.protocol._get_url = mock.Mock()
190 get_url.return_value = []
191 self.assertEqual(self.protocol.home(), 3)
192 get_url.assert_called_once_with(
193 'https://api.twitter.com/1.1/statuses/' +
194 'home_timeline.json?count=50&since_id=240558470661799936')
195
196
197 @mock.patch('friends.utils.base.Model', TestModel)
198 @mock.patch('friends.utils.http.Soup.Message',
165 FakeSoupMessage('friends.tests.data', 'twitter-send.dat'))199 FakeSoupMessage('friends.tests.data', 'twitter-send.dat'))
166 @mock.patch('friends.protocols.twitter.Twitter._login',200 @mock.patch('friends.protocols.twitter.Twitter._login',
167 return_value=True)201 return_value=True)
@@ -216,7 +250,8 @@
216250
217 publish.assert_called_with('tweet', stream='mentions')251 publish.assert_called_with('tweet', stream='mentions')
218 get_url.assert_called_with(252 get_url.assert_called_with(
219 'https://api.twitter.com/1.1/statuses/mentions_timeline.json')253 'https://api.twitter.com/1.1/statuses/' +
254 'mentions_timeline.json?count=50')
220255
221 @mock.patch('friends.utils.base.Model', TestModel)256 @mock.patch('friends.utils.base.Model', TestModel)
222 @mock.patch('friends.utils.base._seen_messages', {})257 @mock.patch('friends.utils.base._seen_messages', {})
@@ -270,8 +305,10 @@
270 publish.assert_called_with('tweet', stream='private')305 publish.assert_called_with('tweet', stream='private')
271 self.assertEqual(306 self.assertEqual(
272 get_url.mock_calls,307 get_url.mock_calls,
273 [mock.call('https://api.twitter.com/1.1/direct_messages.json'),308 [mock.call('https://api.twitter.com/1.1/' +
274 mock.call('https://api.twitter.com/1.1/direct_messages/sent.json')309 'direct_messages.json?count=50'),
310 mock.call('https://api.twitter.com/1.1/' +
311 'direct_messages/sent.json?count=50')
275 ])312 ])
276313
277 @mock.patch('friends.protocols.twitter.Avatar.get_image',314 @mock.patch('friends.protocols.twitter.Avatar.get_image',
@@ -302,8 +339,10 @@
302 message_id='1452456')339 message_id='1452456')
303 self.assertEqual(340 self.assertEqual(
304 get_url.mock_calls,341 get_url.mock_calls,
305 [mock.call('https://api.twitter.com/1.1/direct_messages.json'),342 [mock.call('https://api.twitter.com/1.1/' +
306 mock.call('https://api.twitter.com/1.1/direct_messages/sent.json')343 'direct_messages.json?count=50'),
344 mock.call('https://api.twitter.com/1.1/' +
345 'direct_messages/sent.json?count=50&since_id=1452456')
307 ])346 ])
308347
309 @mock.patch('friends.utils.base.Model', TestModel)348 @mock.patch('friends.utils.base.Model', TestModel)
310349
=== modified file 'friends/utils/base.py'
--- friends/utils/base.py 2013-02-27 22:34:48 +0000
+++ friends/utils/base.py 2013-03-08 02:36:21 +0000
@@ -548,7 +548,7 @@
548 def _is_error(self, data):548 def _is_error(self, data):
549 """Is the return data an error response?"""549 """Is the return data an error response?"""
550 try:550 try:
551 error = data.get('error')551 error = data.get('error') or data.get('errors')
552 except AttributeError:552 except AttributeError:
553 return False553 return False
554 if error is None:554 if error is None:
555555
=== added file 'friends/utils/cache.py'
--- friends/utils/cache.py 1970-01-01 00:00:00 +0000
+++ friends/utils/cache.py 2013-03-08 02:36:21 +0000
@@ -0,0 +1,76 @@
1# friends-dispatcher -- send & receive messages from any social network
2# Copyright (C) 2012 Canonical Ltd
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, version 3 of the License.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program. If not, see <http://www.gnu.org/licenses/>.
15
16"""Persistent data store using JSON."""
17
18__all__ = [
19 'JsonCache',
20 ]
21
22import os
23import json
24import errno
25import logging
26
27from gi.repository import GLib
28
29
30log = logging.getLogger(__name__)
31
32
33class JsonCache(dict):
34 """Simple dict that is backed by JSON data in a text file.
35
36 Serializes itself to disk with every call to __setitem__, so it's
37 not well suited for large, frequently-changing dicts. But useful
38 for small dicts that change infrequently. Typically I expect this
39 to be used for dicts that only change once or twice during the
40 lifetime of the program, but needs to remember its state between
41 invocations.
42
43 If, for some unforeseen reason, you do need to dump a lot of data
44 into this dict without triggering a ton of disk writes, it is
45 possible to call dict.update with all the new values, followed by
46 a single call to .write(). Keep in mind that the more data you
47 store in this dict, the slower read/writes will be with each
48 invocation. At the time of this writing, there are only three
49 instances used throughout Friends, and they are all under 200
50 bytes.
51 """
52 # Where to store all the json files.
53 _root = os.path.join(GLib.get_user_cache_dir(), 'friends', '{}.json')
54
55 def __init__(self, name):
56 dict.__init__(self)
57 self._path = self._root.format(name)
58
59 try:
60 with open(self._path, 'r') as cache:
61 self.update(json.loads(cache.read()))
62 except IOError as error:
63 if error.errno != errno.ENOENT:
64 raise
65 # This writes '{}' to self._filename on first run.
66 self.write()
67
68 def write(self):
69 """Write our dict contents to disk as a JSON string."""
70 with open(self._path, 'w') as cache:
71 cache.write(json.dumps(self))
72
73 def __setitem__(self, key, value):
74 """Write to disk every time dict is updated."""
75 dict.__setitem__(self, key, value)
76 self.write()

Subscribers

People subscribed via source and target branches