├── .editorconfig ├── .flake8 ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── discover_tests.py ├── docs ├── Makefile ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── make.bat ├── readme.rst └── usage.rst ├── examples ├── __init__.py └── starwars │ ├── __init__.py │ ├── data.py │ ├── models.py │ ├── schema.py │ └── tests │ ├── __init__.py │ ├── test_connections.py │ ├── test_mutation.py │ └── test_object_identification.py ├── graphene_gae ├── __init__.py ├── ndb │ ├── __init__.py │ ├── converter.py │ ├── fields.py │ ├── options.py │ ├── registry.py │ └── types.py └── webapp2 │ └── __init__.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── _ndb │ ├── __init__.py │ ├── test_converter.py │ ├── test_types.py │ └── test_types_relay.py ├── _webapp2 │ ├── __init__.py │ └── test_graphql_handler.py ├── base_test.py └── models.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore=W391,E722 3 | max-line-length=160 4 | exclude=tests/base_test.py 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | .eggs 13 | .cache 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | htmlcov 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | 40 | # Complexity 41 | output/*.html 42 | output/*/index.html 43 | 44 | # Sphinx 45 | docs/_build 46 | 47 | # PyCharm 48 | .idea 49 | 50 | # Test results 51 | results.xml 52 | 53 | # Virtualenv 54 | .venv 55 | 56 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | 5 | python: 6 | - "2.7" 7 | 8 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 9 | install: 10 | - wget https://storage.googleapis.com/appengine-sdks/featured/google_appengine_1.9.37.zip -nv 11 | - unzip -q google_appengine_1.9.37.zip 12 | - export GOOGLE_APPENGINE_HOME=$(pwd)/google_appengine/ 13 | - export PYTHONPATH=${PYTHONPATH}:${GOOGLE_APPENGINE_HOME} 14 | - export PATH=${PATH}:${GOOGLE_APPENGINE_HOME} 15 | - pip install -U pip 16 | - pip install coveralls 17 | 18 | # command to run tests, e.g. python setup.py test 19 | script: 20 | - make deps 21 | - make test 22 | - make coverage 23 | - make lint 24 | 25 | after_success: coveralls 26 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Eran Kampf 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/g/graphene_gae/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Your operating system name and version. 21 | * Any details about your local setup that might be helpful in troubleshooting. 22 | * Detailed steps to reproduce the bug. 23 | 24 | Fix Bugs 25 | ~~~~~~~~ 26 | 27 | Look through the GitHub issues for bugs. Anything tagged with "bug" 28 | is open to whoever wants to implement it. 29 | 30 | Implement Features 31 | ~~~~~~~~~~~~~~~~~~ 32 | 33 | Look through the GitHub issues for features. Anything tagged with "feature" 34 | is open to whoever wants to implement it. 35 | 36 | Write Documentation 37 | ~~~~~~~~~~~~~~~~~~~ 38 | 39 | Graphene GAE could always use more documentation, whether as part of the 40 | official Graphene GAE docs, in docstrings, or even on the web in blog posts, 41 | articles, and such. 42 | 43 | Submit Feedback 44 | ~~~~~~~~~~~~~~~ 45 | 46 | The best way to send feedback is to file an issue at https://github.com/graphql-python/graphene_gae/issues. 47 | 48 | If you are proposing a feature: 49 | 50 | * Explain in detail how it would work. 51 | * Keep the scope as narrow as possible, to make it easier to implement. 52 | * Remember that this is a volunteer-driven project, and that contributions 53 | are welcome :) 54 | 55 | Get Started! 56 | ------------ 57 | 58 | Ready to contribute? Here's how to set up `graphene_gae` for local development. 59 | 60 | 1. Fork the `graphene_gae` repo on GitHub. 61 | 2. Clone your fork locally:: 62 | 63 | $ git clone git@github.com:your_name_here/graphene_gae.git 64 | 65 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 66 | 67 | $ mkvirtualenv graphene_gae 68 | $ cd graphene_gae/ 69 | $ python setup.py develop 70 | 71 | 4. Create a branch for local development:: 72 | 73 | $ git checkout -b name-of-your-bugfix-or-feature 74 | 75 | Now you can make your changes locally. 76 | 77 | 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: 78 | 79 | $ flake8 graphene_gae tests 80 | $ python setup.py test 81 | $ tox 82 | 83 | To get flake8 and tox, just pip install them into your virtualenv. 84 | 85 | 6. Commit your changes and push your branch to GitHub:: 86 | 87 | $ git add . 88 | $ git commit -m "Your detailed description of your changes." 89 | $ git push origin name-of-your-bugfix-or-feature 90 | 91 | 7. Submit a pull request through the GitHub website. 92 | 93 | Pull Request Guidelines 94 | ----------------------- 95 | 96 | Before you submit a pull request, check that it meets these guidelines: 97 | 98 | 1. The pull request should include tests. 99 | 2. If the pull request adds functionality, the docs should be updated. Put 100 | your new functionality into a function with a docstring, and add the 101 | feature to the list in README.rst. 102 | 3. The pull request should work for Python 2.7 (because thats what GAE runs). Check 103 | https://travis-ci.org/graphql-python/graphene_gae/pull_requests 104 | and make sure that the tests pass for all supported Python versions. 105 | 106 | Tips 107 | ---- 108 | 109 | To run a subset of tests:: 110 | 111 | $ python -m unittest tests.test_graphene_gae 112 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 1.0.7 (TBD) 6 | ----------- 7 | * GraphQLHandler GET supoort ([PR #27](https://github.com/graphql-python/graphene-gae/pull/27)) 8 | 9 | 1.0.6 (2016-12-06) 10 | ------------------ 11 | * Fixed DeadlineExceededError import swo connections properly handle timeouts 12 | 13 | 1.0.5 (2016-11-23) 14 | ------------------ 15 | * Improved behavior of `NdbConnectionField` when `transform_edges` also filters out some edges ([PR #26](https://github.com/graphql-python/graphene-gae/pull/25)) 16 | 17 | 1.0.3 (2016-11-22) 18 | ------------------ 19 | * Added `transform_edges` to `NdbConnectionField` ([PR #25](https://github.com/graphql-python/graphene-gae/pull/25)) 20 | 21 | 1.0.2 (2016-10-20) 22 | ------------------ 23 | * Added `_handle_graphql_errors` hook to GraphQLHandler 24 | 25 | 1.0.1 (2016-09-28) 26 | ------------------ 27 | * Added missing support for StructuredProperty 28 | 29 | 1.0 (2016-09-26) 30 | ---------------- 31 | * Upgraded to Graphene 1.0 32 | 33 | 0.1.9 (2016-08-17) 34 | --------------------- 35 | * Each NdbObject now exposes an `ndbId` String field that maps to the entity's `key.id()` 36 | * Added ndb boolean argument to NdbKeyStringField so now when looking at KeyProperty we can fetch either global GraphQL id or the NDB internal id. 37 | 38 | 39 | 0.1.8 (2016-08-16) 40 | --------------------- 41 | * Made connection_from_ndb_query resilient to random ndb timeouts 42 | 43 | 44 | 0.1.7 (2016-06-14) 45 | --------------------- 46 | * BREAKING: Fixed behavior of KeyProperty to expose GraphQL Global IDs instead of internal ndb.Key values. ([PR #16](https://github.com/graphql-python/graphene-gae/pull/16)) 47 | 48 | 0.1.6 (2016-06-10) 49 | --------------------- 50 | * Changing development status to Beta 51 | * Added NdbNode.global_id_to_key [PR #15](https://github.com/graphql-python/graphene-gae/pull/15) 52 | 53 | 0.1.5 (2016-06-08) 54 | --------------------- 55 | * Fixed behavior of ndb.KeyProperty ([PR #14](https://github.com/graphql-python/graphene-gae/pull/14)) 56 | 57 | 0.1.4 (2016-06-02) 58 | --------------------- 59 | * NdbConnectionField added arguments that can be used in quert: 60 | * keys_only - to execute a keys only query 61 | * batch_size - to control the NDB query iteration batch size 62 | * page_size - control the page sizes when paginating connection results 63 | * Added support for LocalStructuredProperty. 64 | * Given a property `ndb.LocalStructuredType(Something)` it will automatically 65 | map to a Field(SomethingType) - SomethingType has to be part of the schema. 66 | * Support for `repeated` and `required` propeties. 67 | 68 | 69 | 0.1.3 (2016-05-27) 70 | --------------------- 71 | * Added `graphene_gae.webapp2.GraphQLHandler` - a basic HTTP Handler to process GraphQL requests 72 | 73 | 74 | 0.1.1 (2016-05-25) 75 | --------------------- 76 | 77 | * Updated graphene dependency to latest 0.10.1 version. 78 | * NdbConnection.from_list now gets context as parameter 79 | 80 | 81 | 0.1.0 (2016-05-11) 82 | --------------------- 83 | 84 | * First release on PyPI. 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Eran Kampf 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of Graphene GAE nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude examples * 9 | recursive-exclude * __pycache__ 10 | recursive-exclude * *.py[co] 11 | 12 | recursive-include docs *.rst conf.py Makefile make.bat 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifndef GOOGLE_APPENGINE 2 | export GOOGLE_APPENGINE:=/usr/local/google_appengine 3 | endif 4 | 5 | export PATH:=$(PATH):$(GOOGLE_APPENGINE) 6 | export PYTHONPATH:=$(PYTHONPATH):${GOOGLE_APPENGINE} 7 | 8 | .PHONY: clean-pyc clean-build docs clean 9 | 10 | help: 11 | @echo "clean - remove all build, test, coverage and Python artifacts" 12 | @echo "clean-build - remove build artifacts" 13 | @echo "clean-pyc - remove Python file artifacts" 14 | @echo "clean-test - remove test and coverage artifacts" 15 | @echo "lint - check style with flake8" 16 | @echo "test - run tests quickly with the default Python" 17 | @echo "test-all - run tests on every Python version with tox" 18 | @echo "coverage - check code coverage quickly with the default Python" 19 | @echo "docs - generate Sphinx HTML documentation, including API docs" 20 | @echo "release - package and upload a release" 21 | @echo "dist - package" 22 | @echo "install - install the package to the active Python's site-packages" 23 | 24 | .venv: 25 | if [ ! -e ".venv/bin/activate_this.py" ] ; then virtualenv --clear .venv ; fi 26 | 27 | deps: .venv 28 | PYTHONPATH=.venv ; . .venv/bin/activate && .venv/bin/pip install -U -r requirements.txt 29 | 30 | clean: clean-build clean-pyc clean-test 31 | 32 | clean-build: 33 | rm -fr build/ 34 | rm -fr dist/ 35 | rm -fr .eggs/ 36 | find . -name '*.egg-info' -exec rm -rf {} + 37 | find . -name '*.egg' -exec rm -rf {} + 38 | 39 | clean-pyc: 40 | find . -name '*.pyc' -exec rm -f {} + 41 | find . -name '*.pyo' -exec rm -f {} + 42 | find . -name '*~' -exec rm -f {} + 43 | find . -name '__pycache__' -exec rm -fr {} + 44 | 45 | clean-test: 46 | rm -fr .tox/ 47 | rm -f .coverage 48 | rm -fr htmlcov/ 49 | 50 | lint: 51 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && flake8 graphene_gae tests 52 | 53 | test: 54 | PYTHONPATH=$PYTHONPATH:.venv:. . .venv/bin/activate && python setup.py test 55 | 56 | test-all: 57 | PYTHONPATH=$PYTHONPATH:.venv:. . .venv/bin/activate && tox 58 | 59 | coverage: 60 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && coverage run --source graphene_gae setup.py test 61 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && coverage report -m 62 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && coverage html 63 | 64 | docs: 65 | rm -f docs/graphene_gae.rst 66 | rm -f docs/modules.rst 67 | sphinx-apidoc -o docs/ graphene_gae 68 | $(MAKE) -C docs clean 69 | $(MAKE) -C docs html 70 | open docs/_build/html/index.html 71 | 72 | release: clean 73 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && python setup.py sdist upload 74 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && python setup.py bdist_wheel upload 75 | 76 | dist: clean 77 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && python setup.py sdist 78 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && python setup.py bdist_wheel 79 | ls -l dist 80 | 81 | install: clean 82 | PYTHONPATH=$PYTHONPATH:.venv:. ; . .venv/bin/activate && python setup.py install 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graphene GAE (deprecated!) 2 | 3 | > :warning: This repository is deprecated due to lack of maintainers. 4 | If you're interested in taking over let us know via [the Graphene 5 | Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM) 6 | 7 | A Google AppEngine integration library for 8 | [Graphene](http://graphene-python.org) 9 | 10 | - Free software: BSD license 11 | - Documentation: . 12 | 13 | ## Upgrade Notes 14 | 15 | If you're upgrading from an older version (pre 2.0 version) please check 16 | out the [Graphene Upgrade 17 | Guide](https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md). 18 | 19 | ## Installation 20 | 21 | To install Graphene-GAE on your AppEngine project, go to your project 22 | folder and runthis command in your shell: 23 | 24 | ``` bash 25 | pip install graphene-gae -t ./libs 26 | ``` 27 | 28 | This will install the library and its dependencies to the libs folder under your projects root - so the 30 | dependencies get uploaded withyour GAE project when you publish your 31 | app. 32 | 33 | Make sure the libs folder is in your 34 | python path by adding the following to your \`appengine_config.py\`: 35 | 36 | ``` python 37 | import sys 38 | 39 | for path in ['libs']: 40 | if path not in sys.path: 41 | sys.path[0:0] = [path] 42 | ``` 43 | 44 | ## Examples 45 | 46 | Here's a simple GAE model: 47 | 48 | ``` python 49 | class Article(ndb.Model): 50 | headline = ndb.StringProperty() 51 | summary = ndb.TextProperty() 52 | text = ndb.TextProperty() 53 | 54 | author_key = ndb.KeyProperty(kind='Author') 55 | 56 | created_at = ndb.DateTimeProperty(auto_now_add=True) 57 | updated_at = ndb.DateTimeProperty(auto_now=True) 58 | ``` 59 | 60 | To create a GraphQL schema for it you simply have to write the 61 | following: 62 | 63 | ``` python 64 | import graphene 65 | from graphene_gae import NdbObjectType 66 | 67 | class ArticleType(NdbObjectType): 68 | class Meta: 69 | model = Article 70 | 71 | class QueryRoot(graphene.ObjectType): 72 | articles = graphene.List(ArticleType) 73 | 74 | @graphene.resolve_only_args 75 | def resolve_articles(self): 76 | return Article.query() 77 | 78 | schema = graphene.Schema(query=QueryRoot) 79 | ``` 80 | 81 | Then you can simply query the schema: 82 | 83 | ``` python 84 | query = ''' 85 | query GetArticles { 86 | articles { 87 | headline, 88 | summary, 89 | created_at 90 | } 91 | } 92 | ''' 93 | result = schema.execute(query) 94 | ``` 95 | 96 | To learn more check out the following [examples](examples/): 97 | 98 | - [Starwars](examples/starwars) 99 | 100 | ## Contributing 101 | 102 | After cloning this repo, ensure dependencies are installed by running: 103 | 104 | ``` sh 105 | make deps 106 | make install 107 | ``` 108 | 109 | Make sure tests and lint are running: 110 | 111 | ``` sh 112 | make test 113 | make lint 114 | ``` 115 | -------------------------------------------------------------------------------- /discover_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | 5 | def additional_tests(): 6 | setup_file = sys.modules['__main__'].__file__ 7 | setup_dir = os.path.abspath(os.path.dirname(setup_file)) 8 | print("\n***Looking for tests in %s\n" % setup_dir) 9 | return unittest.defaultTestLoader.discover(setup_dir, top_level_dir=setup_dir) 10 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/graphene_gae.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/graphene_gae.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/graphene_gae" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/graphene_gae" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # graphene_gae documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another 20 | # directory, add these directories to sys.path here. If the directory is 21 | # relative to the documentation root, use os.path.abspath to make it 22 | # absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # Get the project root dir, which is the parent dir of this 26 | cwd = os.getcwd() 27 | project_root = os.path.dirname(cwd) 28 | 29 | # Insert the project root dir as the first element in the PYTHONPATH. 30 | # This lets us ensure that the source package is imported, and that its 31 | # version is used. 32 | sys.path.insert(0, project_root) 33 | 34 | import graphene_gae 35 | 36 | # -- General configuration --------------------------------------------- 37 | 38 | # If your documentation needs a minimal Sphinx version, state it here. 39 | #needs_sphinx = '1.0' 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be 42 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 43 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix of source filenames. 49 | source_suffix = '.rst' 50 | 51 | # The encoding of source files. 52 | #source_encoding = 'utf-8-sig' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # General information about the project. 58 | project = u'Graphene GAE' 59 | copyright = u'2016, Eran Kampf' 60 | 61 | # The version info for the project you're documenting, acts as replacement 62 | # for |version| and |release|, also used in various other places throughout 63 | # the built documents. 64 | # 65 | # The short X.Y version. 66 | version = graphene_gae.__version__ 67 | # The full version, including alpha/beta/rc tags. 68 | release = graphene_gae.__version__ 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | #language = None 73 | 74 | # There are two options for replacing |today|: either, you set today to 75 | # some non-false value, then it is used: 76 | #today = '' 77 | # Else, today_fmt is used as the format for a strftime call. 78 | #today_fmt = '%B %d, %Y' 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | exclude_patterns = ['_build'] 83 | 84 | # The reST default role (used for this markup: `text`) to use for all 85 | # documents. 86 | #default_role = None 87 | 88 | # If true, '()' will be appended to :func: etc. cross-reference text. 89 | #add_function_parentheses = True 90 | 91 | # If true, the current module name will be prepended to all description 92 | # unit titles (such as .. function::). 93 | #add_module_names = True 94 | 95 | # If true, sectionauthor and moduleauthor directives will be shown in the 96 | # output. They are ignored by default. 97 | #show_authors = False 98 | 99 | # The name of the Pygments (syntax highlighting) style to use. 100 | pygments_style = 'sphinx' 101 | 102 | # A list of ignored prefixes for module index sorting. 103 | #modindex_common_prefix = [] 104 | 105 | # If true, keep warnings as "system message" paragraphs in the built 106 | # documents. 107 | #keep_warnings = False 108 | 109 | 110 | # -- Options for HTML output ------------------------------------------- 111 | 112 | # The theme to use for HTML and HTML Help pages. See the documentation for 113 | # a list of builtin themes. 114 | html_theme = 'default' 115 | 116 | # Theme options are theme-specific and customize the look and feel of a 117 | # theme further. For a list of options available for each theme, see the 118 | # documentation. 119 | #html_theme_options = {} 120 | 121 | # Add any paths that contain custom themes here, relative to this directory. 122 | #html_theme_path = [] 123 | 124 | # The name for this set of Sphinx documents. If None, it defaults to 125 | # " v documentation". 126 | #html_title = None 127 | 128 | # A shorter title for the navigation bar. Default is the same as 129 | # html_title. 130 | #html_short_title = None 131 | 132 | # The name of an image file (relative to this directory) to place at the 133 | # top of the sidebar. 134 | #html_logo = None 135 | 136 | # The name of an image file (within the static path) to use as favicon 137 | # of the docs. This file should be a Windows icon file (.ico) being 138 | # 16x16 or 32x32 pixels large. 139 | #html_favicon = None 140 | 141 | # Add any paths that contain custom static files (such as style sheets) 142 | # here, relative to this directory. They are copied after the builtin 143 | # static files, so a file named "default.css" will overwrite the builtin 144 | # "default.css". 145 | html_static_path = ['_static'] 146 | 147 | # If not '', a 'Last updated on:' timestamp is inserted at every page 148 | # bottom, using the given strftime format. 149 | #html_last_updated_fmt = '%b %d, %Y' 150 | 151 | # If true, SmartyPants will be used to convert quotes and dashes to 152 | # typographically correct entities. 153 | #html_use_smartypants = True 154 | 155 | # Custom sidebar templates, maps document names to template names. 156 | #html_sidebars = {} 157 | 158 | # Additional templates that should be rendered to pages, maps page names 159 | # to template names. 160 | #html_additional_pages = {} 161 | 162 | # If false, no module index is generated. 163 | #html_domain_indices = True 164 | 165 | # If false, no index is generated. 166 | #html_use_index = True 167 | 168 | # If true, the index is split into individual pages for each letter. 169 | #html_split_index = False 170 | 171 | # If true, links to the reST sources are added to the pages. 172 | #html_show_sourcelink = True 173 | 174 | # If true, "Created using Sphinx" is shown in the HTML footer. 175 | # Default is True. 176 | #html_show_sphinx = True 177 | 178 | # If true, "(C) Copyright ..." is shown in the HTML footer. 179 | # Default is True. 180 | #html_show_copyright = True 181 | 182 | # If true, an OpenSearch description file will be output, and all pages 183 | # will contain a tag referring to it. The value of this option 184 | # must be the base URL from which the finished HTML is served. 185 | #html_use_opensearch = '' 186 | 187 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 188 | #html_file_suffix = None 189 | 190 | # Output file base name for HTML help builder. 191 | htmlhelp_basename = 'graphene_gaedoc' 192 | 193 | 194 | # -- Options for LaTeX output ------------------------------------------ 195 | 196 | latex_elements = { 197 | # The paper size ('letterpaper' or 'a4paper'). 198 | #'papersize': 'letterpaper', 199 | 200 | # The font size ('10pt', '11pt' or '12pt'). 201 | #'pointsize': '10pt', 202 | 203 | # Additional stuff for the LaTeX preamble. 204 | #'preamble': '', 205 | } 206 | 207 | # Grouping the document tree into LaTeX files. List of tuples 208 | # (source start file, target name, title, author, documentclass 209 | # [howto/manual]). 210 | latex_documents = [ 211 | ('index', 'graphene_gae.tex', 212 | u'Graphene GAE Documentation', 213 | u'Eran Kampf', 'manual'), 214 | ] 215 | 216 | # The name of an image file (relative to this directory) to place at 217 | # the top of the title page. 218 | #latex_logo = None 219 | 220 | # For "manual" documents, if this is true, then toplevel headings 221 | # are parts, not chapters. 222 | #latex_use_parts = False 223 | 224 | # If true, show page references after internal links. 225 | #latex_show_pagerefs = False 226 | 227 | # If true, show URL addresses after external links. 228 | #latex_show_urls = False 229 | 230 | # Documents to append as an appendix to all manuals. 231 | #latex_appendices = [] 232 | 233 | # If false, no module index is generated. 234 | #latex_domain_indices = True 235 | 236 | 237 | # -- Options for manual page output ------------------------------------ 238 | 239 | # One entry per manual page. List of tuples 240 | # (source start file, name, description, authors, manual section). 241 | man_pages = [ 242 | ('index', 'graphene_gae', 243 | u'Graphene GAE Documentation', 244 | [u'Eran Kampf'], 1) 245 | ] 246 | 247 | # If true, show URL addresses after external links. 248 | #man_show_urls = False 249 | 250 | 251 | # -- Options for Texinfo output ---------------------------------------- 252 | 253 | # Grouping the document tree into Texinfo files. List of tuples 254 | # (source start file, target name, title, author, 255 | # dir menu entry, description, category) 256 | texinfo_documents = [ 257 | ('index', 'graphene_gae', 258 | u'Graphene GAE Documentation', 259 | u'Eran Kampf', 260 | 'graphene_gae', 261 | 'One line description of project.', 262 | 'Miscellaneous'), 263 | ] 264 | 265 | # Documents to append as an appendix to all manuals. 266 | #texinfo_appendices = [] 267 | 268 | # If false, no module index is generated. 269 | #texinfo_domain_indices = True 270 | 271 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 272 | #texinfo_show_urls = 'footnote' 273 | 274 | # If true, do not generate a @detailmenu in the "Top" node's menu. 275 | #texinfo_no_detailmenu = False 276 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. graphene_gae documentation master file, created by 2 | sphinx-quickstart on Tue Jul 9 22:26:36 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Graphene GAE's documentation! 7 | ====================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | readme 15 | installation 16 | usage 17 | contributing 18 | authors 19 | history 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | 28 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | $ easy_install graphene_gae 8 | 9 | Or, if you have virtualenvwrapper installed:: 10 | 11 | $ mkvirtualenv graphene_gae 12 | $ pip install graphene_gae 13 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\graphene_gae.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\graphene_gae.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Usage 3 | ======== 4 | 5 | To use Graphene GAE in a project:: 6 | 7 | import graphene_gae 8 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'ekampf' 2 | -------------------------------------------------------------------------------- /examples/starwars/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'ekampf' 2 | -------------------------------------------------------------------------------- /examples/starwars/data.py: -------------------------------------------------------------------------------- 1 | 2 | from .models import Character, Faction, Ship 3 | 4 | __author__ = 'ekampf' 5 | 6 | 7 | def initialize(): 8 | human = Character(name='Human') 9 | human.put() 10 | 11 | droid = Character(name='Droid') 12 | droid.put() 13 | 14 | rebels = Faction(id="rebels", name='Alliance to Restore the Republic', hero_key=human.key) 15 | rebels.put() 16 | 17 | empire = Faction(id="empire", name='Galactic Empire', hero_key=droid.key) 18 | empire.put() 19 | 20 | xwing = Ship(name='X-Wing', faction_key=rebels.key) 21 | xwing.put() 22 | 23 | ywing = Ship(name='Y-Wing', faction_key=rebels.key) 24 | ywing.put() 25 | 26 | awing = Ship(name='A-Wing', faction_key=rebels.key) 27 | awing.put() 28 | 29 | # Yeah, technically it's Corellian. But it flew in the service of the rebels, 30 | # so for the purposes of this demo it's a rebel ship. 31 | falcon = Ship(name='Millenium Falcon', faction_key=rebels.key) 32 | falcon.put() 33 | 34 | homeOne = Ship(name='Home One', faction_key=rebels.key) 35 | homeOne.put() 36 | 37 | tieFighter = Ship(name='TIE Fighter', faction_key=empire.key) 38 | tieFighter.put() 39 | 40 | tieInterceptor = Ship(name='TIE Interceptor', faction_key=empire.key) 41 | tieInterceptor.put() 42 | 43 | executor = Ship(name='Executor', faction_key=empire.key) 44 | executor.put() 45 | 46 | 47 | def create_ship(ship_name, faction_key): 48 | new_ship = Ship(name=ship_name, faction_key=faction_key) 49 | new_ship.put() 50 | return new_ship 51 | -------------------------------------------------------------------------------- /examples/starwars/models.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import ndb 2 | 3 | __author__ = 'ekampf' 4 | 5 | 6 | class Character(ndb.Model): 7 | name = ndb.StringProperty() 8 | 9 | def __str__(self): 10 | return self.name 11 | 12 | 13 | class Faction(ndb.Model): 14 | name = ndb.StringProperty() 15 | hero_key = ndb.KeyProperty(kind=Character) 16 | 17 | def __str__(self): 18 | return self.name 19 | 20 | 21 | class Ship(ndb.Model): 22 | name = ndb.StringProperty() 23 | faction_key = ndb.KeyProperty(kind=Faction) 24 | 25 | def __str__(self): 26 | return self.name 27 | 28 | -------------------------------------------------------------------------------- /examples/starwars/schema.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import ndb 2 | 3 | import graphene 4 | from graphene import relay 5 | from graphene_gae import NdbObjectType, NdbConnectionField 6 | 7 | from .data import create_ship 8 | 9 | from .models import Character as CharacterModel 10 | from .models import Faction as FactionModel 11 | from .models import Ship as ShipModel 12 | 13 | 14 | class Ship(NdbObjectType): 15 | class Meta: 16 | model = ShipModel 17 | interfaces = (relay.Node,) 18 | 19 | 20 | class Character(NdbObjectType): 21 | class Meta: 22 | model = CharacterModel 23 | interfaces = (relay.Node,) 24 | 25 | 26 | class Faction(NdbObjectType): 27 | class Meta: 28 | model = FactionModel 29 | interfaces = (relay.Node,) 30 | 31 | ships = NdbConnectionField(Ship) 32 | 33 | def resolve_ships(self, info, **args): 34 | return ShipModel.query().filter(ShipModel.faction_key == self.key) 35 | 36 | 37 | class IntroduceShip(relay.ClientIDMutation): 38 | class Input: 39 | ship_name = graphene.String(required=True) 40 | faction_id = graphene.String(required=True) 41 | 42 | ship = graphene.Field(Ship) 43 | faction = graphene.Field(Faction) 44 | 45 | @classmethod 46 | def mutate_and_get_payload(cls, root, info, ship_name, faction_id, client_mutation_id=None): 47 | faction_key = ndb.Key(FactionModel, faction_id) 48 | ship = create_ship(ship_name, faction_key) 49 | faction = faction_key.get() 50 | return IntroduceShip(ship=ship, faction=faction) 51 | 52 | 53 | class Query(graphene.ObjectType): 54 | rebels = graphene.Field(Faction) 55 | empire = graphene.Field(Faction) 56 | node = relay.Node.Field() 57 | ships = NdbConnectionField(Ship) 58 | 59 | def resolve_ships(self, info, **args): 60 | return ShipModel.query() 61 | 62 | def resolve_rebels(self, info): 63 | return FactionModel.get_by_id("rebels") 64 | 65 | def resolve_empire(self, info): 66 | return FactionModel.get_by_id("empire") 67 | 68 | 69 | class Mutation(graphene.ObjectType): 70 | introduce_ship = IntroduceShip.Field() 71 | 72 | 73 | schema = graphene.Schema(query=Query, mutation=Mutation) 74 | -------------------------------------------------------------------------------- /examples/starwars/tests/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'ekampf' 2 | -------------------------------------------------------------------------------- /examples/starwars/tests/test_connections.py: -------------------------------------------------------------------------------- 1 | from tests.base_test import BaseTest 2 | 3 | from examples.starwars.data import initialize 4 | from examples.starwars.schema import schema 5 | 6 | __author__ = 'ekampf' 7 | 8 | 9 | class TestStarWarsConnections(BaseTest): 10 | def setUp(self): 11 | super(TestStarWarsConnections, self).setUp() 12 | initialize() 13 | 14 | def testConnection(self): 15 | query = ''' 16 | query RebelsShipsQuery { 17 | rebels { 18 | name, 19 | hero { 20 | name 21 | } 22 | ships(first: 1) { 23 | edges { 24 | node { 25 | name 26 | } 27 | } 28 | } 29 | } 30 | } 31 | ''' 32 | expected = { 33 | 'rebels': { 34 | 'name': 'Alliance to Restore the Republic', 35 | 'hero': { 36 | 'name': 'Human' 37 | }, 38 | 'ships': { 39 | 'edges': [ 40 | { 41 | 'node': { 42 | 'name': 'X-Wing' 43 | } 44 | } 45 | ] 46 | } 47 | } 48 | } 49 | result = schema.execute(query) 50 | self.assertFalse(result.errors, msg=str(result.errors)) 51 | self.assertDictEqual(result.data, expected) 52 | -------------------------------------------------------------------------------- /examples/starwars/tests/test_mutation.py: -------------------------------------------------------------------------------- 1 | from tests.base_test import BaseTest 2 | 3 | from google.appengine.ext import ndb 4 | 5 | from graphql_relay import to_global_id 6 | 7 | from examples.starwars.models import Ship 8 | from examples.starwars.data import initialize 9 | from examples.starwars.schema import schema 10 | 11 | __author__ = 'ekampf' 12 | 13 | 14 | class TestStarWarsMutation(BaseTest): 15 | def setUp(self): 16 | super(TestStarWarsMutation, self).setUp() 17 | initialize() 18 | 19 | def testIntroduceShip(self): 20 | query = ''' 21 | mutation MyMutation { 22 | introduceShip(input:{clientMutationId:"abc", shipName: "XYZWing", factionId: "rebels"}) { 23 | ship { 24 | id 25 | name 26 | } 27 | faction { 28 | name 29 | ships { 30 | edges { 31 | node { 32 | id 33 | name 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | ''' 41 | 42 | expected = { 43 | 'introduceShip': { 44 | 'ship': { 45 | 'id': 'U2hpcDoxMQ==', 46 | 'name': 'XYZWing' 47 | }, 48 | 'faction': { 49 | 'name': 'Alliance to Restore the Republic', 50 | 'ships': { 51 | 'edges': [{ 52 | 'node': { 53 | 'id': 'U2hpcDphaEZuY21Gd2FHVnVaUzFuWVdVdGRHVnpkSElLQ3hJRVUyaHBjQmdEREE=', 54 | 'name': 'X-Wing' 55 | } 56 | }, { 57 | 'node': { 58 | 'id': 'U2hpcDphaEZuY21Gd2FHVnVaUzFuWVdVdGRHVnpkSElLQ3hJRVUyaHBjQmdFREE=', 59 | 'name': 'Y-Wing' 60 | } 61 | }, { 62 | 'node': { 63 | 'id': 'U2hpcDphaEZuY21Gd2FHVnVaUzFuWVdVdGRHVnpkSElLQ3hJRVUyaHBjQmdGREE=', 64 | 'name': 'A-Wing' 65 | } 66 | }, { 67 | 'node': { 68 | 'id': 'U2hpcDphaEZuY21Gd2FHVnVaUzFuWVdVdGRHVnpkSElLQ3hJRVUyaHBjQmdHREE=', 69 | 'name': 'Millenium Falcon' 70 | } 71 | }, { 72 | 'node': { 73 | 'id': 'U2hpcDphaEZuY21Gd2FHVnVaUzFuWVdVdGRHVnpkSElLQ3hJRVUyaHBjQmdIREE=', 74 | 'name': 'Home One' 75 | } 76 | }, { 77 | 'node': { 78 | 'id': 'U2hpcDphaEZuY21Gd2FHVnVaUzFuWVdVdGRHVnpkSElLQ3hJRVUyaHBjQmdMREE=', 79 | 'name': 'XYZWing' 80 | } 81 | }] 82 | }, 83 | } 84 | } 85 | } 86 | result = schema.execute(query) 87 | ship_in_db = Ship.query().filter(Ship.name == 'XYZWing', Ship.faction_key == ndb.Key('Faction', 'rebels')).fetch(1) 88 | 89 | self.assertIsNotNone(ship_in_db) 90 | self.assertLength(ship_in_db, 1) 91 | 92 | new_ship = ship_in_db[0] 93 | self.assertEqual(new_ship.name, 'XYZWing') 94 | 95 | expected['introduceShip']['ship']['id'] = to_global_id('Ship', new_ship.key.urlsafe()) 96 | 97 | self.assertFalse(result.errors, msg=str(result.errors)) 98 | self.assertDictEqual(result.data, expected) 99 | -------------------------------------------------------------------------------- /examples/starwars/tests/test_object_identification.py: -------------------------------------------------------------------------------- 1 | from tests.base_test import BaseTest 2 | 3 | from google.appengine.ext import ndb 4 | 5 | from graphql_relay import to_global_id 6 | 7 | from examples.starwars.data import initialize 8 | from examples.starwars.schema import schema 9 | 10 | __author__ = 'ekampf' 11 | 12 | 13 | class TestStarWarsObjectIdentification(BaseTest): 14 | def setUp(self): 15 | super(TestStarWarsObjectIdentification, self).setUp() 16 | initialize() 17 | 18 | def test_correctly_fetches_id_name_rebels(self): 19 | query = ''' 20 | query RebelsQuery { 21 | rebels { 22 | id, 23 | name 24 | } 25 | } 26 | ''' 27 | expected = { 28 | 'rebels': { 29 | 'id': to_global_id('Faction', ndb.Key('Faction', 'rebels').urlsafe()), 30 | 'name': 'Alliance to Restore the Republic' 31 | } 32 | } 33 | result = schema.execute(query) 34 | self.assertFalse(result.errors, msg=str(result.errors)) 35 | self.assertDictEqual(result.data, expected) 36 | 37 | def test_correctly_refetches_rebels(self): 38 | rebels_key = to_global_id('Faction', ndb.Key('Faction', 'rebels').urlsafe()) 39 | query = ''' 40 | query RebelsRefetchQuery { 41 | node(id: "%s") { 42 | id 43 | ... on Faction { 44 | name 45 | } 46 | } 47 | } 48 | ''' % rebels_key 49 | 50 | expected = { 51 | 'node': { 52 | 'id': rebels_key, 53 | 'name': 'Alliance to Restore the Republic' 54 | } 55 | } 56 | result = schema.execute(query) 57 | self.assertFalse(result.errors, msg=str(result.errors)) 58 | self.assertDictEqual(result.data, expected) 59 | 60 | def test_correctly_fetches_id_name_empire(self): 61 | empire_key = to_global_id('Faction', ndb.Key('Faction', 'empire').urlsafe()) 62 | query = ''' 63 | query EmpireQuery { 64 | empire { 65 | id 66 | name 67 | } 68 | } 69 | ''' 70 | expected = { 71 | 'empire': { 72 | 'id': empire_key, 73 | 'name': 'Galactic Empire' 74 | } 75 | } 76 | result = schema.execute(query) 77 | self.assertFalse(result.errors, msg=str(result.errors)) 78 | self.assertDictEqual(result.data, expected) 79 | 80 | def test_correctly_refetches_id_name_empire(self): 81 | empire_key = to_global_id('Faction', ndb.Key('Faction', 'empire').urlsafe()) 82 | query = ''' 83 | query EmpireRefetchQuery { 84 | node(id: "%s") { 85 | id 86 | ... on Faction { 87 | name 88 | } 89 | } 90 | } 91 | ''' % empire_key 92 | expected = { 93 | 'node': { 94 | 'id': empire_key, 95 | 'name': 'Galactic Empire' 96 | } 97 | } 98 | result = schema.execute(query) 99 | self.assertFalse(result.errors, msg=str(result.errors)) 100 | self.assertDictEqual(result.data, expected) 101 | -------------------------------------------------------------------------------- /graphene_gae/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .ndb.types import ( 4 | NdbObjectType 5 | ) 6 | 7 | from .ndb.fields import ( 8 | NdbConnectionField, 9 | ) 10 | 11 | __author__ = 'Eran Kampf' 12 | __version__ = '2.0.0' 13 | 14 | __all__ = [ 15 | NdbObjectType, 16 | NdbConnectionField, 17 | ] 18 | -------------------------------------------------------------------------------- /graphene_gae/ndb/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'ekampf' 2 | -------------------------------------------------------------------------------- /graphene_gae/ndb/converter.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | import inflect 4 | 5 | from google.appengine.ext import ndb 6 | 7 | from graphene import String, Boolean, Int, Float, List, NonNull, Field, Dynamic 8 | from graphene.types.json import JSONString 9 | from graphene.types.datetime import DateTime, Time 10 | 11 | from .fields import DynamicNdbKeyStringField, DynamicNdbKeyReferenceField 12 | 13 | __author__ = 'ekampf' 14 | 15 | ConversionResult = namedtuple('ConversionResult', ['name', 'field']) 16 | 17 | 18 | p = inflect.engine() 19 | 20 | 21 | def rreplace(s, old, new, occurrence): 22 | li = s.rsplit(old, occurrence) 23 | return new.join(li) 24 | 25 | 26 | def convert_ndb_scalar_property(graphene_type, ndb_prop, registry=None, **kwargs): 27 | kwargs['description'] = "%s %s property" % (ndb_prop._name, graphene_type) 28 | _type = graphene_type 29 | 30 | if ndb_prop._repeated: 31 | _type = List(_type) 32 | 33 | if ndb_prop._required: 34 | _type = NonNull(_type) 35 | 36 | return Field(_type, **kwargs) 37 | 38 | 39 | def convert_ndb_string_property(ndb_prop, registry=None): 40 | return convert_ndb_scalar_property(String, ndb_prop) 41 | 42 | 43 | def convert_ndb_boolean_property(ndb_prop, registry=None): 44 | return convert_ndb_scalar_property(Boolean, ndb_prop) 45 | 46 | 47 | def convert_ndb_int_property(ndb_prop, registry=None): 48 | return convert_ndb_scalar_property(Int, ndb_prop) 49 | 50 | 51 | def convert_ndb_float_property(ndb_prop, registry=None): 52 | return convert_ndb_scalar_property(Float, ndb_prop) 53 | 54 | 55 | def convert_ndb_json_property(ndb_prop, registry=None): 56 | return Field(JSONString, description=ndb_prop._name) 57 | 58 | 59 | def convert_ndb_time_property(ndb_prop, registry=None): 60 | return Field(Time, description=ndb_prop._name) 61 | 62 | 63 | def convert_ndb_datetime_property(ndb_prop, registry=None): 64 | return Field(DateTime, description=ndb_prop._name) 65 | 66 | 67 | def convert_ndb_key_propety(ndb_key_prop, registry=None): 68 | """ 69 | Two conventions for handling KeyProperties: 70 | #1. 71 | Given: 72 | store_key = ndb.KeyProperty(...) 73 | 74 | Result is 2 fields: 75 | store_id = graphene.String() -> resolves to store_key.urlsafe() 76 | store = NdbKeyField() -> resolves to entity 77 | 78 | #2. 79 | Given: 80 | store = ndb.KeyProperty(...) 81 | 82 | Result is 2 fields: 83 | store_id = graphene.String() -> resolves to store_key.urlsafe() 84 | store = NdbKeyField() -> resolves to entity 85 | 86 | """ 87 | is_repeated = ndb_key_prop._repeated 88 | name = ndb_key_prop._code_name 89 | 90 | if name.endswith('_key') or name.endswith('_keys'): 91 | # Case #1 - name is of form 'store_key' or 'store_keys' 92 | string_prop_name = rreplace(name, '_key', '_id', 1) 93 | resolved_prop_name = name[:-4] if name.endswith('_key') else p.plural(name[:-5]) 94 | else: 95 | # Case #2 - name is of form 'store' 96 | singular_name = p.singular_noun(name) if p.singular_noun(name) else name 97 | string_prop_name = singular_name + '_ids' if is_repeated else singular_name + '_id' 98 | resolved_prop_name = name 99 | 100 | return [ 101 | ConversionResult(name=string_prop_name, field=DynamicNdbKeyStringField(ndb_key_prop, registry=registry)), 102 | ConversionResult(name=resolved_prop_name, field=DynamicNdbKeyReferenceField(ndb_key_prop, registry=registry)) 103 | ] 104 | 105 | 106 | def convert_local_structured_property(ndb_structured_property, registry=None): 107 | is_required = ndb_structured_property._required 108 | is_repeated = ndb_structured_property._repeated 109 | model = ndb_structured_property._modelclass 110 | name = ndb_structured_property._code_name 111 | 112 | def dynamic_type(): 113 | _type = registry.get_type_for_model(model) 114 | if not _type: 115 | return None 116 | 117 | if is_repeated: 118 | _type = List(_type) 119 | 120 | if is_required: 121 | _type = NonNull(_type) 122 | 123 | return Field(_type) 124 | 125 | field = Dynamic(dynamic_type) 126 | return ConversionResult(name=name, field=field) 127 | 128 | 129 | def convert_computed_property(ndb_computed_prop, registry=None): 130 | return convert_ndb_scalar_property(String, ndb_computed_prop) 131 | 132 | 133 | converters = { 134 | ndb.StringProperty: convert_ndb_string_property, 135 | ndb.TextProperty: convert_ndb_string_property, 136 | ndb.BooleanProperty: convert_ndb_boolean_property, 137 | ndb.IntegerProperty: convert_ndb_int_property, 138 | ndb.FloatProperty: convert_ndb_float_property, 139 | ndb.JsonProperty: convert_ndb_json_property, 140 | ndb.DateProperty: convert_ndb_datetime_property, 141 | ndb.TimeProperty: convert_ndb_time_property, 142 | ndb.DateTimeProperty: convert_ndb_datetime_property, 143 | ndb.KeyProperty: convert_ndb_key_propety, 144 | ndb.StructuredProperty: convert_local_structured_property, 145 | ndb.LocalStructuredProperty: convert_local_structured_property, 146 | ndb.ComputedProperty: convert_computed_property 147 | } 148 | 149 | 150 | def convert_ndb_property(prop, registry=None): 151 | converter_func = converters.get(type(prop)) 152 | if not converter_func: 153 | raise Exception("Don't know how to convert NDB field %s (%s)" % (prop._code_name, prop)) 154 | 155 | field = converter_func(prop, registry) 156 | if not field: 157 | raise Exception("Failed to convert NDB propeerty to a GraphQL field %s (%s)" % (prop._code_name, prop)) 158 | 159 | if isinstance(field, (list, ConversionResult,)): 160 | return field 161 | 162 | return ConversionResult(name=prop._code_name, field=field) 163 | 164 | -------------------------------------------------------------------------------- /graphene_gae/ndb/fields.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import six 3 | 4 | from google.appengine.ext import ndb 5 | from google.appengine.ext.db import BadArgumentError, Timeout 6 | from google.appengine.runtime import DeadlineExceededError 7 | 8 | from graphql_relay import to_global_id 9 | from graphql_relay.connection.connectiontypes import Edge 10 | from graphene import Argument, Boolean, Int, String, Field, List, NonNull, Dynamic 11 | from graphene.relay import Connection 12 | from graphene.relay.connection import PageInfo, ConnectionField 13 | 14 | 15 | from .registry import get_global_registry 16 | 17 | 18 | __author__ = 'ekampf' 19 | 20 | 21 | def generate_edges_page(ndb_iter, page_size, keys_only, edge_type): 22 | edges = [] 23 | timeouts = 0 24 | while len(edges) < page_size: 25 | try: 26 | entity = ndb_iter.next() 27 | except StopIteration: 28 | break 29 | except Timeout: 30 | timeouts += 1 31 | if timeouts > 2: 32 | break 33 | 34 | continue 35 | except DeadlineExceededError: 36 | break 37 | 38 | if keys_only: 39 | # entity is actualy an ndb.Key and we need to create an empty entity to hold it 40 | entity = edge_type._meta.fields['node']._type._meta.model(key=entity) 41 | 42 | edges.append(edge_type(node=entity, cursor=ndb_iter.cursor_after().urlsafe())) 43 | 44 | return edges 45 | 46 | 47 | def connection_from_ndb_query(query, args=None, connection_type=None, edge_type=None, pageinfo_type=None, 48 | transform_edges=None, context=None, **kwargs): 49 | ''' 50 | A simple function that accepts an ndb Query and used ndb QueryIterator object(https://cloud.google.com/appengine/docs/python/ndb/queries#iterators) 51 | to returns a connection object for use in GraphQL. 52 | It uses array offsets as pagination, 53 | so pagination will only work if the array is static. 54 | ''' 55 | args = args or {} 56 | connection_type = connection_type or Connection 57 | edge_type = edge_type or Edge 58 | pageinfo_type = pageinfo_type or PageInfo 59 | 60 | full_args = dict(args, **kwargs) 61 | first = full_args.get('first') 62 | after = full_args.get('after') 63 | has_previous_page = bool(after) 64 | keys_only = full_args.get('keys_only', False) 65 | batch_size = full_args.get('batch_size', 20) 66 | page_size = first if first else full_args.get('page_size', 20) 67 | start_cursor = ndb.Cursor(urlsafe=after) if after else None 68 | 69 | ndb_iter = query.iter(produce_cursors=True, start_cursor=start_cursor, batch_size=batch_size, keys_only=keys_only, projection=query.projection) 70 | 71 | edges = [] 72 | while len(edges) < page_size: 73 | missing_edges_count = page_size - len(edges) 74 | edges_page = generate_edges_page(ndb_iter, missing_edges_count, keys_only, edge_type) 75 | 76 | edges.extend(transform_edges(edges_page, args, context) if transform_edges else edges_page) 77 | 78 | if len(edges_page) < missing_edges_count: 79 | break 80 | 81 | try: 82 | end_cursor = ndb_iter.cursor_after().urlsafe() 83 | except BadArgumentError: 84 | end_cursor = None 85 | 86 | # Construct the connection 87 | return connection_type( 88 | edges=edges, 89 | page_info=pageinfo_type( 90 | start_cursor=start_cursor.urlsafe() if start_cursor else '', 91 | end_cursor=end_cursor, 92 | has_previous_page=has_previous_page, 93 | has_next_page=ndb_iter.has_next() 94 | ) 95 | ) 96 | 97 | 98 | class NdbConnectionField(ConnectionField): 99 | def __init__(self, type, transform_edges=None, *args, **kwargs): 100 | super(NdbConnectionField, self).__init__( 101 | type, 102 | *args, 103 | keys_only=Boolean(), 104 | batch_size=Int(), 105 | page_size=Int(), 106 | **kwargs 107 | ) 108 | 109 | self.transform_edges = transform_edges 110 | 111 | @property 112 | def type(self): 113 | from .types import NdbObjectType 114 | _type = super(ConnectionField, self).type 115 | assert issubclass(_type, NdbObjectType), ( 116 | "NdbConnectionField only accepts NdbObjectType types" 117 | ) 118 | assert _type._meta.connection, "The type {} doesn't have a connection".format(_type.__name__) 119 | return _type._meta.connection 120 | 121 | @property 122 | def model(self): 123 | return self.type._meta.node._meta.model 124 | 125 | @staticmethod 126 | def connection_resolver(resolver, connection, model, transform_edges, root, info, **args): 127 | ndb_query = resolver(root, info, **args) 128 | if ndb_query is None: 129 | ndb_query = model.query() 130 | 131 | return connection_from_ndb_query( 132 | ndb_query, 133 | args=args, 134 | connection_type=connection, 135 | edge_type=connection.Edge, 136 | pageinfo_type=PageInfo, 137 | transform_edges=transform_edges, 138 | context=info.context 139 | ) 140 | 141 | def get_resolver(self, parent_resolver): 142 | return partial( 143 | self.connection_resolver, parent_resolver, self.type, self.model, self.transform_edges 144 | ) 145 | 146 | 147 | class DynamicNdbKeyStringField(Dynamic): 148 | def __init__(self, ndb_key_prop, registry=None, *args, **kwargs): 149 | kind = ndb_key_prop._kind 150 | if not registry: 151 | registry = get_global_registry() 152 | 153 | def get_type(): 154 | kind_name = kind if isinstance(kind, six.string_types) else kind.__name__ 155 | 156 | _type = registry.get_type_for_model_name(kind_name) 157 | if not _type: 158 | return None 159 | 160 | return NdbKeyStringField(ndb_key_prop, _type._meta.name) 161 | 162 | super(DynamicNdbKeyStringField, self).__init__( 163 | get_type, 164 | *args, **kwargs 165 | ) 166 | 167 | 168 | class DynamicNdbKeyReferenceField(Dynamic): 169 | def __init__(self, ndb_key_prop, registry=None, *args, **kwargs): 170 | kind = ndb_key_prop._kind 171 | if not registry: 172 | registry = get_global_registry() 173 | 174 | def get_type(): 175 | kind_name = kind if isinstance(kind, six.string_types) else kind.__name__ 176 | 177 | _type = registry.get_type_for_model_name(kind_name) 178 | if not _type: 179 | return None 180 | 181 | return NdbKeyReferenceField(ndb_key_prop, _type) 182 | 183 | super(DynamicNdbKeyReferenceField, self).__init__( 184 | get_type, 185 | *args, **kwargs 186 | ) 187 | 188 | 189 | class NdbKeyStringField(Field): 190 | def __init__(self, ndb_key_prop, graphql_type_name, *args, **kwargs): 191 | self.__ndb_key_prop = ndb_key_prop 192 | self.__graphql_type_name = graphql_type_name 193 | is_repeated = ndb_key_prop._repeated 194 | is_required = ndb_key_prop._required 195 | 196 | _type = String 197 | if is_repeated: 198 | _type = List(_type) 199 | 200 | if is_required: 201 | _type = NonNull(_type) 202 | 203 | kwargs['args'] = { 204 | 'ndb': Argument(Boolean, False, description="Return an NDB id (key.id()) instead of a GraphQL global id") 205 | } 206 | 207 | super(NdbKeyStringField, self).__init__(_type, *args, **kwargs) 208 | 209 | def resolve_key_to_string(self, entity, info, ndb=False): 210 | is_global_id = not ndb 211 | key_value = self.__ndb_key_prop._get_user_value(entity) 212 | if not key_value: 213 | return None 214 | 215 | if isinstance(key_value, list): 216 | return [to_global_id(self.__graphql_type_name, k.urlsafe()) for k in key_value] if is_global_id else [k.id() for k in key_value] 217 | 218 | return to_global_id(self.__graphql_type_name, key_value.urlsafe()) if is_global_id else key_value.id() 219 | 220 | def get_resolver(self, parent_resolver): 221 | return self.resolve_key_to_string 222 | 223 | 224 | class NdbKeyReferenceField(Field): 225 | def __init__(self, ndb_key_prop, graphql_type, *args, **kwargs): 226 | self.__ndb_key_prop = ndb_key_prop 227 | self.__graphql_type = graphql_type 228 | is_repeated = ndb_key_prop._repeated 229 | is_required = ndb_key_prop._required 230 | 231 | _type = self.__graphql_type 232 | if is_repeated: 233 | _type = List(_type) 234 | 235 | if is_required: 236 | _type = NonNull(_type) 237 | 238 | super(NdbKeyReferenceField, self).__init__(_type, *args, **kwargs) 239 | 240 | def resolve_key_reference(self, entity, info): 241 | key_value = self.__ndb_key_prop._get_user_value(entity) 242 | if not key_value: 243 | return None 244 | 245 | if isinstance(key_value, list): 246 | return ndb.get_multi(key_value) 247 | 248 | return key_value.get() 249 | 250 | def get_resolver(self, parent_resolver): 251 | return self.resolve_key_reference 252 | 253 | 254 | -------------------------------------------------------------------------------- /graphene_gae/ndb/options.py: -------------------------------------------------------------------------------- 1 | from graphene.core.classtypes.objecttype import ObjectTypeOptions 2 | from graphene.relay.types import Node 3 | from graphene.relay.utils import is_node 4 | 5 | 6 | class NdbOptions(ObjectTypeOptions): 7 | """ 8 | Defines how Graphene will convert the NDB model. 9 | Supports the following properties: 10 | 11 | * model - which model to convert 12 | * only_fields - only convert the following property names 13 | * exclude_fields - exclude specified properties from conversion 14 | 15 | """ 16 | 17 | VALID_ATTRS = ('model', 'only_fields', 'exclude_fields', 'remove_key_property_suffix') 18 | 19 | def __init__(self, *args, **kwargs): 20 | super(NdbOptions, self).__init__(*args, **kwargs) 21 | self.model = None 22 | self.valid_attrs += self.VALID_ATTRS 23 | self.only_fields = None 24 | self.exclude_fields = [] 25 | 26 | def contribute_to_class(self, cls, name): 27 | super(NdbOptions, self).contribute_to_class(cls, name) 28 | if is_node(cls): 29 | self.exclude_fields = list(self.exclude_fields) + ['id'] 30 | self.interfaces.append(Node) 31 | -------------------------------------------------------------------------------- /graphene_gae/ndb/registry.py: -------------------------------------------------------------------------------- 1 | class Registry(object): 2 | 3 | def __init__(self): 4 | self._registry = {} 5 | 6 | def register(self, cls): 7 | from .types import NdbObjectType 8 | assert issubclass(cls, NdbObjectType), ( 9 | 'Only classes of type NdbObjectType can be registered, ', 10 | 'received "{}"' 11 | ).format(cls.__name__) 12 | assert cls._meta.registry == self, 'Registry for a Model have to match.' 13 | self._registry[cls._meta.model] = cls 14 | 15 | def get_type_for_model(self, model): 16 | return self._registry.get(model) 17 | 18 | def get_type_for_model_name(self, model_name): 19 | for ndb_model, type in self._registry.items(): 20 | if ndb_model.__name__ == model_name: 21 | return type 22 | 23 | 24 | registry = None 25 | 26 | 27 | def get_global_registry(): 28 | global registry 29 | if not registry: 30 | registry = Registry() 31 | return registry 32 | 33 | 34 | def reset_global_registry(): 35 | global registry 36 | registry = None 37 | -------------------------------------------------------------------------------- /graphene_gae/ndb/types.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections import OrderedDict 3 | 4 | from google.appengine.ext import ndb 5 | 6 | from graphene import Field, ID # , annotate, ResolveInfo 7 | from graphene.relay import Connection, Node 8 | from graphene.types.objecttype import ObjectType, ObjectTypeOptions 9 | from graphene.types.utils import yank_fields_from_attrs 10 | 11 | from .converter import convert_ndb_property 12 | from .registry import Registry, get_global_registry 13 | 14 | 15 | __author__ = 'ekampf' 16 | 17 | 18 | def fields_for_ndb_model(ndb_model, registry, only_fields, exclude_fields): 19 | ndb_fields = OrderedDict() 20 | for prop_name, prop in ndb_model._properties.iteritems(): 21 | name = prop._code_name 22 | 23 | is_not_in_only = only_fields and name not in only_fields 24 | is_excluded = name in exclude_fields # or name in already_created_fields 25 | if is_not_in_only or is_excluded: 26 | continue 27 | 28 | results = convert_ndb_property(prop, registry) 29 | if not results: 30 | continue 31 | 32 | if not isinstance(results, list): 33 | results = [results] 34 | 35 | for r in results: 36 | ndb_fields[r.name] = r.field 37 | 38 | return ndb_fields 39 | 40 | 41 | class NdbObjectTypeOptions(ObjectTypeOptions): 42 | model = None # type: Model 43 | registry = None # type: Registry 44 | connection = None # type: Type[Connection] 45 | id = None # type: str 46 | 47 | 48 | class NdbObjectType(ObjectType): 49 | class Meta: 50 | abstract = True 51 | 52 | ndb_id = ID(resolver=lambda entity, *_: str(entity.key.id())) 53 | 54 | @classmethod 55 | def __init_subclass_with_meta__(cls, model=None, registry=None, skip_registry=False, 56 | only_fields=(), exclude_fields=(), connection=None, 57 | use_connection=None, interfaces=(), **options): 58 | 59 | if not model: 60 | raise Exception(( 61 | 'NdbObjectType {name} must have a model in the Meta class attr' 62 | ).format(name=cls.__name__)) 63 | 64 | if not inspect.isclass(model) or not issubclass(model, ndb.Model): 65 | raise Exception(( 66 | 'Provided model in {name} is not an NDB model' 67 | ).format(name=cls.__name__)) 68 | 69 | if not registry: 70 | registry = get_global_registry() 71 | 72 | assert isinstance(registry, Registry), ( 73 | 'The attribute registry in {} needs to be an instance of ' 74 | 'Registry, received "{}".' 75 | ).format(cls.__name__, registry) 76 | 77 | ndb_fields = fields_for_ndb_model(model, registry, only_fields, exclude_fields) 78 | ndb_fields = yank_fields_from_attrs( 79 | ndb_fields, 80 | _as=Field, 81 | ) 82 | 83 | if use_connection is None and interfaces: 84 | use_connection = any((issubclass(interface, Node) for interface in interfaces)) 85 | 86 | if use_connection and not connection: 87 | # We create the connection automatically 88 | connection = Connection.create_type('{}Connection'.format(cls.__name__), node=cls) 89 | 90 | if connection is not None: 91 | assert issubclass(connection, Connection), ( 92 | "The connection must be a Connection. Received {}" 93 | ).format(connection.__name__) 94 | 95 | _meta = NdbObjectTypeOptions(cls) 96 | _meta.model = model 97 | _meta.registry = registry 98 | _meta.fields = ndb_fields 99 | _meta.connection = connection 100 | 101 | super(NdbObjectType, cls).__init_subclass_with_meta__(_meta=_meta, interfaces=interfaces, **options) 102 | 103 | if not skip_registry: 104 | registry.register(cls) 105 | 106 | @classmethod 107 | def is_type_of(cls, root, info): 108 | if isinstance(root, cls): 109 | return True 110 | 111 | if not isinstance(root, ndb.Model): 112 | raise Exception(('Received incompatible instance "{}".').format(root)) 113 | 114 | # Returns True if `root` is a PolyModel subclass and `cls` is in the 115 | # class hierarchy of `root` which is retrieved with `_class_key` 116 | if (hasattr(root, '_class_key') and 117 | hasattr(cls._meta.model, '_class_key') and 118 | set(cls._meta.model._class_key()).issubset( 119 | set(root._class_key()))): 120 | return True 121 | 122 | return type(root) == cls._meta.model 123 | 124 | @classmethod 125 | def get_node(cls, info, urlsafe_key): 126 | try: 127 | key = ndb.Key(urlsafe=urlsafe_key) 128 | except: 129 | return None 130 | 131 | model = cls._meta.model 132 | assert key.kind() == model.__name__ 133 | return key.get() 134 | 135 | @classmethod 136 | def resolve_id(cls, entity, info): 137 | return entity.key.urlsafe() 138 | -------------------------------------------------------------------------------- /graphene_gae/webapp2/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import webapp2 4 | import six 5 | 6 | from graphql import GraphQLError, format_error as format_graphql_error 7 | 8 | __author__ = 'ekampf' 9 | 10 | 11 | class GraphQLHandler(webapp2.RequestHandler): 12 | def get(self): 13 | return self._handle_request() 14 | 15 | def post(self): 16 | return self._handle_request() 17 | 18 | def _handle_request(self): 19 | schema = self._get_schema() 20 | pretty = self._get_pretty() 21 | 22 | if not schema: 23 | webapp2.abort(500, detail='GraphQL Schema is missing.') 24 | 25 | query, operation_name, variables, pretty_override = self._get_grapl_params() 26 | pretty = pretty if not pretty_override else pretty_override 27 | 28 | result = schema.execute(query, 29 | operation_name=operation_name, 30 | variable_values=variables, 31 | context_value=self._get_context(), 32 | root_value=self._get_root_value(), 33 | middleware=self._get_middleware()) 34 | 35 | response = {} 36 | if result.errors: 37 | response['errors'] = [self.__format_error(e) for e in result.errors] 38 | logging.warn("Request had errors: %s", response) 39 | self._handle_graphql_errors(result.errors) 40 | 41 | if result.invalid: 42 | logging.error("GraphQL request is invalid: %s", response) 43 | return self.failed_response(400, response, pretty=pretty) 44 | 45 | response['data'] = result.data 46 | return self.successful_response(response, pretty=pretty) 47 | 48 | def handle_exception(self, exception, debug): 49 | logging.exception(exception) 50 | 51 | status_code = 500 52 | if isinstance(exception, webapp2.HTTPException): 53 | status_code = exception.code 54 | 55 | self.failed_response(status_code, { 56 | 'errors': [self.__format_error(exception)] 57 | }) 58 | 59 | def _handle_graphql_errors(self, result): 60 | pass 61 | 62 | def _get_schema(self): 63 | return self.app.config.get('graphql_schema') 64 | 65 | def _get_pretty(self): 66 | return self.app.config.get('graphql_pretty', False) 67 | 68 | def _get_grapl_params(self): 69 | try: 70 | request_data = self.request.json_body 71 | if isinstance(request_data, six.string_types): 72 | request_data = dict(query=request_data) 73 | except: 74 | try: 75 | request_data = json.loads(self.request.body) 76 | except ValueError: 77 | request_data = {} 78 | 79 | request_data.update(dict(self.request.GET)) 80 | 81 | query = request_data.get('query', self.request.body) 82 | if not query: 83 | webapp2.abort(400, "Query is empty.") 84 | 85 | operation_name = request_data.get('operation_name') 86 | variables = request_data.get('variables') 87 | if variables and isinstance(variables, six.text_type): 88 | try: 89 | variables = json.loads(variables) 90 | except: 91 | raise webapp2.abort(400, 'Variables are invalid JSON.') 92 | 93 | pretty = request_data.get('pretty') 94 | 95 | return query, operation_name, variables, pretty 96 | 97 | def _get_root_value(self): 98 | return None 99 | 100 | def _get_context(self): 101 | return self.request 102 | 103 | def _get_middleware(self): 104 | return None 105 | 106 | def __format_error(self, error): 107 | if isinstance(error, GraphQLError): 108 | return format_graphql_error(error) 109 | 110 | return {'message': str(error)} 111 | 112 | def __json_encode(self, data, pretty=False): 113 | if pretty: 114 | return json.dumps(data, indent=2, sort_keys=True, separators=(',', ': ')) 115 | 116 | return json.dumps(data) 117 | 118 | def successful_response(self, data, pretty=False): 119 | serialized_data = self.__json_encode(data, pretty=pretty) 120 | 121 | self.response.set_status(200, 'Success') 122 | self.response.md5_etag() 123 | self.response.content_type = 'application/json' 124 | self.response.out.write(serialized_data) 125 | 126 | def failed_response(self, error_code, data, pretty=False): 127 | serialized_data = self.__json_encode(data, pretty=pretty) 128 | 129 | self.response.set_status(error_code) 130 | self.response.content_type = 'application/json' 131 | self.response.out.write(serialized_data) 132 | 133 | 134 | graphql_application = webapp2.WSGIApplication([ 135 | ('/graphql', GraphQLHandler) 136 | ]) 137 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pip 2 | setuptools 3 | 4 | wheel==0.23.0 5 | tox 6 | coverage 7 | flake8 8 | Sphinx==1.5.2 9 | 10 | PyYAML==3.11 11 | graphene>=2.0.dev 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import codecs 5 | import os 6 | import re 7 | import sys 8 | import unittest 9 | from setuptools import setup, find_packages 10 | 11 | root_dir = os.path.abspath(os.path.dirname(__file__)) 12 | 13 | 14 | def get_build_number(): 15 | fname = 'build.info' 16 | if os.path.isfile(fname): 17 | with open(fname) as f: 18 | build_number = f.read() 19 | build_number = re.sub( 20 | "[^a-z0-9]+", "", build_number, flags=re.IGNORECASE) 21 | return '.' + build_number 22 | 23 | return '' 24 | 25 | 26 | def get_version(package_name): 27 | build_number = get_build_number() 28 | 29 | version_re = re.compile(r"^__version__ = [\"']([\w_.-]+)[\"']$") 30 | package_components = package_name.split('.') 31 | init_path = os.path.join(root_dir, *(package_components + ['__init__.py'])) 32 | with codecs.open(init_path, 'r', 'utf-8') as f: 33 | for line in f: 34 | match = version_re.match(line[:-1]) 35 | if match: 36 | return match.groups()[0] + build_number 37 | 38 | return '0.1.0' + build_number 39 | 40 | 41 | with open('README.rst') as readme_file: 42 | readme = readme_file.read() 43 | 44 | with open('HISTORY.rst') as history_file: 45 | history = history_file.read().replace('.. :changelog:', '') 46 | 47 | 48 | requirements = [ 49 | 'six>=1.10.0', 50 | 'inflect==0.2.5', 51 | 'graphene>=2.0,<3', 52 | 'iso8601' 53 | ] 54 | 55 | test_requirements = [ 56 | 'PyYAML==3.11', 57 | 'webapp2==2.5.2', 58 | 'webob==1.2.3', 59 | 'WebTest==2.0.11', 60 | 'mock==2.0.0', 61 | 'nose' 62 | ] 63 | 64 | setup( 65 | name='graphene_gae', 66 | version=get_version('graphene_gae'), 67 | description="Graphene GAE Integration", 68 | long_description=readme + '\n\n' + history, 69 | author="Eran Kampf", 70 | author_email='eran@ekampf.com', 71 | url='https://github.com/graphql-python/graphene-gae', 72 | packages=find_packages(exclude=['tests*', 'examples*']), 73 | include_package_data=True, 74 | install_requires=requirements, 75 | license="BSD", 76 | zip_safe=False, 77 | keywords='graphene_gae', 78 | classifiers=[ 79 | 'Development Status :: 4 - Beta', 80 | 'Intended Audience :: Developers', 81 | 'License :: OSI Approved :: BSD License', 82 | 'Natural Language :: English', 83 | "Programming Language :: Python :: 2", 84 | "Programming Language :: Python :: 2.7", 85 | "Programming Language :: Python :: 3", 86 | "Programming Language :: Python :: 3.4", 87 | ], 88 | test_suite='discover_tests', 89 | tests_require=test_requirements, 90 | ) 91 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /tests/_ndb/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'ekampf' 2 | -------------------------------------------------------------------------------- /tests/_ndb/test_converter.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from graphene_gae.ndb.types import NdbObjectType 4 | from tests.base_test import BaseTest 5 | 6 | from google.appengine.ext import ndb 7 | 8 | import graphene 9 | from graphene import List, NonNull, String 10 | from graphene.types.json import JSONString 11 | from graphene.types.datetime import DateTime, Time 12 | 13 | from graphene_gae.ndb.fields import NdbKeyStringField, NdbKeyReferenceField, DynamicNdbKeyStringField, DynamicNdbKeyReferenceField 14 | from graphene_gae.ndb.converter import convert_ndb_property 15 | from graphene_gae.ndb.registry import Registry 16 | 17 | __author__ = 'ekampf' 18 | 19 | 20 | class SomeWeirdUnknownProperty(ndb.Property): 21 | pass 22 | 23 | 24 | class TestNDBConverter(BaseTest): 25 | def __assert_conversion(self, ndb_property_type, expected_graphene_type, *args, **kwargs): 26 | ndb_property = ndb_property_type(*args, **kwargs) 27 | result = convert_ndb_property(ndb_property) 28 | graphene_field = result.field 29 | self.assertEqual(graphene_field._type, expected_graphene_type) 30 | 31 | def testUnknownProperty_raisesException(self): 32 | with self.assertRaises(Exception) as context: 33 | prop = SomeWeirdUnknownProperty() 34 | prop._code_name = "my_prop" 35 | convert_ndb_property(prop) 36 | 37 | self.assertTrue("Don't know how to convert" in context.exception.message, msg=context.exception.message) 38 | 39 | @mock.patch('graphene_gae.ndb.converter.converters') 40 | def testNoneResult_raisesException(self, patch_convert): 41 | from graphene_gae.ndb.converter import convert_ndb_property 42 | patch_convert.get.return_value = lambda *_: None 43 | with self.assertRaises(Exception) as context: 44 | prop = ndb.StringProperty() 45 | prop._code_name = "my_prop" 46 | convert_ndb_property(prop) 47 | 48 | expected_message = 'Failed to convert NDB propeerty to a GraphQL field my_prop (StringProperty())' 49 | self.assertTrue(expected_message in context.exception.message, msg=context.exception.message) 50 | 51 | def testStringProperty_shouldConvertToString(self): 52 | self.__assert_conversion(ndb.StringProperty, graphene.String) 53 | 54 | def testStringProperty_repeated_shouldConvertToList(self): 55 | ndb_prop = ndb.StringProperty(repeated=True) 56 | result = convert_ndb_property(ndb_prop) 57 | graphene_type = result.field._type 58 | 59 | self.assertIsInstance(graphene_type, graphene.List) 60 | self.assertEqual(graphene_type.of_type, graphene.String) 61 | 62 | def testStringProperty_required_shouldConvertToList(self): 63 | ndb_prop = ndb.StringProperty(required=True) 64 | result = convert_ndb_property(ndb_prop) 65 | graphene_type = result.field._type 66 | 67 | self.assertIsInstance(graphene_type, graphene.NonNull) 68 | self.assertEqual(graphene_type.of_type, graphene.String) 69 | 70 | def testTextProperty_shouldConvertToString(self): 71 | self.__assert_conversion(ndb.TextProperty, graphene.String) 72 | 73 | def testBoolProperty_shouldConvertToString(self): 74 | self.__assert_conversion(ndb.BooleanProperty, graphene.Boolean) 75 | 76 | def testIntProperty_shouldConvertToString(self): 77 | self.__assert_conversion(ndb.IntegerProperty, graphene.Int) 78 | 79 | def testFloatProperty_shouldConvertToString(self): 80 | self.__assert_conversion(ndb.FloatProperty, graphene.Float) 81 | 82 | def testDateProperty_shouldConvertToString(self): 83 | self.__assert_conversion(ndb.DateProperty, DateTime) 84 | 85 | def testDateTimeProperty_shouldConvertToString(self): 86 | self.__assert_conversion(ndb.DateTimeProperty, DateTime) 87 | 88 | def testTimeProperty_shouldConvertToString(self): 89 | self.__assert_conversion(ndb.TimeProperty, Time) 90 | 91 | def testJsonProperty_shouldConvertToString(self): 92 | self.__assert_conversion(ndb.JsonProperty, JSONString) 93 | 94 | def testKeyProperty_withSuffix(self): 95 | my_registry = Registry() 96 | 97 | class User(ndb.Model): 98 | name = ndb.StringProperty() 99 | 100 | class UserType(NdbObjectType): 101 | class Meta: 102 | model = User 103 | registry = my_registry 104 | 105 | prop = ndb.KeyProperty(kind='User') 106 | prop._code_name = 'user_key' 107 | 108 | conversion = convert_ndb_property(prop, my_registry) 109 | 110 | self.assertLength(conversion, 2) 111 | 112 | self.assertEqual(conversion[0].name, 'user_id') 113 | self.assertIsInstance(conversion[0].field, DynamicNdbKeyStringField) 114 | _type = conversion[0].field.get_type() 115 | self.assertIsInstance(_type, NdbKeyStringField) 116 | self.assertEqual(_type._type, String) 117 | 118 | self.assertEqual(conversion[1].name, 'user') 119 | self.assertIsInstance(conversion[1].field, DynamicNdbKeyReferenceField) 120 | _type = conversion[1].field.get_type() 121 | self.assertIsInstance(_type, NdbKeyReferenceField) 122 | self.assertEqual(_type._type, UserType) 123 | 124 | def testKeyProperty_withSuffix_repeated(self): 125 | my_registry = Registry() 126 | 127 | class User(ndb.Model): 128 | name = ndb.StringProperty() 129 | 130 | class UserType(NdbObjectType): 131 | class Meta: 132 | model = User 133 | registry = my_registry 134 | 135 | prop = ndb.KeyProperty(kind='User', repeated=True) 136 | prop._code_name = 'user_keys' 137 | 138 | conversion = convert_ndb_property(prop, my_registry) 139 | 140 | self.assertLength(conversion, 2) 141 | 142 | self.assertEqual(conversion[0].name, 'user_ids') 143 | self.assertIsInstance(conversion[0].field, DynamicNdbKeyStringField) 144 | _type = conversion[0].field.get_type() 145 | self.assertIsInstance(_type, NdbKeyStringField) 146 | self.assertIsInstance(_type._type, List) 147 | self.assertEqual(_type._type.of_type, String) 148 | 149 | self.assertEqual(conversion[1].name, 'users') 150 | self.assertIsInstance(conversion[1].field, DynamicNdbKeyReferenceField) 151 | _type = conversion[1].field.get_type() 152 | self.assertIsInstance(_type, NdbKeyReferenceField) 153 | self.assertIsInstance(_type._type, List) 154 | self.assertEqual(_type._type.of_type, UserType) 155 | 156 | def testKeyProperty_withSuffix_required(self): 157 | class User(ndb.Model): 158 | name = ndb.StringProperty() 159 | 160 | my_registry = Registry() 161 | 162 | class UserType(NdbObjectType): 163 | class Meta: 164 | model = User 165 | registry = my_registry 166 | 167 | prop = ndb.KeyProperty(kind='User', required=True) 168 | prop._code_name = 'user_key' 169 | 170 | conversion = convert_ndb_property(prop, my_registry) 171 | 172 | self.assertLength(conversion, 2) 173 | 174 | self.assertEqual(conversion[0].name, 'user_id') 175 | self.assertIsInstance(conversion[0].field, DynamicNdbKeyStringField) 176 | _type = conversion[0].field.get_type() 177 | self.assertIsInstance(_type, NdbKeyStringField) 178 | self.assertIsInstance(_type._type, NonNull) 179 | self.assertEqual(_type._type.of_type, String) 180 | 181 | self.assertEqual(conversion[1].name, 'user') 182 | self.assertIsInstance(conversion[1].field, DynamicNdbKeyReferenceField) 183 | _type = conversion[1].field.get_type() 184 | self.assertIsInstance(_type, NdbKeyReferenceField) 185 | self.assertIsInstance(_type._type, NonNull) 186 | self.assertEqual(_type._type.of_type, UserType) 187 | 188 | def testKeyProperty_withoutSuffix(self): 189 | my_registry = Registry() 190 | 191 | class User(ndb.Model): 192 | name = ndb.StringProperty() 193 | 194 | class UserType(NdbObjectType): 195 | class Meta: 196 | model = User 197 | registry = my_registry 198 | 199 | prop = ndb.KeyProperty(kind='User') 200 | prop._code_name = 'user' 201 | 202 | conversion = convert_ndb_property(prop, my_registry) 203 | 204 | self.assertLength(conversion, 2) 205 | 206 | self.assertEqual(conversion[0].name, 'user_id') 207 | self.assertIsInstance(conversion[0].field, DynamicNdbKeyStringField) 208 | _type = conversion[0].field.get_type() 209 | self.assertIsInstance(_type, NdbKeyStringField) 210 | self.assertEqual(_type._type, String) 211 | 212 | self.assertEqual(conversion[1].name, 'user') 213 | self.assertIsInstance(conversion[1].field, DynamicNdbKeyReferenceField) 214 | _type = conversion[1].field.get_type() 215 | self.assertIsInstance(_type, NdbKeyReferenceField) 216 | self.assertEqual(_type._type, UserType) 217 | -------------------------------------------------------------------------------- /tests/_ndb/test_types.py: -------------------------------------------------------------------------------- 1 | from graphql_relay import to_global_id 2 | 3 | from tests.base_test import BaseTest 4 | 5 | import graphene 6 | 7 | from graphene_gae import NdbObjectType 8 | from tests.models import Tag, Comment, Article, Address, Author, PhoneNumber 9 | 10 | __author__ = 'ekampf' 11 | 12 | 13 | class AddressType(NdbObjectType): 14 | class Meta: 15 | model = Address 16 | 17 | 18 | class PhoneNumberType(NdbObjectType): 19 | class Meta: 20 | model = PhoneNumber 21 | 22 | 23 | class AuthorType(NdbObjectType): 24 | class Meta: 25 | model = Author 26 | 27 | 28 | class TagType(NdbObjectType): 29 | class Meta: 30 | model = Tag 31 | 32 | 33 | class CommentType(NdbObjectType): 34 | class Meta: 35 | model = Comment 36 | 37 | 38 | class ArticleType(NdbObjectType): 39 | class Meta: 40 | model = Article 41 | exclude_fields = ['to_be_excluded'] 42 | 43 | 44 | class QueryRoot(graphene.ObjectType): 45 | articles = graphene.List(ArticleType) 46 | 47 | def resolve_articles(self, info): 48 | return Article.query() 49 | 50 | 51 | schema = graphene.Schema(query=QueryRoot) 52 | 53 | 54 | class TestNDBTypes(BaseTest): 55 | 56 | def testNdbObjectType_instanciation(self): 57 | instance = Article(headline="test123") 58 | h = ArticleType(**instance.to_dict(exclude=["tags", "author_key"])) 59 | self.assertEqual(instance.headline, h.headline) 60 | 61 | def testNdbObjectType_should_raise_if_no_model(self): 62 | with self.assertRaises(Exception) as context: 63 | class Character1(NdbObjectType): 64 | pass 65 | 66 | assert 'model in the Meta' in str(context.exception.message) 67 | 68 | def testNdbObjectType_should_raise_if_model_is_invalid(self): 69 | with self.assertRaises(Exception) as context: 70 | class Character2(NdbObjectType): 71 | class Meta: 72 | model = 1 73 | 74 | assert 'not an NDB model' in str(context.exception.message) 75 | 76 | # def testNdbObjectType_keyProperty_kindDoesntExist_raisesException(self): 77 | # with self.assertRaises(Exception) as context: 78 | # class ArticleType(NdbObjectType): 79 | # class Meta: 80 | # model = Article 81 | # only_fields = ('prop',) 82 | # 83 | # prop = NdbKeyReferenceField('foo', 'bar') 84 | # 85 | # class QueryType(graphene.ObjectType): 86 | # articles = graphene.List(ArticleType) 87 | # 88 | # @graphene.resolve_only_args 89 | # def resolve_articles(self): 90 | # return Article.query() 91 | # 92 | # schema = graphene.Schema(query=QueryType) 93 | # schema.execute('query test { articles { prop } }') 94 | # 95 | # self.assertIn("Model 'bar' is not accessible by the schema.", str(context.exception.message)) 96 | 97 | # def testNdbObjectType_keyProperty_stringRepresentation_kindDoesntExist_raisesException(self): 98 | # with self.assertRaises(Exception) as context: 99 | # class ArticleType(NdbObjectType): 100 | # class Meta: 101 | # model = Article 102 | # only_fields = ('prop',) 103 | # 104 | # prop = NdbKeyStringField('foo', 'bar') 105 | # 106 | # class QueryType(graphene.ObjectType): 107 | # articles = graphene.List(ArticleType) 108 | # 109 | # @graphene.resolve_only_args 110 | # def resolve_articles(self): 111 | # return Article.query() 112 | # 113 | # schema = graphene.Schema(query=QueryType) 114 | # schema.execute('query test { articles { prop } }') 115 | # 116 | # self.assertIn("Model 'bar' is not accessible by the schema.", str(context.exception.message)) 117 | 118 | def testQuery_excludedField(self): 119 | Article(headline="h1", summary="s1").put() 120 | 121 | class ArticleType(NdbObjectType): 122 | class Meta: 123 | model = Article 124 | exclude_fields = ['summary'] 125 | 126 | class QueryType(graphene.ObjectType): 127 | articles = graphene.List(ArticleType) 128 | 129 | def resolve_articles(self, info): 130 | return Article.query() 131 | 132 | schema = graphene.Schema(query=QueryType) 133 | query = ''' 134 | query ArticlesQuery { 135 | articles { headline, summary } 136 | } 137 | ''' 138 | 139 | result = schema.execute(query) 140 | 141 | self.assertIsNotNone(result.errors) 142 | self.assertTrue('Cannot query field "summary"' in result.errors[0].message) 143 | 144 | def testQuery_onlyFields(self): 145 | Article(headline="h1", summary="s1").put() 146 | 147 | class ArticleType(NdbObjectType): 148 | class Meta: 149 | model = Article 150 | only_fields = ['headline'] 151 | 152 | class QueryType(graphene.ObjectType): 153 | articles = graphene.List(ArticleType) 154 | 155 | def resolve_articles(self, info): 156 | return Article.query() 157 | 158 | schema = graphene.Schema(query=QueryType) 159 | query = ''' 160 | query ArticlesQuery { 161 | articles { headline } 162 | } 163 | ''' 164 | 165 | result = schema.execute(query) 166 | 167 | self.assertIsNotNone(result.data) 168 | self.assertEqual(result.data['articles'][0]['headline'], 'h1') 169 | 170 | query = ''' 171 | query ArticlesQuery { 172 | articles { headline, summary } 173 | } 174 | ''' 175 | result = schema.execute(query) 176 | 177 | self.assertIsNotNone(result.errors) 178 | self.assertTrue('Cannot query field "summary"' in result.errors[0].message) 179 | 180 | def testQuery_list(self): 181 | Article(headline="Test1", summary="1").put() 182 | Article(headline="Test2", summary="2").put() 183 | Article(headline="Test3", summary="3").put() 184 | 185 | result = schema.execute(""" 186 | query Articles { 187 | articles { 188 | headline 189 | } 190 | } 191 | """) 192 | self.assertEmpty(result.errors) 193 | 194 | self.assertLength(result.data['articles'], 3) 195 | 196 | for article in result.data['articles']: 197 | self.assertLength(article.keys(), 1) 198 | self.assertEqual(article.keys()[0], 'headline') 199 | 200 | def testQuery_repeatedProperty(self): 201 | keywords = ["a", "b", "c"] 202 | a = Article(headline="Test1", keywords=keywords).put() 203 | 204 | result = schema.execute(""" 205 | query Articles { 206 | articles { 207 | headline, 208 | keywords, 209 | createdAt 210 | } 211 | } 212 | """) 213 | self.assertEmpty(result.errors) 214 | 215 | self.assertLength(result.data['articles'], 1) 216 | 217 | article = result.data['articles'][0] 218 | self.assertEqual(article["createdAt"], str(a.get().created_at.isoformat())) 219 | self.assertEqual(article["headline"], "Test1") 220 | self.assertListEqual(article["keywords"], keywords) 221 | 222 | def testQuery_structuredProperty(self): 223 | mobile = PhoneNumber(area="650", number="12345678") 224 | author_key = Author(name="John Dow", email="john@dow.com", mobile=mobile).put() 225 | Article(headline="Test1", author_key=author_key).put() 226 | 227 | result = schema.execute(""" 228 | query Articles { 229 | articles { 230 | headline, 231 | authorId 232 | author { 233 | name 234 | email 235 | mobile { area, number } 236 | } 237 | } 238 | } 239 | """) 240 | self.assertEmpty(result.errors, msg=str(result.errors)) 241 | 242 | article = result.data['articles'][0] 243 | self.assertEqual(article["headline"], "Test1") 244 | 245 | author = article['author'] 246 | self.assertEqual(author["name"], "John Dow") 247 | self.assertEqual(author["email"], "john@dow.com") 248 | self.assertDictEqual(dict(area="650", number="12345678"), dict(author["mobile"])) 249 | 250 | def testQuery_structuredProperty_repeated(self): 251 | address1 = Address(address1="address1", address2="apt 1", city="Mountain View") 252 | address2 = Address(address1="address2", address2="apt 2", city="Mountain View") 253 | author_key = Author(name="John Dow", email="john@dow.com", addresses=[address1, address2]).put() 254 | Article(headline="Test1", author_key=author_key).put() 255 | 256 | result = schema.execute(""" 257 | query Articles { 258 | articles { 259 | headline, 260 | author { 261 | name 262 | email 263 | addresses { 264 | address1 265 | address2 266 | city 267 | } 268 | } 269 | } 270 | } 271 | """) 272 | self.assertEmpty(result.errors) 273 | 274 | article = result.data['articles'][0] 275 | self.assertEqual(article["headline"], "Test1") 276 | 277 | author = article['author'] 278 | self.assertEqual(author["name"], "John Dow") 279 | self.assertEqual(author["email"], "john@dow.com") 280 | self.assertLength(author["addresses"], 2) 281 | 282 | addresses = [dict(d) for d in author["addresses"]] 283 | self.assertIn(address1.to_dict(), addresses) 284 | self.assertIn(address2.to_dict(), addresses) 285 | 286 | def testQuery_keyProperty(self): 287 | author_key = Author(name="john dow", email="john@dow.com").put() 288 | article_key = Article(headline="h1", summary="s1", author_key=author_key).put() 289 | 290 | result = schema.execute(''' 291 | query ArticleWithAuthorID { 292 | articles { 293 | ndbId 294 | headline 295 | authorId 296 | authorNdbId: authorId(ndb: true) 297 | author { 298 | name, email 299 | } 300 | } 301 | } 302 | ''') 303 | 304 | self.assertEmpty(result.errors) 305 | 306 | article = dict(result.data['articles'][0]) 307 | self.assertEqual(article['ndbId'], str(article_key.id())) 308 | self.assertEqual(article['authorNdbId'], str(author_key.id())) 309 | 310 | author = dict(article['author']) 311 | self.assertDictEqual(author, {'name': u'john dow', 'email': u'john@dow.com'}) 312 | self.assertEqual('h1', article['headline']) 313 | self.assertEqual(to_global_id('AuthorType', author_key.urlsafe()), article['authorId']) 314 | 315 | def testQuery_repeatedKeyProperty(self): 316 | tk1 = Tag(name="t1").put() 317 | tk2 = Tag(name="t2").put() 318 | tk3 = Tag(name="t3").put() 319 | tk4 = Tag(name="t4").put() 320 | Article(headline="h1", summary="s1", tags=[tk1, tk2, tk3, tk4]).put() 321 | 322 | result = schema.execute(''' 323 | query ArticleWithAuthorID { 324 | articles { 325 | headline 326 | authorId 327 | tagIds 328 | tags { 329 | name 330 | } 331 | } 332 | } 333 | ''') 334 | 335 | self.assertEmpty(result.errors) 336 | 337 | article = dict(result.data['articles'][0]) 338 | self.assertListEqual(map(lambda k: to_global_id('TagType', k.urlsafe()), [tk1, tk2, tk3, tk4]), article['tagIds']) 339 | 340 | self.assertLength(article['tags'], 4) 341 | for i in range(0, 3): 342 | self.assertEqual(article['tags'][i]['name'], 't%s' % (i + 1)) 343 | -------------------------------------------------------------------------------- /tests/_ndb/test_types_relay.py: -------------------------------------------------------------------------------- 1 | from tests.base_test import BaseTest 2 | 3 | from google.appengine.ext import ndb 4 | 5 | import graphene 6 | from graphene.relay import Node 7 | from graphene_gae import NdbObjectType 8 | from graphene_gae.ndb.fields import NdbConnectionField 9 | 10 | from tests.models import Tag, Comment, Article, Author, Address, PhoneNumber, Reader, ArticleReader 11 | 12 | __author__ = 'ekampf' 13 | 14 | 15 | class AddressType(NdbObjectType): 16 | class Meta: 17 | model = Address 18 | interfaces = (Node,) 19 | 20 | 21 | class PhoneNumberType(NdbObjectType): 22 | class Meta: 23 | model = PhoneNumber 24 | interfaces = (Node,) 25 | 26 | 27 | class ReaderType(NdbObjectType): 28 | class Meta: 29 | model = Reader 30 | interfaces = (Node,) 31 | 32 | 33 | class AuthorType(NdbObjectType): 34 | class Meta: 35 | model = Author 36 | interfaces = (Node,) 37 | 38 | 39 | class TagType(NdbObjectType): 40 | class Meta: 41 | model = Tag 42 | interfaces = (Node,) 43 | 44 | 45 | class CommentType(NdbObjectType): 46 | class Meta: 47 | model = Comment 48 | interfaces = (Node,) 49 | 50 | 51 | def transform_to_reader_edges(edges, args, context): 52 | article_readers = [edge.node for edge in edges] 53 | readers = ndb.get_multi([article_reader.reader_key for article_reader in article_readers]) 54 | transformed_edges = [] 55 | for edge, reader in zip(edges, readers): 56 | if reader.is_alive: 57 | edge.node = reader 58 | transformed_edges.append(edge) 59 | 60 | return transformed_edges 61 | 62 | 63 | def reader_filter(reader, args, context): 64 | return reader.is_alive 65 | 66 | 67 | class ArticleType(NdbObjectType): 68 | class Meta: 69 | model = Article 70 | interfaces = (Node,) 71 | 72 | comments = NdbConnectionField(CommentType) 73 | readers = NdbConnectionField(ReaderType, transform_edges=transform_to_reader_edges) 74 | 75 | def resolve_comments(self, info): 76 | return Comment.query(ancestor=self.key) 77 | 78 | def resolve_readers(self, info, **args): 79 | return ArticleReader.query().filter(ArticleReader.article_key == self.key).order(ArticleReader.key) 80 | 81 | 82 | class QueryRoot(graphene.ObjectType): 83 | articles = NdbConnectionField(ArticleType) 84 | 85 | 86 | schema = graphene.Schema(query=QueryRoot) 87 | 88 | 89 | class TestNDBTypesRelay(BaseTest): 90 | 91 | def testNdbNode_getNode_invalidId_shouldReturnNone(self): 92 | result = ArticleType.get_node(None, "I'm not a valid NDB encoded key") 93 | self.assertIsNone(result) 94 | 95 | def testNdbNode_getNode_validID_entityDoesntExist_shouldReturnNone(self): 96 | article_key = ndb.Key('Article', 'invalid_id_thats_not_in_db') 97 | result = ArticleType.get_node(None, article_key.urlsafe()) 98 | self.assertIsNone(result) 99 | 100 | def testNdbNode_getNode_validID_entityDoes_shouldReturnEntity(self): 101 | article_key = Article( 102 | headline="TestGetNode", 103 | summary="1", 104 | author_key=Author(name="John Dow", email="john@dow.com").put(), 105 | ).put() 106 | 107 | result = ArticleType.get_node(None, article_key.urlsafe()) 108 | article = article_key.get() 109 | 110 | self.assertIsNotNone(result) 111 | self.assertEqual(result.headline, article.headline) 112 | self.assertEqual(result.summary, article.summary) 113 | # self.assertEqual(result.author_key, article_key.author_key) # TODO 114 | 115 | def test_keyProperty(self): 116 | Article( 117 | headline="Test1", 118 | summary="1", 119 | author_key=Author(name="John Dow", email="john@dow.com").put(), 120 | tags=[ 121 | Tag(name="tag1").put(), 122 | Tag(name="tag2").put(), 123 | Tag(name="tag3").put(), 124 | ] 125 | ).put() 126 | 127 | result = schema.execute(""" 128 | query Articles { 129 | articles(first:2) { 130 | edges { 131 | cursor, 132 | node { 133 | headline, 134 | summary, 135 | author { name }, 136 | tags { name } 137 | } 138 | } 139 | 140 | } 141 | } 142 | """) 143 | 144 | self.assertEmpty(result.errors, msg=str(result.errors)) 145 | 146 | articles = result.data.get('articles', {}).get('edges', []) 147 | self.assertLength(articles, 1) 148 | 149 | article = articles[0]['node'] 150 | self.assertEqual(article['headline'], 'Test1') 151 | self.assertEqual(article['summary'], '1') 152 | 153 | author = article['author'] 154 | self.assertLength(author.keys(), 1) 155 | self.assertEqual(author['name'], 'John Dow') 156 | 157 | tags = article['tags'] 158 | tag_names = [t['name'] for t in tags] 159 | self.assertListEqual(tag_names, ['tag1', 'tag2', 'tag3']) 160 | 161 | def test_connectionField(self): 162 | a1 = Article(headline="Test1", summary="1").put() 163 | a2 = Article(headline="Test2", summary="2").put() 164 | a3 = Article(headline="Test3", summary="3").put() 165 | 166 | Comment(parent=a1, body="c1").put() 167 | Comment(parent=a2, body="c2").put() 168 | Comment(parent=a3, body="c3").put() 169 | 170 | result = schema.execute(""" 171 | query Articles { 172 | articles(first:2) { 173 | edges { 174 | cursor, 175 | node { 176 | headline, 177 | summary, 178 | comments { 179 | edges { 180 | cursor, 181 | node { 182 | body 183 | } 184 | } 185 | } 186 | } 187 | } 188 | 189 | } 190 | } 191 | """) 192 | 193 | self.assertEmpty(result.errors) 194 | 195 | articles = result.data.get('articles', {}).get('edges') 196 | self.assertLength(articles, 2) 197 | 198 | for articleNode in articles: 199 | article = articleNode['node'] 200 | self.assertLength(article.keys(), 3) 201 | self.assertIsNotNone(article.get('headline')) 202 | self.assertIsNotNone(article.get('summary')) 203 | 204 | comments = article['comments']['edges'] 205 | self.assertLength(comments, 1) 206 | self.assertEqual(comments[0]['node']['body'], "c" + article['summary']) 207 | 208 | def test_connectionField_keysOnly(self): 209 | a1 = Article(headline="Test1", summary="1").put() 210 | a2 = Article(headline="Test2", summary="2").put() 211 | a3 = Article(headline="Test3", summary="3").put() 212 | 213 | Comment(parent=a1, body="c1").put() 214 | Comment(parent=a2, body="c2").put() 215 | Comment(parent=a3, body="c3").put() 216 | 217 | result = schema.execute(""" 218 | query Articles { 219 | articles(keysOnly: true) { 220 | edges { 221 | cursor, 222 | node { 223 | id 224 | } 225 | } 226 | 227 | } 228 | } 229 | """) 230 | 231 | self.assertEmpty(result.errors) 232 | 233 | articles = result.data.get('articles', {}).get('edges') 234 | self.assertLength(articles, 3) 235 | 236 | def test_connectionField_empty(self): 237 | Article(headline="Test1", summary="1").put() 238 | 239 | result = schema.execute(""" 240 | query Articles { 241 | articles { 242 | edges { 243 | cursor, 244 | node { 245 | headline, 246 | createdAt, 247 | comments { 248 | edges { 249 | node { 250 | body 251 | } 252 | } 253 | } 254 | } 255 | } 256 | 257 | } 258 | } 259 | """) 260 | 261 | articles = result.data.get('articles', {}).get('edges') 262 | self.assertLength(articles, 1) 263 | 264 | article = articles[0]['node'] 265 | self.assertLength(article.keys(), 3) 266 | self.assertIsNotNone(article.get('headline')) 267 | self.assertIsNotNone(article.get('createdAt')) 268 | 269 | comments = article['comments']['edges'] 270 | self.assertEmpty(comments) 271 | 272 | def test_connectionField_model(self): 273 | self.assertEqual(NdbConnectionField(CommentType).model, Comment) 274 | 275 | def test_connectionFieldWithTransformEdges(self): 276 | alive_reader_key = Reader(name="Chuck Norris", email="gmail@chuckNorris.com").put() 277 | dead_reader_key = Reader(name="John Doe", email="johndoe@gmail.com", is_alive=False).put() 278 | article_key = Article(headline="Test1", summary="1").put() 279 | 280 | ArticleReader(reader_key=alive_reader_key, article_key=article_key).put() 281 | ArticleReader(reader_key=dead_reader_key, article_key=article_key).put() 282 | 283 | result = schema.execute(""" 284 | query Articles { 285 | articles { 286 | edges { 287 | node { 288 | readers { 289 | edges { 290 | cursor 291 | node { 292 | name 293 | email 294 | } 295 | } 296 | } 297 | } 298 | } 299 | 300 | } 301 | } 302 | """) 303 | 304 | articles = result.data.get('articles', {}).get('edges') 305 | self.assertLength(articles, 1) 306 | 307 | article = articles[0]['node'] 308 | 309 | readers = article.get('readers', {}).get('edges') 310 | self.assertLength(readers, 1) 311 | 312 | reader = readers[0]['node'] 313 | 314 | self.assertLength(reader.keys(), 2) 315 | self.assertIsNotNone(reader.get('name')) 316 | self.assertIsNotNone(reader.get('email')) 317 | 318 | def test_connectionFieldWithTransformEdges_continualEdgeGeneration(self): 319 | dead1_reader_key = Reader(name="John Doe 1", email="johndoe@gmail.com", is_alive=False).put() 320 | dead2_reader_key = Reader(name="John Doe 2", email="johndoe@gmail.com", is_alive=False).put() 321 | alive_reader_key = Reader(name="Chuck Norris", email="gmail@chuckNorris.com").put() 322 | article_key = Article(headline="Test1", summary="1").put() 323 | 324 | ArticleReader(reader_key=dead1_reader_key, article_key=article_key).put() 325 | ArticleReader(reader_key=dead2_reader_key, article_key=article_key).put() 326 | ArticleReader(reader_key=alive_reader_key, article_key=article_key).put() 327 | 328 | result = schema.execute(""" 329 | query Articles { 330 | articles { 331 | edges { 332 | node { 333 | readers(first:1) { 334 | edges { 335 | cursor 336 | node { 337 | ndbId 338 | name 339 | email 340 | isAlive 341 | } 342 | } 343 | } 344 | } 345 | } 346 | 347 | } 348 | } 349 | """) 350 | 351 | articles = result.data.get('articles', {}).get('edges') 352 | self.assertLength(articles, 1) 353 | 354 | article = articles[0]['node'] 355 | 356 | readers = article.get('readers', {}).get('edges') 357 | self.assertLength(readers, 1) 358 | 359 | reader = readers[0]['node'] 360 | 361 | self.assertLength(reader.keys(), 4) 362 | self.assertEquals(reader['ndbId'], str(alive_reader_key.id())) 363 | -------------------------------------------------------------------------------- /tests/_webapp2/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'ekampf' 2 | -------------------------------------------------------------------------------- /tests/_webapp2/test_graphql_handler.py: -------------------------------------------------------------------------------- 1 | import webapp2 2 | from tests.base_test import BaseTest 3 | 4 | import json 5 | import webtest 6 | import graphene 7 | from graphene import relay 8 | from graphene_gae.webapp2 import graphql_application, GraphQLHandler 9 | 10 | __author__ = 'ekampf' 11 | 12 | 13 | class QueryRootType(graphene.ObjectType): 14 | default_greet = 'World' 15 | 16 | greet = graphene.Field(graphene.String, who=graphene.Argument(graphene.String)) 17 | resolver_raises = graphene.String() 18 | 19 | def resolve_greet(self, info, who): 20 | return 'Hello %s!' % who 21 | 22 | def resolve_resolver_raises(self, info): 23 | raise Exception("TEST") 24 | 25 | 26 | class ChangeDefaultGreetingMutation(relay.ClientIDMutation): 27 | class Input: 28 | value = graphene.String() 29 | 30 | ok = graphene.Boolean() 31 | defaultGreeting = graphene.String() 32 | 33 | @classmethod 34 | def mutate_and_get_payload(cls, root, info, **input): 35 | QueryRootType.default_greet = input.get('value') 36 | return ChangeDefaultGreetingMutation(ok=True, defaultGreeting=QueryRootType.default_greet) 37 | 38 | 39 | class MutationRootType(graphene.ObjectType): 40 | changeDefaultGreeting = ChangeDefaultGreetingMutation.Field() 41 | 42 | 43 | schema = graphene.Schema(query=QueryRootType, mutation=MutationRootType) 44 | 45 | graphql_application.config['graphql_schema'] = schema 46 | graphql_application.config['graphql_pretty'] = True 47 | 48 | 49 | class TestGraphQLHandler(BaseTest): 50 | def setUp(self): 51 | BaseTest.setUp(self) 52 | 53 | self.app = webtest.TestApp(graphql_application) 54 | 55 | def get(self, *args, **kwargs): 56 | return self.app.get(*args, **kwargs) 57 | 58 | def post(self, *args, **kwargs): 59 | if 'params' in kwargs: 60 | kwargs['params'] = json.dumps(kwargs['params']) 61 | return self.app.post(*args, **kwargs) 62 | 63 | def test_noSchema_returns500(self): 64 | graphql_application = webapp2.WSGIApplication([ 65 | ('/graphql', GraphQLHandler) 66 | ]) 67 | 68 | app = webtest.TestApp(graphql_application) 69 | for method in (app.get, app.post): 70 | response = method('/graphql', expect_errors=True) 71 | self.assertEqual(response.status_int, 500) 72 | self.assertEqual(response.json_body['errors'][0]['message'], 'GraphQL Schema is missing.') 73 | 74 | def test_noInput_returns400(self): 75 | for method in (self.app.get, self.app.post): 76 | response = method('/graphql', expect_errors=True) 77 | self.assertEqual(response.status_int, 400) 78 | 79 | def test_supports_operation_name(self): 80 | for method in (self.get, self.post): 81 | response = method('/graphql', params=dict( 82 | query=''' 83 | query helloYou { greet(who: "You"), ...shared } 84 | query helloWorld { greet(who: "World"), ...shared } 85 | query helloDolly { greet(who: "Dolly"), ...shared } 86 | fragment shared on QueryRootType { 87 | shared: greet(who: "Everyone") 88 | } 89 | ''', 90 | operation_name='helloDolly' 91 | )) 92 | 93 | response_dict = json.loads(response.body) 94 | self.assertDictEqual( 95 | response_dict.get('data'), 96 | { 97 | 'greet': 'Hello Dolly!', 98 | 'shared': 'Hello Everyone!' 99 | } 100 | ) 101 | 102 | def testGET_support_json_variables(self): 103 | response = self.app.get('/graphql', params=dict( 104 | query='query helloWho($who: String){ greet(who: $who) }', 105 | variables=json.dumps({'who': "ekampf"}) 106 | )) 107 | 108 | response_dict = json.loads(response.body) 109 | self.assertDictEqual( 110 | response_dict.get('data'), {'greet': 'Hello ekampf!'} 111 | ) 112 | 113 | def testPOST_support_json_variables(self): 114 | response = self.app.post('/graphql', params=json.dumps(dict( 115 | query='query helloWho($who: String){ greet(who: $who) }', 116 | variables={'who': "ekampf"} 117 | ))) 118 | 119 | response_dict = json.loads(response.body) 120 | self.assertDictEqual( 121 | response_dict.get('data'), {'greet': 'Hello ekampf!'} 122 | ) 123 | 124 | def test_reports_argument_validation_errors(self): 125 | for method in (self.get, self.post): 126 | response = method('/graphql', expect_errors=True, params=dict( 127 | query=''' 128 | query helloYou { greet(who: 123), ...shared } 129 | query helloWorld { greet(who: "World"), ...shared } 130 | query helloDolly { greet(who: "Dolly"), ...shared } 131 | fragment shared on Query { 132 | shared: greet(who: "Everyone") 133 | } 134 | ''', 135 | operation_name='helloYou' 136 | )) 137 | 138 | self.assertEqual(response.status_int, 400) 139 | 140 | response_dict = json.loads(response.body) 141 | self.assertEqual(response_dict["errors"][0]["message"], "Argument \"who\" has invalid value 123.\nExpected type \"String\", found 123.") 142 | 143 | def test_reports_missing_operation_name(self): 144 | for method in (self.get, self.post): 145 | response = method('/graphql', expect_errors=True, params=dict( 146 | query=''' 147 | query helloWorld { greet(who: "World"), ...shared } 148 | query helloDolly { greet(who: "Dolly"), ...shared } 149 | fragment shared on QueryRootType { 150 | shared: greet(who: "Everyone") 151 | } 152 | ''' 153 | )) 154 | 155 | self.assertEqual(response.status_int, 400) 156 | 157 | response_dict = json.loads(response.body) 158 | self.assertEqual(response_dict["errors"][0]["message"], "Must provide operation name if query contains multiple operations.") 159 | 160 | def test_handles_syntax_errors(self): 161 | for method in (self.get, self.post): 162 | response = method('/graphql', expect_errors=True, params=dict(query='syntaxerror')) 163 | 164 | self.assertEqual(response.status_int, 400) 165 | 166 | expected = { 167 | u'errors': [{u'locations': [{u'column': 1, u'line': 1}], 168 | u'message': u'Syntax Error GraphQL (1:1) ' 169 | u'Unexpected Name "syntaxerror"\n\n1: syntaxerror\n ^\n'}] 170 | } 171 | response_dict = json.loads(response.body) 172 | self.assertEqual(response_dict, expected) 173 | 174 | def test_handles_poorly_formed_variables(self): 175 | for method in (self.get, self.post): 176 | response = method('/graphql', expect_errors=True, params=dict( 177 | query='query helloWho($who: String){ greet(who: $who) }', 178 | variables='who:You' 179 | )) 180 | 181 | response_data = json.loads(response.body) 182 | self.assertEqual(response.status_int, 400) 183 | self.assertEqual(response_data['errors'][0]['message'], 'Variables are invalid JSON.') 184 | 185 | def testGET_mutations(self): 186 | response = self.app.get('/graphql', params=dict( 187 | query=''' 188 | mutation TestMutatio { 189 | changeDefaultGreeting(input: { value: "universe" } ) { 190 | ok, 191 | defaultGreeting 192 | } 193 | }''' 194 | )) 195 | 196 | self.assertEqual('universe', response.json_body['data']['changeDefaultGreeting']['defaultGreeting']) 197 | self.assertEqual(True, response.json_body['data']['changeDefaultGreeting']['ok']) 198 | 199 | def testPOST_mutations(self): 200 | response = self.app.post('/graphql', 201 | json.dumps( 202 | dict( 203 | query=''' 204 | mutation TestMutatio { 205 | changeDefaultGreeting(input: { value: "universe" } ) { 206 | ok, 207 | defaultGreeting 208 | } 209 | }''' 210 | ) 211 | )) 212 | 213 | self.assertEqual('universe', response.json_body['data']['changeDefaultGreeting']['defaultGreeting']) 214 | self.assertEqual(True, response.json_body['data']['changeDefaultGreeting']['ok']) 215 | 216 | def testPOST_override_pretty_via_query_param(self): 217 | response = self.app.post('/graphql?pretty=true', params=json.dumps(dict( 218 | query='query helloYou { greet(who: "You") }' 219 | ))) 220 | self.assertEqual(response.body, '{\n "data": {\n "greet": "Hello You!"\n }\n}') 221 | 222 | def testPOST_override_pretty_via_post_param(self): 223 | response = self.app.post('/graphql', params=json.dumps(dict( 224 | query='query helloYou { greet(who: "You") }', 225 | pretty=True 226 | ))) 227 | self.assertEqual(response.body, '{\n "data": {\n "greet": "Hello You!"\n }\n}') 228 | 229 | def testPOST_stringBody_readsQueryFromBodyAndRestFromGET(self): 230 | response = self.app.post('/graphql?pretty=True', params='query helloYou { greet(who: "You") }') 231 | self.assertEqual(response.body, '{\n "data": {\n "greet": "Hello You!"\n }\n}') 232 | 233 | -------------------------------------------------------------------------------- /tests/base_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | google_appengine_home = os.environ.get('GOOGLE_APPENGINE_HOME', '/usr/local/google_appengine') 5 | 6 | for path in [google_appengine_home]: 7 | if path not in sys.path: 8 | sys.path[0:0] = [path] 9 | 10 | import unittest 11 | 12 | from google.appengine.ext import testbed 13 | from google.appengine.datastore import datastore_stub_util 14 | from google.appengine.ext import ndb 15 | from google.appengine.ext.cloudstorage import cloudstorage_stub 16 | 17 | 18 | # noinspection PyProtectedMemberDisneyStoreAvailability 19 | class BaseTest(unittest.TestCase): 20 | # so that nosetests run 21 | context = None 22 | 23 | def __init__(self, method_name=''): 24 | super(BaseTest, self).__init__(method_name) 25 | self.datastore_probability = 1 26 | 27 | def setUp(self): 28 | super(BaseTest, self).setUp() 29 | 30 | root_path = '.' 31 | application_id = 'graphene-gae-test' 32 | 33 | # First, create an instance of the Testbed class. 34 | self.testbed = testbed.Testbed() 35 | self.testbed.activate() 36 | self.testbed.setup_env(app_id=application_id, overwrite=True) 37 | policy = datastore_stub_util.PseudoRandomHRConsistencyPolicy(probability=self.datastore_probability) 38 | self.testbed.init_datastore_v3_stub(root_path=root_path, consistency_policy=policy, require_indexes=True) 39 | self.testbed.init_app_identity_stub() 40 | self.testbed.init_blobstore_stub() 41 | self.testbed.init_memcache_stub() 42 | self.testbed.init_taskqueue_stub(root_path=root_path) 43 | self.testbed.init_urlfetch_stub() 44 | self.storage = cloudstorage_stub.CloudStorageStub(self.testbed.get_stub('blobstore').storage) 45 | self.testbed.init_mail_stub() 46 | self.testbed.init_user_stub() 47 | self.taskqueue_stub = self.testbed.get_stub(testbed.TASKQUEUE_SERVICE_NAME) 48 | 49 | ndb.get_context().clear_cache() 50 | ndb.get_context().set_cache_policy(lambda x: True) 51 | 52 | def tearDown(self): 53 | self.testbed.init_datastore_v3_stub(False) 54 | 55 | self.testbed.deactivate() 56 | self.testbed = None 57 | super(BaseTest, self).tearDown() 58 | 59 | def get_filtered_tasks(self, url=None, name=None, queue_names=None): 60 | return self.taskqueue_stub.get_filtered_tasks(url=url, name=name, queue_names=queue_names) 61 | 62 | # region Extra Assertions 63 | def assertEmpty(self, l, msg=None): 64 | if l is None: 65 | return 66 | self.assertEqual(0, len(list(l)), msg=msg or str(l)) 67 | 68 | def assertLength(self, l, expectation, msg=None): 69 | self.assertEqual(len(l), expectation, msg) 70 | 71 | def assertEndsWith(self, str, expected_suffix): 72 | cond = str.endswith(expected_suffix) 73 | self.assertTrue(cond) 74 | 75 | def assertStartsWith(self, str, expected_prefix): 76 | cond = str.startswith(expected_prefix) 77 | self.assertTrue(cond) 78 | # endregion 79 | 80 | def assertPositive(self, n): 81 | self.assertTrue(n > 0) 82 | 83 | def assertNegative(self, n): 84 | self.assertTrue(n < 0) 85 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import ndb 2 | 3 | __author__ = 'ekampf' 4 | 5 | 6 | class Address(ndb.Model): 7 | address1 = ndb.StringProperty() 8 | address2 = ndb.StringProperty() 9 | city = ndb.StringProperty() 10 | 11 | 12 | class PhoneNumber(ndb.Model): 13 | area = ndb.StringProperty() 14 | number = ndb.StringProperty() 15 | 16 | 17 | class Reader(ndb.Model): 18 | name = ndb.StringProperty() 19 | email = ndb.StringProperty() 20 | is_alive = ndb.BooleanProperty(default=True) 21 | 22 | 23 | class Author(ndb.Model): 24 | name = ndb.StringProperty() 25 | email = ndb.StringProperty() 26 | addresses = ndb.LocalStructuredProperty(Address, repeated=True) 27 | mobile = ndb.LocalStructuredProperty(PhoneNumber) 28 | 29 | 30 | class Tag(ndb.Model): 31 | name = ndb.StringProperty() 32 | 33 | 34 | class Comment(ndb.Model): 35 | created_at = ndb.DateTimeProperty(auto_now_add=True) 36 | updated_at = ndb.DateTimeProperty(auto_now=True) 37 | body = ndb.StringProperty() 38 | 39 | 40 | class ArticleReader(ndb.Model): 41 | article_key = ndb.KeyProperty(kind='Article') 42 | reader_key = ndb.KeyProperty(kind='Reader') 43 | 44 | read_at = ndb.DateTimeProperty(auto_now_add=True) 45 | 46 | 47 | class Article(ndb.Model): 48 | headline = ndb.StringProperty(required=True) 49 | summary = ndb.StringProperty() 50 | body = ndb.TextProperty() 51 | body_hash = ndb.ComputedProperty(lambda self: self.calc_body_hash()) 52 | keywords = ndb.StringProperty(repeated=True) 53 | 54 | author_key = ndb.KeyProperty(kind='Author') 55 | tags = ndb.KeyProperty(Tag, repeated=True) 56 | 57 | created_at = ndb.DateTimeProperty(auto_now_add=True) 58 | updated_at = ndb.DateTimeProperty(auto_now=True) 59 | 60 | def calc_body_hash(self): 61 | import hashlib 62 | return hashlib.md5(self.body).hexdigit() if self.body else None 63 | 64 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONPATH = {toxinidir}:{toxinidir}/graphene_gae 7 | commands = python setup.py test 8 | deps = 9 | -r{toxinidir}/requirements.txt 10 | 11 | [testenv:style] 12 | deps = 13 | -r{toxinidir}/requirements.txt 14 | flake8 15 | commands = 16 | python setup.py flake8 17 | 18 | [testenv:docs] 19 | changedir=docs/ 20 | deps = 21 | -r{toxinidir}/requirements.txt 22 | sphinx 23 | commands = 24 | sphinx-build -b linkcheck ./ _build/ 25 | sphinx-build -b html ./ _build/ 26 | --------------------------------------------------------------------------------