├── .coveragerc ├── .editorconfig ├── .gitignore ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── RELEASING.rst ├── azure-pipelines.yml ├── docs ├── Makefile ├── apireference.rst ├── authors.rst ├── conf.py ├── contributing.rst ├── data_flow.png ├── history.rst ├── index.rst ├── instance_template.png ├── make.bat ├── migration.rst ├── ressources │ ├── data_flow.xml │ └── instance_template.xml └── userguide.rst ├── examples ├── flask │ ├── README.rst │ ├── app.py │ ├── requirements.txt │ ├── testbed.py │ └── translations │ │ └── fr │ │ └── LC_MESSAGES │ │ ├── messages.mo │ │ └── messages.po ├── inheritance │ ├── README.rst │ ├── app.py │ └── requirements.txt └── klein │ ├── README.rst │ ├── app.py │ ├── klein_babel.py │ ├── requirements.txt │ └── translations ├── messages.pot ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── common.py ├── conftest.py ├── frameworks │ ├── __init__.py │ ├── common.py │ ├── test_mongomock.py │ ├── test_motor_asyncio.py │ ├── test_pymongo.py │ ├── test_tools.py │ └── test_txmongo.py ├── test_builder.py ├── test_data_proxy.py ├── test_document.py ├── test_embedded_document.py ├── test_fields.py ├── test_i18n.py ├── test_indexes.py ├── test_inheritance.py ├── test_instance.py ├── test_marshmallow.py └── test_query_mapper.py ├── tox.ini └── umongo ├── __init__.py ├── abstract.py ├── builder.py ├── data_objects.py ├── data_proxy.py ├── document.py ├── embedded_document.py ├── exceptions.py ├── expose_missing.py ├── fields.py ├── frameworks ├── __init__.py ├── mongomock.py ├── motor_asyncio.py ├── pymongo.py ├── tools.py └── txmongo.py ├── i18n.py ├── indexes.py ├── instance.py ├── marshmallow_bonus.py ├── mixin.py ├── query_mapper.py ├── template.py └── validate.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | 3 | [report] 4 | 5 | exclude_lines = 6 | # Have to re-enable the standard pragma 7 | pragma: no cover 8 | 9 | # Don't complain about missing debug-only code: 10 | def __repr__ 11 | if self\.debug 12 | 13 | # Don't complain if tests don't hit defensive assertion code: 14 | raise AssertionError 15 | raise NotImplementedError 16 | 17 | # Don't complain if non-runnable code isn't run: 18 | if 0: 19 | if __name__ == .__main__.: 20 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | #*.mo 50 | #*.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Original Author 6 | --------------- 7 | 8 | * Emmanuel Leblond `@touilleMan `_ 9 | 10 | Development Lead 11 | ---------------- 12 | 13 | * Jérôme Lafréchoux `@lafrech `_ 14 | 15 | Contributors 16 | ------------ 17 | 18 | * Walter Scheper `@wfscheper `_ 19 | * Imbolc `@imbolc `_ 20 | * `@patlach42 `_ 21 | * Serj Shevchenko `@serjshevchenko `_ 22 | * Élysson MR `@elyssonmr `_ 23 | * Mandar Upadhye `@mandarup `_ 24 | * Pavel Kulyov `@pkulev `_ 25 | * Felix Sonntag `@fsonntag `_ 26 | * Attila Kóbor `@atti92 `_ 27 | * Denis Moskalets `@denya `_ 28 | * Phil Chiu `@whophil `_ 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/Scille/umongo/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" 30 | is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "feature" 36 | is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | uMongo could always use more documentation, whether as part of the 42 | official uMongo docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/touilleMan/umongo/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `umongo` for local development. 61 | 62 | 1. Fork the `umongo` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/umongo.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv umongo 70 | $ cd umongo/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: 80 | 81 | $ flake8 umongo 82 | $ py.test tests 83 | $ tox 84 | 85 | To get flake8, pytest and tox, just pip install them into your virtualenv. 86 | 87 | .. note:: You need pytest>=2.8 88 | 89 | 6. Commit your changes and push your branch to GitHub:: 90 | 91 | $ git add . 92 | $ git commit -m "Your detailed description of your changes." 93 | $ git push origin name-of-your-bugfix-or-feature 94 | 95 | 7. Submit a pull request through the GitHub website. 96 | 97 | I18n 98 | ---- 99 | 100 | There are additional steps to make changes involving translated strings. 101 | 102 | 1. Extract translatable strings from the code into messages.pot:: 103 | 104 | $ make extract_messages 105 | 106 | 2. Update flask example translation files:: 107 | 108 | $ make update_flask_example_messages 109 | 110 | 3. Update/fix translations 111 | 112 | 4. Compile new binary translation files:: 113 | 114 | $ make compile_flask_example_messages 115 | 116 | Pull Request Guidelines 117 | ----------------------- 118 | 119 | Before you submit a pull request, check that it meets these guidelines: 120 | 121 | 1. The pull request should include tests. 122 | 2. If the pull request adds functionality, the docs should be updated. Put 123 | your new functionality into a function with a docstring, and add the 124 | feature to the list in README.rst. 125 | 3. The pull request should work for Python 3.7 and 3.8. Check 126 | https://travis-ci.org/touilleMan/umongo/pull_requests 127 | and make sure that the tests pass for all supported Python versions. 128 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2020 Scille SAS and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /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 * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs clean 2 | define BROWSER_PYSCRIPT 3 | import os, webbrowser, sys 4 | try: 5 | from urllib import pathname2url 6 | except: 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 13 | 14 | help: 15 | @echo "clean - remove all build, test, coverage and Python artifacts" 16 | @echo "clean-build - remove build artifacts" 17 | @echo "clean-pyc - remove Python file artifacts" 18 | @echo "clean-test - remove test and coverage artifacts" 19 | @echo "lint - check style with flake8" 20 | @echo "test - run tests quickly with the default Python" 21 | @echo "test-all - run tests on every Python version with tox" 22 | @echo "coverage - check code coverage quickly with the default Python" 23 | @echo "docs - generate Sphinx HTML documentation, including API docs" 24 | @echo "release - package and upload a release" 25 | @echo "dist - package" 26 | @echo "install - install the package to the active Python's site-packages" 27 | 28 | clean: clean-build clean-pyc clean-test 29 | 30 | clean-build: 31 | rm -fr build/ 32 | rm -fr dist/ 33 | rm -fr .eggs/ 34 | find . -name '*.egg-info' -exec rm -fr {} + 35 | find . -name '*.egg' -exec rm -f {} + 36 | 37 | clean-pyc: 38 | find . -name '*.pyc' -exec rm -f {} + 39 | find . -name '*.pyo' -exec rm -f {} + 40 | find . -name '*~' -exec rm -f {} + 41 | find . -name '__pycache__' -exec rm -fr {} + 42 | 43 | clean-test: 44 | rm -fr .tox/ 45 | rm -f .coverage 46 | rm -fr htmlcov/ 47 | 48 | lint: 49 | flake8 umongo tests 50 | 51 | test: 52 | python setup.py test 53 | 54 | test-all: 55 | tox 56 | 57 | coverage: 58 | coverage run --source umongo setup.py test 59 | coverage report -m 60 | coverage html 61 | $(BROWSER) htmlcov/index.html 62 | 63 | docs: 64 | rm -f docs/umongo.rst 65 | rm -f docs/modules.rst 66 | sphinx-apidoc -o docs/ umongo 67 | $(MAKE) -C docs clean 68 | $(MAKE) -C docs html 69 | $(BROWSER) docs/_build/html/index.html 70 | 71 | servedocs: docs 72 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 73 | 74 | release: clean 75 | python setup.py sdist upload 76 | python setup.py bdist_wheel upload 77 | 78 | dist: clean 79 | python setup.py sdist 80 | python setup.py bdist_wheel 81 | ls -l dist 82 | 83 | install: clean 84 | python setup.py install 85 | 86 | AUTHOR='Jérôme Lafréchoux ' 87 | 88 | extract_messages: 89 | python setup.py extract_messages 90 | cat marshmallow_messages.pot >> messages.pot 91 | # There is currently no way to pass this as an option to pybabel 92 | # https://github.com/python-babel/babel/issues/82 93 | sed -i s/"FIRST AUTHOR "/$(AUTHOR)/ messages.pot 94 | 95 | update_flask_example_messages: 96 | pybabel update -i messages.pot -l fr -d examples/flask/translations/ 97 | 98 | compile_flask_example_messages: 99 | pybabel compile -d examples/flask/translations/ 100 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | μMongo: sync/async ODM 3 | ====================== 4 | 5 | .. image:: https://img.shields.io/pypi/v/umongo.svg 6 | :target: https://pypi.python.org/pypi/umongo 7 | :alt: Latest version 8 | 9 | .. image:: https://img.shields.io/pypi/pyversions/umongo.svg 10 | :target: https://pypi.org/project/umongo/ 11 | :alt: Python versions 12 | 13 | .. image:: https://img.shields.io/badge/marshmallow-3-blue.svg 14 | :target: https://marshmallow.readthedocs.io/en/latest/upgrading.html 15 | :alt: marshmallow 3 only 16 | 17 | .. image:: https://img.shields.io/pypi/l/umongo.svg 18 | :target: https://umongo.readthedocs.io/en/latest/license.html 19 | :alt: License 20 | 21 | .. image:: https://dev.azure.com/lafrech/umongo/_apis/build/status/Scille.umongo?branchName=master 22 | :target: https://dev.azure.com/lafrech/umongo/_build/latest?definitionId=1&branchName=master 23 | :alt: Build status 24 | 25 | .. image:: https://readthedocs.org/projects/umongo/badge/ 26 | :target: http://umongo.readthedocs.io/ 27 | :alt: Documentation 28 | 29 | μMongo is a Python MongoDB ODM. It inception comes from two needs: 30 | the lack of async ODM and the difficulty to do document (un)serialization 31 | with existing ODMs. 32 | 33 | From this point, μMongo made a few design choices: 34 | 35 | - Stay close to the standards MongoDB driver to keep the same API when possible: 36 | use ``find({"field": "value"})`` like usual but retrieve your data nicely OO wrapped ! 37 | - Work with multiple drivers (PyMongo_, TxMongo_, motor_asyncio_ and mongomock_ for the moment) 38 | - Tight integration with Marshmallow_ serialization library to easily 39 | dump and load your data with the outside world 40 | - i18n integration to localize validation error messages 41 | - Free software: MIT license 42 | - Test with 90%+ coverage ;-) 43 | 44 | .. _PyMongo: https://api.mongodb.org/python/current/ 45 | .. _TxMongo: https://txmongo.readthedocs.org/en/latest/ 46 | .. _motor_asyncio: https://motor.readthedocs.org/en/stable/ 47 | .. _mongomock: https://github.com/vmalloc/mongomock 48 | .. _Marshmallow: http://marshmallow.readthedocs.org 49 | 50 | µMongo requires MongoDB 4.2+ and Python 3.7+. 51 | 52 | Quick example 53 | 54 | .. code-block:: python 55 | 56 | import datetime as dt 57 | from pymongo import MongoClient 58 | from umongo import Document, fields, validate 59 | from umongo.frameworks import PyMongoInstance 60 | 61 | db = MongoClient().test 62 | instance = PyMongoInstance(db) 63 | 64 | @instance.register 65 | class User(Document): 66 | email = fields.EmailField(required=True, unique=True) 67 | birthday = fields.DateTimeField(validate=validate.Range(min=dt.datetime(1900, 1, 1))) 68 | friends = fields.ListField(fields.ReferenceField("User")) 69 | 70 | class Meta: 71 | collection_name = "user" 72 | 73 | # Make sure that unique indexes are created 74 | User.ensure_indexes() 75 | 76 | goku = User(email='goku@sayen.com', birthday=dt.datetime(1984, 11, 20)) 77 | goku.commit() 78 | vegeta = User(email='vegeta@over9000.com', friends=[goku]) 79 | vegeta.commit() 80 | 81 | vegeta.friends 82 | # ])> 83 | vegeta.dump() 84 | # {id': '570ddb311d41c89cabceeddc', 'email': 'vegeta@over9000.com', friends': ['570ddb2a1d41c89cabceeddb']} 85 | User.find_one({"email": 'goku@sayen.com'}) 86 | # , 87 | # 'email': 'goku@sayen.com', 'birthday': datetime.datetime(1984, 11, 20, 0, 0)})> 88 | 89 | Get it now:: 90 | 91 | $ pip install umongo # This installs umongo with pymongo 92 | $ pip install my-mongo-driver # Other MongoDB drivers must be installed manually 93 | 94 | Or to get it along with the MongoDB driver you're planing to use:: 95 | 96 | $ pip install umongo[motor] 97 | $ pip install umongo[txmongo] 98 | $ pip install umongo[mongomock] 99 | -------------------------------------------------------------------------------- /RELEASING.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Releasing μMongo 3 | ================ 4 | 5 | Prerequisites 6 | ------------- 7 | 8 | - Install bumpversion_. The easiest way is to create and activate a virtualenv, 9 | and then run ``pip install -r requirements_dev.txt``. 10 | 11 | Steps 12 | ----- 13 | 14 | #. Add an entry to ``HISTORY.rst``, or update the ``Unreleased`` entry, with the 15 | new version and the date of release. Include any bug fixes, features, or 16 | backwards incompatibilities included in this release. 17 | #. Commit the changes to ``HISTORY.rst``. 18 | #. Run bumpversion_ to update the version string in ``umongo/__init__.py`` and 19 | ``setup.py``. 20 | 21 | * You can combine this step and the previous one by using the ``--allow-dirty`` 22 | flag when running bumpversion_ to make a single release commit. 23 | 24 | #. Run ``git push`` to push the release commits to github. 25 | #. Once the CI tests pass, run ``git push --tags`` to push the tag to github and 26 | trigger the release to pypi. 27 | 28 | .. _bumpversion: https://pypi.org/project/bumpversion/ 29 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: [master, test-me-*] 4 | tags: 5 | include: ['*'] 6 | 7 | resources: 8 | repositories: 9 | - repository: sloria 10 | type: github 11 | endpoint: github 12 | name: sloria/azure-pipeline-templates 13 | ref: refs/heads/sloria 14 | 15 | stages: 16 | - stage: lint 17 | jobs: 18 | - template: job--python-tox.yml@sloria 19 | parameters: 20 | toxenvs: [lint] 21 | coverage: false 22 | - stage: test_mongo_4_2 23 | jobs: 24 | - template: job--python-tox.yml@sloria 25 | parameters: 26 | toxenvs: 27 | - py39-pymongo 28 | - py39-motor 29 | - py39-txmongo 30 | coverage: true 31 | pre_test: 32 | - script: | 33 | sudo rm /etc/apt/sources.list.d/mongodb-org-4.4.list 34 | wget -qO - https://www.mongodb.org/static/pgp/server-4.2.asc | sudo apt-key add - 35 | echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu bionic/mongodb-org/4.2 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-4.2.list 36 | sudo apt-get remove '^mongodb-org.*' 37 | sudo apt-get update 38 | sudo apt-get install -y mongodb-org 39 | - script: mongod --version 40 | - script: sudo systemctl start mongod 41 | - stage: test_mongo_4_4 42 | jobs: 43 | - template: job--python-tox.yml@sloria 44 | parameters: 45 | toxenvs: 46 | - py37-pymongo 47 | - py37-motor 48 | - py37-txmongo 49 | - py39-pymongo 50 | - py39-motor 51 | - py39-txmongo 52 | coverage: true 53 | pre_test: 54 | - script: mongod --version 55 | - script: sudo systemctl start mongod 56 | - stage: release 57 | jobs: 58 | - template: job--pypi-release.yml@sloria 59 | -------------------------------------------------------------------------------- /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/umongo.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/umongo.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/umongo" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/umongo" 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/apireference.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | ============= 4 | API Reference 5 | ============= 6 | 7 | .. module:: umongo 8 | 9 | Instance 10 | ======== 11 | 12 | .. autoclass:: umongo.instance.Instance 13 | :members: 14 | 15 | .. autoclass:: umongo.frameworks.pymongo.PyMongoInstance 16 | 17 | .. autoclass:: umongo.frameworks.txmongo.TxMongoInstance 18 | 19 | .. autoclass:: umongo.frameworks.motor_asyncio.MotorAsyncIOInstance 20 | 21 | .. autoclass:: umongo.frameworks.mongomock.MongoMockInstance 22 | 23 | Document 24 | ======== 25 | 26 | .. autoclass:: umongo.Document 27 | 28 | .. autoclass:: umongo.document.DocumentTemplate 29 | 30 | .. autoclass:: umongo.document.DocumentOpts 31 | 32 | .. autoclass:: umongo.document.DocumentImplementation 33 | :inherited-members: 34 | 35 | EmbeddedDocument 36 | ================ 37 | 38 | .. autoclass:: umongo.EmbeddedDocument 39 | 40 | .. autoclass:: umongo.embedded_document.EmbeddedDocumentTemplate 41 | 42 | .. autoclass:: umongo.embedded_document.EmbeddedDocumentOpts 43 | 44 | .. autoclass:: umongo.embedded_document.EmbeddedDocumentImplementation 45 | :inherited-members: 46 | 47 | MixinDocument 48 | ============= 49 | 50 | .. autoclass:: umongo.MixinDocument 51 | 52 | .. autoclass:: umongo.mixin.MixinDocumentTemplate 53 | 54 | .. autoclass:: umongo.mixin.MixinDocumentOpts 55 | 56 | .. autoclass:: umongo.mixin.MixinDocumentImplementation 57 | :inherited-members: 58 | 59 | .. _api_abstracts: 60 | 61 | Abstracts 62 | ========= 63 | 64 | .. autoclass:: umongo.abstract.BaseSchema 65 | :members: 66 | 67 | .. autoclass:: umongo.abstract.BaseField 68 | :members: 69 | :undoc-members: 70 | 71 | .. autoclass:: umongo.abstract.BaseValidator 72 | :members: 73 | 74 | .. autoclass:: umongo.abstract.BaseDataObject 75 | :members: 76 | :undoc-members: 77 | 78 | .. _api_fields: 79 | 80 | Fields 81 | ====== 82 | 83 | .. automodule:: umongo.fields 84 | :members: 85 | :undoc-members: 86 | 87 | .. _api_data_objects: 88 | 89 | Data objects 90 | ============ 91 | 92 | .. automodule:: umongo.data_objects 93 | :members: 94 | :undoc-members: 95 | 96 | .. _api_marshmallow_intergration: 97 | 98 | Marshmallow integration 99 | ======================= 100 | 101 | .. automodule:: umongo.marshmallow_bonus 102 | :members: 103 | 104 | .. _api_exceptions: 105 | 106 | Exceptions 107 | ========== 108 | 109 | .. automodule:: umongo.exceptions 110 | :members: 111 | :undoc-members: 112 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # umongo 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 umongo # noqa E402 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 = [ 44 | 'sphinx.ext.autodoc', 45 | 'sphinx.ext.intersphinx', 46 | 'sphinx.ext.viewcode', 47 | ] 48 | 49 | intersphinx_mapping = { 50 | 'pymongo': ('https://pymongo.readthedocs.io/en/latest/', None), 51 | 'marshmallow': ('https://marshmallow.readthedocs.io/en/latest/', None), 52 | 'asyncio': ('https://asyncio.readthedocs.io/en/latest/', None), 53 | } 54 | 55 | # Add any paths that contain templates here, relative to this directory. 56 | templates_path = ['_templates'] 57 | 58 | # The suffix of source filenames. 59 | source_suffix = '.rst' 60 | 61 | # The encoding of source files. 62 | #source_encoding = 'utf-8-sig' 63 | 64 | # The master toctree document. 65 | master_doc = 'index' 66 | 67 | # General information about the project. 68 | project = u'uMongo' 69 | copyright = u'2016-2020, Scille SAS and contributors' 70 | 71 | # The version info for the project you're documenting, acts as replacement 72 | # for |version| and |release|, also used in various other places throughout 73 | # the built documents. 74 | # 75 | # The short X.Y version. 76 | version = umongo.__version__ 77 | # The full version, including alpha/beta/rc tags. 78 | release = umongo.__version__ 79 | 80 | # The language for content autogenerated by Sphinx. Refer to documentation 81 | # for a list of supported languages. 82 | #language = None 83 | 84 | # There are two options for replacing |today|: either, you set today to 85 | # some non-false value, then it is used: 86 | #today = '' 87 | # Else, today_fmt is used as the format for a strftime call. 88 | #today_fmt = '%B %d, %Y' 89 | 90 | # List of patterns, relative to source directory, that match files and 91 | # directories to ignore when looking for source files. 92 | exclude_patterns = ['_build'] 93 | 94 | # The reST default role (used for this markup: `text`) to use for all 95 | # documents. 96 | #default_role = None 97 | 98 | # If true, '()' will be appended to :func: etc. cross-reference text. 99 | #add_function_parentheses = True 100 | 101 | # If true, the current module name will be prepended to all description 102 | # unit titles (such as .. function::). 103 | #add_module_names = True 104 | 105 | # If true, sectionauthor and moduleauthor directives will be shown in the 106 | # output. They are ignored by default. 107 | #show_authors = False 108 | 109 | # The name of the Pygments (syntax highlighting) style to use. 110 | pygments_style = 'sphinx' 111 | 112 | # A list of ignored prefixes for module index sorting. 113 | #modindex_common_prefix = [] 114 | 115 | # If true, keep warnings as "system message" paragraphs in the built 116 | # documents. 117 | #keep_warnings = False 118 | 119 | 120 | # -- Options for HTML output ------------------------------------------- 121 | 122 | # The theme to use for HTML and HTML Help pages. See the documentation for 123 | # a list of builtin themes. 124 | html_theme = 'default' 125 | 126 | # Theme options are theme-specific and customize the look and feel of a 127 | # theme further. For a list of options available for each theme, see the 128 | # documentation. 129 | #html_theme_options = {} 130 | 131 | # Add any paths that contain custom themes here, relative to this directory. 132 | #html_theme_path = [] 133 | 134 | # The name for this set of Sphinx documents. If None, it defaults to 135 | # " v documentation". 136 | #html_title = None 137 | 138 | # A shorter title for the navigation bar. Default is the same as 139 | # html_title. 140 | #html_short_title = None 141 | 142 | # The name of an image file (relative to this directory) to place at the 143 | # top of the sidebar. 144 | #html_logo = None 145 | 146 | # The name of an image file (within the static path) to use as favicon 147 | # of the docs. This file should be a Windows icon file (.ico) being 148 | # 16x16 or 32x32 pixels large. 149 | #html_favicon = None 150 | 151 | # Add any paths that contain custom static files (such as style sheets) 152 | # here, relative to this directory. They are copied after the builtin 153 | # static files, so a file named "default.css" will overwrite the builtin 154 | # "default.css". 155 | html_static_path = ['_static'] 156 | 157 | # If not '', a 'Last updated on:' timestamp is inserted at every page 158 | # bottom, using the given strftime format. 159 | #html_last_updated_fmt = '%b %d, %Y' 160 | 161 | # If true, SmartyPants will be used to convert quotes and dashes to 162 | # typographically correct entities. 163 | #html_use_smartypants = True 164 | 165 | # Custom sidebar templates, maps document names to template names. 166 | #html_sidebars = {} 167 | 168 | # Additional templates that should be rendered to pages, maps page names 169 | # to template names. 170 | #html_additional_pages = {} 171 | 172 | # If false, no module index is generated. 173 | #html_domain_indices = True 174 | 175 | # If false, no index is generated. 176 | #html_use_index = True 177 | 178 | # If true, the index is split into individual pages for each letter. 179 | #html_split_index = False 180 | 181 | # If true, links to the reST sources are added to the pages. 182 | #html_show_sourcelink = True 183 | 184 | # If true, "Created using Sphinx" is shown in the HTML footer. 185 | # Default is True. 186 | #html_show_sphinx = True 187 | 188 | # If true, "(C) Copyright ..." is shown in the HTML footer. 189 | # Default is True. 190 | #html_show_copyright = True 191 | 192 | # If true, an OpenSearch description file will be output, and all pages 193 | # will contain a tag referring to it. The value of this option 194 | # must be the base URL from which the finished HTML is served. 195 | #html_use_opensearch = '' 196 | 197 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 198 | #html_file_suffix = None 199 | 200 | # Output file base name for HTML help builder. 201 | htmlhelp_basename = 'umongodoc' 202 | 203 | 204 | # -- Options for LaTeX output ------------------------------------------ 205 | 206 | latex_elements = { 207 | # The paper size ('letterpaper' or 'a4paper'). 208 | #'papersize': 'letterpaper', 209 | 210 | # The font size ('10pt', '11pt' or '12pt'). 211 | #'pointsize': '10pt', 212 | 213 | # Additional stuff for the LaTeX preamble. 214 | #'preamble': '', 215 | } 216 | 217 | # Grouping the document tree into LaTeX files. List of tuples 218 | # (source start file, target name, title, author, documentclass 219 | # [howto/manual]). 220 | latex_documents = [ 221 | ('index', 'umongo.tex', 222 | u'uMongo Documentation', 223 | u'Scille SAS', 'manual'), 224 | ] 225 | 226 | # The name of an image file (relative to this directory) to place at 227 | # the top of the title page. 228 | #latex_logo = None 229 | 230 | # For "manual" documents, if this is true, then toplevel headings 231 | # are parts, not chapters. 232 | #latex_use_parts = False 233 | 234 | # If true, show page references after internal links. 235 | #latex_show_pagerefs = False 236 | 237 | # If true, show URL addresses after external links. 238 | #latex_show_urls = False 239 | 240 | # Documents to append as an appendix to all manuals. 241 | #latex_appendices = [] 242 | 243 | # If false, no module index is generated. 244 | #latex_domain_indices = True 245 | 246 | 247 | # -- Options for manual page output ------------------------------------ 248 | 249 | # One entry per manual page. List of tuples 250 | # (source start file, name, description, authors, manual section). 251 | man_pages = [ 252 | ('index', 'umongo', 253 | u'uMongo Documentation', 254 | [u'Scille SAS'], 1) 255 | ] 256 | 257 | # If true, show URL addresses after external links. 258 | #man_show_urls = False 259 | 260 | 261 | # -- Options for Texinfo output ---------------------------------------- 262 | 263 | # Grouping the document tree into Texinfo files. List of tuples 264 | # (source start file, target name, title, author, 265 | # dir menu entry, description, category) 266 | texinfo_documents = [ 267 | ('index', 'umongo', 268 | u'uMongo Documentation', 269 | u'Scille SAS', 270 | 'umongo', 271 | 'One line description of project.', 272 | 'Miscellaneous'), 273 | ] 274 | 275 | # Documents to append as an appendix to all manuals. 276 | #texinfo_appendices = [] 277 | 278 | # If false, no module index is generated. 279 | #texinfo_domain_indices = True 280 | 281 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 282 | #texinfo_show_urls = 'footnote' 283 | 284 | # If true, do not generate a @detailmenu in the "Top" node's menu. 285 | #texinfo_no_detailmenu = False 286 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/data_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scille/umongo/1b23dc7155448a52fa6e7f3d7da6621632e259f5/docs/data_flow.png -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. umongo 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 | .. include:: ../README.rst 7 | 8 | Contents: 9 | --------- 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | userguide 15 | migration 16 | apireference 17 | contributing 18 | authors 19 | history 20 | 21 | Indices and tables 22 | ------------------ 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | 28 | Misc 29 | ---- 30 | 31 | This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. 32 | 33 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 34 | .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 35 | -------------------------------------------------------------------------------- /docs/instance_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scille/umongo/1b23dc7155448a52fa6e7f3d7da6621632e259f5/docs/instance_template.png -------------------------------------------------------------------------------- /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\umongo.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\umongo.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/migration.rst: -------------------------------------------------------------------------------- 1 | .. _migration: 2 | 3 | ========= 4 | Migrating 5 | ========= 6 | 7 | Migrating from umongo 2 to umongo 3 8 | =================================== 9 | 10 | For a full list of changes, see the CHANGELOG. 11 | 12 | Database migration 13 | ------------------ 14 | 15 | Aside from changes in application code, migrating from umongo 2 to umongo 3 16 | requires changes in the database. 17 | 18 | The way the embedded documents are stored has changed. The `_cls` attribute is 19 | now only set on embedded documents that are subclasses of a concrete embedded 20 | document. Unless documents are non-strict (i.e. transparently handle unknown 21 | fields, default is strict), the database must be migrated to remove the `_cls` 22 | fields on embedded documents that are not subclasses of a concrete document. 23 | 24 | This change is irreversible. It requires the knowledge of the application model 25 | (the document and embedded document classes). 26 | 27 | umongo provides dedicated framework specific ``Instance`` subclasses to help on 28 | this. 29 | 30 | A simple procedure to build a migration tool is to replace one's ``Instance`` 31 | class in the application code with such class and call 32 | ``instance.migrate_2_to_3`` on init. 33 | 34 | For instance, given following umongo 3 application code 35 | 36 | .. code-block:: python 37 | 38 | from umongo.frameworks.pymongo import PyMongoInstance 39 | 40 | instance = PyMongoInstance() 41 | 42 | # Register embedded documents 43 | [...] 44 | 45 | @instance.register 46 | class Doc(Document): 47 | name = fields.StrField() 48 | # Embed documents 49 | embedded = fields.EmbeddedField([...]) 50 | 51 | instance.set_db(pymongo.MongoClient()) 52 | 53 | # This may raise an exception if Doc contains embedded documents 54 | # as described above 55 | Doc.find() 56 | 57 | the migration can be performed by calling migrate_2_to_3. 58 | 59 | .. code-block:: python 60 | 61 | from umongo.frameworks.pymongo import PyMongoMigrationInstance 62 | 63 | instance = PyMongoMigrationInstance() 64 | 65 | # Register embedded documents 66 | [...] 67 | 68 | @instance.register 69 | class Doc(Document): 70 | name = fields.StrField() 71 | # Embed documents 72 | embedded = fields.EmbeddedField([...]) 73 | 74 | instance.set_db(pymongo.MongoClient()) 75 | instance.migrate_2_to_3() 76 | 77 | # This is safe now that the database is migrated 78 | Doc.find() 79 | 80 | Of course, this needs to be done only once. Although the migration is 81 | idempotent, it wouldn't make sense to keep this in the codebase and execute the 82 | migration on every application startup. 83 | 84 | However, it is possible to embed the migration feature in the application code 85 | by defining a dedicated command, like a Flask CLI command for instance. 86 | -------------------------------------------------------------------------------- /docs/ressources/data_flow.xml: -------------------------------------------------------------------------------- 1 | 7Vnfb6M4EP5reOwqxCFkH0uS3mmlvZ7Uh7t7OrlgiLUGR4Y07f71O4YZfnfVHFRtT5uXhLGxx98338xAHLZNH38z/Hj4qiOhnOUienTYzlku3RVbwpe1PFUWf4WGxMgIJzWGO/ldoHGB1pOMRN6ZWGitCnnsGkOdZSIsOjZujD53p8VadXc98oR2bAx3IVdD618yKg6VdbNcN/bfhUwOtLO7/lyN3PPwW2L0KcP9nCWLy081nHJaCw+aH3ikzy0T2wOuRmtY2f5KH7dCWWwJtuq+m2dGa7+NyNC3n9+AtDxwdcKjO37w5e72D8ev3F/kxROhUp5L2DtdhwXngyzE3ZGHdvQMcQC2Q5EqHD4qLrOre7vuwCd080GYQlDM1IeHoBI6FYV5gik4uvKqOzCcXA/hOzfkuATpoUXMGm0c4yGpV24wgR8IyzhEbAyirzpL9C4A+yxg5YXR38RWK23Ku9l6u9kH4GAQS6Va9t31fnOzBXtieCQB0NaYv79e7xcwNgPg6w0i9xaIrwaIh8qe1W6vDUi5jzUcCrZ/HtBMZzCzgyWauJJJBpchLC/AHliIJCSCaxxIZRTZbUYZ1DA7VqV+DzBPwA1BrLMCE5q7pmt01ZKj+L1QgTaRMD1fqpE6gfRG5yC1x+liyOnnEUrZDJSifluU3t7+onManWy9eTM+sRC2+ExtRvxF6cxpd4RSd4FimptTf8DpqSxzYNvp8JSWGfh/VeWYh8X9LaocirfdV/TRFVl0bbtZG/6K57kMu4CKR1n83fr9j43gT5Bs4TIDd+yYhaq8aA1WvRmAWzk8BmKuT6aksWkSC24SgbMwVkTU6aOHQLeAJGzbOJLNCMUL+dDtvsfAxR3+1LJsB4jlrmr67FRnwXvazXBvmUF97C5TnX+wTMlzfeQXUY8PCy3qleYvSZpG5PI7v69T1dE6U7rnBY63uyhdPpPMmieWMYlR0PYlVj+KoXOdx5kx6dlI7KnvCleaGAxXbn9ZLFa0hI7jHOJ4GoUUHJPkSxqtLyqN+qWAUdulfmttl0MXyhe12pYv+vpO5MuoyiFjjLqWSwXMyCFaaPVqEnap2DT8R6f0+BE0XIfuZBFfLT4xj5Cg5DuPiClEcVXMmPNKeOTlx3+W8FiZ/Ul5ni5hTHLvRMI+NaDI14q6pksl7Pc6YOa/noSHb3YK/S8+x3wAGVP4ziJjF1+6TIwDkn9de/FZZV7hDl8QvdfaiyH2fltnj0okCbf/JPlS4XpUtEm4JORXEO7wbVJsdPqBpEsBPId0IdV2G14Uw9QK3AsMypZTlAyXzV8b1fTm/yO2/wE= -------------------------------------------------------------------------------- /docs/ressources/instance_template.xml: -------------------------------------------------------------------------------- 1 | 7Vptb6NGEP41ltIPsTDgl3w8J7n0pJ5UNa3afsRmjbfBrLXgOL5ff7MwA8su+D3ONRdLlmH2hd1n5pmZHdzxbhcvDzJYzr+KkMUd1wlfOt5dx3V7vufCj5JsCsmo1y8EkeQhdqoEj/wbQ6GD0hUPWVrrmAkRZ3xZF05FkrBpVpMFUop1vdtMxPWnLoOInlgJHqdBbEv/5mE2x124g0r+K+PRnJ7cG9wULZNg+hRJsUrweR3Xm+WfonkR0Fy40XQehGKtibx7wFUKATOrq8XLLYsVtgRbMe5zS2u5bskSXNv2AR6OeA7iFe4dF5ZtCAwWAjZ4m4gEfsb5Dpmaw4G7ebaI4bIHl/+xLNugPoNVJkAkZDYXkUiC+DchltgPlic3/6jx3T7d/ovTpVkgs09KiyCYxGL6RMLPHBZfdGJJSF1wTSDR2tNMiqdSeYCraseF9dRtPiEJfBDY0CE2qVjJKWIxRGMMZMSw16gQKZS0YQj3AxMLBnuDDpLFQcaf6xYWoKFGZb9y6O+Cw0Jch0g1QAohpXojNCGaolgTjqpUDhfaMipRbgjNRoGb1GziTkxXC8DlT7ZYwi6YZSOaCUAHnlxP1Ng2RJ+ZzBjtqxktaiWPQLv2EIV1Rcuyz1yjZN9ARwe4Bs0WHFCzGg5fN4BExx3E8JRxyJ/hMlKXVxkC8ws1wtxa+5vh5fp4fwm8gFdthvMF4GHqAiggkovB4Xum+TTAQVs/OxxoqhocXxJwOwk4k4vZA20EAfBuLsifnu1I7OBS+fFpHKQpn9YDyrZAwV54pjXBnWqpMJQqQjVEgr08PZJf9/Sk0J2uXgN3m3GdGhHqqh0YGis2Y8UDaxoaRxR5tbDSs/3pHyziacZkg0slj7qnQwU+KLlmOZKl/FswyTsoa1mqLeSb6o87/TuQBDGPEmV4YAewBm+seMUhC/yEDQsehmr8OA4mLB6Xud2tiAX0h+dSdtdoVGT+JlvLvBVXV8v9mlh87XQhxUT4UE/XyKVjjYi6iNksBQM/UbWY/p7E9EY2F7le6QPcmhNQw2x6V+Tfh+dEap3o1G8n0Umrw3pu5jl2mDk0yrRwvu1Jh7LeMwODOdH5eE9nHc04HljCpEZujdncyBT+t+wnSvyQ7CcrwGloVtP5n8U5lFnLHnl0XftvnU37BkncARYALpE9kQvVYIPVOtfw/StVIdMJ2YwnUCaB5ygH1nY2s8lR+EyyYzw/z+DwbIj2p8h6zjP2uAxyr7qGopA6+EPvWZw7/Dn0YzCgPfXaXykYVUklnmerhHyZrhI6Pp+kEnyWphJ1q6kEqD3j0Qq8j3oUuC+Hlyk/3ENIAspSzuOEeDoqukHJS10UdHvHCjTyVm+Erk3XIJ3TahpEQpykQTsHBZ3qGlwL+aSUt+awGGDVPFfK9uPrO1NQf1RPMtwbnOIibs9OJX2DYzmvNBrVvR9QSVVaV6paDMhVDFRaveLdqJtXk1HVlaZDPpuxHDUYLZJI3AGWTighusoUItHPpXNviHrQdU7FJF3nQwyJp+ic0pDGSolztdzk+rB10Bz3bUAzVXc+B0q+cVj2CLVLMIMiXQtK2csPg5JxSPEJkYugBN7CQKnbBcq/c/rSMZYg79uG2cReOpudhHhDnfPocm/DcckCpt3wbpprWRexO/SDGgrHvC05af/W25JL7t8u9t4vJgxMPDzl7dFJeJSmua38P3wtQOxU82d9tdpeqG+ss+2s2RHX9Jpdbn7tlmB6BvPt6e5aiTlFaVw7KmY7J7LW0lLDO6L04u1Tl/2wwXPZYOEDW42wrLGS4skQjq//9vwjjdCciLzgDhsEpQRqR9QNy6r7/2sBIWgtS1NC0twfLooVHEsIyoG2RKk3yl0Gzu5Y5VKfc8cq/+NvQK/mJxreLxV/lGg3BSI1RRoHT377+4m+yaM+ZiOH+glzopGxlDP5ib6x5V1+om+y5dx+wq5IfBDiPISgfwnZL1zbCUGnWSJE73BCmFOQAR1MCJOcpv89X/bm2wWfDyN8Pa9c5AbtxQWjxFKmNvsboW+eR483wrpXfqXs7VCvPHTqb6lP9MpwW/0Tuuhe/d3cu/8O -------------------------------------------------------------------------------- /examples/flask/README.rst: -------------------------------------------------------------------------------- 1 | Flask application example 2 | ========================= 3 | 4 | `Flask `_ works great with μMongo ! 5 | 6 | This application shows a simple API usecase. It uses: 7 | 8 | - PyMongo as MongoDB driver to use with μMongo 9 | - Flask as web framework 10 | - Babel for error messages localization 11 | 12 | 13 | Some examples using `http command `_: 14 | 15 | Displaying a user: 16 | 17 | .. code-block:: 18 | 19 | $ http http://localhost:5000/users/572723781d41c8223255705e 20 | HTTP/1.0 200 OK 21 | Content-Length: 145 22 | Content-Type: application/json 23 | Date: Mon, 02 May 2016 10:07:35 GMT 24 | Server: Werkzeug/0.14.1 Python/3.5.3 25 | 26 | { 27 | "birthday": "1953-06-15T00:00:00+00:00", 28 | "firstname": "Jinping", 29 | "id": "572723781d41c8223255705e", 30 | "lastname": "Xi", 31 | "nick": "xiji" 32 | } 33 | 34 | Bad payload while trying to create user: 35 | 36 | .. code-block:: 37 | 38 | $ http POST http://localhost:5000/users bad=field birthday=42 39 | HTTP/1.0 400 BAD REQUEST 40 | Content-Length: 132 41 | Content-Type: application/json 42 | Date: Mon, 02 May 2016 10:06:12 GMT 43 | Server: Werkzeug/0.14.1 Python/3.5.3 44 | 45 | { 46 | "message": { 47 | "bad": [ 48 | "Unknown field." 49 | ], 50 | "birthday": [ 51 | "Not a valid datetime." 52 | ] 53 | } 54 | } 55 | 56 | Same thing but with a ``accept-language`` header to specify French as prefered language: 57 | 58 | .. code-block:: 59 | 60 | $ http POST http://localhost:5000/users "Accept-Language:fr; q=1.0, en; q=0.5" bad=field birthday=42 61 | HTTP/1.0 400 BAD REQUEST 62 | Content-Length: 130 63 | Content-Type: application/json 64 | Date: Mon, 02 May 2016 10:05:20 GMT 65 | Server: Werkzeug/0.14.1 Python/3.5.3 66 | 67 | { 68 | "message": { 69 | "bad": [ 70 | "Champ inconnu." 71 | ], 72 | "birthday": [ 73 | "Pas une datetime valide." 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /examples/flask/app.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | from flask import Flask, abort, jsonify, request 4 | from flask_babel import Babel, gettext 5 | from bson import ObjectId 6 | from pymongo import MongoClient 7 | 8 | from umongo import Document, fields, ValidationError, RemoveMissingSchema, set_gettext 9 | from umongo.frameworks import PyMongoInstance 10 | 11 | 12 | app = Flask(__name__) 13 | db = MongoClient().demo_umongo 14 | instance = PyMongoInstance(db) 15 | babel = Babel(app) 16 | set_gettext(gettext) 17 | 18 | 19 | # available languages 20 | LANGUAGES = { 21 | 'en': 'English', 22 | 'fr': 'Français' 23 | } 24 | 25 | 26 | @babel.localeselector 27 | def get_locale(): 28 | return request.accept_languages.best_match(LANGUAGES.keys()) 29 | 30 | 31 | @instance.register 32 | class User(Document): 33 | 34 | # We specify `RemoveMissingSchema` as a base marshmallow schema so that 35 | # auto-generated marshmallow schemas skip missing fields instead of returning None 36 | MA_BASE_SCHEMA_CLS = RemoveMissingSchema 37 | 38 | nick = fields.StrField(required=True, unique=True) 39 | firstname = fields.StrField() 40 | lastname = fields.StrField() 41 | birthday = fields.AwareDateTimeField() 42 | password = fields.StrField() # Don't store it in clear in real life ! 43 | 44 | class Meta: 45 | collection_name = "user" 46 | 47 | 48 | def populate_db(): 49 | User.collection.drop() 50 | User.ensure_indexes() 51 | for data in [ 52 | { 53 | 'nick': 'mze', 'lastname': 'Mao', 'firstname': 'Zedong', 54 | 'birthday': dt.datetime(1893, 12, 26), 'password': 'Serve the people' 55 | }, 56 | { 57 | 'nick': 'lsh', 'lastname': 'Liu', 'firstname': 'Shaoqi', 58 | 'birthday': dt.datetime(1898, 11, 24), 'password': 'Dare to think, dare to act' 59 | }, 60 | { 61 | 'nick': 'lxia', 'lastname': 'Li', 'firstname': 'Xiannian', 62 | 'birthday': dt.datetime(1909, 6, 23), 'password': 'To rebel is justified' 63 | }, 64 | { 65 | 'nick': 'ysh', 'lastname': 'Yang', 'firstname': 'Shangkun', 66 | 'birthday': dt.datetime(1907, 7, 5), 'password': 'Smash the gang of four' 67 | }, 68 | { 69 | 'nick': 'jze', 'lastname': 'Jiang', 'firstname': 'Zemin', 70 | 'birthday': dt.datetime(1926, 8, 17), 'password': 'Seek truth from facts' 71 | }, 72 | { 73 | 'nick': 'huji', 'lastname': 'Hu', 'firstname': 'Jintao', 74 | 'birthday': dt.datetime(1942, 12, 21), 'password': 'It is good to have just 1 child' 75 | }, 76 | { 77 | 'nick': 'xiji', 'lastname': 'Xi', 'firstname': 'Jinping', 78 | 'birthday': dt.datetime(1953, 6, 15), 'password': 'Achieve the 4 modernisations' 79 | } 80 | ]: 81 | User(**data).commit() 82 | 83 | 84 | # Define a custom marshmallow schema to ignore read-only fields 85 | class UserUpdateSchema(User.schema.as_marshmallow_schema()): 86 | class Meta: 87 | dump_only = ('nick', 'password',) 88 | 89 | 90 | user_update_schema = UserUpdateSchema() 91 | 92 | 93 | # Define a custom marshmallow schema from User document to exclude password field 94 | class UserNoPassSchema(User.schema.as_marshmallow_schema()): 95 | class Meta: 96 | exclude = ('password',) 97 | 98 | 99 | user_no_pass_schema = UserNoPassSchema() 100 | 101 | 102 | def dump_user_no_pass(u): 103 | return user_no_pass_schema.dump(u) 104 | 105 | 106 | # Define a custom marshmallow schema from User document to expose only password field 107 | class ChangePasswordSchema(User.schema.as_marshmallow_schema()): 108 | class Meta: 109 | fields = ('password',) 110 | required = ('password',) 111 | 112 | 113 | change_password_schema = ChangePasswordSchema() 114 | 115 | 116 | @app.route('/', methods=['GET']) 117 | def root(): 118 | return """

Umongo flask example

119 |
120 |

routes:


121 |
    122 |
  • GET /users
  • 123 |
  • POST /users
  • 124 |
  • GET /users/<nick_or_id>
  • 125 |
  • PATCH /users/<nick_or_id>
  • 126 |
  • PUT /users/<nick_or_id>/password
  • 127 |
128 | """ 129 | 130 | 131 | def _to_objid(data): 132 | try: 133 | return ObjectId(data) 134 | except Exception: 135 | return None 136 | 137 | 138 | def _nick_or_id_lookup(nick_or_id): 139 | return {'$or': [{'nick': nick_or_id}, {'_id': _to_objid(nick_or_id)}]} 140 | 141 | 142 | @app.route('/users/', methods=['GET']) 143 | def get_user(nick_or_id): 144 | user = User.find_one(_nick_or_id_lookup(nick_or_id)) 145 | if not user: 146 | abort(404) 147 | return jsonify(dump_user_no_pass(user)) 148 | 149 | 150 | @app.route('/users/', methods=['PATCH']) 151 | def update_user(nick_or_id): 152 | payload = request.get_json() 153 | if payload is None: 154 | abort(400, 'Request body must be json with Content-type: application/json') 155 | user = User.find_one(_nick_or_id_lookup(nick_or_id)) 156 | if not user: 157 | abort(404) 158 | try: 159 | data = user_update_schema.load(payload) 160 | user.update(data) 161 | user.commit() 162 | except ValidationError as ve: 163 | resp = jsonify(message=ve.args[0]) 164 | resp.status_code = 400 165 | return resp 166 | return jsonify(dump_user_no_pass(user)) 167 | 168 | 169 | @app.route('/users/', methods=['DELETE']) 170 | def delete_user(nick_or_id): 171 | user = User.find_one(_nick_or_id_lookup(nick_or_id)) 172 | if not user: 173 | abort(404) 174 | try: 175 | user.delete() 176 | except ValidationError as ve: 177 | resp = jsonify(message=ve.args[0]) 178 | resp.status_code = 400 179 | return resp 180 | return 'Ok' 181 | 182 | 183 | @app.route('/users//password', methods=['PUT']) 184 | def change_user_password(nick_or_id): 185 | payload = request.get_json() 186 | if payload is None: 187 | abort(400, 'Request body must be json with Content-type: application/json') 188 | user = User.find_one(_nick_or_id_lookup(nick_or_id)) 189 | if not user: 190 | abort(404) 191 | try: 192 | data = change_password_schema.load(payload) 193 | user.password = data['password'] 194 | user.commit() 195 | except ValidationError as ve: 196 | resp = jsonify(message=ve.args[0]) 197 | resp.status_code = 400 198 | return resp 199 | return jsonify(dump_user_no_pass(user)) 200 | 201 | 202 | @app.route('/users', methods=['GET']) 203 | def list_users(): 204 | page = int(request.args.get('page', 1)) 205 | users = User.find().limit(10).skip((page - 1) * 10) 206 | return jsonify({ 207 | '_total': users.count(), 208 | '_page': page, 209 | '_per_page': 10, 210 | '_items': [dump_user_no_pass(u) for u in users] 211 | }) 212 | 213 | 214 | @app.route('/users', methods=['POST']) 215 | def create_user(): 216 | payload = request.get_json() 217 | if payload is None: 218 | abort(400, 'Request body must be json with Content-type: application/json') 219 | try: 220 | user = User(**payload) 221 | user.commit() 222 | except ValidationError as ve: 223 | resp = jsonify(message=ve.args[0]) 224 | resp.status_code = 400 225 | return resp 226 | return jsonify(dump_user_no_pass(user)) 227 | 228 | 229 | if __name__ == '__main__': 230 | populate_db() 231 | app.run(debug=True) 232 | -------------------------------------------------------------------------------- /examples/flask/requirements.txt: -------------------------------------------------------------------------------- 1 | pymongo 2 | flask 3 | flask-babel 4 | -------------------------------------------------------------------------------- /examples/flask/testbed.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import requests 4 | 5 | 6 | class Tester: 7 | 8 | def __init__(self, test_name): 9 | self.name = test_name 10 | 11 | def __enter__(self): 12 | print('%s...' % self.name, flush=True, end='') 13 | return self 14 | 15 | def __exit__(self, exc_type, exc_val, exc_tb): 16 | if exc_type: 17 | print(' Error !') 18 | else: 19 | print(' OK') 20 | 21 | 22 | def test_list(total): 23 | r = requests.get('http://localhost:5000/users') 24 | assert r.status_code == 200, r.status_code 25 | data = r.json() 26 | assert data['_total'] == total, 'expected %s, got %s' % (total, data['_total']) 27 | assert len(data['_items']) == total, 'expected %s, got %s' % (total, len(data['_items'])) 28 | return data 29 | 30 | 31 | with Tester('List all'): 32 | data = test_list(7) 33 | 34 | with Tester('Get one by id'): 35 | user = data['_items'][0] 36 | r = requests.get('http://localhost:5000/users/%s' % user['id']) 37 | assert r.status_code == 200, r.status_code 38 | data = r.json() 39 | assert user == data, 'user: %s, data: %s' % (user, data) 40 | 41 | with Tester('Get one by nick'): 42 | r = requests.get('http://localhost:5000/users/%s' % user['nick']) 43 | assert r.status_code == 200, r.status_code 44 | assert data == r.json(), 'data: %s, nick_data: %s' % (data, r.json()) 45 | 46 | with Tester('404 on one'): 47 | r = requests.get('http://localhost:5000/users/572c59bf13abf21bf84890a0') 48 | assert r.status_code == 404, r.status_code 49 | 50 | with Tester('Create one'): 51 | payload = { 52 | 'nick': 'n00b', 53 | 'birthday': '2016-05-18T11:40:32+00:00', 54 | 'password': '123456' 55 | } 56 | r = requests.post('http://localhost:5000/users', json=payload) 57 | assert r.status_code == 200, r.status_code 58 | data = r.json() 59 | new_user_id = data.pop('id') 60 | expected = { 61 | 'nick': 'n00b', 62 | 'birthday': '2016-05-18T11:40:32+00:00', 63 | } 64 | assert data == expected, 'data: %s, expected: %s' % (data, expected) 65 | test_list(8) 66 | 67 | with Tester('Update'): 68 | payload = { 69 | 'birthday': '2019-05-18T11:40:32+00:00', 70 | } 71 | r = requests.patch('http://localhost:5000/users/%s' % new_user_id, 72 | json=payload) 73 | assert r.status_code == 200, r.status_code 74 | data = r.json() 75 | del data['id'] 76 | expected = { 77 | 'nick': 'n00b', 78 | 'birthday': '2019-05-18T11:40:32+00:00', 79 | } 80 | assert data == expected, 'data: %s, expected: %s' % (data, expected) 81 | test_list(8) 82 | 83 | with Tester('Change password'): 84 | r = requests.put('http://localhost:5000/users/%s/password' % new_user_id, 85 | json={'password': 'abcdef'}) 86 | assert r.status_code == 200, r.status_code 87 | data = r.json() 88 | assert new_user_id == data.pop('id') 89 | assert data == expected, 'data: %s, expected: %s' % (data, expected) 90 | 91 | with Tester('Bad change password'): 92 | r = requests.put('http://localhost:5000/users/%s/password' % new_user_id, 93 | json={'password': 'abcdef', 'dummy': 42}) 94 | assert r.status_code == 400, r.status_code 95 | data = r.json() 96 | expected = {'message': {'dummy': ['Unknown field.']}} 97 | assert data == expected, 'data: %s, expected: %s' % (data, expected) 98 | 99 | with Tester('404 on change password'): 100 | r = requests.put('http://localhost:5000/users/572c59bf13abf21bf84890a0/password', 101 | json={'password': 'abcdef'}) 102 | assert r.status_code == 404, r.status_code 103 | 104 | with Tester('Delete one'): 105 | r = requests.delete('http://localhost:5000/users/%s' % new_user_id) 106 | assert r.status_code == 200, r.status_code 107 | test_list(7) 108 | 109 | with Tester('404 on delete one'): 110 | r = requests.delete('http://localhost:5000/users/572c59bf13abf21bf84890a0') 111 | assert r.status_code == 404, r.status_code 112 | 113 | with Tester('Create one missing field'): 114 | r = requests.post('http://localhost:5000/users', json={}) 115 | assert r.status_code == 400, r.status_code 116 | data = r.json() 117 | expected = {'message': {'nick': ['Missing data for required field.']}} 118 | assert data == expected, 'data: %s, expected: %s' % (data, expected) 119 | 120 | with Tester('Create one i18n'): 121 | headers = {'Accept-Language': 'fr, en-gb;q=0.8, en;q=0.7'} 122 | r = requests.post('http://localhost:5000/users', headers=headers, json={}) 123 | assert r.status_code == 400, r.status_code 124 | data = r.json() 125 | expected = {'message': {'nick': ['Valeur manquante pour un champ obligatoire.']}} 126 | assert data == expected, 'data: %s, expected: %s' % (data, expected) 127 | -------------------------------------------------------------------------------- /examples/flask/translations/fr/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scille/umongo/1b23dc7155448a52fa6e7f3d7da6621632e259f5/examples/flask/translations/fr/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /examples/flask/translations/fr/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # French translations for PROJECT. 2 | # Copyright (C) 2021 Scille SAS and contributors 3 | # This file is distributed under the same license as the umongo project. 4 | # Jérôme Lafréchoux , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: jerome@jolimont.fr\n" 10 | "POT-Creation-Date: 2021-01-04 15:53+0100\n" 11 | "PO-Revision-Date: 2016-04-19 12:09+0200\n" 12 | "Last-Translator: Jérôme Lafréchoux \n" 13 | "Language: fr\n" 14 | "Language-Team: fr \n" 15 | "Plural-Forms: nplurals=2; plural=(n > 1)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.7.0\n" 20 | 21 | #: umongo/abstract.py:103 22 | msgid "Field value must be unique." 23 | msgstr "La valeur de ce champ doit être unique." 24 | 25 | #: umongo/abstract.py:104 26 | msgid "Values of fields {fields} must be unique together." 27 | msgstr "L'ensemble des valeurs des champs {fields} doit être unique." 28 | 29 | #: umongo/data_objects.py:155 30 | msgid "Reference not found for document {document}." 31 | msgstr "Référence non trouvée pour document {document}." 32 | 33 | #: umongo/data_proxy.py:65 34 | msgid "{cls}: unknown \"{key}\" field found in DB." 35 | msgstr "{cls}: Champ \"{key}\" inconnu trouvé dans la base de données." 36 | 37 | # Marshmallow fields # 38 | #: umongo/data_proxy.py:170 39 | msgid "Missing data for required field." 40 | msgstr "Valeur manquante pour un champ obligatoire." 41 | 42 | #: umongo/fields.py:347 43 | msgid "DBRef must be on collection `{collection}`." 44 | msgstr "DBRef doit être sur la collection `{collection}`." 45 | 46 | #: umongo/fields.py:352 umongo/fields.py:363 47 | msgid "`{document}` reference expected." 48 | msgstr "Référence sur `{document}` attendue." 49 | 50 | #: umongo/fields.py:360 umongo/fields.py:404 51 | msgid "Cannot reference a document that has not been created yet." 52 | msgstr "Impossible de référencer un document qui n'a pas été encore créé." 53 | 54 | #: umongo/fields.py:386 umongo/fields.py:483 55 | msgid "Unknown document `{document}`." 56 | msgstr "Document unconnu `{document}`." 57 | 58 | #: umongo/fields.py:408 umongo/marshmallow_bonus.py:65 59 | msgid "Generic reference must have `id` and `cls` fields." 60 | msgstr "Une référence générique doit avoir des champs `id` et `cls`." 61 | 62 | #: umongo/fields.py:412 umongo/marshmallow_bonus.py:69 63 | msgid "Invalid `id` field." 64 | msgstr "Champ `id` invalide." 65 | 66 | #: umongo/fields.py:415 umongo/marshmallow_bonus.py:63 67 | msgid "Invalid value for generic reference field." 68 | msgstr "Valeur de référence générique invalide." 69 | 70 | #: umongo/marshmallow_bonus.py:29 71 | msgid "Invalid ObjectId." 72 | msgstr "ObjectId invalide." 73 | 74 | msgid "Invalid input type." 75 | msgstr "Type en entrée invalide." 76 | 77 | msgid "Field may not be null." 78 | msgstr "Ce champ ne doit pas être vide." 79 | 80 | msgid "Invalid value." 81 | msgstr "Valeur invalide." 82 | 83 | msgid "Invalid type." 84 | msgstr "Type invalide." 85 | 86 | msgid "Not a valid list." 87 | msgstr "Pas une liste valide." 88 | 89 | msgid "Not a valid string." 90 | msgstr "Pas une chaîne de charactères valide." 91 | 92 | msgid "Not a valid number." 93 | msgstr "Pas un nombre valide." 94 | 95 | msgid "Not a valid integer." 96 | msgstr "Pas un nombre entier valide." 97 | 98 | msgid "Special numeric values are not permitted." 99 | msgstr "Nombre spéciaux non autorisés." 100 | 101 | msgid "Not a valid boolean." 102 | msgstr "Pas un booléen valide." 103 | 104 | msgid "Cannot format string with given data." 105 | msgstr "Impossible de formater la chaine de caractère avec ces données" 106 | 107 | msgid "Not a valid datetime." 108 | msgstr "Pas une datetime valide." 109 | 110 | msgid "\"{input}\" cannot be formatted as a datetime." 111 | msgstr "\"{input}\" ne peut pas être formaté comme une datetime." 112 | 113 | msgid "Not a valid time." 114 | msgstr "Pas un temps valide." 115 | 116 | msgid "\"{input}\" cannot be formatted as a time." 117 | msgstr "\"{input}\" ne peut pas être formatée comme un temps." 118 | 119 | msgid "Not a valid date." 120 | msgstr "Pas une date valide." 121 | 122 | msgid "\"{input}\" cannot be formatted as a date." 123 | msgstr "\"{input}\" ne peut pas être formatée comme une date." 124 | 125 | msgid "Not a valid period of time." 126 | msgstr "Pas une période de temps valide." 127 | 128 | msgid "{input!r} cannot be formatted as a timedelta." 129 | msgstr "{input!r} ne peut pas être formatée comme un timedelta." 130 | 131 | msgid "Not a valid mapping type." 132 | msgstr "Pas un mapping valide." 133 | 134 | # Marshmallow validate # 135 | msgid "Not a valid URL." 136 | msgstr "Pas une URL valide." 137 | 138 | msgid "Not a valid email address." 139 | msgstr "Pas une adresse mail valide." 140 | 141 | msgid "Must be at least {min}." 142 | msgstr "Doit être au moins {min}." 143 | 144 | msgid "Must be at most {max}." 145 | msgstr "Doit être au plus {max}." 146 | 147 | msgid "Must be between {min} and {max}." 148 | msgstr "Doit être entre {min} et {max}." 149 | 150 | msgid "Shorter than minimum length {min}." 151 | msgstr "Plus court que la longueur minimal de {min}." 152 | 153 | msgid "Longer than maximum length {max}." 154 | msgstr "Plus long que la longueur maximale de {max}." 155 | 156 | msgid "Length must be between {min} and {max}." 157 | msgstr "La longueur doit être entre {min} et {max}." 158 | 159 | msgid "Length must be {equal}." 160 | msgstr "La longueur doit être de {equal}." 161 | 162 | msgid "Must be equal to {other}." 163 | msgstr "Doit être égal à {other}." 164 | 165 | msgid "String does not match expected pattern." 166 | msgstr "La chaîne de caractère ne doit pas valider l'expression rationnelle." 167 | 168 | msgid "Invalid input." 169 | msgstr "Entrée invalide." 170 | 171 | msgid "Not a valid choice." 172 | msgstr "Pas un choix valide." 173 | 174 | msgid "One or more of the choices you made was not acceptable." 175 | msgstr "Un ou plusieurs des choix faits n'est pas acceptable." 176 | -------------------------------------------------------------------------------- /examples/inheritance/README.rst: -------------------------------------------------------------------------------- 1 | Inheritance example 2 | =================== 3 | 4 | This example demonstrate how inheritance works in μMongo. 5 | It works as a shell on a garage storing different types of vehicles: 6 | cars and motorbikes. 7 | 8 | Some examples: 9 | 10 | .. code-block:: 11 | $ python examples/inheritance/app.py 12 | Welcome to the garage, type `help` if you're lost 13 | > ls 14 | Found 3 vehicles 15 | 16 | 17 | 18 | > get 573c3d0b13adf21b484cf30e 19 | 20 | > quit 21 | -------------------------------------------------------------------------------- /examples/inheritance/app.py: -------------------------------------------------------------------------------- 1 | from bson import ObjectId 2 | from pymongo import MongoClient 3 | 4 | from umongo import Document, fields, ValidationError, validate 5 | from umongo.frameworks import PyMongoInstance 6 | 7 | 8 | db = MongoClient().demo_umongo 9 | instance = PyMongoInstance(db) 10 | 11 | 12 | @instance.register 13 | class Vehicle(Document): 14 | model = fields.StrField(required=True) 15 | 16 | class Meta: 17 | collection_name = "vehicle" 18 | 19 | 20 | @instance.register 21 | class Car(Vehicle): 22 | doors = fields.IntField(validate=validate.OneOf([3, 5])) 23 | 24 | 25 | @instance.register 26 | class MotorBike(Vehicle): 27 | engine_type = fields.StrField(validate=validate.OneOf(['2-stroke', '4-stroke'])) 28 | 29 | 30 | def populate_db(): 31 | Vehicle.collection.drop() 32 | Vehicle.ensure_indexes() 33 | for data in [ 34 | {'model': 'Chevrolet Impala 1966', 'doors': 5}, 35 | {'model': 'Ford Grand Torino', 'doors': 3}, 36 | ]: 37 | Car(**data).commit() 38 | for data in [ 39 | {'model': 'Honda CB125', 'engine_type': '2-stroke'} 40 | ]: 41 | MotorBike(**data).commit() 42 | 43 | 44 | class Repl(object): 45 | 46 | USAGE = """help: print this message 47 | new: create a vehicle 48 | ls: list vehicles 49 | get : retrieve a vehicle from it id 50 | quit: leave the console""" 51 | 52 | def get_vehicle(self, *args): 53 | if len(args) != 1: 54 | print("Error: need only vehicle's id") 55 | id = args[0] 56 | vehicle = None 57 | try: 58 | vehicle = Vehicle.find_one({'_id': ObjectId(id)}) 59 | except Exception as exc: 60 | print('Error: %s' % exc) 61 | return 62 | if vehicle: 63 | print(vehicle) 64 | else: 65 | print('Error: unknown vehicle `%s`' % id) 66 | 67 | def list_vehicles(self): 68 | print('Found %s vehicles' % Vehicle.find().count()) 69 | print('\n'.join([str(v) for v in Vehicle.find()])) 70 | 71 | def new_vehicle(self): 72 | vehicle_type = input('Type ? car/bike ') or 'car' 73 | data = { 74 | 'model': input('Model ? ') or 'unknown' 75 | } 76 | if vehicle_type == 'car': 77 | try: 78 | data['doors'] = int(input('# of doors ? 3/5 ')) 79 | except ValueError: 80 | pass 81 | vehicle = Car(**data) 82 | else: 83 | strokes = input('Type of stroke-engine ? 2/4 ') 84 | if strokes: 85 | data['engine_type'] = '2-stroke' if strokes == '2' else '4-stroke' 86 | vehicle = MotorBike(**data) 87 | try: 88 | vehicle.commit() 89 | except ValidationError as exc: 90 | print('Error: %s' % exc) 91 | else: 92 | print('Created %s' % vehicle) 93 | 94 | def start(self): 95 | quit = False 96 | print("Welcome to the garage, type `help` if you're lost") 97 | while not quit: 98 | cmd = input('> ') 99 | cmd = cmd.strip() 100 | if cmd == 'help': 101 | print(self.USAGE) 102 | elif cmd.startswith("ls"): 103 | self.list_vehicles() 104 | elif cmd.startswith("new"): 105 | self.new_vehicle() 106 | elif cmd.startswith("get"): 107 | self.get_vehicle(*cmd.split()[1:]) 108 | elif cmd == 'quit': 109 | quit = True 110 | else: 111 | print('Error: Unknow command !') 112 | 113 | 114 | if __name__ == '__main__': 115 | populate_db() 116 | Repl().start() 117 | -------------------------------------------------------------------------------- /examples/inheritance/requirements.txt: -------------------------------------------------------------------------------- 1 | pymongo 2 | -------------------------------------------------------------------------------- /examples/klein/README.rst: -------------------------------------------------------------------------------- 1 | Klein application example 2 | ========================= 3 | 4 | `Klein `_ is a flask-inspired web framework for the twisted ecosystem. 5 | 6 | This example is a port of the flask example for Klein to demonstrate use of txmongo driver. 7 | -------------------------------------------------------------------------------- /examples/klein/app.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import json 3 | 4 | from twisted.internet import reactor 5 | from twisted.internet.defer import inlineCallbacks, returnValue 6 | from klein import Klein 7 | from bson import ObjectId 8 | from txmongo import MongoConnection 9 | from klein_babel import gettext, locale_from_request 10 | 11 | from umongo import Document, fields, ValidationError, RemoveMissingSchema, set_gettext 12 | from umongo.frameworks import PyMongoInstance 13 | 14 | app = Klein() 15 | db = MongoConnection().demo_umongo 16 | instance = PyMongoInstance(db) 17 | set_gettext(gettext) 18 | 19 | 20 | class MongoJsonEncoder(json.JSONEncoder): 21 | def default(self, obj): 22 | if isinstance(obj, (dt.datetime, dt.date)): 23 | return obj.isoformat() 24 | elif isinstance(obj, ObjectId): 25 | return str(obj) 26 | return json.JSONEncoder.default(self, obj) 27 | 28 | 29 | def jsonify(request, *args, **kwargs): 30 | """ 31 | jsonify with support for MongoDB ObjectId 32 | """ 33 | request.setHeader('Content-Type', 'application/json') 34 | return json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder, indent=True) 35 | 36 | 37 | def get_json(request): 38 | return json.loads(request.content.read().decode()) 39 | 40 | 41 | @instance.register 42 | class User(Document): 43 | 44 | # We specify `RemoveMissingSchema` as a base marshmallow schema so that 45 | # auto-generated marshmallow schemas skip missing fields instead of returning None 46 | MA_BASE_SCHEMA_CLS = RemoveMissingSchema 47 | 48 | nick = fields.StrField(required=True, unique=True) 49 | firstname = fields.StrField() 50 | lastname = fields.StrField() 51 | birthday = fields.AwareDateTimeField() 52 | password = fields.StrField() # Don't store it in clear in real life ! 53 | 54 | 55 | @inlineCallbacks 56 | def populate_db(): 57 | yield User.collection.drop() 58 | yield User.ensure_indexes() 59 | for data in [ 60 | { 61 | 'nick': 'mze', 'lastname': 'Mao', 'firstname': 'Zedong', 62 | 'birthday': dt.datetime(1893, 12, 26), 63 | 'password': 'Serve the people' 64 | }, 65 | { 66 | 'nick': 'lsh', 'lastname': 'Liu', 'firstname': 'Shaoqi', 67 | 'birthday': dt.datetime(1898, 11, 24), 68 | 'password': 'Dare to think, dare to act' 69 | }, 70 | { 71 | 'nick': 'lxia', 'lastname': 'Li', 'firstname': 'Xiannian', 72 | 'birthday': dt.datetime(1909, 6, 23), 73 | 'password': 'To rebel is justified' 74 | }, 75 | { 76 | 'nick': 'ysh', 'lastname': 'Yang', 'firstname': 'Shangkun', 77 | 'birthday': dt.datetime(1907, 7, 5), 78 | 'password': 'Smash the gang of four' 79 | }, 80 | { 81 | 'nick': 'jze', 'lastname': 'Jiang', 'firstname': 'Zemin', 82 | 'birthday': dt.datetime(1926, 8, 17), 83 | 'password': 'Seek truth from facts' 84 | }, 85 | { 86 | 'nick': 'huji', 'lastname': 'Hu', 'firstname': 'Jintao', 87 | 'birthday': dt.datetime(1942, 12, 21), 88 | 'password': 'It is good to have just 1 child' 89 | }, 90 | { 91 | 'nick': 'xiji', 'lastname': 'Xi', 'firstname': 'Jinping', 92 | 'birthday': dt.datetime(1953, 6, 15), 93 | 'password': 'Achieve the 4 modernisations' 94 | } 95 | ]: 96 | yield User(**data).commit() 97 | 98 | 99 | # Define a custom marshmallow schema to ignore read-only fields 100 | class UserUpdateSchema(User.schema.as_marshmallow_schema()): 101 | class Meta: 102 | dump_only = ('nick', 'password',) 103 | 104 | 105 | user_update_schema = UserUpdateSchema() 106 | 107 | 108 | # Define a custom marshmallow schema from User document to exclude password field 109 | class UserNoPassSchema(User.schema.as_marshmallow_schema()): 110 | class Meta: 111 | exclude = ('password',) 112 | 113 | 114 | user_no_pass_schema = UserNoPassSchema() 115 | 116 | 117 | def dump_user_no_pass(u): 118 | return user_no_pass_schema.dump(u) 119 | 120 | 121 | # Define a custom marshmallow schema from User document to expose only password field 122 | class ChangePasswordSchema(User.schema.as_marshmallow_schema()): 123 | class Meta: 124 | fields = ('password',) 125 | required = ('password',) 126 | 127 | 128 | change_password_schema = ChangePasswordSchema() 129 | 130 | 131 | @app.route('/', methods=['GET']) 132 | def root(request): 133 | return """

Umongo flask example

134 |
135 |

routes:


136 |
    137 |
  • GET /users
  • 138 |
  • POST /users
  • 139 |
  • GET /users/<nick_or_id>
  • 140 |
  • PATCH /users/<nick_or_id>
  • 141 |
  • PUT /users/<nick_or_id>/password
  • 142 |
143 | """ 144 | 145 | 146 | def _to_objid(data): 147 | try: 148 | return ObjectId(data) 149 | except Exception: 150 | return None 151 | 152 | 153 | def _nick_or_id_lookup(nick_or_id): 154 | return {'$or': [{'nick': nick_or_id}, {'_id': _to_objid(nick_or_id)}]} 155 | 156 | 157 | class Error(Exception): 158 | pass 159 | 160 | 161 | @app.handle_errors(Error) 162 | def error(request, failure): 163 | code, data = failure.value.args 164 | request.setResponseCode(code) 165 | return data 166 | 167 | 168 | @app.route('/users/', methods=['GET']) 169 | @locale_from_request 170 | @inlineCallbacks 171 | def get_user(request, nick_or_id): 172 | user = yield User.find_one(_nick_or_id_lookup(nick_or_id)) 173 | if not user: 174 | raise Error(404, 'Not found') 175 | returnValue(jsonify(request, dump_user_no_pass(user))) 176 | 177 | 178 | @app.route('/users/', methods=['PATCH']) 179 | @locale_from_request 180 | @inlineCallbacks 181 | def update_user(request, nick_or_id): 182 | payload = get_json(request) 183 | if payload is None: 184 | raise Error(400, 'Request body must be json with Content-type: application/json') 185 | user = yield User.find_one(_nick_or_id_lookup(nick_or_id)) 186 | if not user: 187 | raise Error(404, 'Not found') 188 | try: 189 | data = user_update_schema.load(payload) 190 | user.update(data) 191 | yield user.commit() 192 | except ValidationError as ve: 193 | raise Error(400, jsonify(request, message=ve.args[0])) 194 | returnValue(jsonify(request, dump_user_no_pass(user))) 195 | 196 | 197 | @app.route('/users/', methods=['DELETE']) 198 | @locale_from_request 199 | @inlineCallbacks 200 | def delete_user(request, nick_or_id): 201 | user = yield User.find_one(_nick_or_id_lookup(nick_or_id)) 202 | if not user: 203 | raise Error(404, 'Not Found') 204 | try: 205 | yield user.delete() 206 | except ValidationError as ve: 207 | raise Error(400, jsonify(message=ve.args[0])) 208 | returnValue('Ok') 209 | 210 | 211 | @app.route('/users//password', methods=['PUT']) 212 | @locale_from_request 213 | @inlineCallbacks 214 | def change_password_user(request, nick_or_id): 215 | payload = get_json(request) 216 | if payload is None: 217 | raise Error(400, 'Request body must be json with Content-type: application/json') 218 | user = yield User.find_one(_nick_or_id_lookup(nick_or_id)) 219 | if not user: 220 | raise Error(404, 'Not found') 221 | 222 | try: 223 | data = change_password_schema.load(payload) 224 | user.password = data['password'] 225 | yield user.commit() 226 | except ValidationError as ve: 227 | raise Error(400, jsonify(request, message=ve.args[0])) 228 | returnValue(jsonify(request, dump_user_no_pass(user))) 229 | 230 | 231 | @app.route('/users', methods=['GET']) 232 | @locale_from_request 233 | @inlineCallbacks 234 | def list_users(request): 235 | page = int(request.args.get('page', 1)) 236 | users = yield User.find(limit=10, skip=(page - 1) * 10) 237 | returnValue(jsonify(request, { 238 | '_total': (yield User.count()), 239 | '_page': page, 240 | '_per_page': 10, 241 | '_items': [dump_user_no_pass(u) for u in users] 242 | })) 243 | 244 | 245 | @app.route('/users', methods=['POST']) 246 | @locale_from_request 247 | @inlineCallbacks 248 | def create_user(request): 249 | payload = get_json(request) 250 | if payload is None: 251 | raise Error(400, 'Request body must be json with Content-type: application/json') 252 | try: 253 | user = User(**payload) 254 | yield user.commit() 255 | except ValidationError as ve: 256 | raise Error(400, jsonify(request, message=ve.args[0])) 257 | returnValue(jsonify(request, dump_user_no_pass(user))) 258 | 259 | 260 | if __name__ == '__main__': 261 | reactor.callWhenRunning(populate_db) 262 | app.run('localhost', 5000) 263 | -------------------------------------------------------------------------------- /examples/klein/klein_babel.py: -------------------------------------------------------------------------------- 1 | # Inpired by muffin-babel https://github.com/klen/muffin-babel 2 | 3 | import re 4 | from functools import wraps 5 | from twisted.python import context 6 | from babel import support 7 | 8 | 9 | locale_delim_re = re.compile(r'[_-]') 10 | accept_re = re.compile( 11 | r'''( # media-range capturing-parenthesis 12 | [^\s;,]+ # type/subtype 13 | (?:[ \t]*;[ \t]* # ";" 14 | (?: # parameter non-capturing-parenthesis 15 | [^\s;,q][^\s;,]* # token that doesn't start with "q" 16 | | # or 17 | q[^\s;,=][^\s;,]* # token that is more than just "q" 18 | ) 19 | )* # zero or more parameters 20 | ) # end of media-range 21 | (?:[ \t]*;[ \t]*q= # weight is a "q" parameter 22 | (\d*(?:\.\d+)?) # qvalue capturing-parentheses 23 | [^,]* # "extension" accept params: who cares? 24 | )? # accept params are optional 25 | ''', re.VERBOSE) 26 | 27 | 28 | def parse_accept_header(header): 29 | """Parse accept headers.""" 30 | result = [] 31 | for match in accept_re.finditer(header): 32 | quality = match.group(2) 33 | if not quality: 34 | quality = 1 35 | else: 36 | quality = max(min(float(quality), 1), 0) 37 | result.append((match.group(1), quality)) 38 | return result 39 | 40 | 41 | def select_locale_by_request(request, default='en'): 42 | accept_language = request.getHeader('ACCEPT-LANGUAGE') 43 | if not accept_language: 44 | return default 45 | 46 | ulocales = [ 47 | (q, locale_delim_re.split(v)[0]) 48 | for v, q in parse_accept_header(accept_language) 49 | ] 50 | ulocales.sort() 51 | ulocales.reverse() 52 | 53 | return ulocales[0][1] 54 | 55 | 56 | def locale_from_request(fn): 57 | 58 | @wraps(fn) 59 | def wrapper(request, *args, **kwargs): 60 | locale = select_locale_by_request(request) 61 | translations = support.Translations.load( 62 | 'translations', locales=locale, domain='messages') 63 | ctx = {'locale': locale, 'translations': translations} 64 | return context.call(ctx, fn, request, *args, **kwargs) 65 | 66 | return wrapper 67 | 68 | 69 | def gettext(string): 70 | return context.get( 71 | 'translations', default=support.NullTranslations()).gettext(string) 72 | -------------------------------------------------------------------------------- /examples/klein/requirements.txt: -------------------------------------------------------------------------------- 1 | twisted 2 | txmongo 3 | klein 4 | babel 5 | -------------------------------------------------------------------------------- /examples/klein/translations: -------------------------------------------------------------------------------- 1 | ../flask/translations -------------------------------------------------------------------------------- /messages.pot: -------------------------------------------------------------------------------- 1 | # Translations template for umongo. 2 | # Copyright (C) 2021 Scille SAS and contributors 3 | # This file is distributed under the same license as the umongo project. 4 | # Jérôme Lafréchoux , 2021. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: umongo 3.0.0b14\n" 10 | "Report-Msgid-Bugs-To: jerome@jolimont.fr\n" 11 | "POT-Creation-Date: 2021-01-04 15:53+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Jérôme Lafréchoux \n" 14 | "Language-Team: LANGUAGE \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Generated-By: Babel 2.7.0\n" 19 | 20 | #: umongo/abstract.py:103 21 | msgid "Field value must be unique." 22 | msgstr "" 23 | 24 | #: umongo/abstract.py:104 25 | msgid "Values of fields {fields} must be unique together." 26 | msgstr "" 27 | 28 | #: umongo/data_objects.py:155 29 | msgid "Reference not found for document {document}." 30 | msgstr "" 31 | 32 | #: umongo/data_proxy.py:65 33 | msgid "{cls}: unknown \"{key}\" field found in DB." 34 | msgstr "" 35 | 36 | #: umongo/data_proxy.py:170 37 | msgid "Missing data for required field." 38 | msgstr "" 39 | 40 | #: umongo/fields.py:347 41 | msgid "DBRef must be on collection `{collection}`." 42 | msgstr "" 43 | 44 | #: umongo/fields.py:352 umongo/fields.py:363 45 | msgid "`{document}` reference expected." 46 | msgstr "" 47 | 48 | #: umongo/fields.py:360 umongo/fields.py:404 49 | msgid "Cannot reference a document that has not been created yet." 50 | msgstr "" 51 | 52 | #: umongo/fields.py:386 umongo/fields.py:483 53 | msgid "Unknown document `{document}`." 54 | msgstr "" 55 | 56 | #: umongo/fields.py:408 umongo/marshmallow_bonus.py:65 57 | msgid "Generic reference must have `id` and `cls` fields." 58 | msgstr "" 59 | 60 | #: umongo/fields.py:412 umongo/marshmallow_bonus.py:69 61 | msgid "Invalid `id` field." 62 | msgstr "" 63 | 64 | #: umongo/fields.py:415 umongo/marshmallow_bonus.py:63 65 | msgid "Invalid value for generic reference field." 66 | msgstr "" 67 | 68 | #: umongo/marshmallow_bonus.py:29 69 | msgid "Invalid ObjectId." 70 | msgstr "" 71 | 72 | 73 | # Marshmallow fields # 74 | 75 | msgid "Missing data for required field." 76 | msgstr "" 77 | 78 | msgid "Invalid input type." 79 | msgstr "" 80 | 81 | msgid "Field may not be null." 82 | msgstr "" 83 | 84 | msgid "Invalid value." 85 | msgstr "" 86 | 87 | msgid "Invalid type." 88 | msgstr "" 89 | 90 | msgid "Not a valid list." 91 | msgstr "" 92 | 93 | msgid "Not a valid string." 94 | msgstr "" 95 | 96 | msgid "Not a valid number." 97 | msgstr "" 98 | 99 | msgid "Not a valid integer." 100 | msgstr "" 101 | 102 | msgid "Special numeric values are not permitted." 103 | msgstr "" 104 | 105 | msgid "Not a valid boolean." 106 | msgstr "" 107 | 108 | msgid "Cannot format string with given data." 109 | msgstr "" 110 | 111 | msgid "Not a valid datetime." 112 | msgstr "" 113 | 114 | msgid "\"{input}\" cannot be formatted as a datetime." 115 | msgstr "" 116 | 117 | msgid "Not a valid time." 118 | msgstr "" 119 | 120 | msgid "\"{input}\" cannot be formatted as a time." 121 | msgstr "" 122 | 123 | msgid "Not a valid date." 124 | msgstr "" 125 | 126 | msgid "\"{input}\" cannot be formatted as a date." 127 | msgstr "" 128 | 129 | msgid "Not a valid period of time." 130 | msgstr "" 131 | 132 | msgid "{input!r} cannot be formatted as a timedelta." 133 | msgstr "" 134 | 135 | msgid "Not a valid mapping type." 136 | msgstr "" 137 | 138 | msgid "Not a valid URL." 139 | msgstr "" 140 | 141 | msgid "Not a valid email address." 142 | msgstr "" 143 | 144 | # Marshmallow validate # 145 | msgid "Not a valid URL." 146 | msgstr "" 147 | 148 | msgid "Not a valid email address." 149 | msgstr "" 150 | 151 | msgid "Must be at least {min}." 152 | msgstr "" 153 | 154 | msgid "Must be at most {max}." 155 | msgstr "" 156 | 157 | msgid "Must be between {min} and {max}." 158 | msgstr "" 159 | 160 | msgid "Shorter than minimum length {min}." 161 | msgstr "" 162 | 163 | msgid "Longer than maximum length {max}." 164 | msgstr "" 165 | 166 | msgid "Length must be between {min} and {max}." 167 | msgstr "" 168 | 169 | msgid "Length must be {equal}." 170 | msgstr "" 171 | 172 | msgid "Must be equal to {other}." 173 | msgstr "" 174 | 175 | msgid "String does not match expected pattern." 176 | msgstr "" 177 | 178 | msgid "Invalid input." 179 | msgstr "" 180 | 181 | msgid "Not a valid choice." 182 | msgstr "" 183 | 184 | msgid "One or more of the choices you made was not acceptable." 185 | msgstr "" 186 | 187 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | bumpversion 2 | wheel 3 | flake8 4 | tox 5 | coverage 6 | Sphinx 7 | pytest 8 | pytest-cov 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 3.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:umongo/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | ignore = E127,E128,W504 19 | max-line-length = 100 20 | per-file-ignores = 21 | docs/conf.py: E265 22 | 23 | [extract_messages] 24 | project = umongo 25 | copyright_holder = Scille SAS and contributors 26 | msgid_bugs_address = jerome@jolimont.fr 27 | output_file = messages.pot 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | 11 | with open('README.rst', 'rb') as readme_file: 12 | readme = readme_file.read().decode('utf8') 13 | 14 | with open('HISTORY.rst', 'rb') as history_file: 15 | history = history_file.read().decode('utf8') 16 | 17 | requirements = [ 18 | "marshmallow>=3.10.0", 19 | "pymongo>=3.7.0", 20 | ] 21 | 22 | setup( 23 | name='umongo', 24 | version='3.1.0', 25 | description="sync/async MongoDB ODM, yes.", 26 | long_description=readme + '\n\n' + history, 27 | author="Emmanuel Leblond, Jérôme Lafréchoux", 28 | author_email='jerome@jolimont.fr', 29 | url='https://github.com/touilleMan/umongo', 30 | packages=['umongo', 'umongo.frameworks'], 31 | include_package_data=True, 32 | python_requires='>=3.7', 33 | install_requires=requirements, 34 | extras_require={ 35 | 'motor': ['motor>=2.0,<3.0'], 36 | 'txmongo': ['txmongo>=19.2.0'], 37 | 'mongomock': ['mongomock'], 38 | }, 39 | license="MIT", 40 | zip_safe=False, 41 | keywords='umongo mongodb pymongo txmongo motor mongomock asyncio twisted', 42 | classifiers=[ 43 | 'Development Status :: 5 - Production/Stable', 44 | 'Intended Audience :: Developers', 45 | 'License :: OSI Approved :: MIT License', 46 | 'Natural Language :: English', 47 | 'Programming Language :: Python :: 3', 48 | 'Programming Language :: Python :: 3.7', 49 | 'Programming Language :: Python :: 3.8', 50 | 'Programming Language :: Python :: 3.9', 51 | 'Programming Language :: Python :: 3 :: Only', 52 | ], 53 | ) 54 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scille/umongo/1b23dc7155448a52fa6e7f3d7da6621632e259f5/tests/__init__.py -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | import pymongo 2 | 3 | from umongo.document import DocumentImplementation 4 | from umongo.instance import Instance 5 | from umongo.builder import BaseBuilder 6 | from umongo.frameworks import register_instance 7 | 8 | 9 | TEST_DB = 'umongo_test' 10 | 11 | 12 | # Use a sync driver for easily drop the test database 13 | con = pymongo.MongoClient() 14 | 15 | 16 | # Provide mocked database, collection and builder for easier testing 17 | 18 | 19 | class MockedCollection(): 20 | 21 | def __init__(self, db, name): 22 | self.db = db 23 | self.name = name 24 | 25 | def __eq__(self, other): 26 | return (isinstance(other, MockedCollection) and 27 | self.db == other.db and self.name == other.name) 28 | 29 | def __repr__(self): 30 | return "<%s db=%s, name=%s>" % (self.__class__.__name__, self.db, self.name) 31 | 32 | 33 | class MockedDB: 34 | 35 | def __init__(self, name): 36 | self.name = name 37 | self.cols = {} 38 | 39 | def __getattr__(self, name): 40 | if name not in self.cols: 41 | self.cols[name] = MockedCollection(self, name) 42 | return self.cols[name] 43 | 44 | def __getitem__(self, name): 45 | if name not in self.cols: 46 | self.cols[name] = MockedCollection(self, name) 47 | return self.cols[name] 48 | 49 | def __eq__(self, other): 50 | return isinstance(other, MockedDB) and self.name == other.name 51 | 52 | def __repr__(self): 53 | return "<%s name=%s>" % (self.__class__.__name__, self.name) 54 | 55 | 56 | class MockedBuilder(BaseBuilder): 57 | 58 | BASE_DOCUMENT_CLS = DocumentImplementation 59 | 60 | 61 | class MockedInstance(Instance): 62 | BUILDER_CLS = MockedBuilder 63 | 64 | @staticmethod 65 | def is_compatible_with(db): 66 | return isinstance(db, MockedDB) 67 | 68 | 69 | register_instance(MockedInstance) 70 | 71 | 72 | class BaseTest: 73 | 74 | def setup(self): 75 | self.instance = MockedInstance(MockedDB('my_moked_db')) 76 | 77 | 78 | class BaseDBTest: 79 | 80 | def setup(self): 81 | con.drop_database(TEST_DB) 82 | 83 | 84 | def assert_equal_order(dict_a, dict_b): 85 | assert dict_a == dict_b 86 | assert list(dict_a.items()) == list(dict_b.items()) 87 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from functools import namedtuple 3 | 4 | from umongo import Document, EmbeddedDocument, fields 5 | from umongo.instance import Instance 6 | 7 | 8 | @pytest.fixture 9 | def instance(db): 10 | # `db` should be a fixture provided by the current framework's testbench 11 | return Instance.from_db(db) 12 | 13 | 14 | @pytest.fixture 15 | def classroom_model(instance): 16 | 17 | @instance.register 18 | class Teacher(Document): 19 | name = fields.StrField(required=True) 20 | has_apple = fields.BooleanField(required=False, attribute='_has_apple') 21 | 22 | @instance.register 23 | class Room(EmbeddedDocument): 24 | seats = fields.IntField(required=True, attribute='_seats') 25 | 26 | @instance.register 27 | class Course(Document): 28 | name = fields.StrField(required=True) 29 | teacher = fields.ReferenceField(Teacher, required=True, allow_none=True) 30 | room = fields.EmbeddedField(Room, required=False, allow_none=True) 31 | 32 | @instance.register 33 | class Student(Document): 34 | name = fields.StrField(required=True) 35 | birthday = fields.DateTimeField() 36 | courses = fields.ListField(fields.ReferenceField(Course)) 37 | 38 | Mapping = namedtuple('Mapping', ('Teacher', 'Course', 'Student', 'Room')) 39 | return Mapping(Teacher, Course, Student, Room) 40 | -------------------------------------------------------------------------------- /tests/frameworks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scille/umongo/1b23dc7155448a52fa6e7f3d7da6621632e259f5/tests/frameworks/__init__.py -------------------------------------------------------------------------------- /tests/frameworks/common.py: -------------------------------------------------------------------------------- 1 | """Common functions for framework tests""" 2 | from collections.abc import Mapping 3 | 4 | 5 | def name_sorted(indexes): 6 | """Sort indexes by name""" 7 | return sorted(indexes, key=lambda x: x['name']) 8 | 9 | 10 | def strip_indexes(indexes): 11 | """Strip fields from indexes for comparison 12 | 13 | Remove fields that may change between MongoDB versions and configurations 14 | """ 15 | # Indexes may be a list or a dict depending on DB driver 16 | if isinstance(indexes, Mapping): 17 | return { 18 | k: {sk: sv for sk, sv in v.items() if sk not in ('ns', 'v')} 19 | for k, v in indexes.items() 20 | } 21 | return [ 22 | {sk: sv for sk, sv in v.items() if sk not in ('ns', 'v')} 23 | for v in indexes 24 | ] 25 | -------------------------------------------------------------------------------- /tests/frameworks/test_mongomock.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import pytest 4 | 5 | from ..common import TEST_DB 6 | 7 | DEP_ERROR = 'Missing mongomock' 8 | 9 | try: 10 | from mongomock import MongoClient 11 | except ImportError: 12 | dep_error = True 13 | else: 14 | dep_error = False 15 | 16 | 17 | if not dep_error: # Make sure the module is valid by importing it 18 | from umongo.frameworks import mongomock # noqa 19 | 20 | 21 | def make_db(): 22 | return MongoClient()[TEST_DB] 23 | 24 | 25 | @pytest.fixture 26 | def db(): 27 | return make_db() 28 | 29 | 30 | # MongoMockBuilder is 100% based on PyMongoBuilder so no need for really heavy tests 31 | @pytest.mark.skipif(dep_error, reason=DEP_ERROR) 32 | def test_mongomock(classroom_model): 33 | Student = classroom_model.Student 34 | john = Student(name='John Doe', birthday=dt.datetime(1995, 12, 12)) 35 | john.commit() 36 | assert john.to_mongo() == { 37 | '_id': john.id, 38 | 'name': 'John Doe', 39 | 'birthday': dt.datetime(1995, 12, 12) 40 | } 41 | john2 = Student.find_one(john.id) 42 | assert john2._data == john._data 43 | johns = Student.find() 44 | assert list(johns) == [john] 45 | -------------------------------------------------------------------------------- /tests/frameworks/test_tools.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pymongo import MongoClient 4 | 5 | from umongo.frameworks import pymongo as framework_pymongo # noqa 6 | from umongo.frameworks.tools import cook_find_projection 7 | 8 | from ..common import TEST_DB 9 | 10 | 11 | # All dependencies here are mandatory 12 | dep_error = None 13 | 14 | 15 | def make_db(): 16 | return MongoClient()[TEST_DB] 17 | 18 | 19 | @pytest.fixture 20 | def db(): 21 | return make_db() 22 | 23 | 24 | def test_cook_find_projection(classroom_model): 25 | projection = {'has_apple': 0} 26 | cooked = cook_find_projection(classroom_model.Teacher, projection=projection) 27 | assert cooked == {'_has_apple': 0} 28 | 29 | projection = ['has_apple'] 30 | cooked = cook_find_projection(classroom_model.Teacher, projection=projection) 31 | assert cooked == {'_has_apple': 1} 32 | 33 | projection = ['name', 'has_apple'] 34 | cooked = cook_find_projection(classroom_model.Teacher, projection=projection) 35 | assert cooked == {'name': 1, '_has_apple': 1} 36 | 37 | # projection into a nested document's field which has a specified `attribute` 38 | projection = ['room.seats'] 39 | cooked = cook_find_projection(classroom_model.Course, projection=projection) 40 | assert cooked == {'room._seats': 1} 41 | 42 | projection = {'room.seats': 0} 43 | cooked = cook_find_projection(classroom_model.Course, projection=projection) 44 | assert cooked == {'room._seats': 0} 45 | -------------------------------------------------------------------------------- /tests/test_builder.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from umongo.frameworks import InstanceRegisterer 4 | from umongo.builder import BaseBuilder 5 | from umongo.instance import Instance 6 | from umongo.document import DocumentImplementation 7 | from umongo.exceptions import NoCompatibleInstanceError 8 | 9 | 10 | def create_env(prefix): 11 | db_cls = type("f{prefix}DB", (), {}) 12 | document_cls = type(f"{prefix}Document", (DocumentImplementation, ), {}) 13 | builder_cls = type(f"{prefix}Builder", (BaseBuilder, ), { 14 | 'BASE_DOCUMENT_CLS': document_cls, 15 | }) 16 | instance_cls = type(f"{prefix}Instance", (Instance, ), { 17 | 'BUILDER_CLS': builder_cls, 18 | 'is_compatible_with': staticmethod(lambda db: isinstance(db, db_cls)) 19 | }) 20 | return db_cls, document_cls, builder_cls, instance_cls 21 | 22 | 23 | class TestBuilder: 24 | 25 | def test_basic_builder_registerer(self): 26 | registerer = InstanceRegisterer() 27 | AlphaDB, _, _, AlphaInstance = create_env('Alpha') 28 | 29 | with pytest.raises(NoCompatibleInstanceError): 30 | registerer.find_from_db(AlphaDB()) 31 | registerer.register(AlphaInstance) 32 | assert registerer.find_from_db(AlphaDB()) is AlphaInstance 33 | # Multiple registers does nothing 34 | registerer.register(AlphaInstance) 35 | registerer.unregister(AlphaInstance) 36 | with pytest.raises(NoCompatibleInstanceError): 37 | registerer.find_from_db(AlphaDB()) 38 | 39 | def test_multi_builder(self): 40 | registerer = InstanceRegisterer() 41 | AlphaDB, _, _, AlphaInstance = create_env('Alpha') 42 | BetaDB, _, _, BetaInstance = create_env('Beta') 43 | 44 | registerer.register(AlphaInstance) 45 | assert registerer.find_from_db(AlphaDB()) is AlphaInstance 46 | with pytest.raises(NoCompatibleInstanceError): 47 | registerer.find_from_db(BetaDB()) 48 | registerer.register(BetaInstance) 49 | assert registerer.find_from_db(BetaDB()) is BetaInstance 50 | assert registerer.find_from_db(AlphaDB()) is AlphaInstance 51 | 52 | def test_overload_builder(self): 53 | registerer = InstanceRegisterer() 54 | AlphaDB, _, _, AlphaInstance = create_env('Alpha') 55 | 56 | registerer.register(AlphaInstance) 57 | 58 | # Create a new builder compatible with AlphaDB 59 | class Alpha2Instance(AlphaInstance): 60 | pass 61 | 62 | registerer.register(Alpha2Instance) 63 | 64 | # Last registered builder should be tested first 65 | assert registerer.find_from_db(AlphaDB()) is Alpha2Instance 66 | -------------------------------------------------------------------------------- /tests/test_i18n.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import marshmallow as ma 4 | 5 | from umongo import Document, fields, set_gettext, validate 6 | from umongo.i18n import gettext 7 | from umongo.abstract import BaseField 8 | 9 | from .common import BaseTest 10 | 11 | 12 | class TestI18N(BaseTest): 13 | 14 | def teardown_method(self, method): 15 | # Reset i18n config before each test 16 | set_gettext(None) 17 | 18 | def test_default_behavior(self): 19 | msg = BaseField.default_error_messages['unique'] 20 | assert msg == gettext(msg) 21 | 22 | def test_custom_gettext(self): 23 | 24 | def my_gettext(message): 25 | return 'my_' + message 26 | 27 | set_gettext(my_gettext) 28 | assert gettext('hello') == 'my_hello' 29 | 30 | def test_document_validation(self): 31 | 32 | @self.instance.register 33 | class Client(Document): 34 | phone_number = fields.StrField(validate=validate.Regexp(r'^[0-9 ]+$')) 35 | 36 | def my_gettext(message): 37 | return message.upper() 38 | 39 | set_gettext(my_gettext) 40 | with pytest.raises(ma.ValidationError) as exc: 41 | Client(phone_number='not a phone !') 42 | assert exc.value.args[0] == {'phone_number': ['STRING DOES NOT MATCH EXPECTED PATTERN.']} 43 | -------------------------------------------------------------------------------- /tests/test_indexes.py: -------------------------------------------------------------------------------- 1 | from itertools import zip_longest 2 | 3 | import pytest 4 | 5 | from umongo import Document, EmbeddedDocument, fields 6 | from umongo.indexes import ( 7 | explicit_key, parse_index, 8 | IndexModel, ASCENDING, DESCENDING, TEXT, HASHED) 9 | 10 | from .common import BaseTest 11 | 12 | 13 | def assert_indexes(indexes1, indexes2): 14 | if hasattr(indexes1, '__iter__'): 15 | for e1, e2 in zip_longest(indexes1, indexes2): 16 | assert e1, "missing index %s" % e2.document 17 | assert e2, "too much indexes: %s" % e1.document 18 | assert e1.document == e2.document 19 | else: 20 | assert indexes1.document == indexes2.document 21 | 22 | 23 | class TestIndexes(BaseTest): 24 | 25 | def test_parse_index(self): 26 | for value, expected in ( 27 | ('my_index', IndexModel([('my_index', ASCENDING)])), 28 | ('+my_index', IndexModel([('my_index', ASCENDING)])), 29 | ('-my_index', IndexModel([('my_index', DESCENDING)])), 30 | ('$my_index', IndexModel([('my_index', TEXT)])), 31 | ('#my_index', IndexModel([('my_index', HASHED)])), 32 | # Compound indexes 33 | (('index1', '-index2'), IndexModel([('index1', ASCENDING), ('index2', DESCENDING)])), 34 | # No changes if not needed 35 | (IndexModel([('my_index', ASCENDING)]), IndexModel([('my_index', ASCENDING)])), 36 | # Custom index 37 | ( 38 | { 39 | 'name': 'my-custom-index', 40 | 'key': ['+index1', '-index2'], 41 | 'sparse': True, 42 | 'unique': True, 43 | 'expireAfterSeconds': 42 44 | }, 45 | IndexModel([('index1', ASCENDING), ('index2', DESCENDING)], 46 | name='my-custom-index', sparse=True, 47 | unique=True, expireAfterSeconds=42) 48 | ), 49 | ): 50 | assert_indexes(parse_index(value), expected) 51 | 52 | def test_explicit_key(self): 53 | for value, expected in ( 54 | ('my_index', ('my_index', ASCENDING)), 55 | ('+my_index', ('my_index', ASCENDING)), 56 | ('-my_index', ('my_index', DESCENDING)), 57 | ('$my_index', ('my_index', TEXT)), 58 | ('#my_index', ('my_index', HASHED)), 59 | # No changes if not needed 60 | (('my_index', ASCENDING), ('my_index', ASCENDING)), 61 | ): 62 | assert explicit_key(value) == expected 63 | 64 | def test_inheritance(self): 65 | 66 | @self.instance.register 67 | class Parent(Document): 68 | last_name = fields.StrField() 69 | 70 | class Meta: 71 | indexes = ['last_name'] 72 | 73 | @self.instance.register 74 | class Child(Parent): 75 | first_name = fields.StrField() 76 | 77 | class Meta: 78 | indexes = ['-first_name'] 79 | 80 | assert_indexes(Parent.indexes, [IndexModel([('last_name', ASCENDING)])]) 81 | assert_indexes( 82 | Child.indexes, 83 | [ 84 | IndexModel([('last_name', ASCENDING)]), 85 | IndexModel([('first_name', DESCENDING), ('_cls', ASCENDING)]), 86 | IndexModel([('_cls', ASCENDING)]) 87 | ]) 88 | 89 | def test_bad_index(self): 90 | for bad in [1, None, object()]: 91 | with pytest.raises(TypeError) as exc: 92 | parse_index(1) 93 | assert exc.value.args[0] == ( 94 | 'Index type must be , , or ') 95 | 96 | def test_nested_indexes(self): 97 | """Test multikey indexes 98 | 99 | Note: umongo does not check that indexes entered in Meta match existing fields 100 | """ 101 | @self.instance.register 102 | class Doc(Document): 103 | class Meta: 104 | indexes = [ 105 | 'parent', 'parent.child', 'parent.child.grandchild', 106 | ] 107 | 108 | assert_indexes( 109 | Doc.indexes, 110 | [ 111 | IndexModel([('parent', ASCENDING)]), 112 | IndexModel([('parent.child', ASCENDING)]), 113 | IndexModel([('parent.child.grandchild', ASCENDING)]), 114 | ]) 115 | 116 | @pytest.mark.parametrize("unique_field", ("nested", "list")) 117 | def test_unique_indexes(self, unique_field): 118 | 119 | @self.instance.register 120 | class NestedDoc(EmbeddedDocument): 121 | simple = fields.StrField(unique=True) 122 | 123 | u_field, index = { 124 | "nested": ( 125 | fields.EmbeddedField(NestedDoc), 126 | IndexModel([('field.simple', ASCENDING)], unique=True, sparse=True), 127 | ), 128 | "list": ( 129 | fields.ListField(fields.EmbeddedField(NestedDoc)), 130 | IndexModel([('field.simple', ASCENDING)], unique=True, sparse=True), 131 | ), 132 | }[unique_field] 133 | 134 | @self.instance.register 135 | class Doc(Document): 136 | field = u_field 137 | 138 | assert_indexes(Doc.indexes, [index]) 139 | -------------------------------------------------------------------------------- /tests/test_inheritance.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from umongo import Document, fields, exceptions 4 | 5 | from .common import BaseTest 6 | 7 | 8 | class TestInheritance(BaseTest): 9 | 10 | def test_cls_field(self): 11 | 12 | @self.instance.register 13 | class Parent(Document): 14 | last_name = fields.StrField() 15 | 16 | @self.instance.register 17 | class Child(Parent): 18 | first_name = fields.StrField() 19 | 20 | assert 'cls' in Child.schema.fields 21 | Child.schema.fields['cls'] 22 | assert not hasattr(Parent(), 'cls') 23 | assert Child().cls == 'Child' 24 | 25 | loaded = Parent.build_from_mongo( 26 | {'_cls': 'Child', 'first_name': 'John', 'last_name': 'Doe'}, use_cls=True) 27 | assert loaded.cls == 'Child' 28 | 29 | def test_simple(self): 30 | 31 | @self.instance.register 32 | class Parent(Document): 33 | last_name = fields.StrField() 34 | 35 | class Meta: 36 | collection_name = 'parent_col' 37 | 38 | assert Parent.opts.abstract is False 39 | assert Parent.opts.collection_name == 'parent_col' 40 | assert Parent.collection.name == 'parent_col' 41 | 42 | @self.instance.register 43 | class Child(Parent): 44 | first_name = fields.StrField() 45 | 46 | assert Child.opts.abstract is False 47 | assert Child.opts.collection_name == 'parent_col' 48 | assert Child.collection.name == 'parent_col' 49 | Child(first_name='John', last_name='Doe') 50 | 51 | def test_abstract(self): 52 | 53 | # Cannot define a collection_name for an abstract doc ! 54 | with pytest.raises(exceptions.DocumentDefinitionError): 55 | @self.instance.register 56 | class BadAbstractDoc(Document): 57 | class Meta: 58 | abstract = True 59 | collection_name = 'my_col' 60 | 61 | @self.instance.register 62 | class AbstractDoc(Document): 63 | abs_field = fields.StrField(default='from abstract') 64 | 65 | class Meta: 66 | abstract = True 67 | 68 | assert AbstractDoc.opts.abstract is True 69 | # Cannot instanciate also an abstract document 70 | with pytest.raises(exceptions.AbstractDocumentError): 71 | AbstractDoc() 72 | 73 | @self.instance.register 74 | class StillAbstractDoc(AbstractDoc): 75 | class Meta: 76 | abstract = True 77 | 78 | assert StillAbstractDoc.opts.abstract is True 79 | 80 | @self.instance.register 81 | class ConcreteDoc(AbstractDoc): 82 | pass 83 | 84 | assert ConcreteDoc.opts.abstract is False 85 | assert ConcreteDoc().abs_field == 'from abstract' 86 | 87 | def test_non_document_inheritance(self): 88 | 89 | class NotDoc1: 90 | @staticmethod 91 | def my_func1(): 92 | return 24 93 | 94 | class NotDoc2: 95 | @staticmethod 96 | def my_func2(): 97 | return 42 98 | 99 | @self.instance.register 100 | class Doc(NotDoc1, Document, NotDoc2): 101 | a = fields.StrField() 102 | 103 | assert issubclass(Doc, NotDoc1) 104 | assert issubclass(Doc, NotDoc2) 105 | assert isinstance(Doc(), NotDoc1) 106 | assert isinstance(Doc(), NotDoc2) 107 | assert Doc.my_func1() == 24 108 | assert Doc.my_func2() == 42 109 | doc = Doc(a='test') 110 | assert doc.my_func1() == 24 111 | assert doc.my_func2() == 42 112 | assert doc.a == 'test' 113 | -------------------------------------------------------------------------------- /tests/test_instance.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import pytest 4 | 5 | from bson import ObjectId 6 | 7 | from umongo import Document, fields, EmbeddedDocument 8 | from umongo.instance import Instance 9 | from umongo.document import DocumentTemplate, DocumentImplementation 10 | from umongo.embedded_document import EmbeddedDocumentTemplate, EmbeddedDocumentImplementation 11 | import umongo.frameworks 12 | from umongo.exceptions import ( 13 | AlreadyRegisteredDocumentError, NotRegisteredDocumentError, NoDBDefinedError 14 | ) 15 | 16 | from .common import MockedDB, MockedInstance 17 | 18 | 19 | # Try to retrieve framework's db to test against each of them 20 | DB_AND_INSTANCE_PER_FRAMEWORK = [ 21 | (MockedDB('my_db'), MockedInstance), 22 | ] 23 | for mod_name, inst_name in ( 24 | ('mongomock', 'MongoMockInstance'), 25 | ('motor_asyncio', 'MotorAsyncIOInstance'), 26 | ('txmongo', 'TxMongoInstance'), 27 | ('pymongo', 'PyMongoInstance'), 28 | ): 29 | inst = getattr(umongo.frameworks, inst_name, None) 30 | if inst is not None: 31 | mod = importlib.import_module(f"tests.frameworks.test_{mod_name}") 32 | DB_AND_INSTANCE_PER_FRAMEWORK.append((mod.make_db(), inst)) 33 | 34 | 35 | @pytest.fixture(params=DB_AND_INSTANCE_PER_FRAMEWORK) 36 | def db(request): 37 | return request.param[0] 38 | 39 | 40 | @pytest.fixture(params=DB_AND_INSTANCE_PER_FRAMEWORK) 41 | def db_and_instance(request): 42 | return request.param 43 | 44 | 45 | class TestInstance: 46 | 47 | def test_already_register(self, instance): 48 | 49 | class Doc(Document): 50 | pass 51 | 52 | implementation = instance.register(Doc) 53 | assert issubclass(implementation, DocumentImplementation) 54 | with pytest.raises(AlreadyRegisteredDocumentError): 55 | instance.register(Doc) 56 | 57 | class Embedded(EmbeddedDocument): 58 | pass 59 | 60 | implementation = instance.register(Embedded) 61 | assert issubclass(implementation, EmbeddedDocumentImplementation) 62 | with pytest.raises(AlreadyRegisteredDocumentError): 63 | instance.register(Embedded) 64 | 65 | def test_not_register_documents(self, instance): 66 | 67 | with pytest.raises(NotRegisteredDocumentError): 68 | @instance.register 69 | class Doc1(Document): 70 | ref = fields.ReferenceField('DummyDoc') 71 | Doc1(ref=ObjectId('56dee8dd1d41c8860b263d86')) 72 | 73 | with pytest.raises(NotRegisteredDocumentError): 74 | @instance.register 75 | class Doc2(Document): 76 | nested = fields.EmbeddedField('DummyNested') 77 | Doc2(nested={}) 78 | 79 | def test_multiple_instances(self, db): 80 | instance1 = Instance.from_db(db) 81 | instance2 = Instance.from_db(db) 82 | 83 | class Doc(Document): 84 | pass 85 | 86 | class Embedded(EmbeddedDocument): 87 | pass 88 | 89 | Doc1 = instance1.register(Doc) 90 | Doc2 = instance2.register(Doc) 91 | Embedded1 = instance1.register(Embedded) 92 | Embedded2 = instance2.register(Embedded) 93 | 94 | assert issubclass(Doc1, DocumentImplementation) 95 | assert issubclass(Doc2, DocumentImplementation) 96 | assert issubclass(Embedded1, EmbeddedDocumentImplementation) 97 | assert issubclass(Embedded2, EmbeddedDocumentImplementation) 98 | assert Doc1.opts.instance is instance1 99 | assert Doc2.opts.instance is instance2 100 | assert Embedded1.opts.instance is instance1 101 | assert Embedded2.opts.instance is instance2 102 | 103 | def test_register_other_implementation(self, db): 104 | instance1 = Instance.from_db(db) 105 | instance2 = Instance.from_db(db) 106 | 107 | class Doc(Document): 108 | pass 109 | 110 | doc_instance1_cls = instance1.register(Doc) 111 | doc_instance2_cls = instance2.register(doc_instance1_cls) 112 | assert issubclass(doc_instance2_cls, DocumentImplementation) 113 | with pytest.raises(AlreadyRegisteredDocumentError): 114 | instance2.register(Doc) 115 | 116 | class Embedded(EmbeddedDocument): 117 | pass 118 | 119 | embedded_instance1_cls = instance1.register(Embedded) 120 | embedded_instance2_cls = instance2.register(embedded_instance1_cls) 121 | assert issubclass(embedded_instance2_cls, EmbeddedDocumentImplementation) 122 | with pytest.raises(AlreadyRegisteredDocumentError): 123 | instance2.register(Embedded) 124 | 125 | def test_parent_not_registered(self, instance): 126 | class Parent(Document): 127 | pass 128 | 129 | with pytest.raises(NotRegisteredDocumentError): 130 | @instance.register 131 | class Child(Parent): 132 | pass 133 | 134 | class ParentEmbedded(EmbeddedDocument): 135 | pass 136 | 137 | with pytest.raises(NotRegisteredDocumentError): 138 | @instance.register 139 | class ChildEmbedded(ParentEmbedded): 140 | pass 141 | 142 | def test_retrieve_registered(self, instance): 143 | class Doc(Document): 144 | pass 145 | 146 | class Embedded(EmbeddedDocument): 147 | pass 148 | 149 | Doc_imp = instance.register(Doc) 150 | Embedded_imp = instance.register(Embedded) 151 | 152 | assert instance.retrieve_document('Doc') is Doc_imp 153 | assert instance.retrieve_document(Doc) is Doc_imp 154 | assert instance.retrieve_embedded_document('Embedded') is Embedded_imp 155 | assert instance.retrieve_embedded_document(Embedded) is Embedded_imp 156 | 157 | with pytest.raises(NotRegisteredDocumentError): 158 | instance.retrieve_document('Dummy') 159 | 160 | with pytest.raises(NotRegisteredDocumentError): 161 | instance.retrieve_embedded_document('Dummy') 162 | 163 | def test_mix_doc_and_embedded(self, instance): 164 | @instance.register 165 | class Doc(Document): 166 | pass 167 | 168 | @instance.register 169 | class Embedded(EmbeddedDocument): 170 | pass 171 | 172 | with pytest.raises(NotRegisteredDocumentError): 173 | instance.retrieve_document('Embedded') 174 | 175 | with pytest.raises(NotRegisteredDocumentError): 176 | instance.retrieve_embedded_document('Doc') 177 | 178 | def test_instance_lazy_loading(self, db_and_instance): 179 | db, instance = db_and_instance 180 | instance = instance() 181 | 182 | class Doc(Document): 183 | pass 184 | 185 | doc_impl_cls = instance.register(Doc) 186 | 187 | with pytest.raises(NoDBDefinedError, match="db not set, please call set_db"): 188 | doc_impl_cls.collection 189 | 190 | instance.set_db(db) 191 | 192 | assert doc_impl_cls.collection == db['doc'] 193 | 194 | def test_patched_fields(self, db): 195 | 196 | instance1 = Instance.from_db(db) 197 | instance2 = Instance.from_db(db) 198 | 199 | class Embedded(EmbeddedDocument): 200 | simple = fields.IntField() 201 | 202 | Embedded1 = instance1.register(Embedded) 203 | Embedded2 = instance2.register(Embedded1) 204 | 205 | class ToRef(Document): 206 | pass 207 | 208 | ToRef1 = instance1.register(ToRef) 209 | instance2.register(ToRef) 210 | 211 | class Doc(Document): 212 | embedded = fields.EmbeddedField(Embedded1) 213 | ref = fields.ReferenceField(ToRef1) 214 | 215 | Doc1 = instance1.register(Doc) 216 | Doc2 = instance2.register(Doc) 217 | 218 | assert issubclass(Doc.embedded.embedded_document, EmbeddedDocumentTemplate) 219 | assert issubclass( 220 | Doc1.schema.fields['embedded'].embedded_document, EmbeddedDocumentTemplate) 221 | assert issubclass( 222 | Doc2.schema.fields['embedded'].embedded_document, EmbeddedDocumentTemplate) 223 | assert issubclass( 224 | Doc1.schema.fields['embedded'].embedded_document_cls, EmbeddedDocumentImplementation) 225 | assert issubclass( 226 | Doc2.schema.fields['embedded'].embedded_document_cls, EmbeddedDocumentImplementation) 227 | 228 | assert issubclass(Doc.ref.document, DocumentTemplate) 229 | assert issubclass(Doc1.schema.fields['ref'].document, DocumentTemplate) 230 | assert issubclass(Doc2.schema.fields['ref'].document, DocumentTemplate) 231 | assert issubclass(Doc1.schema.fields['ref'].document_cls, DocumentImplementation) 232 | assert issubclass(Doc2.schema.fields['ref'].document_cls, DocumentImplementation) 233 | 234 | assert Embedded1.schema.fields['simple'].instance is instance1 235 | assert Embedded1.opts.instance is instance1 236 | assert Embedded2.schema.fields['simple'].instance is instance2 237 | assert Embedded2.opts.instance is instance2 238 | 239 | assert Doc1.schema.fields['embedded'].instance is instance1 240 | assert Doc1.opts.instance is instance1 241 | assert Doc2.schema.fields['embedded'].instance is instance2 242 | assert Doc2.opts.instance is instance2 243 | -------------------------------------------------------------------------------- /tests/test_query_mapper.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | from bson import ObjectId 4 | 5 | from umongo import Document, EmbeddedDocument, fields 6 | from umongo.query_mapper import map_query 7 | 8 | from .common import BaseTest, assert_equal_order 9 | 10 | 11 | class TestQueryMapper(BaseTest): 12 | 13 | def test_query_mapper(self): 14 | 15 | @self.instance.register 16 | class Editor(Document): 17 | name = fields.StrField() 18 | 19 | @self.instance.register 20 | class Author(EmbeddedDocument): 21 | name = fields.StrField() 22 | birthday = fields.DateTimeField(attribute='b') 23 | 24 | @self.instance.register 25 | class Chapter(EmbeddedDocument): 26 | title = fields.StrField() 27 | pagination = fields.IntField(attribute='p') 28 | 29 | @self.instance.register 30 | class Book(Document): 31 | title = fields.StrField() 32 | length = fields.IntField(attribute='l') 33 | author = fields.EmbeddedField(Author, attribute='a') 34 | chapters = fields.ListField(fields.EmbeddedField(Chapter)) 35 | tags = fields.ListField(fields.StrField(), attribute='t') 36 | editor = fields.ReferenceField(Editor, attribute='e') 37 | 38 | book_fields = Book.schema.fields 39 | # No changes needed 40 | assert map_query({'title': 'The Lord of The Ring'}, book_fields) == { 41 | 'title': 'The Lord of The Ring'} 42 | # Single substitution 43 | assert map_query({'length': 350}, book_fields) == {'l': 350} 44 | # Multiple substitutions 45 | assert map_query({ 46 | 'length': 350, 47 | 'title': 'The Hobbit', 48 | 'author': 'JRR Tolkien' 49 | }, book_fields) == {'l': 350, 'title': 'The Hobbit', 'a': 'JRR Tolkien'} 50 | 51 | # mongo query commands should not be altered 52 | assert map_query({ 53 | 'title': {'$in': ['The Hobbit', 'The Lord of The Ring']}, 54 | 'author': {'$in': ['JRR Tolkien', 'Peter Jackson']} 55 | }, book_fields) == { 56 | 'title': {'$in': ['The Hobbit', 'The Lord of The Ring']}, 57 | 'a': {'$in': ['JRR Tolkien', 'Peter Jackson']} 58 | } 59 | assert map_query({ 60 | '$or': [{'author': 'JRR Tolkien'}, {'length': 350}] 61 | }, book_fields) == { 62 | '$or': [{'a': 'JRR Tolkien'}, {'l': 350}] 63 | } 64 | 65 | # Test dot notation as well 66 | assert map_query({ 67 | 'author.name': 'JRR Tolkien', 68 | 'author.birthday': dt.datetime(1892, 1, 3), 69 | 'chapters.pagination': 81 70 | }, book_fields) == { 71 | 'a.name': 'JRR Tolkien', 72 | 'a.b': dt.datetime(1892, 1, 3), 73 | 'chapters.p': 81 74 | } 75 | assert map_query({ 76 | 'chapters.$.pagination': 81 77 | }, book_fields) == { 78 | 'chapters.$.p': 81 79 | } 80 | 81 | # Test embedded document conversion 82 | assert map_query({ 83 | 'author': { 84 | 'name': 'JRR Tolkien', 85 | 'birthday': dt.datetime(1892, 1, 3) 86 | } 87 | }, book_fields) == { 88 | 'a': {'name': 'JRR Tolkien', 'b': dt.datetime(1892, 1, 3)} 89 | } 90 | 91 | # Test list conversion 92 | assert map_query({ 93 | 'tags': {'$all': ['Fantasy', 'Classic']} 94 | }, book_fields) == { 95 | 't': {'$all': ['Fantasy', 'Classic']} 96 | } 97 | assert map_query({ 98 | 'chapters': {'$all': [ 99 | {'$elemMatch': {'pagination': 81}}, 100 | {'$elemMatch': {'title': 'An Unexpected Party'}} 101 | ]} 102 | }, book_fields) == { 103 | 'chapters': {'$all': [ 104 | {'$elemMatch': {'p': 81}}, 105 | {'$elemMatch': {'title': 'An Unexpected Party'}} 106 | ]} 107 | } 108 | 109 | # Test embedded document in query 110 | query = map_query({ 111 | 'author': Author(name='JRR Tolkien', birthday=dt.datetime(1892, 1, 3)) 112 | }, book_fields) 113 | assert query == { 114 | 'a': {'name': 'JRR Tolkien', 'b': dt.datetime(1892, 1, 3)} 115 | } 116 | assert isinstance(query['a'], dict) 117 | # Check the order is preserved when serializing the embedded document 118 | # in the query. This is necessary as MongoDB only matches embedded 119 | # documents with same order. 120 | expected = {'name': 'JRR Tolkien', 'b': dt.datetime(1892, 1, 3)} 121 | assert_equal_order(query["a"], expected) 122 | 123 | # Test document in query 124 | editor = Editor(name='Allen & Unwin') 125 | editor.id = ObjectId() 126 | query = map_query({'editor': editor}, book_fields) 127 | assert isinstance(query['e'], ObjectId) 128 | assert query['e'] == editor.id 129 | 130 | def test_mix(self): 131 | 132 | @self.instance.register 133 | class Person(EmbeddedDocument): 134 | name = fields.StrField(attribute='pn') 135 | 136 | @self.instance.register 137 | class Company(EmbeddedDocument): 138 | name = fields.StrField(attribute='cn') 139 | contact = fields.EmbeddedField(Person, attribute='cc') 140 | 141 | @self.instance.register 142 | class Team(Document): 143 | name = fields.StrField(attribute='n') 144 | leader = fields.EmbeddedField(Person, attribute='l') 145 | sponsors = fields.ListField(fields.EmbeddedField(Company), attribute='s') 146 | 147 | team_fields = Team.schema.fields 148 | assert map_query({'leader.name': 1}, team_fields) == {'l.pn': 1} 149 | assert map_query({'leader': {'name': 1}}, team_fields) == {'l': {'pn': 1}} 150 | assert map_query({'sponsors.name': 1}, team_fields) == {'s.cn': 1} 151 | assert map_query({'sponsors': {'name': 1}}, team_fields) == {'s': {'cn': 1}} 152 | assert map_query({'sponsors.contact.name': 1}, team_fields) == {'s.cc.pn': 1} 153 | assert map_query( 154 | {'sponsors': {'contact': {'name': 1}}}, team_fields) == {'s': {'cc': {'pn': 1}}} 155 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint,{py37,py38,py39}-{motor,pymongo,txmongo} 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONPATH = {toxinidir}:{toxinidir}/umongo 7 | deps = 8 | pytest>=4.0.0 9 | coverage>=5.3.0 10 | motor: motor>=2.0,<3.0 11 | pymongo: mongomock>=3.5.0 12 | txmongo: pymongo<3.11 13 | txmongo: txmongo>=19.2.0 14 | txmongo: pytest-twisted>=1.12 15 | commands = 16 | coverage run --source=umongo -m pytest 17 | coverage report --show-missing 18 | 19 | 20 | [testenv:lint] 21 | deps = 22 | flake8>=3.7.0 23 | skip_install = true 24 | commands = 25 | flake8 26 | 27 | ; If you want to make tox run the tests with the same versions, create a 28 | ; requirements.txt with the pinned versions and uncomment the following lines: 29 | ; deps = 30 | ; -r{toxinidir}/requirements.txt 31 | -------------------------------------------------------------------------------- /umongo/__init__.py: -------------------------------------------------------------------------------- 1 | from marshmallow import ValidationError, missing # noqa, republishing 2 | 3 | from .instance import Instance 4 | 5 | from .document import ( 6 | Document, 7 | pre_load, 8 | post_load, 9 | pre_dump, 10 | post_dump, 11 | validates_schema 12 | ) 13 | from .exceptions import ( 14 | UMongoError, 15 | UpdateError, 16 | DeleteError, 17 | AlreadyCreatedError, 18 | NotCreatedError, 19 | NoneReferenceError, 20 | UnknownFieldInDBError, 21 | ) 22 | from . import fields, validate 23 | from .data_objects import Reference 24 | from .embedded_document import EmbeddedDocument 25 | from .mixin import MixinDocument 26 | from .expose_missing import ExposeMissing, RemoveMissingSchema 27 | from .i18n import set_gettext 28 | 29 | 30 | __author__ = 'Emmanuel Leblond, Jérôme Lafréchoux' 31 | __email__ = 'jerome@jolimont.fr' 32 | __version__ = '3.1.0' 33 | __all__ = ( 34 | 'missing', 35 | 36 | 'Instance', 37 | 38 | 'Document', 39 | 'pre_load', 40 | 'post_load', 41 | 'pre_dump', 42 | 'post_dump', 43 | 'validates_schema', 44 | 'EmbeddedDocument', 45 | 'MixinDocument', 46 | 'ExposeMissing', 47 | 'RemoveMissingSchema', 48 | 49 | 'UMongoError', 50 | 'ValidationError', 51 | 'UpdateError', 52 | 'DeleteError', 53 | 'AlreadyCreatedError', 54 | 'NotCreatedError', 55 | 'NoneReferenceError', 56 | 'UnknownFieldInDBError', 57 | 58 | 'fields', 59 | 60 | 'Reference', 61 | 62 | 'set_gettext', 63 | 64 | 'validate' 65 | ) 66 | -------------------------------------------------------------------------------- /umongo/abstract.py: -------------------------------------------------------------------------------- 1 | import marshmallow as ma 2 | 3 | from .expose_missing import RemoveMissingSchema 4 | from .exceptions import DocumentDefinitionError 5 | from .i18n import gettext as _, N_ 6 | 7 | 8 | __all__ = ( 9 | 'BaseSchema', 10 | 'BaseMarshmallowSchema', 11 | 'BaseField', 12 | 'BaseValidator', 13 | 'BaseDataObject' 14 | ) 15 | 16 | 17 | class I18nErrorDict(dict): 18 | def __getitem__(self, name): 19 | raw_msg = dict.__getitem__(self, name) 20 | return _(raw_msg) 21 | 22 | 23 | class BaseMarshmallowSchema(RemoveMissingSchema): 24 | """Base schema for pure marshmallow schemas""" 25 | class Meta: 26 | ordered = True 27 | 28 | 29 | class BaseSchema(ma.Schema): 30 | """ 31 | All schema used in umongo should inherit from this base schema 32 | """ 33 | # This class attribute is overriden by the builder upon registration 34 | # to let the template set the base marshmallow schema class. 35 | # It may be overriden in Template classes. 36 | MA_BASE_SCHEMA_CLS = BaseMarshmallowSchema 37 | 38 | class Meta: 39 | ordered = True 40 | 41 | def __init__(self, *args, **kwargs): 42 | super().__init__(*args, **kwargs) 43 | self.error_messages = I18nErrorDict(self.error_messages) 44 | self._ma_schema = None 45 | 46 | def map_to_field(self, func): 47 | """ 48 | Apply a function to every field in the schema 49 | 50 | >>> def func(mongo_path, path, field): 51 | ... pass 52 | """ 53 | for name, field in self.fields.items(): 54 | mongo_path = field.attribute or name 55 | func(mongo_path, name, field) 56 | if hasattr(field, 'map_to_field'): 57 | field.map_to_field(mongo_path, name, func) 58 | 59 | def as_marshmallow_schema(self): 60 | """Return a pure-marshmallow version of this schema class""" 61 | # Use a cache to avoid generating several times the same schema 62 | if self._ma_schema is not None: 63 | return self._ma_schema 64 | 65 | # Create schema if not found in cache 66 | nmspc = { 67 | name: field.as_marshmallow_field() 68 | for name, field in self.fields.items() 69 | } 70 | name = 'Marshmallow%s' % type(self).__name__ 71 | m_schema = type(name, (self.MA_BASE_SCHEMA_CLS, ), nmspc) 72 | # Add i18n support to the schema 73 | # We can't use I18nErrorDict here because __getitem__ is not called 74 | # when error_messages is updated with _default_error_messages. 75 | m_schema._default_error_messages = { 76 | k: _(v) for k, v in m_schema._default_error_messages.items()} 77 | self._ma_schema = m_schema 78 | return m_schema 79 | 80 | 81 | class BaseField(ma.fields.Field): 82 | """ 83 | All fields used in umongo should inherit from this base field. 84 | 85 | ============================== =============== 86 | Enabled flags resulting index 87 | ============================== =============== 88 | 89 | allow_none 90 | required 91 | required, allow_none 92 | required, unique, allow_none unique 93 | unique unique, sparse 94 | unique, required unique 95 | unique, allow_none unique, sparse 96 | ============================== =============== 97 | 98 | .. note:: Even with allow_none flag, the unique flag will refuse duplicated 99 | `null` value. Consider unsetting the field with `del` instead. 100 | """ 101 | 102 | default_error_messages = { 103 | 'unique': N_('Field value must be unique.'), 104 | 'unique_compound': N_('Values of fields {fields} must be unique together.') 105 | } 106 | 107 | MARSHMALLOW_ARGS_PREFIX = 'marshmallow_' 108 | 109 | def __init__(self, *args, io_validate=None, unique=False, instance=None, **kwargs): 110 | if 'missing' in kwargs: 111 | raise DocumentDefinitionError( 112 | "uMongo doesn't use `missing` argument, use `default` " 113 | "instead and `marshmallow_missing`/`marshmallow_default` " 114 | "to tell `as_marshmallow_field` to use a custom value when " 115 | "generating pure Marshmallow field." 116 | ) 117 | if 'default' in kwargs: 118 | kwargs['missing'] = kwargs['default'] 119 | 120 | # Store attributes prefixed with marshmallow_ to use them when 121 | # creating pure marshmallow Schema 122 | self._ma_kwargs = { 123 | key[len(self.MARSHMALLOW_ARGS_PREFIX):]: val 124 | for key, val in kwargs.items() 125 | if key.startswith(self.MARSHMALLOW_ARGS_PREFIX) 126 | } 127 | kwargs = { 128 | key: val 129 | for key, val in kwargs.items() 130 | if not key.startswith(self.MARSHMALLOW_ARGS_PREFIX) 131 | } 132 | 133 | super().__init__(*args, **kwargs) 134 | 135 | self._ma_kwargs.setdefault('missing', self.default) 136 | self._ma_kwargs.setdefault('default', self.default) 137 | 138 | # Overwrite error_messages to handle i18n translation 139 | self.error_messages = I18nErrorDict(self.error_messages) 140 | # `io_validate` will be run after `io_validate_resursive` 141 | # only if this one doesn't returns errors. This is useful for 142 | # list and embedded fields. 143 | self.io_validate = io_validate 144 | self.io_validate_recursive = None 145 | self.unique = unique 146 | self.instance = instance 147 | 148 | def __repr__(self): 149 | return ('' 160 | .format(ClassName=self.__class__.__name__, self=self)) 161 | 162 | def _validate_missing(self, value): 163 | # Overwrite marshmallow.Field._validate_missing given it also checks 164 | # for missing required fields (this is done at commit time in umongo 165 | # using `DataProxy.required_validate`). 166 | if value is None and getattr(self, 'allow_none', False) is False: 167 | self.fail('null') 168 | 169 | def serialize_to_mongo(self, obj): 170 | if obj is None and getattr(self, 'allow_none', False) is True: 171 | return None 172 | if obj is ma.missing: 173 | return ma.missing 174 | return self._serialize_to_mongo(obj) 175 | 176 | # def serialize_to_mongo_update(self, path, obj): 177 | # return self._serialize_to_mongo(attr, obj=obj, update=update) 178 | 179 | def deserialize_from_mongo(self, value): 180 | if value is None and getattr(self, 'allow_none', False) is True: 181 | return None 182 | return self._deserialize_from_mongo(value) 183 | 184 | def _serialize_to_mongo(self, obj): 185 | return obj 186 | 187 | def _deserialize_from_mongo(self, value): 188 | return value 189 | 190 | def _extract_marshmallow_field_params(self): 191 | params = { 192 | attribute: getattr(self, attribute) 193 | for attribute in ( 194 | 'validate', 'required', 'allow_none', 195 | 'load_only', 'dump_only', 'error_messages' 196 | ) 197 | } 198 | # Override uMongo attributes with marshmallow_ prefixed attributes 199 | params.update(self._ma_kwargs) 200 | return params 201 | 202 | def as_marshmallow_field(self): 203 | """Return a pure-marshmallow version of this field""" 204 | field_kwargs = self._extract_marshmallow_field_params() 205 | # Retrieve the marshmallow class we inherit from 206 | for m_class in type(self).mro(): 207 | if (not issubclass(m_class, BaseField) and 208 | issubclass(m_class, ma.fields.Field)): 209 | m_field = m_class(**field_kwargs, metadata=self.metadata) 210 | # Add i18n support to the field 211 | m_field.error_messages = I18nErrorDict(m_field.error_messages) 212 | return m_field 213 | # Cannot escape the loop given BaseField itself inherits marshmallow's Field 214 | 215 | 216 | class BaseValidator(ma.validate.Validator): 217 | """ 218 | All validators in umongo should inherit from this base validator. 219 | """ 220 | 221 | def __init__(self, *args, **kwargs): 222 | self._error = None 223 | super().__init__(*args, **kwargs) 224 | 225 | @property 226 | def error(self): 227 | return _(self._error) 228 | 229 | @error.setter 230 | def error(self, value): 231 | self._error = value 232 | 233 | 234 | class BaseDataObject: 235 | """ 236 | All data objects in umongo should inherit from this base data object. 237 | """ 238 | 239 | def is_modified(self): 240 | raise NotImplementedError() 241 | 242 | def clear_modified(self): 243 | raise NotImplementedError() 244 | 245 | @classmethod 246 | def build_from_mongo(cls, data): 247 | doc = cls() 248 | doc.from_mongo(data) 249 | return doc 250 | 251 | def from_mongo(self, data): 252 | return self(data) 253 | 254 | def to_mongo(self, update=False): 255 | return self 256 | 257 | def dump(self): 258 | return self 259 | -------------------------------------------------------------------------------- /umongo/builder.py: -------------------------------------------------------------------------------- 1 | """Builder module 2 | 3 | A builder connect a :class:`umongo.document.Template` with a 4 | :class:`umongo.instance.BaseInstance` by generating an 5 | :class:`umongo.document.Implementation`. 6 | """ 7 | import re 8 | from copy import copy 9 | 10 | import marshmallow as ma 11 | 12 | from .abstract import BaseSchema 13 | from .template import Template, Implementation 14 | from .data_proxy import data_proxy_factory 15 | from .document import DocumentTemplate, DocumentOpts, DocumentImplementation 16 | from .embedded_document import ( 17 | EmbeddedDocumentTemplate, EmbeddedDocumentOpts, EmbeddedDocumentImplementation) 18 | from .mixin import MixinDocumentTemplate, MixinDocumentOpts, MixinDocumentImplementation 19 | from .exceptions import DocumentDefinitionError, NotRegisteredDocumentError 20 | from . import fields 21 | 22 | 23 | TEMPLATE_IMPLEMENTATION_MAPPING = { 24 | DocumentTemplate: DocumentImplementation, 25 | EmbeddedDocumentTemplate: EmbeddedDocumentImplementation, 26 | MixinDocumentTemplate: MixinDocumentImplementation, 27 | } 28 | 29 | TEMPLATE_OPTIONS_MAPPING = { 30 | DocumentTemplate: DocumentOpts, 31 | EmbeddedDocumentTemplate: EmbeddedDocumentOpts, 32 | MixinDocumentTemplate: MixinDocumentOpts, 33 | } 34 | 35 | 36 | def _get_base_template_cls(template): 37 | if issubclass(template, DocumentTemplate): 38 | return DocumentTemplate 39 | if issubclass(template, EmbeddedDocumentTemplate): 40 | return EmbeddedDocumentTemplate 41 | if issubclass(template, MixinDocumentTemplate): 42 | return MixinDocumentTemplate 43 | assert False 44 | 45 | 46 | def camel_to_snake(name): 47 | tmp_str = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) 48 | return re.sub('([a-z0-9])([A-Z])', r'\1_\2', tmp_str).lower() 49 | 50 | 51 | def _is_child(template, base_tmpl_cls): 52 | """Return true if the (embedded) document has a concrete parent""" 53 | return any( 54 | b for b in template.__bases__ 55 | if issubclass(b, base_tmpl_cls) and 56 | b is not base_tmpl_cls and 57 | ('Meta' not in b.__dict__ or not getattr(b.Meta, 'abstract', False)) 58 | ) 59 | 60 | 61 | def _on_need_add_id_field(bases, fields_dict): 62 | """ 63 | If the given fields make no reference to `_id`, add an `id` field 64 | (type ObjectId, dump_only=True, attribute=`_id`) to handle it 65 | """ 66 | 67 | def find_id_field(fields_dict): 68 | for name, field in fields_dict.items(): 69 | # Skip fake fields present in schema (e.g. `post_load` decorated function) 70 | if not isinstance(field, ma.fields.Field): 71 | continue 72 | if (name == '_id' and not field.attribute) or field.attribute == '_id': 73 | return name 74 | return None 75 | 76 | # Search among parents for the id field 77 | for base in bases: 78 | schema = base() 79 | name = find_id_field(schema.fields) 80 | if name is not None: 81 | return name 82 | 83 | # Search among our own fields 84 | name = find_id_field(fields_dict) 85 | if name is not None: 86 | return name 87 | 88 | # No id field found, add a default one 89 | fields_dict['id'] = fields.ObjectIdField(attribute='_id', dump_only=True) 90 | return 'id' 91 | 92 | 93 | def _collect_schema_attrs(template): 94 | """ 95 | Split dict between schema fields and non-fields elements and retrieve 96 | marshmallow tags if any. 97 | """ 98 | schema_fields = {} 99 | schema_non_fields = {} 100 | nmspc = {} 101 | for key, item in template.__dict__.items(): 102 | if hasattr(item, '__marshmallow_hook__'): 103 | # Decorated special functions (e.g. `post_load`) 104 | schema_non_fields[key] = item 105 | elif isinstance(item, ma.fields.Field): 106 | # Given the fields provided by the template are going to be 107 | # customized in the implementation, we copy them to avoid 108 | # overwriting if two implementations are created 109 | schema_fields[key] = copy(item) 110 | else: 111 | nmspc[key] = item 112 | return nmspc, schema_fields, schema_non_fields 113 | 114 | 115 | class BaseBuilder: 116 | """ 117 | A builder connect a :class:`umongo.document.Template` with a 118 | :class:`umongo.instance.BaseInstance` by generating an 119 | :class:`umongo.document.Implementation`. 120 | 121 | .. note:: This class should not be used directly, it should be inherited by 122 | concrete implementations such as :class:`umongo.frameworks.pymongo.PyMongoBuilder` 123 | """ 124 | 125 | BASE_DOCUMENT_CLS = None 126 | 127 | def __init__(self, instance): 128 | assert self.BASE_DOCUMENT_CLS 129 | self.instance = instance 130 | self._templates_lookup = { 131 | DocumentTemplate: self.BASE_DOCUMENT_CLS, 132 | EmbeddedDocumentTemplate: EmbeddedDocumentImplementation, 133 | MixinDocumentTemplate: MixinDocumentImplementation, 134 | } 135 | 136 | def _convert_bases(self, bases): 137 | "Replace template parents by their implementation inside this instance" 138 | converted_bases = [] 139 | for base in bases: 140 | assert not issubclass(base, Implementation), \ 141 | 'Document cannot inherit of implementations' 142 | if issubclass(base, Template): 143 | if base not in self._templates_lookup: 144 | raise NotRegisteredDocumentError('Unknown document `%r`' % base) 145 | converted_bases.append(self._templates_lookup[base]) 146 | else: 147 | converted_bases.append(base) 148 | return tuple(converted_bases) 149 | 150 | def _patch_field(self, field): 151 | # Recursively set the `instance` attribute to all fields 152 | field.instance = self.instance 153 | if isinstance(field, fields.ListField): 154 | self._patch_field(field.inner) 155 | elif isinstance(field, fields.DictField): 156 | if field.key_field: 157 | self._patch_field(field.key_field) 158 | if field.value_field: 159 | self._patch_field(field.value_field) 160 | 161 | def _build_schema(self, template, schema_bases, schema_fields, schema_non_fields): 162 | # Recursively set the `instance` attribute to all fields 163 | for field in schema_fields.values(): 164 | self._patch_field(field) 165 | 166 | # Finally build the schema class 167 | schema_nmspc = {} 168 | schema_nmspc.update(schema_fields) 169 | schema_nmspc.update(schema_non_fields) 170 | schema_nmspc['MA_BASE_SCHEMA_CLS'] = template.MA_BASE_SCHEMA_CLS 171 | return type('%sSchema' % template.__name__, schema_bases, schema_nmspc) 172 | 173 | def _build_document_opts(self, template, bases, is_child): 174 | base_tmpl_cls = _get_base_template_cls(template) 175 | base_impl_cls = TEMPLATE_IMPLEMENTATION_MAPPING[base_tmpl_cls] 176 | base_opts_cls = TEMPLATE_OPTIONS_MAPPING[base_tmpl_cls] 177 | kwargs = {} 178 | kwargs['instance'] = self.instance 179 | kwargs['template'] = template 180 | 181 | if base_tmpl_cls in (DocumentTemplate, EmbeddedDocumentTemplate): 182 | meta = template.__dict__.get('Meta') 183 | kwargs['abstract'] = getattr(meta, 'abstract', False) 184 | kwargs['is_child'] = is_child 185 | kwargs['strict'] = getattr(meta, 'strict', True) 186 | if base_tmpl_cls is DocumentTemplate: 187 | collection_name = getattr(meta, 'collection_name', None) 188 | 189 | # Handle option inheritance and integrity checks 190 | for base in bases: 191 | if not issubclass(base, base_impl_cls): 192 | continue 193 | popts = base.opts 194 | if kwargs['abstract'] and not popts.abstract: 195 | raise DocumentDefinitionError( 196 | "Abstract document should have all its parents abstract") 197 | if base_tmpl_cls is DocumentTemplate: 198 | if popts.collection_name: 199 | if collection_name: 200 | raise DocumentDefinitionError( 201 | "Cannot redefine collection_name in a child, use abstract instead") 202 | collection_name = popts.collection_name 203 | 204 | if base_tmpl_cls is DocumentTemplate: 205 | if collection_name: 206 | if kwargs['abstract']: 207 | raise DocumentDefinitionError( 208 | 'Abstract document cannot define collection_name') 209 | elif not kwargs['abstract']: 210 | # Determine the collection name from the class name 211 | collection_name = camel_to_snake(template.__name__) 212 | kwargs['collection_name'] = collection_name 213 | 214 | return base_opts_cls(**kwargs) 215 | 216 | def build_from_template(self, template): 217 | """ 218 | Generate a :class:`umongo.document.DocumentImplementation` for this 219 | instance from the given :class:`umongo.document.DocumentTemplate`. 220 | """ 221 | base_tmpl_cls = _get_base_template_cls(template) 222 | base_impl_cls = TEMPLATE_IMPLEMENTATION_MAPPING[base_tmpl_cls] 223 | is_child = _is_child(template, base_tmpl_cls) 224 | name = template.__name__ 225 | bases = self._convert_bases(template.__bases__) 226 | nmspc, schema_fields, schema_non_fields = _collect_schema_attrs(template) 227 | 228 | # Build opts 229 | opts = self._build_document_opts(template, bases, is_child) 230 | nmspc['opts'] = opts 231 | 232 | # Create schema by retrieving inherited schema classes 233 | schema_bases = tuple( 234 | base.Schema for base in bases 235 | if issubclass(base, Implementation) and hasattr(base, 'Schema') 236 | ) 237 | if not schema_bases: 238 | schema_bases = (BaseSchema, ) 239 | if base_tmpl_cls is DocumentTemplate: 240 | nmspc['pk_field'] = _on_need_add_id_field(schema_bases, schema_fields) 241 | 242 | if base_tmpl_cls is not MixinDocumentTemplate: 243 | if is_child: 244 | schema_fields['cls'] = fields.StringField( 245 | attribute='_cls', default=name, dump_only=True 246 | ) 247 | schema_cls = self._build_schema(template, schema_bases, schema_fields, schema_non_fields) 248 | nmspc['Schema'] = schema_cls 249 | schema = schema_cls() 250 | nmspc['schema'] = schema 251 | if base_tmpl_cls is not MixinDocumentTemplate: 252 | nmspc['DataProxy'] = data_proxy_factory(name, schema, strict=opts.strict) 253 | # Add field names set as class attribute 254 | nmspc['_fields'] = set(schema.fields.keys()) 255 | 256 | implementation = type(name, bases, nmspc) 257 | self._templates_lookup[template] = implementation 258 | # Notify the parent & grand parents of the newborn ! 259 | if base_tmpl_cls is not MixinDocumentTemplate: 260 | for base in bases: 261 | for parent in base.mro(): 262 | if issubclass(parent, base_impl_cls) and parent is not base_impl_cls: 263 | parent.opts.offspring.add(implementation) 264 | return implementation 265 | -------------------------------------------------------------------------------- /umongo/data_objects.py: -------------------------------------------------------------------------------- 1 | from bson import DBRef 2 | 3 | from .abstract import BaseDataObject, I18nErrorDict 4 | from .i18n import N_ 5 | 6 | 7 | __all__ = ('List', 'Dict', 'Reference') 8 | 9 | 10 | class List(BaseDataObject, list): 11 | 12 | __slots__ = ('inner_field', '_modified') 13 | 14 | def __init__(self, inner_field, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | self._modified = False 17 | self.inner_field = inner_field 18 | 19 | def __setitem__(self, key, obj): 20 | obj = self.inner_field.deserialize(obj) 21 | super().__setitem__(key, obj) 22 | self.set_modified() 23 | 24 | def __delitem__(self, key): 25 | super().__delitem__(key) 26 | self.set_modified() 27 | 28 | def append(self, obj): 29 | obj = self.inner_field.deserialize(obj) 30 | ret = super().append(obj) 31 | self.set_modified() 32 | return ret 33 | 34 | def insert(self, i, obj): 35 | obj = self.inner_field.deserialize(obj) 36 | ret = super().insert(i, obj) 37 | self.set_modified() 38 | return ret 39 | 40 | def pop(self, *args, **kwargs): 41 | ret = super().pop(*args, **kwargs) 42 | self.set_modified() 43 | return ret 44 | 45 | def clear(self, *args, **kwargs): 46 | ret = super().clear(*args, **kwargs) 47 | self.set_modified() 48 | return ret 49 | 50 | def remove(self, *args, **kwargs): 51 | ret = super().remove(*args, **kwargs) 52 | self.set_modified() 53 | return ret 54 | 55 | def reverse(self, *args, **kwargs): 56 | ret = super().reverse(*args, **kwargs) 57 | self.set_modified() 58 | return ret 59 | 60 | def sort(self, *args, **kwargs): 61 | ret = super().sort(*args, **kwargs) 62 | self.set_modified() 63 | return ret 64 | 65 | def extend(self, iterable): 66 | iterable = [self.inner_field.deserialize(obj) for obj in iterable] 67 | ret = super().extend(iterable) 68 | self.set_modified() 69 | return ret 70 | 71 | def __repr__(self): 72 | return '' % ( 73 | self.__module__, self.__class__.__name__, list(self)) 74 | 75 | def is_modified(self): 76 | if self._modified: 77 | return True 78 | if self and isinstance(self[0], BaseDataObject): 79 | return any(obj.is_modified() for obj in self) 80 | return False 81 | 82 | def set_modified(self): 83 | self._modified = True 84 | 85 | def clear_modified(self): 86 | self._modified = False 87 | if self and isinstance(self[0], BaseDataObject): 88 | for obj in self: 89 | obj.clear_modified() 90 | 91 | 92 | class Dict(BaseDataObject, dict): 93 | 94 | __slots__ = ('key_field', 'value_field', '_modified') 95 | 96 | def __init__(self, key_field, value_field, *args, **kwargs): 97 | super().__init__(*args, **kwargs) 98 | self._modified = False 99 | self.key_field = key_field 100 | self.value_field = value_field 101 | 102 | def __setitem__(self, key, obj): 103 | key = self.key_field.deserialize(key) if self.key_field else key 104 | obj = self.value_field.deserialize(obj) if self.value_field else obj 105 | super().__setitem__(key, obj) 106 | self.set_modified() 107 | 108 | def __delitem__(self, key): 109 | super().__delitem__(key) 110 | self.set_modified() 111 | 112 | def pop(self, *args, **kwargs): 113 | ret = super().pop(*args, **kwargs) 114 | self.set_modified() 115 | return ret 116 | 117 | def popitem(self, *args, **kwargs): 118 | ret = super().popitem(*args, **kwargs) 119 | self.set_modified() 120 | return ret 121 | 122 | def setdefault(self, key, obj=None): 123 | key = self.key_field.deserialize(key) if self.key_field else key 124 | obj = self.value_field.deserialize(obj) if self.value_field else obj 125 | ret = super().setdefault(key, obj) 126 | self.set_modified() 127 | return ret 128 | 129 | def update(self, other): 130 | new = { 131 | self.key_field.deserialize(k) if self.key_field else k: 132 | self.value_field.deserialize(v) if self.value_field else v 133 | for k, v in other.items() 134 | } 135 | super().update(new) 136 | self.set_modified() 137 | 138 | def __repr__(self): 139 | return '' % ( 140 | self.__module__, self.__class__.__name__, dict(self)) 141 | 142 | def is_modified(self): 143 | if self._modified: 144 | return True 145 | if self and any(isinstance(v, BaseDataObject) for v in self.values()): 146 | return any(obj.is_modified() for obj in self.values()) 147 | return False 148 | 149 | def set_modified(self): 150 | self._modified = True 151 | 152 | def clear_modified(self): 153 | self._modified = False 154 | if self and any(isinstance(v, BaseDataObject) for v in self.values()): 155 | for obj in self.values(): 156 | obj.clear_modified() 157 | 158 | 159 | class Reference: 160 | 161 | error_messages = I18nErrorDict(not_found=N_('Reference not found for document {document}.')) 162 | 163 | def __init__(self, document_cls, pk): 164 | self.document_cls = document_cls 165 | self.pk = pk 166 | self._document = None 167 | 168 | def fetch(self, no_data=False, force_reload=False, projection=None): 169 | """ 170 | Retrieve from the database the referenced document 171 | 172 | :param no_data: if True, the caller is only interested in whether 173 | the document is present in database. This means the 174 | implementation may not retrieve document's data to save bandwidth. 175 | :param force_reload: if True, ignore any cached data and reload referenced 176 | document from database. 177 | :param projection: if supplied, this is a dictionary or list describing 178 | a projection which limits the data returned from database. 179 | """ 180 | raise NotImplementedError 181 | 182 | @property 183 | def exists(self): 184 | """ 185 | Check if the reference document exists in the database. 186 | """ 187 | raise NotImplementedError 188 | 189 | def __repr__(self): 190 | return '' % ( 191 | self.__module__, self.__class__.__name__, self.document_cls.__name__, self.pk) 192 | 193 | def __eq__(self, other): 194 | if isinstance(other, self.document_cls): 195 | return other.pk == self.pk 196 | if isinstance(other, Reference): 197 | return self.pk == other.pk and self.document_cls == other.document_cls 198 | if isinstance(other, DBRef): 199 | return self.pk == other.id and self.document_cls.collection.name == other.collection 200 | return NotImplemented 201 | -------------------------------------------------------------------------------- /umongo/data_proxy.py: -------------------------------------------------------------------------------- 1 | """umongo BaseDataProxy""" 2 | import marshmallow as ma 3 | 4 | from .abstract import BaseDataObject 5 | from .exceptions import UnknownFieldInDBError 6 | from .i18n import gettext as _ 7 | 8 | 9 | __all__ = ('data_proxy_factory') 10 | 11 | 12 | class BaseDataProxy: 13 | 14 | __slots__ = ('_data', '_modified_data') 15 | schema = None 16 | _fields = None 17 | _fields_from_mongo_key = None 18 | 19 | def __init__(self, data=None): 20 | # Inside data proxy, data are stored in mongo world representation 21 | self._modified_data = set() 22 | self._data = {} 23 | self.load(data or {}) 24 | 25 | def to_mongo(self, update=False): 26 | if update: 27 | return self._to_mongo_update() 28 | return self._to_mongo() 29 | 30 | def _to_mongo(self): 31 | mongo_data = {} 32 | for key, val in self._data.items(): 33 | field = self._fields_from_mongo_key[key] 34 | val = field.serialize_to_mongo(val) 35 | if val is not ma.missing: 36 | mongo_data[key] = val 37 | return mongo_data 38 | 39 | def _to_mongo_update(self): 40 | mongo_data = {} 41 | set_data = {} 42 | unset_data = [] 43 | for name in self.get_modified_fields(): 44 | field = self._fields[name] 45 | name = field.attribute or name 46 | val = field.serialize_to_mongo(self._data[name]) 47 | if val is ma.missing: 48 | unset_data.append(name) 49 | else: 50 | set_data[name] = val 51 | if set_data: 52 | mongo_data['$set'] = set_data 53 | if unset_data: 54 | mongo_data['$unset'] = {k: "" for k in unset_data} 55 | return mongo_data or None 56 | 57 | def from_mongo(self, data): 58 | self._data = {} 59 | for key, val in data.items(): 60 | try: 61 | field = self._fields_from_mongo_key[key] 62 | except KeyError: 63 | raise UnknownFieldInDBError(_( 64 | '{cls}: unknown "{key}" field found in DB.' 65 | .format(key=key, cls=self.__class__.__name__) 66 | )) 67 | self._data[key] = field.deserialize_from_mongo(val) 68 | self.clear_modified() 69 | self._add_missing_fields() 70 | 71 | def dump(self): 72 | return self.schema.dump(self._data) 73 | 74 | def _mark_as_modified(self, key): 75 | self._modified_data.add(key) 76 | 77 | def update(self, data): 78 | # Always use marshmallow partial load to skip required checks 79 | loaded_data = self.schema.load(data, partial=True) 80 | self._data.update(loaded_data) 81 | for key in loaded_data: 82 | self._mark_as_modified(key) 83 | 84 | def load(self, data): 85 | # Always use marshmallow partial load to skip required checks 86 | loaded_data = self.schema.load(data, partial=True) 87 | # Cast to dict to ignore field order in comparisons 88 | self._data = dict(loaded_data) 89 | # Map the modified fields list on the the loaded data 90 | self.clear_modified() 91 | for key in loaded_data: 92 | self._mark_as_modified(key) 93 | # TODO: mark added missing fields as modified? 94 | self._add_missing_fields() 95 | 96 | def _get_field(self, name): 97 | field = self._fields[name] 98 | name = field.attribute or name 99 | return name, field 100 | 101 | def get(self, name): 102 | name, _ = self._get_field(name) 103 | return self._data[name] 104 | 105 | def set(self, name, value): 106 | name, field = self._get_field(name) 107 | if value is None and not getattr(field, 'allow_none', False): 108 | raise ma.ValidationError(field.error_messages['null']) 109 | if value is not None: 110 | value = field._deserialize(value, name, None) 111 | field._validate(value) 112 | self._data[name] = value 113 | self._mark_as_modified(name) 114 | 115 | def delete(self, name): 116 | name, field = self._get_field(name) 117 | default = field.default 118 | self._data[name] = default() if callable(default) else default 119 | self._mark_as_modified(name) 120 | 121 | def __repr__(self): 122 | # Display data in oo world format 123 | return "<%s(%s)>" % (self.__class__.__name__, dict(self.items())) 124 | 125 | def __eq__(self, other): 126 | if isinstance(other, dict): 127 | return self._data == other 128 | if hasattr(other, '_data'): 129 | return self._data == other._data 130 | return NotImplemented 131 | 132 | def get_modified_fields(self): 133 | modified = set() 134 | for name, field in self._fields.items(): 135 | value_name = field.attribute or name 136 | value = self._data[value_name] 137 | if value_name in self._modified_data or ( 138 | isinstance(value, BaseDataObject) and value.is_modified()): 139 | modified.add(name) 140 | return modified 141 | 142 | def clear_modified(self): 143 | self._modified_data.clear() 144 | for val in self._data.values(): 145 | if isinstance(val, BaseDataObject): 146 | val.clear_modified() 147 | 148 | def is_modified(self): 149 | return ( 150 | bool(self._modified_data) or 151 | any(isinstance(v, BaseDataObject) and v.is_modified() 152 | for v in self._data.values()) 153 | ) 154 | 155 | def _add_missing_fields(self): 156 | # TODO: we should be able to do that by configuring marshmallow... 157 | for name, field in self._fields.items(): 158 | mongo_name = field.attribute or name 159 | if mongo_name not in self._data: 160 | if callable(field.missing): 161 | self._data[mongo_name] = field.missing() 162 | else: 163 | self._data[mongo_name] = field.missing 164 | 165 | def required_validate(self): 166 | errors = {} 167 | for name, field in self.schema.fields.items(): 168 | value = self._data[field.attribute or name] 169 | if field.required and value is ma.missing: 170 | errors[name] = [_("Missing data for required field.")] 171 | elif value is ma.missing or value is None: 172 | continue 173 | elif hasattr(field, '_required_validate'): 174 | try: 175 | field._required_validate(value) 176 | except ma.ValidationError as exc: 177 | errors[name] = exc.messages 178 | if errors: 179 | raise ma.ValidationError(errors) 180 | 181 | # Standards iterators providing oo and mongo worlds views 182 | 183 | def items(self): 184 | return ( 185 | (key, self._data[field.attribute or key]) for key, field in self._fields.items() 186 | ) 187 | 188 | def keys(self): 189 | return (field.attribute or key for key, field in self._fields.items()) 190 | 191 | def values(self): 192 | return self._data.values() 193 | 194 | 195 | class BaseNonStrictDataProxy(BaseDataProxy): 196 | """ 197 | This data proxy will accept unknown data comming from mongo and will 198 | return them along with other data when ask. 199 | """ 200 | 201 | __slots__ = ('_additional_data', ) 202 | 203 | def __init__(self, data=None): 204 | self._additional_data = {} 205 | super().__init__(data=data) 206 | 207 | def _to_mongo(self): 208 | mongo_data = super()._to_mongo() 209 | mongo_data.update(self._additional_data) 210 | return mongo_data 211 | 212 | def from_mongo(self, data): 213 | self._data = {} 214 | for key, val in data.items(): 215 | try: 216 | field = self._fields_from_mongo_key[key] 217 | except KeyError: 218 | self._additional_data[key] = val 219 | else: 220 | self._data[key] = field.deserialize_from_mongo(val) 221 | self.clear_modified() 222 | self._add_missing_fields() 223 | 224 | 225 | def data_proxy_factory(basename, schema, strict=True): 226 | """ 227 | Generate a DataProxy from the given schema. 228 | 229 | This way all generic informations (like schema and fields lookups) 230 | are kept inside the DataProxy class and it instances are just flyweights. 231 | """ 232 | 233 | cls_name = "%sDataProxy" % basename 234 | 235 | nmspc = { 236 | '__slots__': (), 237 | 'schema': schema, 238 | '_fields': schema.fields, 239 | '_fields_from_mongo_key': {v.attribute or k: v for k, v in schema.fields.items()} 240 | } 241 | 242 | data_proxy_cls = type(cls_name, (BaseDataProxy if strict else BaseNonStrictDataProxy, ), nmspc) 243 | return data_proxy_cls 244 | -------------------------------------------------------------------------------- /umongo/document.py: -------------------------------------------------------------------------------- 1 | """umongo Document""" 2 | from copy import deepcopy 3 | 4 | from bson import DBRef 5 | import marshmallow as ma 6 | from marshmallow import ( 7 | pre_load, post_load, pre_dump, post_dump, validates_schema, # republishing 8 | ) 9 | 10 | from .exceptions import ( 11 | AlreadyCreatedError, NotCreatedError, NoDBDefinedError, AbstractDocumentError 12 | ) 13 | from .template import Template, MetaImplementation 14 | from .embedded_document import EmbeddedDocumentImplementation 15 | from .data_objects import Reference 16 | from .indexes import parse_index 17 | 18 | 19 | __all__ = ( 20 | 'DocumentTemplate', 21 | 'Document', 22 | 'DocumentOpts', 23 | 'MetaDocumentImplementation', 24 | 'DocumentImplementation', 25 | 'pre_load', 26 | 'post_load', 27 | 'pre_dump', 28 | 'post_dump', 29 | 'validates_schema' 30 | ) 31 | 32 | 33 | class DocumentTemplate(Template): 34 | """ 35 | Base class to define a umongo document. 36 | 37 | .. note:: 38 | Once defined, this class must be registered inside a 39 | :class:`umongo.instance.BaseInstance` to obtain it corresponding 40 | :class:`umongo.document.DocumentImplementation`. 41 | .. note:: 42 | You can provide marshmallow tags (e.g. `marshmallow.pre_load` 43 | or `marshmallow.post_dump`) to this class that will be passed 44 | to the marshmallow schema internally used for this document. 45 | """ 46 | 47 | 48 | Document = DocumentTemplate 49 | "Shortcut to DocumentTemplate" 50 | 51 | 52 | class DocumentOpts: 53 | """ 54 | Configuration for a document. 55 | 56 | Should be passed as a Meta class to the :class:`Document` 57 | 58 | .. code-block:: python 59 | 60 | @instance.register 61 | class Doc(Document): 62 | class Meta: 63 | abstract = True 64 | 65 | assert Doc.opts.abstract == True 66 | 67 | 68 | ==================== ====================== =========== 69 | attribute configurable in Meta description 70 | ==================== ====================== =========== 71 | template no Origine template of the Document 72 | instance no Implementation's instance 73 | abstract yes Document has no collection 74 | and can only be inherited 75 | collection_name yes Name of the collection to store 76 | the document into 77 | is_child no Document inherit of a non-abstract document 78 | strict yes Don't accept unknown fields from mongo 79 | (default: True) 80 | indexes yes List of custom indexes 81 | offspring no List of Documents inheriting this one 82 | ==================== ====================== =========== 83 | """ 84 | def __repr__(self): 85 | return ('<{ClassName}(' 86 | 'instance={self.instance}, ' 87 | 'template={self.template}, ' 88 | 'abstract={self.abstract}, ' 89 | 'collection_name={self.collection_name}, ' 90 | 'is_child={self.is_child}, ' 91 | 'strict={self.strict}, ' 92 | 'indexes={self.indexes}, ' 93 | 'offspring={self.offspring})>' 94 | .format(ClassName=self.__class__.__name__, self=self)) 95 | 96 | def __init__(self, instance, template, collection_name=None, abstract=False, 97 | indexes=None, is_child=True, strict=True, offspring=None): 98 | self.instance = instance 99 | self.template = template 100 | self.collection_name = collection_name if not abstract else None 101 | self.abstract = abstract 102 | self.indexes = indexes or [] 103 | self.is_child = is_child 104 | self.strict = strict 105 | self.offspring = set(offspring) if offspring else set() 106 | 107 | 108 | class MetaDocumentImplementation(MetaImplementation): 109 | 110 | def __init__(cls, *args, **kwargs): 111 | cls._indexes = None 112 | 113 | @property 114 | def collection(cls): 115 | """ 116 | Return the collection used by this document class 117 | """ 118 | if cls.opts.abstract: 119 | raise NoDBDefinedError('Abstract document has no collection') 120 | if cls.opts.instance.db is None: 121 | raise NoDBDefinedError('Instance must be initialized first') 122 | return cls.opts.instance.db[cls.opts.collection_name] 123 | 124 | @property 125 | def indexes(cls): 126 | """ 127 | Retrieve all indexes (custom defined in meta class, by inheritances 128 | and unique attributes in fields) 129 | """ 130 | if cls._indexes is None: 131 | 132 | idxs = [] 133 | is_child = cls.opts.is_child 134 | 135 | # First collect parent indexes (including inherited field's unique indexes) 136 | for base in cls.mro(): 137 | if ( 138 | base is not cls and 139 | issubclass(base, DocumentImplementation) and 140 | # Skip base framework doc classes 141 | hasattr(base, "schema") 142 | ): 143 | idxs += base.indexes 144 | 145 | # Then get our own custom indexes 146 | if hasattr(cls, "Meta") and hasattr(cls.Meta, "indexes"): 147 | custom_indexes = [ 148 | parse_index(x, base_compound_field="_cls" if is_child else None) 149 | for x in cls.Meta.indexes 150 | ] 151 | idxs += custom_indexes 152 | 153 | # Add _cls to indexes 154 | if is_child: 155 | idxs.append(parse_index('_cls')) 156 | 157 | # Finally parse our own fields (i.e. not inherited) for unique indexes 158 | def parse_field(mongo_path, path, field): 159 | if field.unique: 160 | index = {'unique': True, 'key': [mongo_path]} 161 | if not field.required or field.allow_none: 162 | index['sparse'] = True 163 | if is_child: 164 | index['key'].append('_cls') 165 | idxs.append(parse_index(index)) 166 | 167 | for name, field in cls.schema.fields.items(): 168 | parse_field(name or field.attribute, name, field) 169 | if hasattr(field, 'map_to_field'): 170 | field.map_to_field(name or field.attribute, name, parse_field) 171 | 172 | cls._indexes = idxs 173 | 174 | return cls._indexes 175 | 176 | 177 | class DocumentImplementation( 178 | EmbeddedDocumentImplementation, 179 | metaclass=MetaDocumentImplementation 180 | ): 181 | """ 182 | Represent a document once it has been implemented inside a 183 | :class:`umongo.instance.BaseInstance`. 184 | 185 | .. note:: This class should not be used directly, it should be inherited by 186 | concrete implementations such as :class:`umongo.frameworks.pymongo.PyMongoDocument` 187 | """ 188 | 189 | __slots__ = ('is_created', '_data') 190 | opts = DocumentOpts(None, DocumentTemplate, abstract=True) 191 | 192 | def __init__(self, **kwargs): 193 | if self.opts.abstract: 194 | raise AbstractDocumentError("Cannot instantiate an abstract Document") 195 | self.is_created = False 196 | "Return True if the document has been commited to database" # is_created's docstring 197 | super().__init__(**kwargs) 198 | 199 | def __repr__(self): 200 | return '' % ( 201 | self.__module__, self.__class__.__name__, dict(self._data.items())) 202 | 203 | def __eq__(self, other): 204 | if self.pk is None: 205 | return self is other 206 | if isinstance(other, self.__class__) and other.pk is not None: 207 | return self.pk == other.pk 208 | if isinstance(other, DBRef): 209 | return other.collection == self.collection.name and other.id == self.pk 210 | if isinstance(other, Reference): 211 | return isinstance(self, other.document_cls) and self.pk == other.pk 212 | return NotImplemented 213 | 214 | def clone(self): 215 | """Return a copy of this Document as a new Document instance 216 | 217 | All fields are deep-copied except the _id field. 218 | """ 219 | new = self.__class__() 220 | data = deepcopy(self._data._data) 221 | # Replace ID with new ID ("missing" unless a default value is provided) 222 | data['_id'] = new._data._data['_id'] 223 | new._data._data = data 224 | new._data._modified_data = set(data.keys()) 225 | return new 226 | 227 | @property 228 | def collection(self): 229 | """ 230 | Return the collection used by this document class 231 | """ 232 | # Cannot implicitly access to the class's property 233 | return type(self).collection 234 | 235 | @property 236 | def pk(self): 237 | """ 238 | Return the document's primary key (i.e. ``_id`` in mongo notation) or 239 | None if not available yet 240 | 241 | .. warning:: Use ``is_created`` field instead to test if the document 242 | has already been commited to database given ``_id`` 243 | field could be generated before insertion 244 | """ 245 | value = self._data.get(self.pk_field) 246 | return value if value is not ma.missing else None 247 | 248 | @property 249 | def dbref(self): 250 | """ 251 | Return a pymongo DBRef instance related to the document 252 | """ 253 | if not self.is_created: 254 | raise NotCreatedError('Must create the document before' 255 | ' having access to DBRef') 256 | return DBRef(collection=self.collection.name, id=self.pk) 257 | 258 | @classmethod 259 | def build_from_mongo(cls, data, use_cls=False): 260 | """ 261 | Create a document instance from MongoDB data 262 | 263 | :param data: data as retrieved from MongoDB 264 | :param use_cls: if the data contains a ``_cls`` field, 265 | use it determine the Document class to instanciate 266 | """ 267 | # If a _cls is specified, we have to use this document class 268 | if use_cls and '_cls' in data: 269 | cls = cls.opts.instance.retrieve_document(data['_cls']) 270 | doc = cls() 271 | doc.from_mongo(data) 272 | return doc 273 | 274 | def from_mongo(self, data): 275 | """ 276 | Update the document with the MongoDB data 277 | 278 | :param data: data as retrieved from MongoDB 279 | """ 280 | self._data.from_mongo(data) 281 | self.is_created = True 282 | 283 | def to_mongo(self, update=False): 284 | """ 285 | Return the document as a dict compatible with MongoDB driver. 286 | 287 | :param update: if True the return dict should be used as an 288 | update payload instead of containing the entire document 289 | """ 290 | if update and not self.is_created: 291 | raise NotCreatedError('Must create the document before using update') 292 | return self._data.to_mongo(update=update) 293 | 294 | def update(self, data): 295 | """Update the document with the given data.""" 296 | if self.is_created and self.pk_field in data.keys(): 297 | raise AlreadyCreatedError("Can't modify id of a created document") 298 | self._data.update(data) 299 | 300 | def dump(self): 301 | """ 302 | Dump the document. 303 | """ 304 | return self._data.dump() 305 | 306 | def is_modified(self): 307 | """ 308 | Returns True if and only if the document was modified since last commit. 309 | """ 310 | return not self.is_created or self._data.is_modified() 311 | 312 | # Data-proxy accessor shortcuts 313 | 314 | def __setitem__(self, name, value): 315 | if self.is_created and name == self.pk_field: 316 | raise AlreadyCreatedError("Can't modify id of a created document") 317 | super().__setitem__(name, value) 318 | 319 | def __delitem__(self, name): 320 | if self.is_created and name == self.pk_field: 321 | raise AlreadyCreatedError("Can't modify id of a created document") 322 | super().__delitem__(name) 323 | 324 | def __setattr__(self, name, value): 325 | if name in self._fields: 326 | if self.is_created and name == self.pk_field: 327 | raise AlreadyCreatedError("Can't modify id of a created document") 328 | self._data.set(name, value) 329 | else: 330 | super().__setattr__(name, value) 331 | 332 | def __delattr__(self, name): 333 | if name in self._fields: 334 | if self.is_created and name == self.pk_field: 335 | raise AlreadyCreatedError("Can't modify pk of a created document") 336 | self._data.delete(name) 337 | else: 338 | super().__delattr__(name) 339 | 340 | # Callbacks 341 | 342 | def pre_insert(self): 343 | """ 344 | Overload this method to get a callback before document insertion. 345 | 346 | .. note:: If you use an async driver, this callback can be asynchronous. 347 | """ 348 | 349 | def pre_update(self): 350 | """ 351 | Overload this method to get a callback before document update. 352 | :return: Additional filters dict that will be used for the query to 353 | select the document to update. 354 | 355 | .. note:: If you use an async driver, this callback can be asynchronous. 356 | """ 357 | 358 | def pre_delete(self): 359 | """ 360 | Overload this method to get a callback before document deletion. 361 | :return: Additional filters dict that will be used for the query to 362 | select the document to update. 363 | 364 | .. note:: If you use an async driver, this callback can be asynchronous. 365 | """ 366 | 367 | def post_insert(self, ret): 368 | """ 369 | Overload this method to get a callback after document insertion. 370 | :param ret: Pymongo response sent by the database. 371 | 372 | .. note:: If you use an async driver, this callback can be asynchronous. 373 | """ 374 | 375 | def post_update(self, ret): 376 | """ 377 | Overload this method to get a callback after document update. 378 | :param ret: Pymongo response sent by the database. 379 | 380 | .. note:: If you use an async driver, this callback can be asynchronous. 381 | """ 382 | 383 | def post_delete(self, ret): 384 | """ 385 | Overload this method to get a callback after document deletion. 386 | :param ret: Pymongo response sent by the database. 387 | 388 | .. note:: If you use an async driver, this callback can be asynchronous. 389 | """ 390 | -------------------------------------------------------------------------------- /umongo/embedded_document.py: -------------------------------------------------------------------------------- 1 | """umongo EmbeddedDocument""" 2 | import marshmallow as ma 3 | 4 | from .template import Implementation, Template 5 | from .data_objects import BaseDataObject 6 | from .expose_missing import EXPOSE_MISSING 7 | from .exceptions import AbstractDocumentError 8 | 9 | 10 | __all__ = ( 11 | 'EmbeddedDocumentTemplate', 12 | 'EmbeddedDocument', 13 | 'EmbeddedDocumentOpts', 14 | 'EmbeddedDocumentImplementation' 15 | ) 16 | 17 | 18 | class EmbeddedDocumentTemplate(Template): 19 | """ 20 | Base class to define a umongo embedded document. 21 | 22 | .. note:: 23 | Once defined, this class must be registered inside a 24 | :class:`umongo.instance.BaseInstance` to obtain it corresponding 25 | :class:`umongo.embedded_document.EmbeddedDocumentImplementation`. 26 | """ 27 | 28 | 29 | EmbeddedDocument = EmbeddedDocumentTemplate 30 | "Shortcut to EmbeddedDocumentTemplate" 31 | 32 | 33 | class EmbeddedDocumentOpts: 34 | """ 35 | Configuration for an :class:`umongo.embedded_document.EmbeddedDocument`. 36 | 37 | Should be passed as a Meta class to the :class:`EmbeddedDocument` 38 | 39 | .. code-block:: python 40 | 41 | @instance.register 42 | class MyEmbeddedDoc(EmbeddedDocument): 43 | class Meta: 44 | abstract = True 45 | 46 | assert MyEmbeddedDoc.opts.abstract == True 47 | 48 | 49 | ==================== ====================== =========== 50 | attribute configurable in Meta description 51 | ==================== ====================== =========== 52 | template no Origin template of the embedded document 53 | instance no Implementation's instance 54 | abstract yes Embedded document can only be inherited 55 | is_child no Embedded document inherit of a non-abstract 56 | embedded document 57 | strict yes Don't accept unknown fields from mongo 58 | (default: True) 59 | offspring no List of embedded documents inheriting this one 60 | ==================== ====================== =========== 61 | """ 62 | def __repr__(self): 63 | return ('<{ClassName}(' 64 | 'instance={self.instance}, ' 65 | 'template={self.template}, ' 66 | 'abstract={self.abstract}, ' 67 | 'is_child={self.is_child}, ' 68 | 'strict={self.strict}, ' 69 | 'offspring={self.offspring})>' 70 | .format(ClassName=self.__class__.__name__, self=self)) 71 | 72 | def __init__(self, instance, template, abstract=False, 73 | is_child=False, strict=True, offspring=None): 74 | self.instance = instance 75 | self.template = template 76 | self.abstract = abstract 77 | self.is_child = is_child 78 | self.strict = strict 79 | self.offspring = set(offspring) if offspring else set() 80 | 81 | 82 | class EmbeddedDocumentImplementation(Implementation, BaseDataObject): 83 | """ 84 | Represent an embedded document once it has been implemented inside a 85 | :class:`umongo.instance.BaseInstance`. 86 | """ 87 | 88 | __slots__ = ('_data', ) 89 | opts = EmbeddedDocumentOpts(None, EmbeddedDocumentTemplate, abstract=True) 90 | 91 | def __init__(self, **kwargs): 92 | super().__init__() 93 | if self.opts.abstract: 94 | raise AbstractDocumentError("Cannot instantiate an abstract EmbeddedDocument") 95 | self._data = self.DataProxy(kwargs) 96 | 97 | def __repr__(self): 98 | return '' % ( 99 | self.__module__, self.__class__.__name__, dict(self._data.items())) 100 | 101 | def __eq__(self, other): 102 | if isinstance(other, dict): 103 | return self._data == other 104 | if hasattr(other, '_data'): 105 | return self._data == other._data 106 | return NotImplemented 107 | 108 | def is_modified(self): 109 | return self._data.is_modified() 110 | 111 | def clear_modified(self): 112 | """ 113 | Reset the list of document's modified items. 114 | """ 115 | self._data.clear_modified() 116 | 117 | def required_validate(self): 118 | self._data.required_validate() 119 | 120 | @classmethod 121 | def build_from_mongo(cls, data, use_cls=True): 122 | """ 123 | Create an embedded document instance from MongoDB data 124 | 125 | :param data: data as retrieved from MongoDB 126 | :param use_cls: if the data contains a ``_cls`` field, 127 | use it determine the EmbeddedDocument class to instanciate 128 | """ 129 | # If a _cls is specified, we have to use this document class 130 | if use_cls and '_cls' in data: 131 | cls = cls.opts.instance.retrieve_embedded_document(data['_cls']) 132 | doc = cls() 133 | doc.from_mongo(data) 134 | return doc 135 | 136 | def from_mongo(self, data): 137 | self._data.from_mongo(data) 138 | 139 | def to_mongo(self, update=False): 140 | return self._data.to_mongo(update=update) 141 | 142 | def update(self, data): 143 | """ 144 | Update the embedded document with the given data. 145 | """ 146 | return self._data.update(data) 147 | 148 | def dump(self): 149 | """ 150 | Dump the embedded document. 151 | """ 152 | return self._data.dump() 153 | 154 | def items(self): 155 | return self._data.items() 156 | 157 | # Data-proxy accessor shortcuts 158 | 159 | def __getitem__(self, name): 160 | value = self._data.get(name) 161 | return None if value is ma.missing and not EXPOSE_MISSING.get() else value 162 | 163 | def __delitem__(self, name): 164 | self._data.delete(name) 165 | 166 | def __setitem__(self, name, value): 167 | self._data.set(name, value) 168 | 169 | def __setattr__(self, name, value): 170 | if name in self._fields: 171 | self._data.set(name, value) 172 | else: 173 | super().__setattr__(name, value) 174 | 175 | def __getattr__(self, name): 176 | if name in self._fields: 177 | value = self._data.get(name) 178 | return None if value is ma.missing and not EXPOSE_MISSING.get() else value 179 | raise AttributeError(name) 180 | 181 | def __delattr__(self, name): 182 | if name in self._fields: 183 | self._data.delete(name) 184 | else: 185 | super().__delattr__(name) 186 | 187 | def __dir__(self): 188 | return dir(type(self)) + list(self._fields) 189 | -------------------------------------------------------------------------------- /umongo/exceptions.py: -------------------------------------------------------------------------------- 1 | """umongo exceptions""" 2 | 3 | 4 | class UMongoError(Exception): 5 | """Base umongo error""" 6 | 7 | 8 | class NoCompatibleInstanceError(UMongoError): 9 | """Can't find instance compatible with database""" 10 | 11 | 12 | class AbstractDocumentError(UMongoError): 13 | """Raised when instantiating an abstract document""" 14 | 15 | 16 | class DocumentDefinitionError(UMongoError): 17 | """Error in document definition""" 18 | 19 | 20 | class NoDBDefinedError(UMongoError): 21 | """No database defined""" 22 | 23 | 24 | class NotRegisteredDocumentError(UMongoError): 25 | """Document not registered""" 26 | 27 | 28 | class AlreadyRegisteredDocumentError(UMongoError): 29 | """Document already registerd""" 30 | 31 | 32 | class UpdateError(UMongoError): 33 | """Error while updating document""" 34 | 35 | 36 | class DeleteError(UMongoError): 37 | """Error while deleting document""" 38 | 39 | 40 | class AlreadyCreatedError(UMongoError): 41 | """Modifying id of an already created document""" 42 | 43 | 44 | class NotCreatedError(UMongoError): 45 | """Document does not exist in database""" 46 | 47 | 48 | class NoneReferenceError(UMongoError): 49 | """Retrieving a None reference""" 50 | 51 | 52 | class UnknownFieldInDBError(UMongoError): 53 | """Data from database contains unknown field""" 54 | -------------------------------------------------------------------------------- /umongo/expose_missing.py: -------------------------------------------------------------------------------- 1 | """Expose missing context variable 2 | 3 | Allows the user to let umongo document return missing rather than None for 4 | empty fields. 5 | """ 6 | from contextvars import ContextVar 7 | from contextlib import AbstractContextManager 8 | 9 | import marshmallow as ma 10 | 11 | 12 | __all__ = ( 13 | 'ExposeMissing', 14 | 'RemoveMissingSchema', 15 | ) 16 | 17 | 18 | EXPOSE_MISSING = ContextVar("expose_missing", default=False) 19 | 20 | 21 | class ExposeMissing(AbstractContextManager): 22 | """Let Document expose missing values rather than returning None 23 | 24 | By default, getting a document item returns None if the value is missing. 25 | Inside this context manager, the missing singleton is returned. This can 26 | be useful is cases where the user want to distinguish between None and 27 | missing value. 28 | """ 29 | def __enter__(self): 30 | self.token = EXPOSE_MISSING.set(True) 31 | 32 | def __exit__(self, *args, **kwargs): 33 | EXPOSE_MISSING.reset(self.token) 34 | 35 | 36 | class RemoveMissingSchema(ma.Schema): 37 | """ 38 | Custom :class:`marshmallow.Schema` subclass that skips missing fields 39 | rather than returning None for missing fields when dumping umongo 40 | :class:`umongo.Document`s. 41 | """ 42 | def dump(self, *args, **kwargs): 43 | with ExposeMissing(): 44 | return super().dump(*args, **kwargs) 45 | -------------------------------------------------------------------------------- /umongo/frameworks/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Frameworks 3 | ========== 4 | """ 5 | from ..exceptions import NoCompatibleInstanceError 6 | from .pymongo import PyMongoInstance 7 | 8 | 9 | __all__ = ( 10 | 'InstanceRegisterer', 11 | 12 | 'default_instance_registerer', 13 | 'register_instance', 14 | 'unregister_instance', 15 | 'find_instance_from_db', 16 | 17 | 'PyMongoInstance', 18 | 'TxMongoInstance', 19 | 'MotorAsyncIOInstance', 20 | 'MongoMockInstance' 21 | ) 22 | 23 | 24 | class InstanceRegisterer: 25 | 26 | def __init__(self): 27 | self.instances = [] 28 | 29 | def register(self, instance): 30 | if instance not in self.instances: 31 | # Insert new item first to overload older compatible instances 32 | self.instances.insert(0, instance) 33 | 34 | def unregister(self, instance): 35 | # Basically only used for tests 36 | self.instances.remove(instance) 37 | 38 | def find_from_db(self, db): 39 | for instance in self.instances: 40 | if instance.is_compatible_with(db): 41 | return instance 42 | raise NoCompatibleInstanceError( 43 | 'Cannot find a umongo instance compatible with %s' % type(db)) 44 | 45 | 46 | default_instance_registerer = InstanceRegisterer() 47 | register_instance = default_instance_registerer.register 48 | unregister_instance = default_instance_registerer.unregister 49 | find_instance_from_db = default_instance_registerer.find_from_db 50 | 51 | 52 | # Define instances for each driver 53 | 54 | register_instance(PyMongoInstance) 55 | 56 | 57 | try: 58 | from .txmongo import TxMongoInstance 59 | register_instance(TxMongoInstance) 60 | except ImportError: # pragma: no cover 61 | pass 62 | 63 | 64 | try: 65 | from .motor_asyncio import MotorAsyncIOInstance 66 | register_instance(MotorAsyncIOInstance) 67 | except ImportError: # pragma: no cover 68 | pass 69 | 70 | 71 | try: 72 | from .mongomock import MongoMockInstance 73 | register_instance(MongoMockInstance) 74 | except ImportError: # pragma: no cover 75 | pass 76 | -------------------------------------------------------------------------------- /umongo/frameworks/mongomock.py: -------------------------------------------------------------------------------- 1 | from mongomock.database import Database 2 | from mongomock.collection import Cursor 3 | 4 | from .pymongo import PyMongoBuilder, PyMongoDocument, BaseWrappedCursor 5 | from ..instance import Instance 6 | from ..document import DocumentImplementation 7 | 8 | 9 | # Mongomock aims at working like pymongo 10 | 11 | 12 | class WrappedCursor(BaseWrappedCursor, Cursor): 13 | __slots__ = () 14 | 15 | 16 | class MongoMockDocument(PyMongoDocument): 17 | __slots__ = () 18 | cursor_cls = WrappedCursor 19 | opts = DocumentImplementation.opts 20 | 21 | 22 | class MongoMockBuilder(PyMongoBuilder): 23 | BASE_DOCUMENT_CLS = MongoMockDocument 24 | 25 | 26 | class MongoMockInstance(Instance): 27 | """ 28 | :class:`umongo.instance.Instance` implementation for mongomock 29 | """ 30 | BUILDER_CLS = MongoMockBuilder 31 | 32 | @staticmethod 33 | def is_compatible_with(db): 34 | return isinstance(db, Database) 35 | -------------------------------------------------------------------------------- /umongo/frameworks/tools.py: -------------------------------------------------------------------------------- 1 | from ..query_mapper import map_query 2 | 3 | 4 | def cook_find_filter(doc_cls, filter): 5 | """ 6 | Add the `_cls` field if needed and replace the fields' name by the one 7 | they have in database. 8 | """ 9 | filter = map_query(filter, doc_cls.schema.fields) 10 | if doc_cls.opts.is_child: 11 | filter = filter or {} 12 | # Filter should be either a dict or an id 13 | if not isinstance(filter, dict): 14 | filter = {'_id': filter} 15 | # Current document shares the collection with a parent, 16 | # we must use the _cls field to discriminate 17 | if doc_cls.opts.offspring: 18 | # Current document has itself offspring, we also have 19 | # to search through them 20 | filter['_cls'] = { 21 | '$in': [o.__name__ for o in doc_cls.opts.offspring] + [doc_cls.__name__]} 22 | else: 23 | filter['_cls'] = doc_cls.__name__ 24 | return filter 25 | 26 | 27 | def cook_find_projection(doc_cls, projection): 28 | """ 29 | Replace field names in a projection by their database names. 30 | """ 31 | # a projection may be either: 32 | # - a list of field names to return, or 33 | # - a dict of field names and values to either return (value of 1) or not return (value of 0) 34 | # in order to reuse as much of the `cook_find_filter` logic as possible, 35 | # convert a list projection to a dict which produces the same result 36 | if isinstance(projection, list): 37 | projection = {field: 1 for field in projection} 38 | projection = map_query(projection, doc_cls.schema.fields) 39 | return projection 40 | 41 | 42 | def remove_cls_field_from_embedded_docs(dict_in, embedded_docs): 43 | """Recursively remove _cls field from nested embedded documents 44 | 45 | This is meant to be used in umongo 2 to 3 migration. The embedded_docs list 46 | should be the list of concrete embedded documents that are not subclasses 47 | of a concrete document. 48 | 49 | :param dict dict_in: Input document content (dump) 50 | :param list embedded_docs: List of embedded documents for which to remove _cls 51 | """ 52 | if isinstance(dict_in, dict): 53 | return { 54 | k: remove_cls_field_from_embedded_docs(v, embedded_docs) 55 | for k, v in dict_in.items() 56 | if k != "_cls" or v not in embedded_docs 57 | } 58 | if isinstance(dict_in, list): 59 | return [ 60 | remove_cls_field_from_embedded_docs(item, embedded_docs) 61 | for item in dict_in 62 | ] 63 | return dict_in 64 | -------------------------------------------------------------------------------- /umongo/i18n.py: -------------------------------------------------------------------------------- 1 | _gettext = None 2 | 3 | 4 | def gettext(message): 5 | """ 6 | Return the localized translation of message. 7 | 8 | .. note:: If :func:`set_gettext` is not called prior, this function 9 | retuns the message unchanged 10 | """ 11 | return message if not _gettext else _gettext(message) 12 | 13 | 14 | def set_gettext(gettext): 15 | """ 16 | Define a function that will be used to localize messages. 17 | 18 | .. note:: Most common function to use for this would be default :func:`gettext.gettext` 19 | """ 20 | global _gettext 21 | _gettext = gettext 22 | 23 | 24 | def N_(message): 25 | """ 26 | Dummy function to mark strings as translatable for babel indexing. 27 | see https://docs.python.org/3.5/library/gettext.html#deferred-translations 28 | """ 29 | return message 30 | -------------------------------------------------------------------------------- /umongo/indexes.py: -------------------------------------------------------------------------------- 1 | from pymongo import IndexModel, ASCENDING, DESCENDING, TEXT, HASHED 2 | 3 | 4 | def explicit_key(index): 5 | if isinstance(index, (list, tuple)): 6 | assert len(index) == 2, 'Must be a (`key`, `direction`) tuple' 7 | return index 8 | if index.startswith('+'): 9 | return (index[1:], ASCENDING) 10 | if index.startswith('-'): 11 | return (index[1:], DESCENDING) 12 | if index.startswith('$'): 13 | return (index[1:], TEXT) 14 | if index.startswith('#'): 15 | return (index[1:], HASHED) 16 | return (index, ASCENDING) 17 | 18 | 19 | def parse_index(index, base_compound_field=None): 20 | keys = None 21 | args = {} 22 | if isinstance(index, IndexModel): 23 | keys = index.document['key'].items() 24 | args = {k: v for k, v in index.document.items() if k != 'key'} 25 | elif isinstance(index, (tuple, list)): 26 | # Compound indexes 27 | keys = [explicit_key(e) for e in index] 28 | elif isinstance(index, str): 29 | keys = [explicit_key(index)] 30 | elif isinstance(index, dict): 31 | assert 'key' in index, 'Index passed as dict must have a `key` entry' 32 | assert hasattr(index['key'], '__iter__'), '`key` entry must be iterable' 33 | keys = [explicit_key(e) for e in index['key']] 34 | args = {k: v for k, v in index.items() if k != 'key'} 35 | else: 36 | raise TypeError('Index type must be , , or ') 37 | if base_compound_field: 38 | keys.append(explicit_key(base_compound_field)) 39 | return IndexModel(keys, **args) 40 | -------------------------------------------------------------------------------- /umongo/instance.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from .exceptions import ( 4 | NotRegisteredDocumentError, AlreadyRegisteredDocumentError, NoDBDefinedError) 5 | from .document import DocumentTemplate 6 | from .embedded_document import EmbeddedDocumentTemplate 7 | from .template import get_template 8 | 9 | 10 | class Instance(abc.ABC): 11 | """ 12 | Abstract instance class 13 | 14 | Instances aims at collecting and implementing :class:`umongo.template.Template`:: 15 | 16 | # Doc is a template, cannot use it for the moment 17 | class Doc(DocumentTemplate): 18 | pass 19 | 20 | instance = MyFrameworkInstance() 21 | # doc_cls is the instance's implementation of Doc 22 | doc_cls = instance.register(Doc) 23 | # Implementations are registered as attribute into the instance 24 | instance.Doc is doc_cls 25 | # Now we can work with the implementations 26 | doc_cls.find() 27 | 28 | .. note:: 29 | Instance registration is divided between :class:`umongo.Document` and 30 | :class:`umongo.EmbeddedDocument`. 31 | """ 32 | BUILDER_CLS = None 33 | 34 | def __init__(self, db=None): 35 | self.builder = self.BUILDER_CLS(self) 36 | self._doc_lookup = {} 37 | self._embedded_lookup = {} 38 | self._mixin_lookup = {} 39 | self._db = db 40 | if db is not None: 41 | self.set_db(db) 42 | 43 | @classmethod 44 | def from_db(cls, db): 45 | from .frameworks import find_instance_from_db 46 | instance_cls = find_instance_from_db(db) 47 | instance = instance_cls() 48 | instance.set_db(db) 49 | return instance 50 | 51 | def retrieve_document(self, name_or_template): 52 | """ 53 | Retrieve a :class:`umongo.document.DocumentImplementation` registered into this 54 | instance from it name or it template class (i.e. :class:`umongo.Document`). 55 | """ 56 | if not isinstance(name_or_template, str): 57 | name_or_template = name_or_template.__name__ 58 | if name_or_template not in self._doc_lookup: 59 | raise NotRegisteredDocumentError( 60 | 'Unknown document class "%s"' % name_or_template) 61 | return self._doc_lookup[name_or_template] 62 | 63 | def retrieve_embedded_document(self, name_or_template): 64 | """ 65 | Retrieve a :class:`umongo.embedded_document.EmbeddedDocumentImplementation` 66 | registered into this instance from it name or it template class 67 | (i.e. :class:`umongo.EmbeddedDocument`). 68 | """ 69 | if not isinstance(name_or_template, str): 70 | name_or_template = name_or_template.__name__ 71 | if name_or_template not in self._embedded_lookup: 72 | raise NotRegisteredDocumentError( 73 | 'Unknown embedded document class "%s"' % name_or_template) 74 | return self._embedded_lookup[name_or_template] 75 | 76 | def register(self, template): 77 | """ 78 | Generate an :class:`umongo.template.Implementation` from the given 79 | :class:`umongo.template.Template` for this instance. 80 | 81 | :param template: :class:`umongo.template.Template` to implement 82 | :return: The :class:`umongo.template.Implementation` generated 83 | 84 | .. note:: 85 | This method can be used as a decorator. This is useful when you 86 | only have a single instance to work with to directly use the 87 | class you defined:: 88 | 89 | @instance.register 90 | class MyEmbedded(EmbeddedDocument): 91 | pass 92 | 93 | @instance.register 94 | class MyDoc(Document): 95 | emb = fields.EmbeddedField(MyEmbedded) 96 | 97 | MyDoc.find() 98 | 99 | """ 100 | # Retrieve the template if another implementation has been provided instead 101 | template = get_template(template) 102 | if issubclass(template, DocumentTemplate): 103 | implementation = self._register_doc(template) 104 | elif issubclass(template, EmbeddedDocumentTemplate): 105 | implementation = self._register_embedded_doc(template) 106 | else: # MixinDocument 107 | implementation = self._register_mixin_doc(template) 108 | return implementation 109 | 110 | def _register_doc(self, template): 111 | implementation = self.builder.build_from_template(template) 112 | if implementation.__name__ in self._doc_lookup: 113 | raise AlreadyRegisteredDocumentError( 114 | 'Document `%s` already registered' % implementation.__name__) 115 | self._doc_lookup[implementation.__name__] = implementation 116 | return implementation 117 | 118 | def _register_embedded_doc(self, template): 119 | implementation = self.builder.build_from_template(template) 120 | if implementation.__name__ in self._embedded_lookup: 121 | raise AlreadyRegisteredDocumentError( 122 | 'EmbeddedDocument `%s` already registered' % implementation.__name__) 123 | self._embedded_lookup[implementation.__name__] = implementation 124 | return implementation 125 | 126 | def _register_mixin_doc(self, template): 127 | implementation = self.builder.build_from_template(template) 128 | if implementation.__name__ in self._mixin_lookup: 129 | raise AlreadyRegisteredDocumentError( 130 | 'MixinDocument `%s` already registered' % implementation.__name__) 131 | self._mixin_lookup[implementation.__name__] = implementation 132 | return implementation 133 | 134 | @property 135 | def db(self): 136 | if self._db is None: 137 | raise NoDBDefinedError('db not set, please call set_db') 138 | return self._db 139 | 140 | @abc.abstractmethod 141 | def is_compatible_with(self, db): 142 | return NotImplemented 143 | 144 | def set_db(self, db): 145 | """ 146 | Set the database to use whithin this instance. 147 | 148 | .. note:: 149 | The documents registered in the instance cannot be used 150 | before this function is called. 151 | """ 152 | assert self.is_compatible_with(db) 153 | self._db = db 154 | -------------------------------------------------------------------------------- /umongo/marshmallow_bonus.py: -------------------------------------------------------------------------------- 1 | """Pure marshmallow fields used in umongo""" 2 | import bson 3 | import marshmallow as ma 4 | 5 | from .i18n import gettext as _ 6 | 7 | 8 | __all__ = ( 9 | 'ObjectId', 10 | 'Reference', 11 | 'GenericReference' 12 | ) 13 | 14 | 15 | class ObjectId(ma.fields.Field): 16 | """Marshmallow field for :class:`bson.objectid.ObjectId`""" 17 | 18 | def _serialize(self, value, attr, obj): 19 | if value is None: 20 | return None 21 | return str(value) 22 | 23 | def _deserialize(self, value, attr, data, **kwargs): 24 | try: 25 | return bson.ObjectId(value) 26 | except (TypeError, bson.errors.InvalidId): 27 | raise ma.ValidationError(_('Invalid ObjectId.')) 28 | 29 | 30 | class Reference(ObjectId): 31 | """Marshmallow field for :class:`umongo.fields.ReferenceField`""" 32 | 33 | def _serialize(self, value, attr, obj): 34 | if value is None: 35 | return None 36 | # In OO world, value is a :class:`umongo.data_object.Reference` 37 | # or an ObjectId before being loaded into a Document 38 | if isinstance(value, bson.ObjectId): 39 | return str(value) 40 | return str(value.pk) 41 | 42 | 43 | class GenericReference(ma.fields.Field): 44 | """Marshmallow field for :class:`umongo.fields.GenericReferenceField`""" 45 | 46 | def _serialize(self, value, attr, obj): 47 | if value is None: 48 | return None 49 | # In OO world, value is a :class:`umongo.data_object.Reference` 50 | # or a dict before being loaded into a Document 51 | if isinstance(value, dict): 52 | return {'id': str(value['id']), 'cls': value['cls']} 53 | return {'id': str(value.pk), 'cls': value.document_cls.__name__} 54 | 55 | def _deserialize(self, value, attr, data, **kwargs): 56 | if not isinstance(value, dict): 57 | raise ma.ValidationError(_("Invalid value for generic reference field.")) 58 | if value.keys() != {'cls', 'id'}: 59 | raise ma.ValidationError(_("Generic reference must have `id` and `cls` fields.")) 60 | try: 61 | _id = bson.ObjectId(value['id']) 62 | except ValueError: 63 | raise ma.ValidationError(_("Invalid `id` field.")) 64 | return {'cls': value['cls'], 'id': _id} 65 | -------------------------------------------------------------------------------- /umongo/mixin.py: -------------------------------------------------------------------------------- 1 | """umongo MixinDocument""" 2 | from .template import Implementation, Template 3 | 4 | __all__ = ( 5 | 'MixinDocumentTemplate', 6 | 'MixinDocument', 7 | 'MixinDocumentImplementation' 8 | ) 9 | 10 | 11 | class MixinDocumentTemplate(Template): 12 | """ 13 | Base class to define a umongo mixin document. 14 | 15 | .. note:: 16 | Once defined, this class must be registered inside a 17 | :class:`umongo.instance.BaseInstance` to obtain it corresponding 18 | :class:`umongo.mixin.MixinDocumentImplementation`. 19 | """ 20 | 21 | 22 | MixinDocument = MixinDocumentTemplate 23 | "Shortcut to MixinDocumentTemplate" 24 | 25 | 26 | class MixinDocumentOpts: 27 | """ 28 | Configuration for an :class:`umongo.mixin.MixinDocument`. 29 | 30 | ==================== ====================== =========== 31 | attribute configurable in Meta description 32 | ==================== ====================== =========== 33 | template no Origin template of the embedded document 34 | instance no Implementation's instance 35 | ==================== ====================== =========== 36 | """ 37 | def __repr__(self): 38 | return ('<{ClassName}(' 39 | 'instance={self.instance}, ' 40 | 'template={self.template}, ' 41 | .format(ClassName=self.__class__.__name__, self=self)) 42 | 43 | def __init__(self, instance, template): 44 | self.instance = instance 45 | self.template = template 46 | 47 | 48 | class MixinDocumentImplementation(Implementation): 49 | """ 50 | Represent a mixin document once it has been implemented inside a 51 | :class:`umongo.instance.BaseInstance`. 52 | """ 53 | opts = MixinDocumentOpts(None, MixinDocumentTemplate) 54 | 55 | def __repr__(self): 56 | return '' % (self.__module__, self.__class__.__name__) 57 | -------------------------------------------------------------------------------- /umongo/query_mapper.py: -------------------------------------------------------------------------------- 1 | from umongo.fields import ListField, EmbeddedField 2 | from umongo.document import DocumentImplementation 3 | from umongo.embedded_document import EmbeddedDocumentImplementation 4 | 5 | 6 | def map_entry(entry, fields): 7 | """ 8 | Retrieve the entry from the given fields and replace it if it should 9 | have a different name within the database. 10 | 11 | :param entry: is one of the followings: 12 | - invalid field name 13 | - command (i.g. $eq) 14 | - valid field with no attribute name 15 | - valid field with an attribute name to use instead 16 | """ 17 | field = fields.get(entry) 18 | if isinstance(field, ListField) and isinstance(field.inner, EmbeddedField): 19 | fields = field.inner.embedded_document_cls.schema.fields 20 | elif isinstance(field, EmbeddedField): 21 | fields = field.embedded_document_cls.schema.fields 22 | return getattr(field, 'attribute', None) or entry, fields 23 | 24 | 25 | def map_entry_with_dots(entry, fields): 26 | """ 27 | Consider the given entry can be a '.' separated combination of single entries. 28 | """ 29 | mapped = [] 30 | for sub_entry in entry.split('.'): 31 | mapped_sub_entry, fields = map_entry(sub_entry, fields) 32 | mapped.append(mapped_sub_entry) 33 | return '.'.join(mapped), fields 34 | 35 | 36 | def map_query(query, fields): 37 | """ 38 | Retrieve given fields within the query and replace their names with 39 | the names they should have within the database. 40 | """ 41 | if isinstance(query, dict): 42 | mapped_query = {} 43 | for entry, entry_query in query.items(): 44 | mapped_entry, entry_fields = map_entry_with_dots(entry, fields) 45 | mapped_query[mapped_entry] = map_query(entry_query, entry_fields) 46 | return mapped_query 47 | if isinstance(query, (list, tuple)): 48 | return [map_query(x, fields) for x in query] 49 | # Passing a Document only makes sense in a Reference, let's query on ObjectId 50 | if isinstance(query, DocumentImplementation): 51 | return query.pk 52 | if isinstance(query, EmbeddedDocumentImplementation): 53 | return query.to_mongo() 54 | return query 55 | -------------------------------------------------------------------------------- /umongo/template.py: -------------------------------------------------------------------------------- 1 | from .abstract import BaseMarshmallowSchema 2 | 3 | 4 | class MetaTemplate(type): 5 | 6 | def __new__(cls, name, bases, nmspc): 7 | # If user has passed parent documents as implementation, we need 8 | # to retrieve the original templates 9 | cooked_bases = [] 10 | for base in bases: 11 | if issubclass(base, Implementation): 12 | base = base.opts.template 13 | cooked_bases.append(base) 14 | return type.__new__(cls, name, tuple(cooked_bases), nmspc) 15 | 16 | def __repr__(cls): 17 | return "