Skip to content

Commit 3c79cd3

Browse files
author
Takashi Matsuo
committed
Implemented automatic caching for the discovery documents.
1 parent a98add2 commit 3c79cd3

7 files changed

Lines changed: 411 additions & 13 deletions

File tree

googleapiclient/discovery.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,9 @@ def build(serviceName,
149149
developerKey=None,
150150
model=None,
151151
requestBuilder=HttpRequest,
152-
credentials=None):
152+
credentials=None,
153+
cache_discovery=True,
154+
cache=None):
153155
"""Construct a Resource for interacting with an API.
154156
155157
Construct a Resource object for interacting with an API. The serviceName and
@@ -171,6 +173,9 @@ def build(serviceName,
171173
request.
172174
credentials: oauth2client.Credentials, credentials to be used for
173175
authentication.
176+
cache_discovery: Boolean, whether or not to cache the discovery doc.
177+
cache: googleapiclient.discovery_cache.base.CacheBase, an optional
178+
cache object for the discovery documents.
174179
175180
Returns:
176181
A Resource object with methods for interacting with the service.
@@ -185,22 +190,53 @@ def build(serviceName,
185190

186191
requested_url = uritemplate.expand(discoveryServiceUrl, params)
187192

193+
content = _retrieve_discovery_doc(requested_url, http, cache_discovery, cache)
194+
195+
return build_from_document(content, base=discoveryServiceUrl, http=http,
196+
developerKey=developerKey, model=model, requestBuilder=requestBuilder,
197+
credentials=credentials)
198+
199+
200+
def _retrieve_discovery_doc(url, http, cache_discovery, cache=None):
201+
"""Retrieves the discovery_doc from cache or the internet.
202+
203+
Args:
204+
url: string, the URL of the discovery document.
205+
http: httplib2.Http, An instance of httplib2.Http or something that acts
206+
like it through which HTTP requests will be made.
207+
cache_discovery: Boolean, whether or not to cache the discovery doc.
208+
cache: googleapiclient.discovery_cache.base.Cache, an optional cache
209+
object for the discovery documents.
210+
211+
Returns:
212+
A unicode string representation of the discovery document.
213+
"""
214+
if cache_discovery:
215+
from . import discovery_cache
216+
from .discovery_cache import base
217+
if cache is None:
218+
cache = discovery_cache.autodetect()
219+
if cache:
220+
content = cache.get(url)
221+
if content:
222+
return content
223+
224+
actual_url = url
188225
# REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
189226
# variable that contains the network address of the client sending the
190227
# request. If it exists then add that to the request for the discovery
191228
# document to avoid exceeding the quota on discovery requests.
192229
if 'REMOTE_ADDR' in os.environ:
193-
requested_url = _add_query_parameter(requested_url, 'userIp',
194-
os.environ['REMOTE_ADDR'])
195-
logger.info('URL being requested: GET %s' % requested_url)
230+
actual_url = _add_query_parameter(url, 'userIp', os.environ['REMOTE_ADDR'])
231+
logger.info('URL being requested: GET %s', actual_url)
196232

197-
resp, content = http.request(requested_url)
233+
resp, content = http.request(actual_url)
198234

199235
if resp.status == 404:
200236
raise UnknownApiNameOrVersion("name: %s version: %s" % (serviceName,
201-
version))
237+
version))
202238
if resp.status >= 400:
203-
raise HttpError(resp, content, uri=requested_url)
239+
raise HttpError(resp, content, uri=actual_url)
204240

205241
try:
206242
content = content.decode('utf-8')
@@ -212,10 +248,9 @@ def build(serviceName,
212248
except ValueError as e:
213249
logger.error('Failed to parse as JSON: ' + content)
214250
raise InvalidJsonError()
215-
216-
return build_from_document(content, base=discoveryServiceUrl, http=http,
217-
developerKey=developerKey, model=model, requestBuilder=requestBuilder,
218-
credentials=credentials)
251+
if cache_discovery and cache:
252+
cache.set(url, content)
253+
return content
219254

220255

221256
@positional(1)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright 2014 Google Inc. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Caching utility for the discovery document."""
16+
17+
from __future__ import absolute_import
18+
19+
import logging
20+
import datetime
21+
22+
DISCOVERY_DOC_MAX_AGE = 60 * 60 * 24 # 1 day
23+
24+
25+
def autodetect():
26+
"""Detects an appropriate cache module and returns it.
27+
28+
Returns:
29+
googleapiclient.discovery_cache.base.Cache, a cache object which
30+
is auto detected, or None if no cache object is available.
31+
"""
32+
try:
33+
from google.appengine.api import memcache
34+
from . import appengine_memcache
35+
return appengine_memcache.cache
36+
except Exception:
37+
try:
38+
from . import file_cache
39+
return file_cache.cache
40+
except Exception as e:
41+
logging.warning(e, exc_info=True)
42+
return None
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Copyright 2014 Google Inc. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""App Engine memcache based cache for the discovery document."""
16+
17+
import logging
18+
19+
# This is only an optional dependency because we only import this
20+
# module when google.appengine.api.memcache is available.
21+
from google.appengine.api import memcache
22+
23+
from . import base
24+
from ..discovery_cache import DISCOVERY_DOC_MAX_AGE
25+
26+
NAMESPACE = 'google-api-client'
27+
28+
29+
class Cache(base.Cache):
30+
"""A cache with app engine memcache API."""
31+
32+
def __init__(self, max_age):
33+
"""Constructor.
34+
35+
Args:
36+
max_age: Cache expiration in seconds.
37+
"""
38+
self._max_age = max_age
39+
40+
def get(self, url):
41+
try:
42+
return memcache.get(url, namespace=NAMESPACE)
43+
except Exception as e:
44+
logging.warning(e, exc_info=True)
45+
46+
def set(self, url, content):
47+
try:
48+
memcache.set(url, content, time=int(self._max_age), namespace=NAMESPACE)
49+
except Exception as e:
50+
logging.warning(e, exc_info=True)
51+
52+
cache = Cache(max_age=DISCOVERY_DOC_MAX_AGE)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright 2014 Google Inc. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""An abstract class for caching the discovery document."""
16+
17+
import abc
18+
19+
20+
class Cache(object):
21+
"""A base abstract cache class."""
22+
__metaclass__ = abc.ABCMeta
23+
24+
@abc.abstractmethod
25+
def get(self, url):
26+
"""Gets the content from the memcache with a given key.
27+
28+
Args:
29+
url: string, the key for the cache.
30+
31+
Returns:
32+
object, the value in the cache for the given key, or None if the key is
33+
not in the cache.
34+
"""
35+
raise NotImplementedError()
36+
37+
@abc.abstractmethod
38+
def set(self, url, content):
39+
"""Sets the given key and content in the cache.
40+
41+
Args:
42+
url: string, the key for the cache.
43+
content: string, the discovery document.
44+
"""
45+
raise NotImplementedError()
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright 2014 Google Inc. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""File based cache for the discovery document.
16+
The cache is stored in a single file so that multiple processes can
17+
18+
share the same cache. It locks the file whenever accesing to the
19+
file. When the cache content is corrupted, it will be initialized with
20+
an empty cache.
21+
"""
22+
23+
import datetime
24+
import json
25+
import logging
26+
import os
27+
import tempfile
28+
import threading
29+
30+
from oauth2client.locked_file import LockedFile
31+
32+
from . import base
33+
from ..discovery_cache import DISCOVERY_DOC_MAX_AGE
34+
35+
logger = logging.getLogger(__name__)
36+
37+
FILENAME = 'google-api-python-client-discovery-doc.cache'
38+
EPOCH = datetime.datetime.utcfromtimestamp(0)
39+
40+
41+
def _to_timestamp(d):
42+
return (d - EPOCH).total_seconds()
43+
44+
45+
def _read_or_initialize_cache(f):
46+
f.file_handle().seek(0)
47+
try:
48+
cache = json.load(f.file_handle())
49+
except Exception:
50+
# This means it opens the file for the first time, or the cache is
51+
# corrupted, so initializing the file with an empty dict.
52+
cache = {}
53+
f.file_handle().truncate(0)
54+
f.file_handle().seek(0)
55+
json.dump(cache, f.file_handle())
56+
return cache
57+
58+
59+
class Cache(base.Cache):
60+
"""A file based cache for the discovery documents."""
61+
62+
def __init__(self, max_age):
63+
"""Constructor.
64+
65+
Args:
66+
max_age: Cache expiration in seconds.
67+
"""
68+
self._max_age = max_age
69+
self._file = os.path.join(tempfile.gettempdir(), FILENAME)
70+
f = LockedFile(self._file, 'a+', 'r')
71+
try:
72+
f.open_and_lock()
73+
if f.is_locked():
74+
_read_or_initialize_cache(f)
75+
# If we can not obtain the lock, other process or thread must
76+
# have initialized the file.
77+
except Exception as e:
78+
logging.warning(e, exc_info=True)
79+
finally:
80+
f.unlock_and_close()
81+
82+
def get(self, url):
83+
f = LockedFile(self._file, 'r+', 'r')
84+
try:
85+
f.open_and_lock()
86+
if f.is_locked():
87+
cache = _read_or_initialize_cache(f)
88+
if url in cache:
89+
content, t = cache.get(url, (None, 0))
90+
if _to_timestamp(datetime.datetime.now()) < t + self._max_age:
91+
return content
92+
return None
93+
else:
94+
logger.debug('Could not obtain a lock for the cache file.')
95+
return None
96+
except Exception as e:
97+
logger.warning(e, exc_info=True)
98+
finally:
99+
f.unlock_and_close()
100+
101+
def set(self, url, content):
102+
f = LockedFile(self._file, 'r+', 'r')
103+
try:
104+
f.open_and_lock()
105+
if f.is_locked():
106+
cache = _read_or_initialize_cache(f)
107+
cache[url] = (content, _to_timestamp(datetime.datetime.now()))
108+
# Remove stale cache
109+
for k, (_, t) in cache.items():
110+
if _to_timestamp(datetime.datetime.now()) >= t + self._max_age:
111+
del cache[key]
112+
f.file_handle().truncate(0)
113+
f.file_handle().seek(0)
114+
json.dump(cache, f.file_handle())
115+
else:
116+
logger.debug('Could not obtain a lock for the cache file.')
117+
except Exception as e:
118+
logger.warning(e, exc_info=True)
119+
finally:
120+
f.unlock_and_close()
121+
122+
123+
cache = Cache(max_age=DISCOVERY_DOC_MAX_AGE)

0 commit comments

Comments
 (0)