diff --git a/appengine/ndb/async/README.md b/appengine/ndb/async/README.md new file mode 100644 index 000000000000..745e754e6b22 --- /dev/null +++ b/appengine/ndb/async/README.md @@ -0,0 +1,11 @@ +## App Engine Datastore NDB Asynchronous Operations Samples + +This contains snippets used in the NDB asynchronous operations documentation, +demonstrating various ways to make asynchronous ndb operations. + + +These samples are used on the following documentation page: + +> https://cloud.google.com/appengine/docs/python/ndb/async + + diff --git a/appengine/ndb/async/app_async.py b/appengine/ndb/async/app_async.py new file mode 100644 index 000000000000..a9403f1fc39b --- /dev/null +++ b/appengine/ndb/async/app_async.py @@ -0,0 +1,35 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.appengine.api import users +from google.appengine.ext import ndb +import webapp2 + + +class Account(ndb.Model): + view_counter = ndb.IntegerProperty() + + +class MyRequestHandler(webapp2.RequestHandler): + def get(self): + acct = Account.get_by_id(users.get_current_user().user_id()) + acct.view_counter += 1 + future = acct.put_async() + + # ...read something else from Datastore... + + self.response.out.write('Content of the page') + future.get_result() + +app = webapp2.WSGIApplication([('/', MyRequestHandler)]) diff --git a/appengine/ndb/async/app_async_test.py b/appengine/ndb/async/app_async_test.py new file mode 100644 index 000000000000..ba0af15c3ed4 --- /dev/null +++ b/appengine/ndb/async/app_async_test.py @@ -0,0 +1,35 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import app_async +import pytest +import webtest + + +@pytest.fixture +def app(testbed): + return webtest.TestApp(app_async.app) + + +def test_main(app, testbed, login): + app_async.Account(id='123', view_counter=4).put() + + # Log the user in + login(id='123') + + response = app.get('/') + + assert response.status_int == 200 + account = app_async.Account.get_by_id('123') + assert account.view_counter == 5 diff --git a/appengine/ndb/async/app_sync.py b/appengine/ndb/async/app_sync.py new file mode 100644 index 000000000000..690e6b75b251 --- /dev/null +++ b/appengine/ndb/async/app_sync.py @@ -0,0 +1,34 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.appengine.api import users +from google.appengine.ext import ndb +import webapp2 + + +class Account(ndb.Model): + view_counter = ndb.IntegerProperty() + + +class MyRequestHandler(webapp2.RequestHandler): + def get(self): + acct = Account.get_by_id(users.get_current_user().user_id()) + acct.view_counter += 1 + acct.put() + + # ...read something else from Datastore... + + self.response.out.write('Content of the page') + +app = webapp2.WSGIApplication([('/', MyRequestHandler)]) diff --git a/appengine/ndb/async/app_sync_test.py b/appengine/ndb/async/app_sync_test.py new file mode 100644 index 000000000000..3f42385f40c2 --- /dev/null +++ b/appengine/ndb/async/app_sync_test.py @@ -0,0 +1,35 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import app_sync +import pytest +import webtest + + +@pytest.fixture +def app(testbed): + return webtest.TestApp(app_sync.app) + + +def test_main(app, testbed, login): + app_sync.Account(id='123', view_counter=4).put() + + # Log the user in + login(id='123') + + response = app.get('/') + + assert response.status_int == 200 + account = app_sync.Account.get_by_id('123') + assert account.view_counter == 5 diff --git a/appengine/ndb/async/app_toplevel/README.md b/appengine/ndb/async/app_toplevel/README.md new file mode 100644 index 000000000000..a234077ecf85 --- /dev/null +++ b/appengine/ndb/async/app_toplevel/README.md @@ -0,0 +1,2 @@ +This is in a separate folder to isolate it from the other apps. +This is necessary because the test won't pass when run with the other tests. diff --git a/appengine/ndb/async/app_toplevel/app_toplevel.py b/appengine/ndb/async/app_toplevel/app_toplevel.py new file mode 100644 index 000000000000..a1a4a2ac7747 --- /dev/null +++ b/appengine/ndb/async/app_toplevel/app_toplevel.py @@ -0,0 +1,39 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.appengine.api import users +from google.appengine.ext import ndb +import webapp2 + + +class Account(ndb.Model): + view_counter = ndb.IntegerProperty() + + +class MyRequestHandler(webapp2.RequestHandler): + @ndb.toplevel + def get(self): + acct = Account.get_by_id(users.get_current_user().user_id()) + acct.view_counter += 1 + acct.put_async() # Ignoring the Future this returns + + # ...read something else from Datastore... + + self.response.out.write('Content of the page') + + +# This is actually redundant, since the `get` decorator already handles it, but +# for demonstration purposes, you can also make the entire app toplevel with +# the following. +app = ndb.toplevel(webapp2.WSGIApplication([('/', MyRequestHandler)])) diff --git a/appengine/ndb/async/app_toplevel/app_toplevel_test.py b/appengine/ndb/async/app_toplevel/app_toplevel_test.py new file mode 100644 index 000000000000..4833d316296d --- /dev/null +++ b/appengine/ndb/async/app_toplevel/app_toplevel_test.py @@ -0,0 +1,35 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import app_toplevel +import pytest +import webtest + + +@pytest.fixture +def app(testbed): + return webtest.TestApp(app_toplevel.app) + + +def test_main(app, testbed, login): + app_toplevel.Account(id='123', view_counter=4).put() + + # Log the user in + login(id='123') + + response = app.get('/') + + assert response.status_int == 200 + account = app_toplevel.Account.get_by_id('123') + assert account.view_counter == 5 diff --git a/appengine/ndb/async/app_toplevel/index.html b/appengine/ndb/async/app_toplevel/index.html new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/appengine/ndb/async/guestbook.py b/appengine/ndb/async/guestbook.py new file mode 100644 index 000000000000..4c2d452c97fb --- /dev/null +++ b/appengine/ndb/async/guestbook.py @@ -0,0 +1,101 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.appengine.api import users +from google.appengine.ext import ndb +import webapp2 + + +class Guestbook(ndb.Model): + content = ndb.StringProperty() + post_date = ndb.DateTimeProperty(auto_now_add=True) + + +class Account(ndb.Model): + email = ndb.StringProperty() + nickname = ndb.StringProperty() + + def nick(self): + return self.nickname or self.email # Whichever is non-empty + + +class Message(ndb.Model): + text = ndb.StringProperty() + when = ndb.DateTimeProperty(auto_now_add=True) + author = ndb.KeyProperty(kind=Account) # references Account + + +class MainPage(webapp2.RequestHandler): + def get(self): + if self.request.path == '/guestbook': + if self.request.get('async'): + self.get_guestbook_async() + else: + self.get_guestbook_sync() + elif self.request.path == '/messages': + if self.request.get('async'): + self.get_messages_async() + else: + self.get_messages_sync() + + def get_guestbook_sync(self): + uid = users.get_current_user().user_id() + acct = Account.get_by_id(uid) # I/O action 1 + qry = Guestbook.query().order(-Guestbook.post_date) + recent_entries = qry.fetch(10) # I/O action 2 + + # ...render HTML based on this data... + self.response.out.write('{}'.format(''.join( + '

{}

'.format(entry.content) for entry in recent_entries))) + + return acct, qry + + def get_guestbook_async(self): + uid = users.get_current_user().user_id() + acct_future = Account.get_by_id_async(uid) # Start I/O action #1 + qry = Guestbook.query().order(-Guestbook.post_date) + recent_entries_future = qry.fetch_async(10) # Start I/O action #2 + acct = acct_future.get_result() # Complete #1 + recent_entries = recent_entries_future.get_result() # Complete #2 + + # ...render HTML based on this data... + self.response.out.write('{}'.format(''.join( + '

{}

'.format(entry.content) for entry in recent_entries))) + + return acct, recent_entries + + def get_messages_sync(self): + qry = Message.query().order(-Message.when) + for msg in qry.fetch(20): + acct = msg.author.get() + self.response.out.write( + '

On {}, {} wrote:'.format(msg.when, acct.nick())) + self.response.out.write('

{}'.format(msg.text)) + + def get_messages_async(self): + @ndb.tasklet + def callback(msg): + acct = yield msg.author.get_async() + raise ndb.Return('On {}, {} wrote:\n{}'.format( + msg.when, acct.nick(), msg.text)) + + qry = Message.query().order(-Message.when) + outputs = qry.map(callback, limit=20) + for output in outputs: + self.response.out.write('

{}

'.format(output)) + + +app = webapp2.WSGIApplication([ + ('/.*', MainPage), +], debug=True) diff --git a/appengine/ndb/async/guestbook_test.py b/appengine/ndb/async/guestbook_test.py new file mode 100644 index 000000000000..418c12d35c63 --- /dev/null +++ b/appengine/ndb/async/guestbook_test.py @@ -0,0 +1,73 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import guestbook +import pytest +import webtest + + +@pytest.fixture +def app(testbed): + return webtest.TestApp(guestbook.app) + + +def test_get_guestbook_sync(app, testbed, login): + guestbook.Account(id='123').put() + # Log the user in + login(id='123') + + for i in range(11): + guestbook.Guestbook(content='Content {}'.format(i)).put() + + response = app.get('/guestbook') + + assert response.status_int == 200 + assert 'Content 1' in response.body + + +def test_get_guestbook_async(app, testbed, login): + guestbook.Account(id='123').put() + # Log the user in + login(id='123') + for i in range(11): + guestbook.Guestbook(content='Content {}'.format(i)).put() + + response = app.get('/guestbook?async=1') + + assert response.status_int == 200 + assert 'Content 1' in response.body + + +def test_get_messages_sync(app, testbed): + for i in range(21): + account_key = guestbook.Account(nickname='Nick {}'.format(i)).put() + guestbook.Message(author=account_key, text='Text {}'.format(i)).put() + + response = app.get('/messages') + + assert response.status_int == 200 + assert 'Nick 1 wrote:' in response.body + assert '

Text 1' in response.body + + +def test_get_messages_async(app, testbed): + for i in range(21): + account_key = guestbook.Account(nickname='Nick {}'.format(i)).put() + guestbook.Message(author=account_key, text='Text {}'.format(i)).put() + + response = app.get('/messages?async=1') + + assert response.status_int == 200 + assert 'Nick 1 wrote:' in response.body + assert '\nText 1' in response.body diff --git a/appengine/ndb/async/shopping_cart.py b/appengine/ndb/async/shopping_cart.py new file mode 100644 index 000000000000..1ddf1306d42f --- /dev/null +++ b/appengine/ndb/async/shopping_cart.py @@ -0,0 +1,137 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.appengine.ext import ndb + + +# [START models] +class Account(ndb.Model): + pass + + +class InventoryItem(ndb.Model): + name = ndb.StringProperty() + + +class CartItem(ndb.Model): + account = ndb.KeyProperty(kind=Account) + inventory = ndb.KeyProperty(kind=InventoryItem) + quantity = ndb.IntegerProperty() + + +class SpecialOffer(ndb.Model): + inventory = ndb.KeyProperty(kind=InventoryItem) +# [END models] + + +def get_cart_plus_offers(acct): + cart = CartItem.query(CartItem.account == acct.key).fetch() + offers = SpecialOffer.query().fetch(10) + ndb.get_multi([item.inventory for item in cart] + + [offer.inventory for offer in offers]) + return cart, offers + + +def get_cart_plus_offers_async(acct): + cart_future = CartItem.query(CartItem.account == acct.key).fetch_async() + offers_future = SpecialOffer.query().fetch_async(10) + cart = cart_future.get_result() + offers = offers_future.get_result() + ndb.get_multi([item.inventory for item in cart] + + [offer.inventory for offer in offers]) + return cart, offers + + +# [START cart_offers_tasklets] +@ndb.tasklet +def get_cart_tasklet(acct): + cart = yield CartItem.query(CartItem.account == acct.key).fetch_async() + yield ndb.get_multi_async([item.inventory for item in cart]) + raise ndb.Return(cart) + + +@ndb.tasklet +def get_offers_tasklet(acct): + offers = yield SpecialOffer.query().fetch_async(10) + yield ndb.get_multi_async([offer.inventory for offer in offers]) + raise ndb.Return(offers) + + +@ndb.tasklet +def get_cart_plus_offers_tasklet(acct): + cart, offers = yield get_cart_tasklet(acct), get_offers_tasklet(acct) + raise ndb.Return((cart, offers)) +# [END cart_offers_tasklets] + + +@ndb.tasklet +def iterate_over_query_results_in_tasklet(Model, is_the_entity_i_want): + qry = Model.query() + qit = qry.iter() + while (yield qit.has_next_async()): + entity = qit.next() + # Do something with entity + if is_the_entity_i_want(entity): + raise ndb.Return(entity) + + +@ndb.tasklet +def blocking_iteration_over_query_results(Model, is_the_entity_i_want): + # DO NOT DO THIS IN A TASKLET + qry = Model.query() + for entity in qry: + # Do something with entity + if is_the_entity_i_want(entity): + raise ndb.Return(entity) + + +def define_get_google(): + @ndb.tasklet + def get_google(): + context = ndb.get_context() + result = yield context.urlfetch("http://www.google.com/") + if result.status_code == 200: + raise ndb.Return(result.content) + # else return None + + return get_google + + +def define_update_counter_async(): + @ndb.transactional_async + def update_counter(counter_key): + counter = counter_key.get() + counter.value += 1 + counter.put() + return counter.value + + return update_counter + + +def define_update_counter_tasklet(): + @ndb.transactional_tasklet + def update_counter(counter_key): + counter = yield counter_key.get_async() + counter.value += 1 + yield counter.put_async() + + return update_counter + + +def get_first_ready(): + urls = ["http://www.google.com/", "http://www.blogspot.com/"] + context = ndb.get_context() + futures = [context.urlfetch(url) for url in urls] + first_future = ndb.Future.wait_any(futures) + return first_future.get_result().content diff --git a/appengine/ndb/async/shopping_cart_test.py b/appengine/ndb/async/shopping_cart_test.py new file mode 100644 index 000000000000..198125c33d04 --- /dev/null +++ b/appengine/ndb/async/shopping_cart_test.py @@ -0,0 +1,143 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.appengine.ext import ndb +import pytest +import shopping_cart + + +@pytest.fixture +def items(testbed): + account = shopping_cart.Account(id='123') + account.put() + + items = [shopping_cart.InventoryItem(name='Item {}'.format(i)) + for i in range(6)] + special_items = [shopping_cart.InventoryItem(name='Special {}'.format(i)) + for i in range(6)] + for i in items + special_items: + i.put() + + special_offers = [shopping_cart.SpecialOffer(inventory=item.key) + for item in special_items] + cart_items = [ + shopping_cart.CartItem( + account=account.key, inventory=item.key, quantity=i) + for i, item in enumerate(items[:6] + special_items[:6])] + for i in special_offers + cart_items: + i.put() + + return account, items, special_items, cart_items, special_offers + + +def test_get_cart_plus_offers(items): + account, items, special_items, cart_items, special_offers = items + + cart, offers = shopping_cart.get_cart_plus_offers(account) + + assert len(cart) == 12 + assert len(offers) == 6 + + +def test_get_cart_plus_offers_async(items): + account, items, special_items, cart_items, special_offers = items + + cart, offers = shopping_cart.get_cart_plus_offers_async(account) + + assert len(cart) == 12 + assert len(offers) == 6 + + +def test_get_cart_tasklet(items): + account, items, special_items, cart_items, special_offers = items + + future = shopping_cart.get_cart_tasklet(account) + cart = future.get_result() + + assert len(cart) == 12 + + +def test_get_offers_tasklet(items): + account, items, special_items, cart_items, special_offers = items + + future = shopping_cart.get_offers_tasklet(account) + offers = future.get_result() + + assert len(offers) == 6 + + +def test_get_cart_plus_offers_tasklet(items): + account, items, special_items, cart_items, special_offers = items + + future = shopping_cart.get_cart_plus_offers_tasklet( + account) + cart, offers = future.get_result() + + assert len(cart) == 12 + assert len(offers) == 6 + + +def test_iterate_over_query_results_in_tasklet(items): + account, items, special_items, cart_items, special_offers = items + + future = shopping_cart.iterate_over_query_results_in_tasklet( + shopping_cart.InventoryItem, lambda item: '3' in item.name) + + assert '3' in future.get_result().name + + +def test_do_not_iterate_over_tasklet_like_this(items): + account, items, special_items, cart_items, special_offers = items + + future = shopping_cart.blocking_iteration_over_query_results( + shopping_cart.InventoryItem, lambda item: '3' in item.name) + + assert '3' in future.get_result().name + + +def test_get_google(testbed): + testbed.init_urlfetch_stub() + + get_google = shopping_cart.define_get_google() + future = get_google() + assert 'Google' in future.get_result() + + +class Counter(ndb.Model): + value = ndb.IntegerProperty() + + +def test_update_counter_async(testbed): + counter_key = Counter(value=1).put() + update_counter = shopping_cart.define_update_counter_async() + future = update_counter(counter_key) + assert counter_key.get().value == 1 + assert future.get_result() == 2 + assert counter_key.get().value == 2 + + +def test_update_counter_tasklet(testbed): + counter_key = Counter(value=1).put() + update_counter = shopping_cart.define_update_counter_tasklet() + future = update_counter(counter_key) + assert counter_key.get().value == 1 + future.get_result() + assert counter_key.get().value == 2 + + +def test_get_first_ready(testbed): + testbed.init_urlfetch_stub() + + content = shopping_cart.get_first_ready() + assert 'html' in content