├── .gitignore ├── .idea ├── .name ├── encodings.xml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── sondra.iml ├── vcs.xml └── workspace.xml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── dev_requirements.txt ├── docs ├── 404.html ├── basics │ ├── index.html │ ├── index.xml │ ├── tutorial-1 │ │ └── index.html │ ├── tutorial-2 │ │ └── index.html │ ├── tutorial-3 │ │ └── index.html │ └── tutorial-4 │ │ └── index.html ├── extending │ ├── document-processors │ │ └── index.html │ ├── index.html │ ├── index.xml │ ├── output-formats │ │ └── index.html │ ├── request-processors │ │ └── index.html │ └── value-handlers │ │ └── index.html ├── fonts │ ├── icon.eot │ ├── icon.svg │ ├── icon.ttf │ └── icon.woff ├── images │ ├── colors.png │ ├── favicon.ico │ ├── logo.png │ └── screen.png ├── img │ └── sondra.svg ├── index.html ├── index.xml ├── javascripts │ ├── application.js │ └── modernizr.js ├── sitemap.xml ├── stylesheets │ ├── application.css │ ├── highlight │ │ └── highlight.css │ ├── palettes.css │ └── temporary.css └── topical-guides │ ├── applications │ └── index.html │ ├── collections │ └── index.html │ ├── documents │ └── index.html │ ├── flask │ └── index.html │ ├── index.html │ ├── index.xml │ ├── javascript │ └── index.html │ ├── methods │ └── index.html │ ├── querying │ └── index.html │ ├── querysets │ └── index.html │ └── suites │ └── index.html ├── examples ├── __init__.py └── todo │ ├── __init__.py │ └── __main__.py ├── requirements-versioned.txt ├── requirements.txt ├── setup.py ├── sondra ├── __init__.py ├── _decorators.py ├── api │ ├── __init__.py │ ├── api_request.py │ ├── expose.py │ ├── query_set.py │ ├── ref.py │ └── request_processor.py ├── application │ ├── __init__.py │ └── signals.py ├── auth │ ├── __init__.py │ ├── application.py │ ├── collections.py │ ├── decorators.py │ ├── documents.py │ └── request_processor.py ├── client.py ├── collection │ ├── __init__.py │ ├── query_set.py │ └── signals.py ├── commands │ ├── __init__.py │ ├── admin.py │ ├── document_collections.py │ └── schema2doc.py ├── document │ ├── __init__.py │ ├── processors.py │ ├── schema_parser.py │ └── signals.py ├── exceptions.py ├── file3.py ├── files.py ├── files_old.py ├── flask.py ├── formatters │ ├── __init__.py │ ├── geojson.py │ ├── help.py │ ├── html.py │ ├── json.py │ └── schema.py ├── help.py ├── lazy.py ├── remote.py ├── schema.py ├── static │ └── css │ │ ├── basic.css │ │ └── flasky.css ├── suite │ ├── __init__.py │ └── signals.py ├── tests │ ├── __init__.py │ ├── api.py │ ├── data │ │ └── test.json │ ├── test_applications.py │ ├── test_auth.py │ ├── test_collections.py │ ├── test_documentprocessors.py │ ├── test_documents.py │ ├── test_exposed.py │ ├── test_filestorage.py │ ├── test_requests.py │ ├── test_requests_auth.py │ ├── test_suite.py │ ├── test_valuehandlers.py │ └── web │ │ ├── __init__.py │ │ └── app.py └── utils.py └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | _media/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 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 | 61 | .DS_Store/ 62 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | sondra -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/sondra.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 17 | 18 | 19 | 22 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include docs *.html *.css *.png *.gif *.js *.json -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. sondra documentation master file, created by 2 | sphinx-quickstart on Tue Oct 6 10:46:40 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ###### 7 | Sondra 8 | ###### 9 | 10 | Author 11 | `Jefferson Heard`_ 12 | 13 | Copyright 14 | 2015 Jefferson Heard 15 | 16 | License 17 | Apache 2.0 18 | 19 | Development home 20 | https://github.com/jeffersonheard/sondra 21 | 22 | Development status 23 | beta 24 | 25 | Maintainer 26 | Jefferson Heard 27 | 28 | Documentation 29 | https://jeffersonheard.github.io/sondra 30 | 31 | Sondra is an "ORM" and REST-ful webservice framework for Python 3.x, Flask, and RethinkDB with some unique 32 | features. Sondra's goal is to aid full stack developers by letting them focus 33 | on data models and functionality instead of writing workarounds and glue code. 34 | It embraces common "shortcuts" developers take in common full-stack web 35 | applications, e.g. merging "Model" and "Controller" in the oft-used MVC 36 | pattern. 37 | 38 | Sondra does not currently support asynchronous access to RethinkDB. The goal 39 | is to eventually support `Tornado`_ 40 | 41 | Features 42 | ======== 43 | 44 | * A clear, DRY heirarchical application structure that emphasizes convention over configuration. 45 | * Authentication via `JSON Web Tokens`_ (JWT) 46 | * `JSON-Schema`_ validation for documents. 47 | * Expose methods on documents, collections, and applications, complete with schemas for call and return. 48 | * A clear, predictable URL scheme for all manner of API calls, covering a broad set of use-cases. 49 | * Self documenting APIs with both human-readable help based on docstrings and schemas for every call. 50 | * Use API idiomatically over HTTP and native Python without writing boilerplate code 51 | 52 | Installation 53 | ============ 54 | 55 | :: 56 | 57 | $ python setup.py 58 | 59 | Contribute 60 | ========== 61 | 62 | Contributions are welcome. I haven't gotten around to posting a "master plan" of any sort yet, but I will get there. 63 | If you're interested in contributing, please `email me directly`_ or simply fork the GitHub project and issue a pull 64 | request. 65 | 66 | Support 67 | ======= 68 | 69 | For support, please see the `project page on GitHub`_ 70 | 71 | License 72 | ======= 73 | 74 | This project is licensed under the Apache license V2. See `LICENSE`_. 75 | 76 | Concept 77 | ======= 78 | 79 | A Sondra API is exposed in Flask as a suite of applications. Each application 80 | contains a number of document collections, and each collection contains a 81 | number of documents. 82 | 83 | Sondra tries to take advantage of all manner of Python idioms in a sane manner. 84 | It generates as much as possible, while avoiding "magic tricks" or introducing 85 | conventions that are already covered by Python idioms. This means that: 86 | 87 | * Online documentation is generated at every level from reStructuredText or Google style docstrings. 88 | * Method schemas are generated from annotated function signatures 89 | * All URL features that `urllib.parse` recognizes are taken advantage of to 90 | create a regular URL scheme that encompasses all manner of calls. 91 | 92 | Web Services 93 | ~~~~~~~~~~~~ 94 | 95 | Sondra uses a regular URL scheme for its web services. See the docs directory for more details: 96 | 97 | * Underscores in Python identifiers are replaced with more web-friendly dashes (slugs) 98 | * Paths address app / collection / instance (document) 99 | * The last path element include an optional `.` (dot), which separates the object being addressed and a method being 100 | called. 101 | * Fragments allow the caller to address sub-documents in JSON responses. Fragments delimted with `#` are not passed by 102 | browsers to the server. The alternative for delimiting fragments is `@!` 103 | * Path parameters (on the last element, with a ";", see the URL spec or `urlparse`_ docs) are used to specify output 104 | format or content directives. Currently supported: 105 | 106 | - `json` or `format=json` retrieves data in JSON format. 107 | - `geojson` or `format=geojson` retrieves data in GeoJSON feature or feature collection format. 108 | - `help` or `format=help` retrieves HTML autogenerated help. 109 | - `schema` or `format=schema` retrieves a JSON-Schema document for the service call. 110 | 111 | Documents 112 | ~~~~~~~~~ 113 | 114 | A `Document` is a single document that conforms to a JSON-Schema `object` type. 115 | That is, it is never a simple type nor an array of items. 116 | 117 | Documents may expose methods to the HTTP api. These are similar to instance 118 | methods in Python. They operate on an individual document in a collection 119 | instead. Document methods might include operations that combine multiple 120 | documents to make a third (add, multiply, divide, subtract, or similar) or they 121 | might provide specific views of a document. Anything that you would write as 122 | an "instance method" in Python. 123 | 124 | Collections 125 | ~~~~~~~~~~~ 126 | 127 | A `Collection` is a RethinkDB table that contains a specific subclass of 128 | `Document`, which is defined by a single JSON-Schema. The collection class 129 | defines additionally: 130 | 131 | * The primary key name (defaults to the RethinkDB default of "id") 132 | * Indexes 133 | * Any document properties that require "special treatment" in RethinkDB such as geographical and date/time types. 134 | * Relations to other Collections 135 | * The `Application` class it belongs to. 136 | 137 | Collections may expose methods to the HTTP api. These are similar to class 138 | methods in Python, as they operate on the collection itself and not the 139 | individual documents. Collection methods might provide special filtering, 140 | create documents according to a specific template, or set properties on the 141 | collection itself. Anything you would write as a "class method" in Python 142 | 143 | Applications 144 | ~~~~~~~~~~~~ 145 | 146 | An `Application` is a reusable grouping of collections and a set of optional 147 | *application methods*, which operate a bit like globally available functions. 148 | Applications are bound to a single database within RethinkDB. 149 | 150 | Applications may expose methods to the HTTP api. These are similar to the 151 | functions that are defined at the module level in Python. They are not 152 | specific to a particular class or instance, but instead are defined to provide 153 | broad functionality for the whole application. 154 | 155 | The Suite 156 | ~~~~~~~~~ 157 | 158 | A `Suite` defines the environment of applications, including database 159 | connections and provides some basic functionality. Every application is 160 | registered with the global `Suite` object, which itself implements Python's 161 | Mapping protocol to provide dictionary-like lookup of application objects. The 162 | "Suite" object determines the base path of all Application APIs. Suites are 163 | similar in nature to Django's `settings.py` except that they are class-based. 164 | There may be only *one* concrete class of Suite in your Flask app, although it 165 | may derive from any number of abstract Suite mixins. 166 | 167 | 168 | 169 | .. External links go below here. 170 | ----------------------------- 171 | 172 | .. _email me directly: mailto:jefferson.r.heard@gmail.com 173 | .. _project page on GitHub: https://github.com/JeffHeard/sondra 174 | .. _JSON Web Tokens: https://self-issued.info/docs/draft-ietf-oauth-json-web-token.html 175 | .. _JSON-Schema: http://json-schema.org 176 | .. _LICENSE: https://github.com/JeffHeard/sondra/blob/master/LICENSE 177 | .. _Tornado: http://www.tornadoweb.org/en/stable/ 178 | .. _urlparse: https://docs.python.org/3/library/urllib.parse.html 179 | .. _Jefferson Heard: https://jeffersonheard.github.io 180 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/docs/404.html -------------------------------------------------------------------------------- /docs/extending/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Extending-rsses on Sondra Documentation 5 | https://jeffersonheard.github.io/sondra/extending/index.xml 6 | Recent content in Extending-rsses on Sondra Documentation 7 | Hugo -- gohugo.io 8 | en-us 9 | Released under the MIT license 10 | Wed, 12 Oct 2016 16:38:34 -0400 11 | 12 | 13 | 14 | Document Processors 15 | https://jeffersonheard.github.io/sondra/extending/document-processors/ 16 | Wed, 12 Oct 2016 16:38:34 -0400 17 | 18 | https://jeffersonheard.github.io/sondra/extending/document-processors/ 19 | <p>Coming Soon&hellip;</p> 20 | 21 | 22 | 23 | 24 | Output Formats 25 | https://jeffersonheard.github.io/sondra/extending/output-formats/ 26 | Wed, 12 Oct 2016 16:38:34 -0400 27 | 28 | https://jeffersonheard.github.io/sondra/extending/output-formats/ 29 | <p>Coming Soon&hellip;</p> 30 | 31 | 32 | 33 | 34 | Request Processors 35 | https://jeffersonheard.github.io/sondra/extending/request-processors/ 36 | Wed, 12 Oct 2016 16:38:34 -0400 37 | 38 | https://jeffersonheard.github.io/sondra/extending/request-processors/ 39 | <p>Coming Soon&hellip;</p> 40 | 41 | 42 | 43 | 44 | Value Handlers 45 | https://jeffersonheard.github.io/sondra/extending/value-handlers/ 46 | Wed, 12 Oct 2016 16:38:34 -0400 47 | 48 | https://jeffersonheard.github.io/sondra/extending/value-handlers/ 49 | <p>Coming Soon&hellip;</p> 50 | 51 | 52 | 53 | 54 | Extending 55 | https://jeffersonheard.github.io/sondra/extending/ 56 | Wed, 12 Oct 2016 16:38:08 -0400 57 | 58 | https://jeffersonheard.github.io/sondra/extending/ 59 | 60 | 61 | <h2 id="table-of-contents">Table of Contents</h2> 62 | 63 | <ul> 64 | <li><a href="https://jeffersonheard.github.io/sondra/extending/output-formats">Adding new Output Formats</a></li> 65 | <li><a href="https://jeffersonheard.github.io/sondra/extending/request-processors">Getting info and editing requests with RequestProcessors</a></li> 66 | <li><a href="https://jeffersonheard.github.io/sondra/extending/document-processors">Adding data to documents with DocumentProcessors</a></li> 67 | <li><a href="https://jeffersonheard.github.io/sondra/extending/value-handlers">Treating JSON properties specially with ValueHandlers</a></li> 68 | </ul> 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /docs/fonts/icon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/docs/fonts/icon.eot -------------------------------------------------------------------------------- /docs/fonts/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/fonts/icon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/docs/fonts/icon.ttf -------------------------------------------------------------------------------- /docs/fonts/icon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/docs/fonts/icon.woff -------------------------------------------------------------------------------- /docs/images/colors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/docs/images/colors.png -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/docs/images/favicon.ico -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/docs/images/logo.png -------------------------------------------------------------------------------- /docs/images/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/docs/images/screen.png -------------------------------------------------------------------------------- /docs/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | https://jeffersonheard.github.io/sondra/ 6 | 2016-10-12T16:38:34-04:00 7 | 8 | 9 | 10 | https://jeffersonheard.github.io/sondra/extending/document-processors/ 11 | 2016-10-12T16:38:34-04:00 12 | 13 | 14 | 15 | https://jeffersonheard.github.io/sondra/extending/output-formats/ 16 | 2016-10-12T16:38:34-04:00 17 | 18 | 19 | 20 | https://jeffersonheard.github.io/sondra/extending/request-processors/ 21 | 2016-10-12T16:38:34-04:00 22 | 23 | 24 | 25 | https://jeffersonheard.github.io/sondra/topical-guides/flask/ 26 | 2016-10-12T16:38:34-04:00 27 | 28 | 29 | 30 | https://jeffersonheard.github.io/sondra/extending/value-handlers/ 31 | 2016-10-12T16:38:34-04:00 32 | 33 | 34 | 35 | https://jeffersonheard.github.io/sondra/ 36 | 2016-03-08T21:07:13+01:00 37 | 38 | 39 | 40 | https://jeffersonheard.github.io/sondra/topical-guides/applications/ 41 | 2016-10-12T16:38:34-04:00 42 | 43 | 44 | 45 | https://jeffersonheard.github.io/sondra/basics/tutorial-1/ 46 | 2016-10-12T16:38:34-04:00 47 | 48 | 49 | 50 | https://jeffersonheard.github.io/sondra/basics/ 51 | 2016-10-12T16:38:08-04:00 52 | 53 | 54 | 55 | https://jeffersonheard.github.io/sondra/topical-guides/collections/ 56 | 2016-10-12T16:38:34-04:00 57 | 58 | 59 | 60 | https://jeffersonheard.github.io/sondra/basics/tutorial-2/ 61 | 2016-10-12T16:38:34-04:00 62 | 63 | 64 | 65 | https://jeffersonheard.github.io/sondra/topical-guides/ 66 | 2016-10-12T16:38:08-04:00 67 | 68 | 69 | 70 | https://jeffersonheard.github.io/sondra/basics/tutorial-3/ 71 | 2016-10-12T16:38:34-04:00 72 | 73 | 74 | 75 | https://jeffersonheard.github.io/sondra/topical-guides/documents/ 76 | 2016-10-12T16:38:34-04:00 77 | 78 | 79 | 80 | https://jeffersonheard.github.io/sondra/basics/tutorial-4/ 81 | 2016-10-12T16:38:34-04:00 82 | 83 | 84 | 85 | https://jeffersonheard.github.io/sondra/topical-guides/methods/ 86 | 2016-10-12T16:38:34-04:00 87 | 88 | 89 | 90 | https://jeffersonheard.github.io/sondra/topical-guides/javascript/ 91 | 2016-10-12T16:38:34-04:00 92 | 93 | 94 | 95 | https://jeffersonheard.github.io/sondra/extending/ 96 | 2016-10-12T16:38:08-04:00 97 | 98 | 99 | 100 | https://jeffersonheard.github.io/sondra/topical-guides/querying/ 101 | 2016-10-12T16:38:34-04:00 102 | 103 | 104 | 105 | https://jeffersonheard.github.io/sondra/topical-guides/querysets/ 106 | 2016-10-12T16:38:34-04:00 107 | 108 | 109 | 110 | https://jeffersonheard.github.io/sondra/topical-guides/suites/ 111 | 2016-10-12T16:38:34-04:00 112 | 113 | 114 | -------------------------------------------------------------------------------- /docs/stylesheets/highlight/highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | * overwrite the current primary color of the 3 | * theme that is used as fallback in codeblocks 4 | */ 5 | .article pre code { 6 | color: rgba(0, 0, 0, 0.8) !important; 7 | } 8 | 9 | 10 | /* 11 | HIGHLIGHT.JS THEME 12 | 13 | tweaked version of the Github theme 14 | */ 15 | 16 | .hljs { 17 | display:block; 18 | overflow-x:auto; 19 | } 20 | 21 | .hljs-comment, 22 | .hljs-quote { 23 | color:#998; 24 | font-style:italic; 25 | } 26 | 27 | .hljs-keyword, 28 | .hljs-selector-tag, 29 | .hljs-subst { 30 | color:#333; 31 | font-weight:700; 32 | } 33 | 34 | .hljs-number, 35 | .hljs-literal, 36 | .hljs-variable, 37 | .hljs-template-variable, 38 | .hljs-tag .hljs-attr { 39 | color:teal; 40 | } 41 | 42 | .hljs-string, 43 | .hljs-doctag { 44 | color:#d14; 45 | } 46 | 47 | .hljs-title, 48 | .hljs-section, 49 | .hljs-selector-id { 50 | color:#900; 51 | font-weight:700; 52 | } 53 | 54 | .hljs-subst { 55 | font-weight:400; 56 | } 57 | 58 | .hljs-type, 59 | .hljs-class .hljs-title { 60 | color:#458; 61 | font-weight:700; 62 | } 63 | 64 | .hljs-tag, 65 | .hljs-name, 66 | .hljs-attribute { 67 | color:navy; 68 | font-weight:400; 69 | } 70 | 71 | .hljs-regexp, 72 | .hljs-link { 73 | color:#009926; 74 | } 75 | 76 | .hljs-symbol, 77 | .hljs-bullet { 78 | color:#990073; 79 | } 80 | 81 | .hljs-built_in, 82 | .hljs-builtin-name { 83 | color:#0086b3; 84 | } 85 | 86 | .hljs-meta { 87 | color:#999; 88 | font-weight:700; 89 | } 90 | 91 | .hljs-deletion { 92 | background:#fdd; 93 | } 94 | 95 | .hljs-addition { 96 | background:#dfd; 97 | } 98 | 99 | .hljs-emphasis { 100 | font-style:italic; 101 | } 102 | 103 | .hljs-strong { 104 | font-weight:700; 105 | } 106 | -------------------------------------------------------------------------------- /docs/stylesheets/temporary.css: -------------------------------------------------------------------------------- 1 | /* This file only exists (temporarily) until the 2 | custom styling can be replaced with the 3 | implementation of the upstream project. 4 | */ 5 | 6 | blockquote { 7 | padding: 0 20px; 8 | margin: 0 0 20px; 9 | font-size: inherit; 10 | border-left: 5px solid #eee; 11 | } 12 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'jeff' 2 | -------------------------------------------------------------------------------- /examples/todo/__init__.py: -------------------------------------------------------------------------------- 1 | from sondra.collection import Collection 2 | from sondra.document import Document 3 | from sondra.application import Application 4 | from sondra.suite import Suite 5 | from sondra.schema import S 6 | 7 | class Item(Document): 8 | schema = S.object( 9 | required=['title'], 10 | properties=S.props( 11 | ("title", S.string(description="The title of the item")), 12 | ("complete", S.boolean(default=False)), 13 | ("created", S.datetime()), 14 | )) 15 | 16 | class Items(Collection): 17 | document_class = Item 18 | indexes = ["title", "complete"] 19 | order_by = ["created"] 20 | 21 | class TodoApp(Application): 22 | collections = (Items,) 23 | 24 | class TodoSuite(Suite): 25 | cross_origin = True 26 | debug = True 27 | 28 | 29 | def todo_session(): 30 | todo = TodoSuite("sondra_ex_") 31 | TodoApp(todo) 32 | 33 | todo.validate() 34 | return todo 35 | 36 | -------------------------------------------------------------------------------- /examples/todo/__main__.py: -------------------------------------------------------------------------------- 1 | from examples.todo import * 2 | 3 | from flask import Flask 4 | from sondra.flask import api_tree, init 5 | 6 | # Create the Flask instance and the suite. 7 | app = Flask(__name__) 8 | app.debug = True 9 | app.suite = TodoSuite() 10 | init(app) 11 | 12 | # Register all the applications. 13 | TodoApp(app.suite) 14 | 15 | # Create all databases and tables. 16 | app.suite.validate() # remember this call? 17 | app.suite.ensure_database_objects() # and this one? 18 | 19 | # Attach the API to the /api/ endpoint. 20 | app.register_blueprint(api_tree, url_prefix='/api') 21 | 22 | app.run() -------------------------------------------------------------------------------- /requirements-versioned.txt: -------------------------------------------------------------------------------- 1 | bcrypt==2.0.0 2 | blinker==1.4 3 | cffi==1.4.1 4 | click==6.2 5 | docutils==0.12 6 | Flask==0.10.1 7 | iso8601==0.1.11 8 | itsdangerous==0.24 9 | Jinja2==2.8 10 | jsonschema==2.5.1 11 | MarkupSafe==0.23 12 | pockets==0.3 13 | py==1.4.31 14 | pycparser==2.14 15 | Pygments==2.0.2 16 | PyJWT==1.4.0 17 | pytest==2.8.5 18 | python-slugify==1.1.4 19 | requests==2.9.0 20 | rethinkdb==2.2.0.post1 21 | Shapely==1.5.13 22 | simplegeneric==0.8.1 23 | six==1.10.0 24 | Sphinx==1.2.3 25 | sphinxcontrib-napoleon==0.3.11 26 | Unidecode==0.4.18 27 | Werkzeug==0.11.2 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bcrypt==2.0.0 2 | blinker==1.4 3 | cffi==1.4.1 4 | click==6.2 5 | docutils==0.12 6 | Flask==0.10.1 7 | Flask-Cors==2.1.2 8 | iso8601==0.1.11 9 | itsdangerous==0.24 10 | Jinja2==2.8 11 | jsonschema==2.5.1 12 | MarkupSafe==0.23 13 | pockets==0.3 14 | py==1.4.31 15 | pycparser==2.14 16 | Pygments==2.0.2 17 | PyJWT==1.4.0 18 | pytest==2.8.5 19 | python-slugify==1.1.4 20 | requests==2.9.0 21 | rethinkdb==2.2.0.post1 22 | Shapely==1.5.13 23 | simplegeneric==0.8.1 24 | six==1.10.0 25 | Sphinx==1.2.3 26 | sphinxcontrib-napoleon==0.3.11 27 | Unidecode==0.4.18 28 | Werkzeug==0.11.2 29 | slugify 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='sondra', 5 | packages=[ 6 | 'sondra', 7 | 'sondra.auth', 8 | 'sondra.commands', 9 | 'sondra.application', 10 | 'sondra.collection', 11 | 'sondra.document', 12 | 'sondra.suite', 13 | 'sondra.formatters', 14 | 'sondra.tests', 15 | 'sondra.tests.web', 16 | ], 17 | version='1.0.1', 18 | description='JSON-Schema-based ORM for RethinkDB', 19 | author="Jefferson Heard", 20 | author_email="jefferson.r.heard@gmail.com", 21 | url="https://github.com/JeffHeard/sondra", 22 | keywords=["json","rethinkdb","flask"], 23 | classifiers=[ 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Development Status :: 4 - Beta", 27 | "Environment :: Web Environment", 28 | "Framework :: Flask", 29 | "License :: OSI Approved :: Apache Software License", 30 | "Intended Audience :: Developers", 31 | "Operating System :: OS Independent", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | "Topic :: Database", 34 | "Topic :: Internet :: WWW/HTTP", 35 | "Topic :: Software Development :: Libraries", 36 | "Natural Language :: English", 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /sondra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/sondra/__init__.py -------------------------------------------------------------------------------- /sondra/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_request import * 2 | from .request_processor import * -------------------------------------------------------------------------------- /sondra/api/expose.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | import io 3 | import re 4 | import inspect 5 | from sondra.exceptions import ParseError 6 | from sondra import help 7 | from functools import wraps 8 | from copy import deepcopy 9 | 10 | 11 | def expose_method(method): 12 | method.exposed = True 13 | method.slug = method.__name__.replace('_','-') 14 | return method 15 | 16 | 17 | # TODO validate input against schema. 18 | # TODO Add document processors and invoke method with properly dereferenced documents instead of keys 19 | def invoke_method(request, method): 20 | pass 21 | 22 | # TODO validate output against schema. 23 | # TODO Fix formatter to handle referencing or dereferencing documents as requested. 24 | def method_return(request, method): 25 | pass 26 | 27 | 28 | # TODO indicate that the API request should be passed as a parameter and what its name is, by default _request 29 | def accepts_request(): 30 | pass 31 | 32 | 33 | def ignore_request(): 34 | pass 35 | 36 | 37 | # TODO indicate that this method should ignore the _user parameter, even if it is authenticated. 38 | def ignore_user(): 39 | pass 40 | 41 | # TODO indicate that this method accepts a user parameter and what its name is, by default _user 42 | def accepts_user(): 43 | pass 44 | 45 | 46 | def expose_method_explicit(request_schema=None, response_schema=None, side_effects=False, title=None, description=None): 47 | request_schema = request_schema or {'type': 'null'} 48 | response_schema = response_schema or {'type': 'null'} 49 | 50 | def expose_method_decorator(func): 51 | 52 | @wraps(func) 53 | def func_wrapper(*args, **kwargs): 54 | return func(*args, **kwargs) 55 | 56 | func_wrapper.exposed = True 57 | func_wrapper.slug = func.__name__.replace('_', '-') 58 | 59 | # auto-fill request schema items based on metadata if they were not explicitly provided. 60 | req_schema = deepcopy(request_schema) 61 | if 'title' not in req_schema: 62 | req_schema['title'] = title or func.__name__ 63 | if 'description' not in req_schema: 64 | req_schema['description'] = req_schema.get('description', description or func.__doc__ or '*No description provided*') 65 | req_schema['side_effects'] = side_effects 66 | 67 | rsp_schema = deepcopy(response_schema) 68 | if 'title' not in rsp_schema: 69 | rsp_schema['title'] = title or func.__name__ 70 | if 'description' not in rsp_schema: 71 | rsp_schema['description'] = rsp_schema.get('description', description or func.__doc__ or '*No description provided*') 72 | 73 | func_wrapper.title = title or func.__name__ 74 | func_wrapper.request_schema = req_schema 75 | func_wrapper.response_schema = rsp_schema 76 | 77 | @wraps(func) 78 | def invoke(*args, request=None): 79 | func(*args, **request) 80 | 81 | func_wrapper.invoke = invoke 82 | 83 | return func_wrapper 84 | 85 | return expose_method_decorator 86 | 87 | 88 | def method_url(instance, method): 89 | return instance.url + '.' + method.slug if instance is not None else method.slug 90 | 91 | 92 | def method_schema(instance, method): 93 | id = method.slug 94 | if instance is not None: 95 | if instance: 96 | id = instance.url 97 | else: 98 | id = "*" + method.slug 99 | 100 | return { 101 | "id": id, 102 | "title": getattr(method, 'title', method.__name__), 103 | "description": method.__doc__ or "*No description provided*", 104 | "oneOf": [{"$ref": "#/definitions/method_request"}, {"$ref": "#/definitions/method_response"}], 105 | "definitions": { 106 | "method_request": method_request_schema(instance, method), 107 | "method_response": method_response_schema(instance, method) 108 | } 109 | } 110 | 111 | 112 | def method_response_schema(instance, method): 113 | if hasattr(method, 'response_schema'): 114 | return method.response_schema 115 | 116 | # parse the return schema 117 | metadata = inspect.signature(method) 118 | if metadata.return_annotation is not metadata.empty: 119 | argtype = _parse_arg(instance, metadata.return_annotation) 120 | if 'type' in argtype: 121 | if argtype['type'] in {'list', 'object'}: 122 | return argtype 123 | else: 124 | return { 125 | "type": "object", 126 | "properties": { 127 | "_": argtype 128 | } 129 | } 130 | elif "$ref" in argtype: 131 | return argtype 132 | else: 133 | return { 134 | "type": "object", 135 | "properties": { 136 | "_": argtype 137 | } 138 | } 139 | else: 140 | return {"type": "object", "description": "no return value."} 141 | 142 | 143 | def method_request_schema(instance, method): 144 | if hasattr(method, 'request_schema'): 145 | return method.request_schema 146 | 147 | required_args = [] 148 | metadata = inspect.signature(method) 149 | properties = {} 150 | 151 | for i, (name, param) in enumerate(metadata.parameters.items()): 152 | if name.startswith('_'): 153 | continue # skips parameters filled in by decorators 154 | 155 | # if i == 0: # skip the first arg. 156 | # continue 157 | 158 | schema = _parse_arg(instance, param.annotation) 159 | if param.default is not metadata.empty: 160 | schema['default'] = param.default 161 | else: 162 | required_args.append(name) 163 | properties[name] = schema 164 | 165 | ret = { 166 | "type": "object", 167 | "properties": properties 168 | } 169 | if required_args: 170 | ret['required'] = required_args 171 | return ret 172 | 173 | 174 | def _parse_arg(instance, arg): 175 | from sondra.document import Document 176 | from sondra.collection import Collection 177 | 178 | if isinstance(arg, tuple): 179 | arg, description = arg 180 | else: 181 | description = None 182 | 183 | if arg is None: 184 | return {"type": "null"} 185 | if isinstance(arg, str): 186 | arg = {"type": "string", "foreignKey": arg} 187 | elif arg is str: 188 | arg = {"type": "string"} 189 | elif arg is bytes: 190 | arg = {"type": "string", "formatters": "attachment"} 191 | elif arg is int: 192 | arg = {"type": "integer"} 193 | elif arg is float: 194 | arg = {"type": "number"} 195 | elif arg is bool: 196 | arg = {"type": "boolean"} 197 | elif arg is list: 198 | arg = {"type": "array"} 199 | elif arg is dict: 200 | arg = {"type": "object"} 201 | elif isinstance(arg, re._pattern_type): 202 | arg = {"type": "string", "pattern": arg.pattern} 203 | elif isinstance(arg, list): 204 | arg = {"type": "array", "items": _parse_arg(instance, arg[0])} 205 | elif isinstance(arg, dict): 206 | arg = {"type": "object", "properties": {k: _parse_arg(instance, v) for k, v in arg.items()}} 207 | elif issubclass(arg, Collection): 208 | arg = {"$ref": (instance.application[arg.slug].url if instance is not None else "") + ";schema"} 209 | elif issubclass(arg, Document): 210 | arg = copy(arg.schema) 211 | else: 212 | arg = {"type": ['string','boolean','integer','number','array','object'], "description": "Unspecified type arg."} 213 | 214 | if description: 215 | arg['description'] = description 216 | 217 | return arg 218 | 219 | 220 | def method_help(instance, method, out=None, initial_heading_level=0): 221 | out = out or io.StringIO() 222 | builder = help.SchemaHelpBuilder( 223 | method_schema(instance, method), 224 | out=out, 225 | initial_heading_level=0 226 | ) 227 | builder.build() 228 | builder.line() 229 | return out.getvalue() -------------------------------------------------------------------------------- /sondra/api/query_set.py: -------------------------------------------------------------------------------- 1 | import json 2 | import rethinkdb as r 3 | 4 | from sondra.exceptions import ValidationError 5 | 6 | class QuerySet(object): 7 | """ 8 | Limit the objects we are targeting in an API request 9 | """ 10 | MAX_RESULTS = 100 11 | SAFE_OPS = { 12 | 'with_fields', 13 | 'count', 14 | 'max', 15 | 'min', 16 | 'avg', 17 | 'sample', 18 | 'sum', 19 | 'distinct', 20 | 'contains', 21 | 'pluck', 22 | 'without', 23 | 'has_fields', 24 | 'order_by', 25 | 'between' 26 | } 27 | 28 | GEOSPATIAL_OPS = { 29 | 'get_intersecting', 30 | 'get_nearest', 31 | } 32 | 33 | def __init__(self, coll): 34 | self.coll = coll 35 | self.use_raw_results = False 36 | 37 | def is_restricted(self, api_arguments, objects=None): 38 | """ 39 | Determine if the user has requested specific objects or is limiting the objects by filter. 40 | 41 | :param api_arguments: 42 | :param objects: 43 | :return: 44 | """ 45 | return objects or api_arguments.get('flt', None) or api_arguments.get('geo', None) 46 | 47 | def get_query(self, api_arguments, objects=None): 48 | """ 49 | Apply all filters in turn and return a ReQL query. 50 | 51 | :param api_arguments: flt, geo, agg, start, end, limit filters 52 | :param objects: A list of object IDs. 53 | :return: 54 | """ 55 | q = self.coll.table 56 | 57 | q = self._handle_keys(api_arguments, q) 58 | q = self._handle_simple_filters(api_arguments, q) 59 | q = self._handle_spatial_filters(self.coll, api_arguments, q) 60 | q = self._apply_ordering(api_arguments, q) 61 | q = self._handle_aggregations(api_arguments, q) 62 | q = self._handle_limits(api_arguments, q) 63 | return q 64 | 65 | def __call__(self, api_arguments, objects=None): 66 | q = self.get_query(api_arguments, objects) 67 | return self.coll.q(q) 68 | 69 | def _apply_ordering(self, api_arguments, q): 70 | if 'order_by_index' in api_arguments: 71 | q = q.order_by(index=api_arguments['order_by_index']) 72 | if 'order_by' in api_arguments: 73 | if isinstance(api_arguments['order_by'], list): 74 | ordering = api_arguments['order_by'] 75 | else: 76 | ordering = (api_arguments['order_by'],) 77 | q = q.order_by(*ordering) 78 | else: 79 | q = self.coll.apply_ordering(q) 80 | 81 | return q 82 | 83 | def _handle_keys(self, api_arguments, q): 84 | if 'keys' in api_arguments: 85 | if 'index' in api_arguments: 86 | q = self.coll.table.get_all(*json.loads(api_arguments['keys']), index=api_arguments['index']) 87 | else: 88 | q = self.coll.table.get_all(*json.loads(api_arguments['keys'])) 89 | return q 90 | 91 | 92 | def _handle_simple_filters(self, api_arguments, q): 93 | # handle simple filters 94 | if 'flt' in api_arguments: 95 | flt = json.loads( 96 | api_arguments['flt']) \ 97 | if isinstance(api_arguments['flt'], str) \ 98 | else api_arguments['flt'] 99 | if isinstance(flt, dict): 100 | flt = [flt] 101 | 102 | for f in flt: 103 | default = f.get('default', False) 104 | op = f.get('op', '==') 105 | if op == '==': 106 | q = q.filter({f['lhs']: f['rhs']}, default=default) 107 | elif op == '!=': 108 | q = q.filter(r.row[f['lhs']] != f['rhs'], default=default) 109 | elif op == '<': 110 | q = q.filter(r.row[f['lhs']] < f['rhs'], default=default) 111 | elif op == '<=': 112 | q = q.filter(r.row[f['lhs']] <= f['rhs'], default=default) 113 | elif op == '>': 114 | q = q.filter(r.row[f['lhs']] > f['rhs'], default=default) 115 | elif op == '>=': 116 | q = q.filter(r.row[f['lhs']] >= f['rhs'], default=default) 117 | elif op == 'match': 118 | field = f['lhs'] 119 | pattern = f['rhs'] 120 | q = q.filter(lambda x: x[field].match(pattern), default=default) 121 | elif op == 'contains': 122 | field = f['lhs'] 123 | pattern = f['rhs'] 124 | q = q.filter(lambda x: x[field].contains(pattern)) 125 | elif op == 'has_fields': 126 | q = q.filter(lambda x: x.has_fields(f['fields']), default=default) 127 | else: 128 | raise ValidationError("Unrecognized op in filter specification.") 129 | return q 130 | 131 | def _handle_spatial_filters(self, coll, api_arguments, q): 132 | # handle geospatial queries 133 | if 'geo' in api_arguments: 134 | print("Geospatial limit") 135 | geo = json.loads( 136 | api_arguments['geo']) \ 137 | if isinstance(api_arguments['geo'], str) \ 138 | else api_arguments['geo'] 139 | 140 | geometries = [k for k in coll.document_class.specials if coll.document_class.specials[k].is_geometry] 141 | 142 | if not geometries: 143 | raise ValidationError("Requested a geometric query on a non geometric collection") 144 | if 'against' not in geo: 145 | test_property = geometries[0] 146 | elif geo['against'] not in geometries: 147 | raise KeyError('Not a valid geometry name') 148 | else: 149 | test_property = geo['against'] 150 | op = geo['op'] 151 | geom = r.geojson(geo['test']) 152 | if op not in self.GEOSPATIAL_OPS: 153 | raise ValidationError("Cannot perform non geometry op in geometry query") 154 | q = getattr(q, op)(geom, index=test_property, *geo.get('args',[]), **geo.get('kwargs', {})) 155 | return q 156 | 157 | def _handle_aggregations(self, api_arguments, q): 158 | # handle aggregation queries 159 | if 'agg' in api_arguments: 160 | op = json.loads(api_arguments['agg']) 161 | if op['op'] not in self.SAFE_OPS: 162 | raise ValidationError("Cannot perform unsafe op in GET") 163 | q = getattr(q, op['op'])(*op.get('args',[]), **op.get('kwargs', {})) 164 | self.use_raw_results = True 165 | return q 166 | 167 | def _handle_limits(self, api_arguments, q): 168 | # handle start, limit, and end 169 | if 'start' and 'end' in api_arguments: 170 | s = api_arguments['start'] 171 | e = api_arguments['end'] 172 | if e == 0: 173 | q = q.skip(s) 174 | else: 175 | q = q.slice(s, e) 176 | else: 177 | if 'start' in api_arguments: 178 | s = api_arguments['start'] 179 | q = q.skip(s) 180 | if 'limit' in api_arguments: 181 | limit = api_arguments['limit'] 182 | q = q.limit(limit) 183 | #else: 184 | # q = q.limit(self.MAX_RESULTS) 185 | return q -------------------------------------------------------------------------------- /sondra/api/request_processor.py: -------------------------------------------------------------------------------- 1 | class RequestProcessor(object): 2 | """ 3 | Request Processors are arbitrary thunks run before the request is executed. For an example, see the auth application. 4 | """ 5 | def process_api_request(self, r): 6 | return r 7 | 8 | def cleanup_after_exception(self, r, e): 9 | pass 10 | 11 | def __call__(self, r): 12 | return self.process_api_request(r) -------------------------------------------------------------------------------- /sondra/application/signals.py: -------------------------------------------------------------------------------- 1 | from blinker import signal 2 | 3 | pre_init = signal('application-pre-init') 4 | post_init = signal('application-post-init') 5 | pre_registration = signal('application-pre-registration') 6 | post_registration = signal('application-post-registration') 7 | pre_create_database = signal('application-pre-create-database') 8 | post_create_database = signal('application-post-create-database') 9 | pre_create_tables = signal('application-pre-create-tables') 10 | post_create_tables = signal('application-post-create-tables') 11 | pre_delete_database = signal('application-pre-delete-database') 12 | post_delete_database = signal('application-post-delete-database') 13 | pre_delete_tables = signal('application-pre-delete-tables') 14 | post_delete_tables = signal('application-post-delete-tables') -------------------------------------------------------------------------------- /sondra/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .application import Auth 2 | from .collections import LoggedInUsers, Users, UserCredentials, Roles 3 | from .documents import User, LoggedInUser, Credentials, Role 4 | from .request_processor import AuthRequestProcessor 5 | from .decorators import authorized_method, authenticated_method, authorization_required, authentication_required 6 | 7 | from . import decorators, application, collections, documents, request_processor -------------------------------------------------------------------------------- /sondra/auth/application.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import bcrypt 4 | import jwt 5 | 6 | from sondra.api.expose import expose_method, expose_method_explicit 7 | from sondra.application import Application 8 | from sondra.utils import utc_timestamp 9 | from .collections import Users, UserCredentials, LoggedInUsers, Roles, IssuedTokens 10 | from .decorators import authenticated_method 11 | 12 | 13 | class Auth(Application): 14 | 15 | db = 'auth' 16 | collections = ( 17 | Roles, 18 | Users, 19 | UserCredentials, 20 | LoggedInUsers, 21 | IssuedTokens 22 | ) 23 | 24 | def __init__(self, suite, name=None, expiration=None, single_login=True, valid_issuers=None, extra_claims=None, validators=None): 25 | """ 26 | A sample authentication and authorization app that uses JWT. 27 | 28 | Args: 29 | suite (sondra.suite.Suite: the suite to register the app to 30 | name (str): the name to register the app as. 31 | expiration (optional timedelta): The amount of time to 32 | single_login (boolean = True): Whether we validate just the token, or we also check to make sure that it's in a single-use registry. 33 | valid_issuers (list or set): A list of valid issuers to validate the issue claim against. 34 | extra_claims (optional dict): A dict of claim names to functions that accept a single argument, the user record and return claim content. 35 | validators (optional list): A list of functions that accept a decoded token and raise an error if the claims aren't verified. The error is passed through. 36 | """ 37 | super(Auth, self).__init__(suite, name) 38 | self.expiration = expiration 39 | self.single_login = single_login 40 | self.extra_claims = extra_claims or {} 41 | self.validators = validators or () 42 | self.valid_issuers = {self.url} 43 | if valid_issuers: 44 | self.valid_issuers.update(set(valid_issuers)) 45 | 46 | def get_expiration_claims(self): 47 | now = utc_timestamp() 48 | 49 | claims = { 50 | 'iat': int(now.timestamp()), 51 | 'nbf': int(now.timestamp()), 52 | } 53 | if self.expiration: 54 | later = now + self.expiration 55 | claims['exp'] = int(later.timestamp()) 56 | return claims 57 | 58 | @expose_method_explicit( 59 | request_schema={ 60 | "type": "object", 61 | "properties": { 62 | "username": {"type": "string"}, 63 | "password": {"type": "string"}}}, 64 | response_schema={ 65 | "type": "object", 66 | "properties": 67 | {"_": {"type": "string"}}}, 68 | side_effects=True, 69 | title='Login' 70 | ) 71 | def login(self, username: str, password: str) -> str: 72 | """Log the user in and get a JWT (JSON Web Token). 73 | 74 | Args: 75 | username (str): The username 76 | password (str): The password 77 | 78 | Returns: 79 | str: A JWT String. 80 | 81 | Raises: 82 | PermissionError: if the password is invalid or the user does not exist 83 | """ 84 | if username not in self['users']: 85 | self.log.warning("Failed login attempt by nonexistent user: {0}".format(username)) 86 | raise PermissionError("Login not valid") 87 | 88 | credentials = self['user-credentials'][username] 89 | hashed_real_password = credentials['password'].encode('utf-8') 90 | hashed_given_password = bcrypt.hashpw(password.encode('utf-8'), credentials['salt'].encode('utf-8')) 91 | if hashed_real_password == hashed_given_password: 92 | if credentials['secret'] in self['logged-in-users']: 93 | # self['logged-in-users'][credentials['secret']].delete() 94 | return self['logged-in-users'][credentials['secret']]['token'] # return the current logged in token for tihs user. Less secure but more expected. 95 | else: 96 | return self.issue(username, credentials) 97 | else: 98 | self.log.warning("Failed login attempt by registered user: {0}".format(username)) 99 | raise PermissionError("Login not valid") 100 | 101 | @authenticated_method 102 | @expose_method 103 | def logout(self, token: str, _user=None) -> bool: 104 | u = self['logged-in-users'].for_token(token) 105 | if u: 106 | u.delete() 107 | return True 108 | else: 109 | return False 110 | 111 | @authenticated_method 112 | @expose_method 113 | def renew(self, token: str, _user=None) -> str: 114 | """Renew a currently logged in user's token. 115 | 116 | Args: 117 | token (str): A JSON Web Token (JWT) that is currently valid 118 | 119 | Returns: 120 | str: A JWT String. 121 | 122 | Raises: 123 | PermissionError: if the current token is not the user's valid token. 124 | """ 125 | 126 | logged_in_user = self['logged-in-users'].for_token(token) 127 | 128 | secret = logged_in_user['secret'].encode('utf-8') 129 | claims = jwt.decode(token.encode('utf-8'), secret, issuer=self.url, verify=True) 130 | claims['iat'] = datetime.datetime.now().timestamp() 131 | claims.update(self.get_expiration_claims()) # make sure this token expires 132 | token = jwt.encode(claims, secret).decode('utf-8') 133 | 134 | if 'expires' in logged_in_user: 135 | del logged_in_user['expires'] 136 | if 'exp' in claims: 137 | logged_in_user['expires'] = claims['exp'] 138 | logged_in_user['token'] = token 139 | logged_in_user.save(conflict='replace') 140 | return token 141 | 142 | def issue(self, username, credentials): 143 | """Issue a JWT for the given user. 144 | 145 | Args: 146 | username (str): The username to issue the ticket to. 147 | credentials (Credentials): The user's credentials object 148 | 149 | Returns: 150 | str: A JWT String. 151 | 152 | """ 153 | claims = { 154 | 'iss': self.url, 155 | 'user': username, 156 | } 157 | if self.extra_claims: 158 | user = self['users'][username] 159 | for claim_name, claim_function in self.extra_claims.items(): 160 | claims[claim_name] = claim_function(user) 161 | 162 | if 'extraClaims' in credentials: 163 | claims.update(credentials['extraClaims']) 164 | 165 | claims.update(self.get_expiration_claims()) # make sure this token expires and that extra claims can't override it. 166 | token = jwt.encode(claims, credentials['secret']).decode('utf-8') 167 | 168 | if self.single_login: 169 | logged_in_user = self['logged-in-users'].doc({ 170 | "token": token, 171 | "secret": credentials['secret'] 172 | }) 173 | 174 | if 'exp' in claims: 175 | logged_in_user['exp'] = claims['exp'] 176 | 177 | self['logged-in-users'].save(logged_in_user, conflict='replace') 178 | else: 179 | issued_token = { 180 | 'user': username, 181 | 'token': token, 182 | } 183 | if 'exp' in claims: 184 | issued_token['exp'] = datetime.datetime.utcfromtimestamp(claims['exp']) 185 | 186 | self['issued-tokens'].create(issued_token) 187 | return token 188 | 189 | def check(self, token, **claims): 190 | """Check a user's JWT for validity and against any extra claims. 191 | 192 | Args: 193 | token (str): the JWT token to check against. 194 | **claims: a dictionary of extra claims to check 195 | Returns: 196 | User: the decoded auth token. 197 | Raisees: 198 | DecodeError: if the JWT token is out of date, not issued by this authority, or otherwise invalid. 199 | PermissionError: if a claim is not present, or if claims differ. 200 | """ 201 | 202 | if self.single_login: 203 | logged_in_user = self['logged-in-users'].for_token(token) 204 | if logged_in_user is None: 205 | raise PermissionError("Token not present and single login has been configured by the application owner.") 206 | else: 207 | logged_in_user = self['issued-tokens'][token]['user'] 208 | 209 | secret = logged_in_user['secret'].encode('utf-8') 210 | decoded = jwt.decode(token.encode('utf-8'), secret, issuer=self.url, verify=True) 211 | for name, value in claims.items(): 212 | if name not in decoded: 213 | raise PermissionError("Claim not present in {0}: {1}".format(decoded['user'], name)) 214 | elif decoded[name] != value: 215 | raise PermissionError("Claims differ for {0}: {1}".format(decoded['user'], name)) 216 | return self['users'][decoded['user']], decoded # WAS: self['users'][decoded['user']] 217 | 218 | 219 | @expose_method_explicit( 220 | request_schema={ 221 | "type": "object", 222 | "required": ["token"], 223 | "properties": { 224 | "token": {"type": "string"} 225 | }}, 226 | response_schema={ 227 | "type": "object", 228 | "properties": 229 | {"_": {"type": "boolean"}}}, 230 | side_effects=False, 231 | title='Check', 232 | description="Check to make sure the token is held in the token store." 233 | ) 234 | def verify(self, token): 235 | try: 236 | self.check(token) 237 | except PermissionError as e: 238 | return False 239 | 240 | return True 241 | 242 | -------------------------------------------------------------------------------- /sondra/auth/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | def authorized_method(o): 4 | o.authentication_required = o.slug 5 | o.authorization_required = o.slug 6 | return o 7 | 8 | 9 | def authenticated_method(o): 10 | o.authentication_required = o.slug 11 | return o 12 | 13 | 14 | def anonymous_method(o): 15 | o.authentication_required = False 16 | o.authorization_required = False 17 | return o 18 | 19 | 20 | class authorization_required(object): 21 | """ 22 | Class decorator for documents, collections, applications that require authorization to access. 23 | 24 | Adds authentication_required and authorization_required attributes to the decorated class at a minimum. It is also 25 | possible to specify a filter function that filters documents based on a user's authentication information and 26 | each individual document. This is achieved via rethinkdb's filter API and must use rethinkdb predicates. This should 27 | be a nested function:: 28 | 29 | def example_filter_function(auth_info, method): 30 | username = auth_info.username 31 | permission = 'can_' + method 32 | return lambda(doc): \ 33 | doc[permission].contains(username) 34 | 35 | Args: 36 | *protected (str): Items should be 'read', 'write', or the name of a method 37 | filter_function (function): Should be a function that accepts a decoded auth token and an access method, then 38 | returns another function. The second function should accept a document instance and return True or False 39 | whether the user has access to that document. 40 | """ 41 | def __init__(self, *protected, filter_function=None): 42 | self.protected = protected 43 | self.filter_function = filter_function 44 | 45 | def __call__(self, cls): 46 | cls.authentication_required = self.protected 47 | cls.authorization_required = self.protected 48 | if self.filter_function: 49 | cls.document_level_authorization = True 50 | cls.authorization_filter = self.filter_function 51 | else: 52 | cls.document_level_authorization = False 53 | 54 | return cls 55 | 56 | 57 | class authentication_required(object): 58 | def __init__(self, *protected): 59 | self.protected = protected 60 | 61 | def __call__(self, cls): 62 | cls.authentication_required = self.protected 63 | return cls 64 | -------------------------------------------------------------------------------- /sondra/auth/documents.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import operator 3 | 4 | from sondra.api.expose import expose_method 5 | from sondra.application import Application 6 | from sondra.auth.decorators import authorized_method 7 | from sondra.collection import Collection 8 | from sondra.document import Document 9 | from sondra.document.processors import SlugPropertyProcessor 10 | from sondra.schema import S 11 | 12 | 13 | class Role(Document): 14 | template = '${title}' 15 | schema = { 16 | "type": "object", 17 | "properties": { 18 | "title": {"type": "string"}, 19 | "slug": {"type": "string"}, 20 | "description": {"type": "string"}, 21 | "permissions": {"type": "array", "items": {"$ref": "#/definitions/permission"}} 22 | } 23 | } 24 | definitions = { 25 | "permission": { 26 | "type": "object", 27 | "properties": { 28 | "application": {"type": "string"}, 29 | "collection": {"type": "string"}, 30 | "document": {"type": "string"}, 31 | "allowed": { 32 | "type": "array", 33 | "items": {"type": "string"}, 34 | "description": "Should be one of 'read','write','add','delete' or the slugged-name of a " 35 | "(application/collection/document) method" 36 | } 37 | } 38 | } 39 | } 40 | processors = [ 41 | SlugPropertyProcessor('title') 42 | ] 43 | 44 | def authorizes(self, value, perm=None): 45 | if isinstance(value, Application): 46 | application = value.slug 47 | for permission in self.get('permissions', []): 48 | if permission.get('application', None) == application: 49 | return perm in permission['allowed'] 50 | else: 51 | return False 52 | 53 | elif isinstance(value, Collection): 54 | collection = value.slug 55 | application = value.application.slug 56 | for permission in self.get('permissions', []): 57 | if permission.get('collection', None) == collection and \ 58 | permission.get('application', None) == application: 59 | return perm in permission['allowed'] 60 | else: 61 | return False 62 | 63 | elif isinstance(value, Document): 64 | document = value.slug 65 | collection = value.collection.slug 66 | application = value.application.slug 67 | for permission in self.get('permissions', []): 68 | if permission.get('collection', None) == collection and \ 69 | permission.get('application', None) == application and \ 70 | permission.get('document', None) == document: 71 | return perm in permission['allowed'] 72 | else: 73 | return False 74 | 75 | else: 76 | return False 77 | 78 | 79 | class User(Document): 80 | """A basic, but fairly complete system user record""" 81 | active_by_default = True 82 | template = "${username}" 83 | 84 | schema = { 85 | 'type': 'object', 86 | 'required': ['email'], 87 | 'properties': S.props( 88 | ('username', { 89 | 'title': 'Username', 90 | 'type': 'string', 91 | 'description': 'The user\'s username', 92 | }), 93 | ('email', { 94 | 'title': 'Email', 95 | 'type': 'string', 96 | 'description': 'The user\'s email address', 97 | 'format': 'email', 98 | 'pattern': '^[^@]+@[^@]+\.[^@]+$' 99 | }), 100 | ('email_verified', { 101 | 'title': 'Email Verified', 102 | 'type': 'boolean', 103 | 'description': 'Whether or not this email address has been verified', 104 | }), 105 | ('picture', S.image(description='A URL resource of a photograph')), 106 | ('family_name', { 107 | 'title': 'Family Name', 108 | 'type': 'string', 109 | 'description': 'The user\'s family name', 110 | }), 111 | ('given_name', { 112 | 'title': 'Given Name', 113 | 'type': 'string', 114 | 'description': 'The user\'s family name', 115 | }), 116 | ('names', { 117 | 'title': 'Other Names', 118 | 'type': 'array', 119 | 'items': {'type': 'string'}, 120 | 'description': 'A list of names that go between the given name and the family name.', 121 | }), 122 | ('locale', { 123 | 'title': 'Default Language', 124 | 'type': 'string', 125 | 'description': "The user's locale. Default is en-US", 126 | 'default': 'en-US' 127 | }), 128 | ('active', { 129 | 'title': 'Active', 130 | 'type': 'boolean', 131 | 'description': 'Whether or not the user is currently able to log into the system.', 132 | 'default': active_by_default 133 | }), 134 | ('admin', { 135 | 'title': 'Administrator', 136 | 'type': 'boolean', 137 | 'description': 'If true, this user can access all methods of all APIs.', 138 | 'default': False 139 | }), 140 | ('created', { 141 | 'title': 'Created', 142 | 'format': 'date-time', 143 | 'type': 'string', 144 | 'description': 'The timestamp this user was created', 145 | }), 146 | ("roles", { 147 | 'title': 'Roles', 148 | "type": "array", 149 | "items": S.fk('api', 'auth', 'roles'), 150 | "description": "Roles that have been granted to this user", 151 | }), 152 | ("dob", { 153 | "title": "Date of Birth", 154 | "type": "string", 155 | "format": "date-time", 156 | "description": "The user's birthday" 157 | }) 158 | ) 159 | } 160 | 161 | @expose_method 162 | def permissions(self) -> [dict]: 163 | return functools.reduce(operator.add, [role['permissions'] for role in self.fetch('roles')], []) 164 | 165 | @expose_method 166 | def confirm_email(self, confirmation_code: str) -> bool: 167 | confirmed = self.application['credentials'][self.url]['confirmation_code'] == confirmation_code 168 | self['confirmed'] = self['confirmed'] or confirmed 169 | self.save() 170 | return self['confirmed'] 171 | 172 | @authorized_method 173 | @expose_method 174 | def send_confirmation_email(self, _user=None) -> None: 175 | raise NotImplemented() 176 | 177 | def __str__(self): 178 | return self['username'] 179 | 180 | 181 | class Credentials(Document): 182 | """A set of credentials for a user""" 183 | schema = { 184 | 'type': 'object', 185 | 'required': ['password', 'salt', 'secret'], 186 | 'properties': { 187 | 'user': S.fk('api', 'auth', 'users'), 188 | 'password': { 189 | 'type': 'string', 190 | 'description': "The user's (encrypted) password." 191 | }, 192 | 'salt': { 193 | 'type': 'string', 194 | 'description': "The salt applied to the password" 195 | }, 196 | 'secret': { 197 | 'type': 'string', 198 | 'description': "The user's secret key, used in JWT auth" 199 | }, 200 | 'jwtClaims': { 201 | 'type': 'object', 202 | 'description': "Any additional claims to add to a user's JSON Web Token before encoding." 203 | }, 204 | 'confirmation_code': { 205 | 'type': 'string', 206 | 'description': "A generated code that confirms a user's email" 207 | } 208 | } 209 | } 210 | 211 | 212 | class LoggedInUser(Document): 213 | schema = { 214 | "type": "object", 215 | "properties": { 216 | 'token': {"type": "string"}, 217 | "secret": {"type": "string"}, 218 | "expires": {"type": "string"} 219 | } 220 | } 221 | 222 | class IssuedToken(Document): 223 | schema = S.object( 224 | properties=S.props( 225 | ('token', S.string()), 226 | ('user', S.fk('auth','users')), 227 | ('exp', S.datetime()), 228 | ) 229 | ) 230 | -------------------------------------------------------------------------------- /sondra/auth/request_processor.py: -------------------------------------------------------------------------------- 1 | from sondra.api.request_processor import RequestProcessor 2 | from sondra.suite import Suite 3 | from sondra.document import Document 4 | from sondra.collection import Collection 5 | from sondra.application import Application 6 | 7 | class AuthRequestProcessor(RequestProcessor): 8 | """APIRequest processor makes sure that a user is authorized to perform an operation""" 9 | 10 | def authentication_requirement(self, target, permission): 11 | """Check the target to see if it or any of its 'parents' require authentication.""" 12 | if isinstance(target, tuple): 13 | req = getattr(target[1], 'authentication_required', None) 14 | if req is False: 15 | return None 16 | elif req is not None: 17 | return target[0] # return the object the method is bound to, as this is what permissions are set upon 18 | else: 19 | return self.authentication_requirement(target[0], 'write') # assume all methods are write-dangerous 20 | elif permission in getattr(target, 'authentication_required', []): 21 | return target 22 | elif isinstance(target, Document): 23 | return self.authentication_requirement(target.collection, permission) 24 | elif isinstance(target, Collection): 25 | return self.authentication_requirement(target.application, permission) 26 | elif isinstance(target, Application): 27 | return self.authentication_requirement(target.suite, permission) 28 | else: 29 | return None 30 | 31 | def authorization_requirement(self, target, permission): 32 | """Check the target to see if it or any of its 'parents' require authorization.""" 33 | if isinstance(target, tuple): 34 | req = getattr(target[1], 'authorization_required', None) 35 | if req is False: 36 | return None 37 | elif req is not None: 38 | return target[0] # return the object the method is bound to, as this is what permissions are set upon 39 | else: 40 | return self.authorization_requirement(target[0], 'write') # assume all methods are write-dangerous 41 | elif permission in getattr(target, 'authorization_required', []): 42 | return target 43 | elif isinstance(target, Document): 44 | return self.authorization_requirement(target.collection, permission) 45 | elif isinstance(target, Collection): 46 | return self.authorization_requirement(target.application, permission) 47 | elif isinstance(target, Application): 48 | return self.authorization_requirement(target.suite, permission) 49 | else: 50 | return None 51 | 52 | def process_api_request(self, request): 53 | reference = request.reference 54 | if reference.kind == 'subdocument': 55 | _, value, _, _ = reference.value 56 | else: 57 | value = reference.value 58 | 59 | permission_name = self._get_permission_name(request) 60 | authentication_target = self.authentication_requirement(value, permission_name) 61 | authorization_target = self.authorization_requirement(value, permission_name) 62 | 63 | if authentication_target is None: # authentication is not required 64 | return request 65 | if reference.format == 'schema': # always allow schema calls 66 | return request 67 | if reference.format == 'help': # always allow help calls 68 | return request 69 | 70 | # Check to see if the user has passed a JWT 71 | auth_token = request.api_arguments.get('_auth', None) # if the user passed it as a parameter 72 | print(auth_token) 73 | if not auth_token: # maybe the user passed it as a header 74 | bearer = request.headers.get('Authorization', None) 75 | if bearer: 76 | auth_token = bearer[7:] # skip "Bearer " 77 | 78 | if auth_token: # check which user the token belongs to; that is the request's user 79 | user, decoded_token = request.suite['auth'].check(auth_token) 80 | request.user = user 81 | else: 82 | request.user = None 83 | decoded_token = None 84 | user = None 85 | 86 | if ((authentication_target is not None) or (authorization_target is not None)) \ 87 | and (not user): 88 | print("Permission error!!!!!") 89 | raise PermissionError("Target {url} requires authentication or authorization, but user is anonymous".format(url=reference.url)) 90 | if user and user['admin']: # allow the superuser unfettered access 91 | return request 92 | if authorization_target is None: # we've authenticated, that's all we need. 93 | return request 94 | 95 | filter_function = getattr(authorization_target, 'document_level_filter_function', None) 96 | if filter_function: 97 | flt = filter_function(user, decoded_token, permission_name) 98 | request.additional_filters.append(flt) 99 | 100 | if request.reference.kind in {'document', 'subdocument'}: 101 | document_auth_function = getattr(value, 'authorize', None) 102 | if document_auth_function: 103 | if not document_auth_function(user, decoded_token, permission_name): 104 | msg = "Permission '{name}' denied for '{user}' accessing '{url}'".format( 105 | user=user, 106 | url=reference.url, 107 | name=permission_name 108 | ) 109 | raise PermissionError(msg) 110 | return request 111 | # for role in user.fetch('roles'): # check each role. return at the first successful authorization. 112 | # if role.authorizes(authorization_target, permission_name): 113 | # return request 114 | # 115 | # else: # we made it through all the user's roles and none authorized access. 116 | # msg = "Permission '{name}' denied for '{user}' accessing '{url}'".format( 117 | # user=user, 118 | # url=reference.url, 119 | # name=permission_name 120 | # ) 121 | # request.suite.log.error(msg) 122 | # raise PermissionError(msg) 123 | 124 | @staticmethod 125 | def _get_permission_name(request): 126 | if request.reference.kind.endswith('method'): 127 | return request.reference.value[1].slug 128 | if request.reference.format in ('help','schema'): 129 | return 'meta' 130 | if request.request_method == 'GET': 131 | return 'read' 132 | elif request.request_method == 'POST': 133 | return 'write' 134 | elif request.request_method == 'PUT': 135 | return 'write' 136 | elif request.request_method == 'PATCH': 137 | return 'write' 138 | elif request.request_method == 'DELETE': 139 | return 'write' 140 | else: 141 | raise ValueError("request method is not GET, POST, PUT, PATCH, or DELETE") -------------------------------------------------------------------------------- /sondra/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Python client that gives symmetry to the way all APIs are called. This allows you to make lookups and method calls 3 | in the same way on remote services as you would on local collections, also enabling you to authenticate. 4 | 5 | Currently very simple, but at least supports authentication. 6 | """ 7 | 8 | import requests 9 | import json 10 | 11 | class Client(object): 12 | def __init__(self, suite, url, auth_app="auth", auth=None): 13 | self._suite = suite 14 | self._log = suite.log 15 | self._apps = {} 16 | self._auth_token = None 17 | 18 | self.url = url 19 | self.auth_app = auth_app 20 | if auth: 21 | self.authorize(*auth) 22 | 23 | self._fetch_schema() 24 | 25 | @property 26 | def headers(self): 27 | return {} if not self._auth_token else {"Authorization", self._auth_token} 28 | 29 | def authorize(self, username, password): 30 | result = requests.post("{url}/{auth_app}.login".format(url=self.url, auth_app=self.auth_app), {"username": username, "password": password}) 31 | if result.ok: 32 | self._log.info("Authorized API {url} with {username}".format(url=self.url, username=username)) 33 | self._auth_token = result.text 34 | else: 35 | result.raise_for_status() 36 | 37 | def _fetch_schema(self): 38 | result = requests.get("{url};schema".format(url=self.url), headers=self.headers) 39 | if result.ok: 40 | schema = result.json() 41 | for app, url in schema['applications'].items(): 42 | self._apps[app] = ApplicationClient(self, url) 43 | 44 | def __getitem__(self, key): 45 | return self._apps[key] 46 | 47 | 48 | class MethodClient(object): 49 | def __init__(self, client, url, method_name): 50 | self.url = url + "." + method_name 51 | self.client = client 52 | self._fetch_schema() 53 | 54 | def _fetch_schema(self): 55 | result = requests.get("{url};schema".format(url=self.url), headers=self.client.headers) 56 | if result.ok: 57 | self.schema = result.json() 58 | else: 59 | result.raise_for_status() 60 | 61 | def __call__(self, *args, **kwargs): 62 | result = requests.post("url;json".format(url=self.url), headers=self.client.headers, data=json.dumps(kwargs)) 63 | if result.ok: 64 | return result.json() 65 | else: 66 | result.raise_for_status() 67 | 68 | 69 | class ApplicationClient(object): 70 | def __init__(self, client, url): 71 | self.collections = {} 72 | self.methods = {} 73 | self.client = client 74 | self.url = url 75 | self._fetch_schema() 76 | 77 | def _fetch_schema(self): 78 | result = requests.get("{url};schema".format(url=self.url), headers=self.client.headers) 79 | if result.ok: 80 | self.schema = result.json() 81 | for collection, url in self.schema['collections'].items(): 82 | self.collections[collection] = CollectionClient(self, url) 83 | for method in self.schema['methods']: 84 | self.methods[method] = MethodClient(self.client, self.url, method) 85 | else: 86 | result.raise_for_status() 87 | 88 | def __getitem__(self, item): 89 | return self.collections[item] 90 | 91 | def __getattr__(self, item): 92 | """If the item is in the application's methods dictionary, return a methodcall""" 93 | if item not in self.methods: 94 | raise AttributeError(item) 95 | 96 | return self.methods[item] 97 | 98 | 99 | class CollectionClient(object): 100 | def __init__(self, application, url): 101 | self.client = application.client 102 | self.methods = {} 103 | self.document_methods = {} 104 | self.application = application 105 | self.url = url 106 | 107 | self._fetch_schema() 108 | 109 | def _fetch_schema(self): 110 | result = requests.get("{url};schema".format(url=self.url), headers=self.client.headers) 111 | if result.ok: 112 | self.schema = result.json() 113 | for method in self.schema['methods']: 114 | self.methods[method] = MethodClient(self.client, self.url, method) 115 | for method in self.schema['document_methods']: 116 | self.document_methods[method] = MethodClient(self.client, self.url, method) 117 | else: 118 | result.raise_for_status() 119 | 120 | def __getitem__(self, item): 121 | return DocumentClient(self, item) 122 | 123 | def __setitem__(self, key, value): 124 | doc = DocumentClient(self, key, src=value) 125 | doc.save() 126 | 127 | def __delitem__(self, key): 128 | doc = DocumentClient(self, key) 129 | doc.delete() 130 | 131 | def __getattr__(self, item): 132 | if item not in self.methods: 133 | raise AttributeError(item) 134 | 135 | return self.methods[item] 136 | 137 | def append(self, value): 138 | result = requests.post("{url};json".format(url=self.url), headers=self.client.headers, data=json.dumps(value)) 139 | if result.ok: 140 | return result.json() 141 | else: 142 | result.raise_for_status() 143 | 144 | def query(self, **query): 145 | result = requests.get("{url};json".format(url=self.url), headers=self.client.headers, data=query) 146 | if result.ok: 147 | return [DocumentClient(self, r['id'], r) for r in result.json()] 148 | else: 149 | result.raise_for_status() 150 | 151 | 152 | class DocumentClient(object): 153 | def __init__(self, collection, key, src=None): 154 | self.client = collection.client 155 | self.methods = collection.document_methods 156 | self.application = collection.application 157 | self.collection = collection 158 | self.url = self.collection.url = "/" + key 159 | self.key = key 160 | self.obj = src 161 | 162 | def fetch(self): 163 | result = requests.get("{url};json".format(url=self.url), headers=self.client.headers) 164 | if result.ok: 165 | self.obj = result 166 | else: 167 | result.raise_for_status() 168 | 169 | def __getitem__(self, item): 170 | if not self.obj: 171 | self.fetch() 172 | 173 | return self.obj[item] 174 | 175 | def __getattr__(self, item): 176 | if item not in self.methods: 177 | raise AttributeError(item) 178 | 179 | return self.methods[item] 180 | 181 | def save(self): 182 | result = requests.put("{url};json".format(url=self.url), headers=self.client.headers, data=json.dumps(self.obj)) 183 | if result.ok: 184 | return result.json() 185 | else: 186 | result.raise_for_status() 187 | 188 | def delete(self): 189 | result = requests.delete("{url};json".format(url=self.url), headers=self.client.headers) 190 | if result.ok: 191 | return result.json() 192 | else: 193 | result.raise_for_status() 194 | 195 | 196 | -------------------------------------------------------------------------------- /sondra/collection/query_set.py: -------------------------------------------------------------------------------- 1 | class QWrapper(object): 2 | """Wraps a rethinkdb query so that we can return instances when we want to and not use the raw interface""" 3 | def __init__(self, flt, name): 4 | self.name = name 5 | self.flt = flt 6 | 7 | def __call__(self, *args, **kwargs): 8 | res = getattr(self.flt.query, self.name)(*args, **kwargs) 9 | self.flt.query = res 10 | return self.flt 11 | 12 | 13 | class QuerySet(object): 14 | """Wraps a rethinkdb query so that we can return instances when we want to and not use the raw interface""" 15 | def __init__(self, coll): 16 | self.coll = coll 17 | self.query = self.coll.table 18 | self.result = None 19 | 20 | def __getattribute__(self, name): 21 | if name.startswith('__') or name in { 'query', 'coll', 'result', 'first', 'drop', 'pop' }: 22 | return object.__getattribute__(self, name) 23 | else: 24 | return QWrapper(self, name) 25 | 26 | def __enter__(self): 27 | return self 28 | 29 | def __exit__(self, *args): 30 | pass 31 | 32 | def __call__(self): 33 | return self.coll.q(self.query) 34 | 35 | def __iter__(self): 36 | return self.coll.q(self.query) 37 | 38 | def __bool__(self): 39 | return len(self) > 0 40 | 41 | def __len__(self): 42 | return self.query.count().run(self.coll.application.connection) 43 | 44 | def drop(self): 45 | """ 46 | Delete documents one at a time for safety and signal processing. 47 | """ 48 | for d in self: 49 | d.delete() 50 | 51 | def pop(self): 52 | """ 53 | Same as drop, but yields each document as part of a generator before deleting. 54 | 55 | Yields: 56 | A Document object for every document that will be deleted 57 | """ 58 | for d in self: 59 | yield d 60 | d.delete() 61 | 62 | def first(self): 63 | """ 64 | Return the first result of the query. 65 | 66 | Returns: 67 | The first result, or None if the query is empty. 68 | """ 69 | try: 70 | return next(iter(self)) 71 | except StopIteration: 72 | return None 73 | 74 | 75 | class RawQuerySet(object): 76 | def __init__(self, coll, cls=None): 77 | self.coll = coll 78 | self.query = self.coll.table 79 | self.result = None 80 | self.cls = cls 81 | 82 | def __getattribute__(self, name): 83 | if name.startswith('__') or name in { 'query', 'coll', 'result', 'clear', 'cls', 'first'}: 84 | return object.__getattribute__(self, name) 85 | else: 86 | return QWrapper(self, name) 87 | 88 | def __enter__(self): 89 | return self 90 | 91 | def __exit__(self, *args): 92 | pass 93 | 94 | def __call__(self): 95 | return self.query.run(self.coll.application.connection) 96 | 97 | def __iter__(self): 98 | if self.cls: 99 | return (self.cls(d) for d in self.query.run(self.coll.application.connection)) 100 | else: 101 | return self.query.run(self.coll.application.connection) 102 | 103 | def __bool__(self): 104 | return len(self) > 0 105 | 106 | def __len__(self): 107 | return self.query.count().run(self.coll.application.connection) 108 | 109 | def first(self): 110 | try: 111 | return next(iter(self)) 112 | except StopIteration: 113 | return None -------------------------------------------------------------------------------- /sondra/collection/signals.py: -------------------------------------------------------------------------------- 1 | from blinker import signal 2 | 3 | 4 | pre_init = signal('collection-pre-init') 5 | post_init = signal('collection-post-init') 6 | pre_validation = signal('collection-pre-validation') 7 | post_validation = signal('collection-post-validation') 8 | pre_table_creation = signal('collection-pre-table-create') 9 | pre_table_clear = signal('collection-pre-table-clear') 10 | post_table_clear = signal('collection-post-table-clear') 11 | post_table_creation = signal('collection-post-table-create') 12 | pre_table_deletion = signal('collection-pre-table-deletion') 13 | post_table_deletion = signal('collection-post-table-deletion') 14 | before_validation = signal('collection-before-validation') 15 | after_validation = signal('collection-after-validation') 16 | 17 | -------------------------------------------------------------------------------- /sondra/commands/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'jeff' 2 | -------------------------------------------------------------------------------- /sondra/commands/admin.py: -------------------------------------------------------------------------------- 1 | import click 2 | import importlib 3 | import json 4 | from sondra.auth import Auth 5 | 6 | suite = None 7 | auth = None 8 | 9 | @click.group() 10 | @click.option("--config", "-c", envvar="SONDRA_SUITE") 11 | def cli(config): 12 | global suite, auth 13 | modulename, classname = config.rsplit(".", 1) 14 | mod = importlib.import_module(modulename) 15 | suite = getattr(mod, classname)() 16 | auth = Auth(suite) 17 | 18 | 19 | @cli.command() 20 | @click.option("--username", "-u") 21 | @click.option("--email", "-e", prompt=True) 22 | @click.option("--password", "-p", prompt=True) 23 | @click.option("--givenName", "-f", prompt="First Name") 24 | @click.option("--familyName", "-l", prompt="Last Name") 25 | @click.option('--locale', default='en-US') 26 | def add_user(username, email, givenname, familyname, password, locale): 27 | auth['users'].create_user( 28 | username, password, email=email, given_name=givenname, family_name=familyname, locale=locale) 29 | 30 | @cli.command() 31 | @click.option("--username", "-u") 32 | @click.option("--email", "-e", prompt=True) 33 | @click.option("--password", "-p", prompt=True) 34 | @click.option("--givenName", "-f", prompt="First Name", default=None) 35 | @click.option("--familyName", "-l", prompt="Last Name", default=None) 36 | @click.option('--locale', default='en-US') 37 | def add_superuser(username, email, givenName, familyName, password, locale): 38 | auth['users'].create_user( 39 | username, password, email=email, given_name=givenName, family_name=familyName, locale=locale) 40 | 41 | new_user = auth['users'][username] 42 | new_user['admin'] = True 43 | new_user.save() 44 | 45 | 46 | @cli.command() 47 | @click.argument('username') 48 | @click.argument('json_value') 49 | def update_user(username, json_value): 50 | user = auth['users'][username] 51 | updates = json.loads(json_value) 52 | for k, v in updates.items(): 53 | user[k] = v 54 | user.save() 55 | 56 | 57 | @cli.command() 58 | @click.argument('username') 59 | def delete_user(username): 60 | del auth['users'][username] 61 | 62 | # 63 | # @cli.command() 64 | # @click.argument('username') 65 | # @click.argument('roles', nargs=-1) 66 | # def add_user_roles(username, roles): 67 | # u = auth['users'][username] 68 | # old_roles = set(u['roles']) 69 | # new_roles = old_roles.union(set(roles)) 70 | # u['roles'] = list(new_roles) 71 | # u.save() 72 | # 73 | # 74 | # @cli.command() 75 | # @click.argument('username') 76 | # @click.argument('roles', nargs=-1) 77 | # def delete_user_roles(username, roles): 78 | # u = auth['users'][username] 79 | # old_roles = set(u['roles']) 80 | # new_roles = old_roles.difference(set(roles)) 81 | # u['roles'] = list(new_roles) 82 | # u.save() 83 | # 84 | # 85 | # 86 | # @cli.command() 87 | # @click.argument('name') 88 | # @click.argument('includes', nargs=-1) 89 | # def create_role(name, includes): 90 | # auth['roles'].create({ 91 | # "name": name, 92 | # "includes": includes 93 | # }) 94 | # 95 | # 96 | # @cli.command() 97 | # @click.option("--role", "-r") 98 | # @click.option("--application", "-a") 99 | # @click.option("--collection", "-c", default=None) 100 | # @click.option("--document", "-c", default=None) 101 | # @click.option("--method", "-m", default=None) 102 | # @click.option("--read", default=True) 103 | # @click.option("--add", default=False) 104 | # @click.option("--update", default=False) 105 | # @click.option("--delete", default=False) 106 | # @click.option("--schema", default=False) 107 | # @click.option("--help", default=False) 108 | # def grant(role, application, collection, document, method, read, update, add, delete, schema, help): 109 | # r = auth['roles'][role] 110 | # 111 | # app = suite[application] 112 | # coll = app[collection] if collection else None 113 | # doc = coll[document] if document else None 114 | # 115 | # if method: 116 | # r.grant(application=app, collection=coll, document=doc, method=method) 117 | # if read or add or update or delete: 118 | # r.grant(application=app, collection=coll, document=doc, action='help') 119 | # r.grant(application=app, collection=coll, document=doc, action='schema') 120 | # if read: 121 | # r.grant(application=app, collection=coll, document=doc, action='read') 122 | # if add: 123 | # r.grant(application=app, collection=coll, document=doc, action='add') 124 | # if update: 125 | # r.grant(application=app, collection=coll, document=doc, action='update') 126 | # if delete: 127 | # r.grant(application=app, collection=coll, document=doc, action='delete') 128 | # if schema: 129 | # r.grant(application=app, collection=coll, document=doc, action='schema') 130 | # if help: 131 | # r.grant(application=app, collection=coll, document=doc, action='help') 132 | # 133 | # 134 | # @cli.command() 135 | # @click.option("--role", "-r") 136 | # @click.option("--application", "-a") 137 | # @click.option("--collection", "-c", default=None) 138 | # @click.option("--document", "-c", default=None) 139 | # @click.option("--method", "-m", default=None) 140 | # @click.option("--read", default=True) 141 | # @click.option("--add", default=False) 142 | # @click.option("--update", default=False) 143 | # @click.option("--delete", default=False) 144 | # @click.option("--schema", default=False) 145 | # @click.option("--help", default=False) 146 | # def revoke(role, application, collection, document, method, read, update, add, delete, schema, help): 147 | # r = auth['roles'][role] 148 | # 149 | # app = suite[application] 150 | # coll = app[collection] if collection else None 151 | # doc = coll[document] if document else None 152 | # 153 | # if method: 154 | # r.revoke(application=app, collection=coll, document=doc, method=method) 155 | # if read or add or update or delete: 156 | # r.revoke(application=app, collection=coll, document=doc, action='help') 157 | # r.revoke(application=app, collection=coll, document=doc, action='schema') 158 | # if read: 159 | # r.revoke(application=app, collection=coll, document=doc, action='read') 160 | # if add: 161 | # r.revoke(application=app, collection=coll, document=doc, action='add') 162 | # if update: 163 | # r.revoke(application=app, collection=coll, document=doc, action='update') 164 | # if delete: 165 | # r.revoke(application=app, collection=coll, document=doc, action='delete') 166 | # if schema: 167 | # r.revoke(application=app, collection=coll, document=doc, action='schema') 168 | # if help: 169 | # r.revoke(application=app, collection=coll, document=doc, action='help') 170 | # 171 | # 172 | # @cli.command() 173 | # @click.option("--role", "-r") 174 | # @click.option("--application", "-a") 175 | # @click.option("--collection", "-c", default=None) 176 | # @click.option("--document", "-c", default=None) 177 | # @click.option("--method", "-m", default=None) 178 | # @click.option("--read", default=True) 179 | # @click.option("--add", default=False) 180 | # @click.option("--update", default=False) 181 | # @click.option("--delete", default=False) 182 | # @click.option("--schema", default=False) 183 | # @click.option("--help", default=False) 184 | # def inherit(role, application, collection, document, method, read, update, add, delete, schema, help): 185 | # r = auth['roles'][role] 186 | # 187 | # app = suite[application] 188 | # coll = app[collection] if collection else None 189 | # doc = coll[document] if document else None 190 | # 191 | # if method: 192 | # r.inherit(application=app, collection=coll, document=doc, method=method) 193 | # if read or add or update or delete: 194 | # r.inherit(application=app, collection=coll, document=doc, action='help') 195 | # r.inherit(application=app, collection=coll, document=doc, action='schema') 196 | # if read: 197 | # r.inherit(application=app, collection=coll, document=doc, action='read') 198 | # if add: 199 | # r.inherit(application=app, collection=coll, document=doc, action='add') 200 | # if update: 201 | # r.inherit(application=app, collection=coll, document=doc, action='update') 202 | # if delete: 203 | # r.inherit(application=app, collection=coll, document=doc, action='delete') 204 | # if schema: 205 | # r.inherit(application=app, collection=coll, document=doc, action='schema') 206 | # if help: 207 | # r.inherit(application=app, collection=coll, document=doc, action='help') 208 | 209 | 210 | if __name__=='__main__': 211 | cli() -------------------------------------------------------------------------------- /sondra/commands/document_collections.py: -------------------------------------------------------------------------------- 1 | import click 2 | import io 3 | from docutils.core import publish_string 4 | import importlib 5 | import os.path 6 | from sondra.help import SchemaHelpBuilder 7 | import logging 8 | from sphinxcontrib import napoleon 9 | 10 | logging.basicConfig(level=logging.DEBUG) 11 | 12 | @click.group() 13 | def cli(): 14 | pass 15 | 16 | @cli.command() 17 | @click.option("--formatters", '-f', type=click.Choice(['html','rst', 'odt']), default='html') 18 | @click.option("--destpath", '-d', default='.') 19 | @click.argument("classnames", nargs=-1) 20 | def classes(format, destpath, classnames): 21 | for c in classnames: 22 | output_filename = os.path.join(destpath, c + '.' + format) 23 | module_name, classname = c.rsplit('.', 1) 24 | mod = importlib.import_module(module_name) 25 | klass = getattr(mod, classname) 26 | try: 27 | with open(output_filename, 'w') as output: 28 | tmp = io.StringIO() 29 | tmp.write("#" * len(klass.__name__)) 30 | tmp.write("\n") 31 | tmp.write(klass.__name__) 32 | tmp.write('\n') 33 | tmp.write("#" * len(klass.__name__)) 34 | tmp.write("\n\n") 35 | 36 | tmp.write(str(napoleon.GoogleDocstring(klass.__doc__))) 37 | tmp.write('\n\n') 38 | 39 | builder = SchemaHelpBuilder(klass.schema, fmt=format) 40 | tmp.write(builder.rst) 41 | 42 | if format == 'html': 43 | output.write(publish_string(tmp.getvalue(), writer_name='html', settings_overrides={"stylesheet_path": "sondra/css/flasky.css"}).decode('utf-8')) 44 | elif format == 'rst': 45 | output.write(tmp.getvalue()) 46 | logging.info("Wrote {0} to {1}".format(c, output_filename)) 47 | except Exception as e: 48 | logging.error(str(e)) 49 | 50 | 51 | @cli.command() 52 | @click.option("--formatters", '-f', type=click.Choice(['html','rst', 'odt']), default='html') 53 | @click.option("--destpath", '-d', default='.') 54 | @click.argument("suite", nargs=1) 55 | @click.argument("apps", nargs=-1) 56 | def suite(format, destpath, suite, apps): 57 | module_name, classname = suite.rsplit('.', 1) 58 | mod = importlib.import_module(module_name) 59 | klass = getattr(mod, classname) 60 | suite = klass() 61 | 62 | for c in apps: 63 | module_name, classname = c.rsplit('.', 1) 64 | mod = importlib.import_module(module_name) 65 | klass = getattr(mod, classname) 66 | klass(suite) 67 | 68 | output_filename = os.path.join(destpath, "{suite}.{fmt}".format(suite=suite.name, fmt=format)) 69 | with open(output_filename, 'w') as suite_help: 70 | suite_help.write(suite.help()) 71 | 72 | for app in suite: 73 | output_filename = os.path.join(destpath, "{suite}.{app}.{fmt}".format(suite=suite.name, app=app, fmt=format)) 74 | 75 | try: 76 | with open(output_filename, 'w') as output: 77 | tmp = suite[app].help() 78 | if format == 'html': 79 | output.write(suite.docstring_processor(tmp).decode('utf-8')) 80 | elif format == 'rst': 81 | output.write(tmp.getvalue().decode('utf-8')) 82 | logging.info("Wrote {0} to {1}".format(app, output_filename)) 83 | except Exception as e: 84 | logging.error(str(e)) 85 | 86 | for collection in suite[app]: 87 | output_filename = os.path.join(destpath, "{suite}.{app}.{coll}.{fmt}".format(suite=suite.name, app=app, coll=collection, fmt=format)) 88 | 89 | try: 90 | with open(output_filename, 'w') as output: 91 | tmp = suite[app][collection].help() 92 | if format == 'html': 93 | output.write(suite.docstring_processor(tmp).decode('utf-8')) 94 | elif format == 'rst': 95 | output.write(tmp.getvalue().decode('utf-8')) 96 | logging.info("Wrote {0}.{1} to {2}".format(app, collection, output_filename)) 97 | except Exception as e: 98 | logging.error(collection) 99 | logging.error(str(e)) 100 | 101 | output_filename = os.path.join(destpath, "{suite}.{app}.{coll}.doc.{fmt}".format(suite=suite.name, app=app, coll=collection, fmt=format)) 102 | 103 | 104 | try: 105 | with open(output_filename, 'w') as output: 106 | tmp = suite[app][collection].doc({}).help() 107 | if format == 'html': 108 | output.write(suite.docstring_processor(tmp).decode('utf-8')) 109 | elif format == 'rst': 110 | output.write(tmp.getvalue().decode('utf-8')) 111 | logging.info("Wrote {0}.{1} document class to {2}".format(app, collection, output_filename)) 112 | except Exception as e: 113 | logging.error(collection + " docclass") 114 | logging.error(str(e)) 115 | 116 | if __name__=='__main__': 117 | cli() -------------------------------------------------------------------------------- /sondra/commands/schema2doc.py: -------------------------------------------------------------------------------- 1 | import click 2 | import requests 3 | from urllib.parse import urlparse 4 | import json 5 | import os.path 6 | from sondra.help import SchemaHelpBuilder 7 | import logging 8 | 9 | logging.basicConfig(level=logging.DEBUG) 10 | 11 | @click.group() 12 | def cli(): 13 | pass 14 | 15 | 16 | @cli.command() 17 | @click.option("--formatters", '-f', type=click.Choice(['html','rst', 'odt']), default='html') 18 | @click.option("--destpath", '-d', default='.') 19 | @click.argument("filenames", nargs=-1) 20 | def files(format, destpath, filenames): 21 | for f in filenames: 22 | filename, ext = os.path.splitext(os.path.basename(f)) 23 | output_filename = os.path.join(destpath, filename + '.' + format) 24 | #try: 25 | with open(f) as input, open(output_filename, 'w') as output: 26 | builder = SchemaHelpBuilder(json.load(input), fmt=format) 27 | if format == 'html': 28 | output.write(builder.html) 29 | elif format == 'rst': 30 | output.write(builder.rst) 31 | logging.info("Wrote {0} to {1}".format(f, output_filename)) 32 | #except Exception as e: 33 | # logging.error(str(e)) 34 | 35 | @cli.command() 36 | @click.option("--formatters", '-f', type=click.Choice(['html','rst']), default='html') 37 | @click.option("--destpath", '-d', default='.') 38 | @click.argument("urls", nargs=-1) 39 | def urls(format, destpath, urls): 40 | for url in urls: 41 | p_url = urlparse(url) 42 | filename = p_url.path.split('/')[-1] 43 | if '.' in filename: 44 | filename, ext = os.path.splitext(filename) 45 | output_filename = os.path.join(destpath, filename + '.' + format) 46 | resource = requests.get(url) 47 | if resource.ok: 48 | #try: 49 | with open(output_filename, 'w') as output: 50 | builder = SchemaHelpBuilder(json.load(input), fmt=format) 51 | if format == 'html': 52 | output.write(builder.html) 53 | elif format == 'rst': 54 | output.write(builder.rst) 55 | elif format == 'odt': 56 | output.write(builder.odt) 57 | logging.info('Wrote {0} to {1}'.format(url, output_filename)) 58 | #except Exception as e: 59 | # logging.error(str(e)) 60 | 61 | 62 | if __name__=='__main__': 63 | cli() -------------------------------------------------------------------------------- /sondra/document/processors.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from datetime import datetime 3 | from slugify import slugify 4 | 5 | 6 | class DocumentProcessor(object): 7 | """Modify a document based on a condition, such as before it's saved or when a property changes.""" 8 | 9 | def is_necessary(self, changed_props): 10 | """Override this method to determine whether the processor should run.""" 11 | return True 12 | 13 | def run_after_set(self, document, *changed_props): 14 | pass 15 | 16 | def run_before_save(self, document): 17 | pass 18 | 19 | def run_before_delete(self, document): 20 | pass 21 | 22 | def run_on_constructor(self, document): 23 | pass 24 | 25 | def run(self, document): 26 | """ 27 | Override this method to post-process a document after it has changed. 28 | 29 | Args: 30 | document: the document instance that is being processed. 31 | """ 32 | pass 33 | 34 | 35 | class CascadingDelete(DocumentProcessor): 36 | """ 37 | Cascades deletes from one document to a set of related documents. 38 | """ 39 | def __init__(self, app, coll, related_key=None): 40 | self.app = app 41 | self.coll = coll 42 | self.related_key = related_key 43 | 44 | def run_before_delete(self, document): 45 | self.run(document) 46 | 47 | def run(self, document): 48 | document.rel(self.app, self.coll, related_key=self.related_key).drop() 49 | 50 | 51 | class CascadingOperation(DocumentProcessor): 52 | """ 53 | 54 | """ 55 | def __init__(self, operation, changed_properties, app, coll, related_key=None): 56 | self.changed_properties = changed_properties 57 | self.operation = operation 58 | self.app = app 59 | self.coll = coll 60 | self.related_key = related_key 61 | 62 | def run_after_set(self, document, *changed_props): 63 | if any([p in self.changed_properties for p in changed_props]): 64 | self.run(document) 65 | 66 | def run(self, document): 67 | for related_doc in document.rel(self.app, self.coll, related_key=self.related_key).delete(): 68 | self.operation(related_doc, document) 69 | 70 | 71 | def join(delimiter=','): 72 | """Returns a function that joins all the passed in properties with the given delimiter.""" 73 | return lambda doc: delimiter.join(str(v) for v in doc.values()) 74 | 75 | 76 | def _prune(d): 77 | for k, v in d.items(): 78 | if v is None: 79 | del d[k] 80 | return d 81 | 82 | 83 | class DeferredDefaults(DocumentProcessor): 84 | """ 85 | Set defaults for properties where the default value is not valid JSON schema. 86 | """ 87 | def __init__(self, **defaults): 88 | self.defaults = defaults 89 | 90 | def run_on_constructor(self, document): 91 | for k, default_value in self.defaults.items(): 92 | if k not in document: 93 | if callable(default_value): 94 | document[k] = default_value(document) 95 | else: 96 | document[k] = default_value 97 | 98 | 99 | class DerivedProperty(DocumentProcessor): 100 | """ 101 | Derive a property value based on other property values. 102 | 103 | 104 | Args: 105 | dest_prop: the destination property 106 | required_source_props: the source properties that must be present 107 | optional_source_props: the source properties that may be None 108 | derivation: a lambda that receives all 109 | """ 110 | 111 | def __init__(self, dest_prop, required_source_props=None, optional_source_props=None, modify_existing=True, derivation=join(',')): 112 | self.dest_prop = dest_prop 113 | self.required_source_props = tuple(required_source_props) if required_source_props else None 114 | self.optional_source_props = tuple(optional_source_props) if optional_source_props else None 115 | self.source_props = (required_source_props or ()) + (optional_source_props or ()) 116 | self.modify_existing = modify_existing 117 | self.derivation = derivation 118 | 119 | def run_on_constructor(self, document): 120 | if self.modify_existing or not self.dest_prop in document: 121 | if not self.source_props or all([p in document for p in self.required_source_props]): 122 | self.run(document) 123 | 124 | def run_after_set(self, document, *changed_props): 125 | if self.modify_existing or not self.dest_prop in document: 126 | if not self.source_props or ( 127 | any([p in changed_props for p in self.source_props]) and 128 | all([p in document for p in self.required_source_props])): 129 | self.run(document) 130 | 131 | def run(self, document): 132 | if self.source_props: 133 | document[self.dest_prop] = self.derivation( 134 | _prune(OrderedDict([(k, document.get(k, None)) for k in self.source_props]))) 135 | else: 136 | document[self.dest_prop] = self.derivation(document) 137 | 138 | 139 | class SlugPropertyProcessor(DerivedProperty): 140 | """Slugify a document property""" 141 | 142 | def op(self, doc): 143 | return '-'.join([slugify(str(doc[p])) for p in self.source_props]) 144 | 145 | def __init__(self, *source_props, dest_prop='slug'): 146 | super(SlugPropertyProcessor, self).__init__( 147 | dest_prop, 148 | source_props, 149 | None, 150 | False, 151 | self.op 152 | ) 153 | 154 | def is_necessary(self, changed_props): 155 | return any([p in set(changed_props) for p in self.source_props]) 156 | 157 | def run_on_constructor(self, document): 158 | if (document.get(self.dest_prop, None) is None) and all([p in document for p in self.source_props]): 159 | self.run(document) 160 | 161 | def run_after_set(self, document, *changed_props): 162 | if (document.get(self.dest_prop, None) is None) and self.is_necessary(changed_props): 163 | self.run(document) 164 | 165 | 166 | class TimestampOnUpdate(DocumentProcessor): 167 | """Stamp a document when it's saved""" 168 | 169 | def __init__(self, dest_prop='timestamp'): 170 | self.dest_prop = dest_prop 171 | 172 | def run_on_constructor(self, document): 173 | if self.dest_prop not in document: 174 | self.run(document) 175 | 176 | def run_before_save(self, document): 177 | self.run(document) 178 | 179 | def run(self, document): 180 | document[self.dest_prop] = datetime.utcnow() 181 | 182 | 183 | class TimestampOnCreate(DocumentProcessor): 184 | """Stamp a document when it's created""" 185 | 186 | def __init__(self, dest_prop='created'): 187 | self.dest_prop = dest_prop 188 | 189 | def run_before_save(self, document): 190 | if not document.saved: 191 | self.run(document) 192 | 193 | def run(self, document): 194 | document[self.dest_prop] = datetime.utcnow() 195 | -------------------------------------------------------------------------------- /sondra/document/signals.py: -------------------------------------------------------------------------------- 1 | from blinker import signal 2 | 3 | pre_save = signal('document-pre-save') 4 | pre_delete = signal('document-pre-delete') 5 | post_save = signal('document-post-save') 6 | post_delete = signal('document-post-delete') -------------------------------------------------------------------------------- /sondra/exceptions.py: -------------------------------------------------------------------------------- 1 | class ValidationError(Exception): 2 | """This kind of validation error is thrown whenever an :class:`Application` or :class:`Collection` is 3 | misconfigured.""" 4 | 5 | class ParseError(Exception): 6 | "Raised when a method signature cannot be parsed." 7 | 8 | class SuspiciousFileOperation(Exception): 9 | "A suspicious file operation was attempted" -------------------------------------------------------------------------------- /sondra/file3.py: -------------------------------------------------------------------------------- 1 | """ 2 | One more go at simple file storage. 3 | 4 | Files are valid values when: 5 | 6 | * posting an update to a/many document(s) 7 | * storing documents programmatically 8 | * calling methods on documents, collections, applications, and the suite. 9 | 10 | Files should be deleted when records are deleted. 11 | 12 | Sequence: 13 | 14 | * API call incoming 15 | * Check for permission to 16 | 17 | Notes: 18 | 19 | * Make sure to make this processor run AFTER any authentication, otherwise you could get lots of 20 | temp files littering the filesystem. 21 | 22 | """ 23 | from collections import defaultdict 24 | from urllib.parse import urlparse 25 | 26 | from werkzeug.utils import secure_filename 27 | 28 | from sondra.api.request_processor import RequestProcessor 29 | import rethinkdb as r 30 | from uuid import uuid4 31 | import os 32 | from flask import request 33 | 34 | from sondra.document.schema_parser import ValueHandler 35 | 36 | 37 | class LocalFileStorage(object): 38 | chunk_size = 65356 39 | 40 | class File(object): 41 | """ 42 | A read only file object. To write a file, use FileStorage.save() 43 | """ 44 | 45 | def __init__(self, filename, orig_filename, url, **kwargs): 46 | self.filename = filename 47 | self.orig_filename = orig_filename 48 | self.url = url 49 | self._stream = None 50 | 51 | 52 | def __enter__(self): 53 | self._stream = open(self.filename, 'rb') 54 | return self._stream 55 | 56 | def __exit__(self, exc_type, exc_val, exc_tb): 57 | self._stream.close() 58 | 59 | 60 | def __init__(self, root, url_root, chunk_size=None): 61 | self.root = root 62 | self.url_root = url_root 63 | self.chunk_size = chunk_size or LocalFileStorage.chunk_size 64 | self._table_list = defaultdict(set) 65 | 66 | def _path(self, collection): 67 | p = os.path.join( 68 | self.root, 69 | collection.application.slug, 70 | collection.slug, 71 | ) 72 | os.makedirs(p, exist_ok=True) 73 | return p 74 | 75 | def ensure_table(self, collection): 76 | file_list_name = collection.name + "__files" 77 | 78 | app_table_list = self._table_list[collection.application.name] 79 | if file_list_name not in app_table_list: 80 | try: 81 | r.db(collection.application.db).table_create(file_list_name).run(collection.application.connection) 82 | r.db(collection.application.db).index_create('url').run(collection.application.connection) 83 | app_table_list.add(file_list_name) 84 | except Exception as e: 85 | print(e) 86 | return r.db(collection.application.db).table(file_list_name) 87 | 88 | def save(self, file_obj, orig_filename, collection, **meta): 89 | filename = uuid4().hex 90 | p = self._path(collection) 91 | url = os.path.join( 92 | self.url_root, 93 | collection.application.slug, 94 | collection.slug, 95 | filename + ',' + secure_filename(orig_filename) 96 | ) 97 | full_path = os.path.join(p, filename) 98 | 99 | with open(full_path, 'wb') as dest: 100 | size = 0 101 | while True: 102 | data = file_obj.read(self.chunk_size) 103 | if not data: 104 | break 105 | else: 106 | size += len(data) 107 | dest.write(data) 108 | 109 | db_record = { 110 | "id": filename, 111 | "filename": full_path, 112 | "orig_filename": orig_filename, 113 | "url": url, 114 | "refs": 1, 115 | "created": r.now(), 116 | "size": size, 117 | "metadata": meta, 118 | } 119 | 120 | self.ensure_table(collection)\ 121 | .insert(db_record)\ 122 | .run(collection.application.connection) 123 | 124 | return url 125 | 126 | def delete(self, collection, url): 127 | __, filename = url.rsplit('/', 1) 128 | uuid, __ = filename.split(',', 1) 129 | 130 | db_record = r.db(collection.application.db).table(collection.name + "__files")\ 131 | .get(uuid)\ 132 | .update({'refs': r.row['refs']-1}, return_changes=True)\ 133 | .run(collection.application.connection) 134 | 135 | if len(db_record['changes']): 136 | if db_record['changes'][0]['new_val']['refs'] == 0: 137 | os.unlink(db_record['changes'][0]['new_val']['full_pathname']) 138 | r.db(collection.application.db).table(collection.name + "__files")\ 139 | .get(uuid)\ 140 | .delete()\ 141 | .run(collection.application.connection) 142 | 143 | def get(self, suite, url): 144 | try: 145 | p_url = urlparse(url) 146 | *root, app, collection, filename = p_url.path.split('/') 147 | app_name = app.replace('-','_') 148 | collection_name = collection.replace('-','_') 149 | 150 | db_record = r.db(app_name).table(collection_name + "__files")\ 151 | .get_all(url, index='url')\ 152 | .run(suite[app].connection) 153 | 154 | if db_record: 155 | return LocalFileStorage.File(**db_record[0]) 156 | else: 157 | return None 158 | except Exception as e: 159 | raise FileNotFoundError(str(e)) 160 | 161 | 162 | class FileUploadProcessor(RequestProcessor): 163 | def process_api_request(self, rq): 164 | if rq.files: 165 | if len(rq.objects) > 1: 166 | return self.process_multiple_objects_files(rq) 167 | else: 168 | return self.process_object_files(rq) 169 | else: 170 | return rq 171 | 172 | def process_object_files(self, rq): 173 | storage = rq.suite.file_storage 174 | for key, v in rq.files.items(): 175 | rq.objects[0][key] = storage.save(v, v.filename, rq.reference.get_collection(), mimetype=v.mimetype) 176 | return rq 177 | 178 | def process_multiple_objects_files(self, rq): 179 | storage = rq.suite.file_storage 180 | for k, v in rq.files.items(): 181 | index, key = k.split(':', 1) 182 | rq.objects[int(index)][key] = storage.save( 183 | v, v.filename, rq.reference.get_collection(), mimetype=v.mimetype) 184 | return rq 185 | 186 | 187 | class FileHandler(ValueHandler): 188 | def __init__(self, key, content_type='application/octet-stream'): 189 | self._key = key 190 | self._content_type = content_type 191 | 192 | def to_json_repr(self, value, document): 193 | if not hasattr(value, 'read'): 194 | return super(FileHandler, self).to_json_repr(value, document) 195 | else: 196 | return document.suite.file_storage.save( 197 | value, 198 | document=document, 199 | key=self._key, 200 | original_filename=getattr(value, "filename", "uploaded-file.dat"), 201 | content_type=self._content_type, 202 | ) 203 | 204 | def pre_delete(self, document): 205 | if document.get(self._key): 206 | document.suite.file_storage.delete(document.collection, document[self._key]) 207 | 208 | def to_python_repr(self, value, document): 209 | return document.suite.file_storage.get(document.suite, value) 210 | 211 | def to_rql_repr(self, value, document): 212 | if not hasattr(value, 'read'): 213 | return super().to_rql_repr(value, document) 214 | else: 215 | return document.suite.file_storage.save( 216 | value, 217 | document=document, 218 | key=self._key, 219 | original_filename=getattr(value, "filename", "uploaded-file.dat"), 220 | content_type=self._content_type, 221 | ) -------------------------------------------------------------------------------- /sondra/files.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | import os 3 | import rethinkdb as r 4 | 5 | from sondra.document.schema_parser import ValueHandler 6 | 7 | try: 8 | from werkzeug.utils import secure_filename 9 | except ImportError: 10 | import re 11 | 12 | def secure_filename(name): 13 | name = re.sub(r'\s+', '-', name) # Replace white space with dash 14 | name = name.sub(r'([a-zA-Z]):\\', '') 15 | return name.sub(r'[^a-zA-Z0-9\-.]+', '_', name) # Replace non alphanumerics with a single _ 16 | 17 | 18 | def _strip_slashes(p): 19 | p = p[1:] if p.startswith('/') else p 20 | return p[:-1] if p.endswith('/') else p 21 | 22 | 23 | def _join_components(*paths): 24 | return '/'.join(_strip_slashes(p) for p in paths) 25 | 26 | 27 | class FileHandler(ValueHandler): 28 | def __init__(self, storage_service, key, content_type='application/octet-stream'): 29 | self._storage_service = storage_service 30 | self._key = key 31 | self._content_type = content_type 32 | 33 | def post_save(self, document): 34 | self._storage_service.assoc(document, document.obj[self._key]) 35 | 36 | def to_json_repr(self, value, document): 37 | if not hasattr(value, 'read'): 38 | return super().to_json_repr(value, document) 39 | else: 40 | return self._storage_service.store( 41 | document=document, 42 | key=self._key, 43 | original_filename=getattr(value, "filename", "uploaded-file.dat"), 44 | content_type=self._content_type, 45 | stream=value 46 | ) 47 | 48 | def pre_delete(self, document): 49 | self._storage_service.delete_for_document(document) 50 | 51 | def to_python_repr(self, value, document): 52 | return self._storage_service.stream(value) 53 | 54 | def to_rql_repr(self, value, document): 55 | if not hasattr(value, 'read'): 56 | return super().to_rql_repr(value, document) 57 | else: 58 | return self._storage_service.store( 59 | document=document, 60 | key=self._key, 61 | original_filename=getattr(value, "filename", "uploaded-file.dat"), 62 | content_type=self._content_type, 63 | stream=value 64 | ) 65 | 66 | 67 | class FileStorageDefaults(object): 68 | """Suite mixin for suite containing defaults for file storage""" 69 | media_url_path = "media" 70 | 71 | 72 | class FileStorageService(object): 73 | def __init__(self): 74 | self._suite = None 75 | self._media_url = None 76 | self._path_start = None 77 | 78 | def _db(self, collection): 79 | return r.db(collection.application.db) 80 | 81 | def _conn(self, collection): 82 | return collection.application.connection 83 | 84 | @lru_cache() 85 | def _table_name(self, collection): 86 | return "_sondra_files__{collection}".format(collection=collection.name) 87 | 88 | @lru_cache() 89 | def _table(self, collection): 90 | db = self._db(collection) 91 | conn = self._conn(collection) 92 | table_name = self._table_name(collection) 93 | table = db.table(table_name) 94 | 95 | all_tables = { name for name in db.table_list().run(conn) } 96 | if table_name not in all_tables: 97 | db.table_create(table_name).run(conn) 98 | table.index_create('document').run(conn) 99 | table.index_create('collection').run(conn) 100 | 101 | return table 102 | 103 | def connect(self, suite): 104 | self._suite = suite 105 | 106 | host = "{scheme}://{netloc}".format( 107 | scheme=suite.base_url_scheme, netloc=suite.base_url_netloc) 108 | self._media_url = _join_components(host, suite.media_url_path) 109 | self._path_start = len(self._media_url) + 1 110 | 111 | def assoc(self, document, url): 112 | app, coll, pk_ext = url[self._path_start:].split('/', 2) 113 | pk, ext = os.path.splitext(pk_ext) 114 | self._table(document.collection).get(pk).update({"document": document.id}).run(self._conn(document.collection)) 115 | 116 | def store(self, document, key, original_filename, content_type, stream): 117 | collection = document.collection 118 | if document.id is not None: 119 | self.delete_for_document(document, key) 120 | 121 | _, filename = os.path.split(original_filename) 122 | _, extension = os.path.splitext(filename) 123 | result = self._table(collection).insert({ 124 | "collection": collection.name, 125 | "document": None, 126 | "key": key, 127 | "original_filename": filename, 128 | "extension": extension, 129 | "content_type": content_type, 130 | }).run(self._conn(collection)) 131 | 132 | new_filename = "{id}{ext}".format(id=result['generated_keys'][0], ext=extension) 133 | self.store_file(collection, new_filename, stream) 134 | return "{media_url}/{app}/{coll}/{new_filename}".format( 135 | media_url=self._media_url, 136 | app=collection.application.slug, 137 | coll=collection.slug, 138 | new_filename=new_filename 139 | ) 140 | 141 | def stream_file(self, collection, ident_ext): 142 | raise NotImplementedError("Implement stream_file in a concrete class") 143 | 144 | def store_file(self, collection, ident_ext, stream): 145 | raise NotImplementedError("Implement store_stream in an concrete class") 146 | 147 | def delete_file(self, collection, ident_ext): 148 | raise NotImplementedError("Implement delete_file in a concrete class") 149 | 150 | def delete_from_collection(self, collection, ident): 151 | self.delete_file(collection, ident) 152 | self._table(collection).get(id).delete().run(self._conn) 153 | 154 | def delete_for_document(self, document, key=None): 155 | if key is not None: 156 | existing = self._table(document.collection)\ 157 | .get_all(document, index='document')\ 158 | .filter({'key': key})\ 159 | .run(self._conn(document.collection)) 160 | 161 | for f in existing: # should only be one 162 | self.delete_file(document.collection, f['id'] + f['extension']) 163 | else: 164 | self._table(document.collection)\ 165 | .get_all(document, index='document')\ 166 | .delete()\ 167 | .run(self._conn(document.collection)) 168 | 169 | def stream(self, url): 170 | app, coll, pk = url[self._path_start:].split('/', 2) 171 | pk, ext = os.path.splitext(pk) 172 | collection = self._suite[app][coll] 173 | record = self._table(collection).get(pk).run(self._conn(collection)) 174 | in_stream = self.stream_file(collection, pk + ext) 175 | return { 176 | "content_type": record['content_type'], 177 | "filename": record['original_filename'], 178 | "stream": in_stream 179 | } 180 | 181 | 182 | class LocalFileStorageDefaults(FileStorageDefaults): 183 | """Suite mixin for local file storage defaults""" 184 | media_path = os.path.join(os.getcwd(), "_media") 185 | media_path_permissions = 0o755 186 | chunk_size = 16384 187 | 188 | 189 | class LocalFileStorageService(FileStorageService): 190 | def __init__(self): 191 | super(LocalFileStorageService, self).__init__() 192 | 193 | self._root = None 194 | 195 | def connect(self, suite): 196 | super(LocalFileStorageService, self).connect(suite) 197 | 198 | self._root = suite.media_path \ 199 | if suite.media_path.startswith('/') \ 200 | else os.path.join(os.getcwd(), suite.media_path) 201 | 202 | os.makedirs(self._root, self._suite.media_path_permissions, exist_ok=True) 203 | 204 | def _path(self, collection, make=False): 205 | p = os.path.join(self._root, collection.application.slug, collection.slug) 206 | if make: 207 | os.makedirs(p, exist_ok=True) 208 | return p 209 | 210 | def stream_file(self, collection, ident_ext): 211 | return open(os.path.join(self._path(collection), ident_ext)) 212 | 213 | def delete_file(self, collection, ident_ext): 214 | os.unlink(os.path.join(self._path(collection), ident_ext)) 215 | 216 | def store_file(self, collection, ident_ext, stream): 217 | p = self._path(collection, True) 218 | dest = os.path.join(p, ident_ext) 219 | with open(dest, 'w') as out: 220 | chunk = stream.read(self._suite.chunk_size) 221 | while chunk: 222 | out.write(chunk) 223 | chunk = stream.read(self._suite.chunk_size) 224 | out.flush() 225 | 226 | 227 | 228 | 229 | 230 | -------------------------------------------------------------------------------- /sondra/files_old.py: -------------------------------------------------------------------------------- 1 | # Experimental file support. Maybe this should just be a document processor... 2 | 3 | import os 4 | import rethinkdb as r 5 | from functools import partial 6 | 7 | from sondra.utils import get_random_string, mapjson 8 | from sondra.document import signals as document_signals 9 | from sondra.application import signals as app_signals 10 | 11 | try: 12 | from werkzeug.utils import secure_filename 13 | except ImportError: 14 | import re 15 | 16 | def secure_filename(name): 17 | name = re.sub(r'\s+', '-', name) # Replace white space with dash 18 | name = name.sub(r'([a-zA-Z]):\\', '') 19 | return name.sub(r'[^a-zA-Z0-9\-.]+', '_', name) # Replace non alphanumerics with a single _ 20 | 21 | 22 | def _strip_slashes(p): 23 | p = p[1:] if p.startswith('/') else p 24 | return p[:-1] if p.endswith('/') else p 25 | 26 | 27 | def _join_components(*paths): 28 | return '/'.join(_strip_slashes(p) for p in paths) 29 | 30 | 31 | def _persist(storage, document, value): 32 | if hasattr(value, 'read') and callable(value.read): 33 | return storage.create(document, value) 34 | else: 35 | return value 36 | 37 | 38 | class FileStorage(object): 39 | chunk_size = 16384 # amount of data to read in at once 40 | 41 | @classmethod 42 | def configured(cls, *args, **kwargs): 43 | return (lambda collection: cls(collection, *args, **kwargs)) 44 | 45 | def __init__(self, collection): 46 | self._collection = collection 47 | self._conn = self._collection.application.connection 48 | self._db = self._collection.application.db 49 | self._table_name = "sondra__{collection}_filestorage".format(collection=self._collection.name) 50 | self._table = r.db(self._db).table(self._table_name) 51 | self.ensure() 52 | self._connect_signals() 53 | 54 | def _connect_signals(self): 55 | def _delete_before_database_drop(sender, instance, **kwargs): 56 | if instance.slug == self._collection.application.slug: 57 | self.drop() 58 | 59 | self._app_pre_delete_database_receiver = app_signals.pre_delete_database.connect(_delete_before_database_drop) 60 | 61 | def _delete_document_files(sender, instance, **kwargs): 62 | if instance.id and instance.collection and (instance.collection.slug == self._collection.slug): 63 | self.delete_records_for_document(instance) 64 | 65 | self._doc_pre_delete_document_receiver = document_signals.pre_delete.connect(_delete_document_files) 66 | 67 | def __del__(self): 68 | app_signals.pre_delete_database.disconnect(self._app_pre_delete_database_receiver) 69 | document_signals.pre_delete.disconnect(self._doc_pre_delete_document_receiver) 70 | 71 | def persist_document_files(self, document): 72 | document.obj = mapjson(partial(_persist, self, document), document.obj) 73 | return document 74 | 75 | def ensure(self): 76 | try: 77 | self._db.table_create(self._table_name).run(self._conn) 78 | self._table.index_create('document').run(self._conn) 79 | self._table.index_create('url').run(self._conn) 80 | except r.ReqlError: 81 | pass # fixme log exception 82 | 83 | def drop(self): 84 | for rec in self._table.run(self._conn): 85 | self.delete(rec) 86 | self._db.table_drop('sondra__file_storage_meta').run(self._conn) 87 | 88 | def clear(self): 89 | self.drop() 90 | self.ensure() 91 | 92 | def create(self, document, from_file): 93 | name = self.get_available_name(from_file.filename) 94 | 95 | record = { 96 | "original_filename": from_file.filename, 97 | "stored_filename": name, 98 | "url": self.get_url(name), 99 | "size": None, 100 | "document": document.id 101 | } 102 | 103 | record = self.store(record, from_file) 104 | self._table.insert(record) 105 | return record['url'] 106 | 107 | def record_for_url(self, url): 108 | try: 109 | return next(self._table.get_all(url, index='url').run(self._conn)) 110 | except r.ReqlError: 111 | return None 112 | 113 | def delete_records_for_document(self, doc): 114 | return self._table.get_all(doc.id, index='document').delete().run(self._conn) 115 | 116 | def delete_record(self, url): 117 | self._table.get_all(url, index='url').delete().run(self._conn) 118 | 119 | def save_record(self, record): 120 | self._table.insert(record, conflict='replace') 121 | 122 | def store(self, record, from_file): 123 | raise NotImplementedError("Must implement store() in a non-abstract class") 124 | 125 | def fetch(self, record): 126 | raise NotImplementedError("Must implement fetch() in a non-abstract class") 127 | 128 | def stream(self, record): 129 | raise NotImplementedError("Must implement stream() in a non-abstract class") 130 | 131 | def delete(self, record): 132 | raise NotImplementedError("Must implement delete() in a non-abstract class") 133 | 134 | def exists(self, filename): 135 | raise NotImplementedError("Must implement exists() in a non-abstract class") 136 | 137 | def replace(self, record, from_file): 138 | self.delete(record) 139 | record = self.store(record, from_file) 140 | self.save_record(record) 141 | 142 | def get_available_name(self, filename): 143 | filename_candidate = secure_filename(filename) 144 | if self.exists(filename_candidate): 145 | _original = filename_candidate 146 | while self.exists(filename_candidate): 147 | filename_candidate = _original + get_random_string() 148 | 149 | def get_url(self, name): 150 | raise NotImplementedError("Must implement get_url() in a non-abstract class") 151 | 152 | 153 | class LocalStorage(FileStorage): 154 | def __init__(self, upload_path=None, media_url="uploads", *args, **kwargs): 155 | super(LocalStorage, self).__init__(*args, **kwargs) 156 | 157 | suite = self._collection.suite 158 | host = "{scheme}://{netloc}".format( 159 | scheme=suite.base_url_scheme, netloc=suite.base_url_netloc) 160 | self._media_url = _join_components(host, media_url, self._collection.application.slug, self._collection.slug) 161 | self._storage_root = upload_path or getattr(suite, 'file_storage_path', os.path.join(os.getcwd(), 'media')) 162 | self._storage_path = os.path.join(self._storage_root, self._collection.application.slug, self._collection.slug) 163 | 164 | def _disk_name(self, record): 165 | return os.path.join(self._storage_path, record['stored_filename']) 166 | 167 | def ensure(self): 168 | super(LocalStorage, self).ensure() 169 | 170 | if not os.path.exists(self._storage_path): 171 | os.makedirs(self._storage_path) 172 | 173 | def get_url(self, name): 174 | return _join_components(self._media_url, name) 175 | 176 | def store(self, record, from_file): 177 | size = 0 178 | 179 | with open(self._disk_name(record), 'w') as output: 180 | while True: 181 | chunk = from_file.read(self.chunk_size) 182 | if chunk: 183 | output.write(chunk) 184 | size += len(chunk) 185 | else: 186 | break 187 | output.flush() 188 | 189 | record['size'] = size 190 | return record 191 | 192 | def exists(self, filename): 193 | return os.path.exists(os.path.join(self._storage_path, filename)) 194 | 195 | def delete(self, record): 196 | disk_name = self._disk_name(record) 197 | if os.path.exists(disk_name): 198 | os.unlink(disk_name) 199 | else: 200 | raise FileNotFoundError(record['original_filename']) 201 | self.delete_record(record['url']) 202 | 203 | def stream(self, record): 204 | return open(self._disk_name(record)) 205 | 206 | def fetch(self, record): 207 | pass 208 | 209 | -------------------------------------------------------------------------------- /sondra/flask.py: -------------------------------------------------------------------------------- 1 | from flask import request, Blueprint, current_app, Response, abort 2 | from flask.ext.cors import CORS 3 | 4 | import json 5 | import traceback 6 | import sys 7 | 8 | from jsonschema import ValidationError 9 | 10 | from .api import APIRequest 11 | 12 | 13 | api_tree = Blueprint('api', __name__) 14 | 15 | def init(app): 16 | if hasattr(app.suite, 'max_content_length'): 17 | app.config['MAX_CONTENT_LENGTH'] = app.suite.max_content_length 18 | if app.suite.cross_origin: 19 | CORS(api_tree, intercept_exceptions=True) 20 | 21 | 22 | 23 | @api_tree.route('/schema') 24 | @api_tree.route(';schema') 25 | @api_tree.route(';format=schema') 26 | def suite_schema(): 27 | resp = Response( 28 | json.dumps(current_app.suite.schema, indent=4), 29 | status=200, 30 | mimetype='application/json' 31 | ) 32 | return resp 33 | 34 | @api_tree.route('/help') 35 | @api_tree.route(';help') 36 | @api_tree.route(';format=help') 37 | def suite_help(): 38 | h = current_app.suite.help() 39 | help_text = current_app.suite.docstring_processor(h) 40 | 41 | resp = Response( 42 | help_text, 43 | status=200, 44 | mimetype='text/html' 45 | ) 46 | return resp 47 | 48 | def format_error(req, code, err, reason): 49 | if isinstance(reason, Exception): 50 | kind, value, tb = sys.exc_info() 51 | reason = "{kind}: {value}\n------\n\n{tb}".format( 52 | kind=reason.__class__.__name__, 53 | value=value, 54 | tb='\n'.join(traceback.format_tb(tb, limit=100)) 55 | ) 56 | else: 57 | reason = str(reason) 58 | 59 | try: 60 | if req.reference.format == 'json': 61 | return Response( 62 | status=code, 63 | mimetype='application/json', 64 | response=json.dumps({"err": err, "reason": str(reason)}) 65 | ) 66 | else: 67 | rsp = """ 68 | 69 | {code} - {url} 70 | 71 | 72 |

{code} - {err}

73 |
74 |
URL
75 |
{url}
76 | 77 |
Method
78 |
{method}
79 | 80 |
81 |

Reason

82 |
 83 |     {reason}
 84 |                 
85 | 86 | """.format( 87 | code=code, 88 | url=req.reference.url, 89 | err=err, 90 | method=req.request_method, 91 | reason=reason 92 | ) 93 | return Response( 94 | status=code, 95 | mimetype='text/html', 96 | response=rsp 97 | ) 98 | except: 99 | return Response( 100 | status=code, 101 | mimetype='application/json', 102 | response=json.dumps({ 103 | "err": "InvalidRequest", 104 | "reason": reason, 105 | "request_data": req.body.decode('utf-8'), 106 | "request_path": req.reference.url, 107 | "method": req.method, 108 | }, indent=4) 109 | ) 110 | 111 | 112 | @api_tree.route('/', methods=['GET','POST','PUT','PATCH', 'DELETE']) 113 | def api_request(path): 114 | if request.method == 'HEAD': 115 | return Response(status=200) 116 | else: 117 | current_app.suite.check_connections() 118 | args = {k:v for k, v in request.values.items()} 119 | r = APIRequest( 120 | current_app.suite, 121 | request.headers, 122 | request.data, 123 | request.method, 124 | current_app.suite.url + '/' + path, 125 | args, 126 | request.files 127 | ) 128 | 129 | try: 130 | # Run any number of post-processing steps on this request, including 131 | try: 132 | for p in current_app.suite.api_request_processors: 133 | r = p(r) 134 | except Exception as e: 135 | for p in current_app.suite.api_request_processors: 136 | p.cleanup_after_exception(r, e) 137 | raise e 138 | 139 | r.validate() 140 | 141 | mimetype, response = r() 142 | resp = Response( 143 | response=response, 144 | status=200, 145 | mimetype=mimetype) 146 | return resp 147 | 148 | except PermissionError as denial: 149 | return format_error(r, 403, "PermissionDenied", denial) 150 | 151 | except KeyError as not_found: 152 | return format_error(r, 404, "NotFound", not_found) 153 | 154 | except ValidationError as invalid_entry: 155 | return format_error(r, 400, "InvalidRequest", invalid_entry) 156 | 157 | except Exception as error: 158 | return format_error(r, 500, "ServerError", error) 159 | 160 | 161 | -------------------------------------------------------------------------------- /sondra/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'jeff' 2 | 3 | from .geojson import GeoJSON 4 | from .json import JSON 5 | from .html import HTML 6 | from .schema import Schema 7 | from .help import Help -------------------------------------------------------------------------------- /sondra/formatters/geojson.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sondra import collection, document 4 | from sondra.document.schema_parser import Geometry 5 | from sondra.utils import mapjson 6 | 7 | class GeoJSON(object): 8 | def __call__(self, reference, result, **kwargs): 9 | if 'geom' in kwargs: 10 | geometry_field = kwargs['geom'] 11 | else: 12 | geometry_field = None 13 | 14 | def fun(doc): 15 | if isinstance(doc, document.Document): 16 | if doc.specials: 17 | for s, t in doc.specials.items(): 18 | if isinstance(t, Geometry): 19 | result = mapjson(fun, doc.obj) 20 | result = { 21 | "type": "Feature", 22 | "geometry": doc[s], 23 | "properties": result 24 | } 25 | break 26 | else: 27 | result = mapjson(fun, doc.obj) 28 | return result 29 | else: 30 | return doc 31 | 32 | if 'indent' in kwargs: 33 | kwargs['indent'] = int(kwargs['indent']) 34 | 35 | if 'ordered' in kwargs: 36 | ordered = bool(kwargs.get('ordered', False)) 37 | del kwargs['ordered'] 38 | 39 | 40 | result = mapjson(fun, result) # make sure to serialize a full Document structure if we have one. 41 | 42 | if isinstance(result, list): 43 | ret = { 44 | "type": "FeatureCollection", 45 | "features": result 46 | } 47 | else: 48 | ret = result 49 | return 'application/json', json.dumps(ret, indent=4) -------------------------------------------------------------------------------- /sondra/formatters/help.py: -------------------------------------------------------------------------------- 1 | from sondra.api.expose import method_help 2 | 3 | class Help(object): 4 | def __call__(self, reference, result): 5 | value = reference.value 6 | if 'method' in reference.kind: 7 | return 'text/html', reference.value[0].suite.docstring_processor(method_help(*reference.value)) 8 | else: 9 | return 'text/html', reference.value.suite.docstring_processor(value.help()) 10 | 11 | -------------------------------------------------------------------------------- /sondra/formatters/html.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sondra import document 4 | from sondra.utils import mapjson 5 | from sondra.api.ref import Reference 6 | from io import StringIO 7 | 8 | class HTML(object): 9 | """ 10 | This formats the API output as HTML. Used when ;formatters=json or ;json is a parameter on the last item of a URL. 11 | 12 | Optional arguments: 13 | 14 | * **indent** (int) - Formats the JSON output for human reading by inserting newlines and indenting ``indent`` spaces. 15 | * **fetch** (string) - A key in the document. Fetches the sub-document(s) associated with that key. 16 | * **ordered** (bool) - Sorts the keys in dictionary order. 17 | * **bare_keys** (bool) - Sends bare foreign keys instead of URLs. 18 | """ 19 | # TODO make dotted keys work in the fetch parameter. 20 | 21 | def format(self, structure, buf=None, wrap=True): 22 | base = buf is None 23 | buf = buf or StringIO() 24 | 25 | if base and wrap: 26 | buf.write(""" 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | """) 39 | 40 | if isinstance(structure, list): 41 | buf.write('
    ') 42 | for x in structure: 43 | buf.write('
  1. ') 44 | self.format(x, buf=buf) 45 | buf.write('
  2. ') 46 | buf.write('
') 47 | elif isinstance(structure, dict): 48 | buf.write('
') 49 | for k, v in structure.items(): 50 | if isinstance(v, dict): 51 | buf.write("
{0}
".format(k)) 52 | buf.write('
') 53 | for a, b in v.items(): 54 | buf.write("
{0}
".format(a)) 55 | buf.write("
") 56 | self.format(b, buf=buf) 57 | buf.write("
") 58 | buf.write('
') 59 | 60 | elif isinstance(v, list): 61 | buf.write("
{0}
".format(k)) 62 | buf.write('
    ') 63 | for x in v: 64 | buf.write('
  1. ') 65 | self.format(x, buf=buf) 66 | buf.write('
  2. ') 67 | buf.write('
') 68 | 69 | elif base: 70 | buf.write('
{0}
{1}
'.format(k, v)) 71 | 72 | else: 73 | buf.write("
{0}
".format(k)) 74 | buf.write("
({1}) {0}
".format(v, str(v.__class__.__name__))) 75 | buf.write("
") 76 | else: 77 | buf.write(str(structure)) 78 | 79 | if base and wrap: 80 | buf.write("") 81 | return buf.getvalue() 82 | 83 | 84 | def __call__(self, reference, results, **kwargs): 85 | 86 | # handle indent the same way python's json library does 87 | if 'indent' in kwargs: 88 | kwargs['indent'] = int(kwargs['indent']) 89 | 90 | if 'ordered' in kwargs: 91 | ordered = bool(kwargs.get('ordered', False)) 92 | del kwargs['ordered'] 93 | else: 94 | ordered = False 95 | 96 | # fetch a foreign key reference and append it as if it were part of the document. 97 | if 'fetch' in kwargs: 98 | fetch = kwargs['fetch'].split(',') 99 | del kwargs['fetch'] 100 | else: 101 | fetch = [] 102 | 103 | if 'bare_keys' in kwargs: 104 | bare_keys = bool(kwargs.get('bare_keys', False)) 105 | del kwargs['bare_keys'] 106 | else: 107 | bare_keys = False 108 | 109 | # note this is a closure around the fetch parameter. Consider before refactoring out of the method. 110 | def serialize(doc): 111 | if isinstance(doc, document.Document): 112 | ret = doc.json_repr(ordered=ordered, bare_keys=bare_keys) 113 | for f in fetch: 114 | if f in ret: 115 | if isinstance(doc[f], list): 116 | ret[f] = [d.json_repr(ordered=ordered, bare_keys=bare_keys) for d in doc[f]] 117 | elif isinstance(doc[f], dict): 118 | ret[f] = {k: v.json_repr(ordered=ordered, bare_keys=bare_keys) for k, v in doc[f].items()} 119 | else: 120 | ret[f] = doc[f].json_repr(ordered=ordered, bare_keys=bare_keys) 121 | return ret 122 | else: 123 | return doc 124 | 125 | result = mapjson(serialize, results) # make sure to serialize a full Document structure if we have one. 126 | 127 | if not (isinstance(result, dict) or isinstance(result, list)): 128 | result = {"_": result} 129 | 130 | from json2html import json2html 131 | rsp = StringIO() 132 | rsp.write(""" 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | """) 145 | rsp.write(json2html.convert(json=result, table_attributes="class=\"table table-bordered table-hover\"")) 146 | rsp.write('') 147 | return 'text/html',rsp.getvalue() -------------------------------------------------------------------------------- /sondra/formatters/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sondra import document 4 | from sondra.utils import mapjson 5 | from sondra.api.ref import Reference 6 | from datetime import datetime 7 | 8 | def json_serial(bare_keys=False): 9 | def inner(obj): 10 | """JSON serializer for objects not serializable by default json code""" 11 | 12 | if isinstance(obj, datetime): 13 | serial = obj.isoformat() 14 | return serial 15 | elif isinstance(obj, document.Document): 16 | if bare_keys: 17 | return obj.id 18 | else: 19 | return obj.url 20 | 21 | raise TypeError ("Type not serializable") 22 | 23 | return inner 24 | 25 | 26 | class JSON(object): 27 | """ 28 | This formats the API output as JSON. Used when ;formatters=json or ;json is a parameter on the last item of a URL. 29 | 30 | Optional arguments: 31 | 32 | * **indent** (int) - Formats the JSON output for human reading by inserting newlines and indenting ``indent`` spaces. 33 | * **fetch** (string) - A key in the document. Fetches the sub-document(s) associated with that key. 34 | * **ordered** (bool) - Sorts the keys in dictionary order. 35 | * **bare_keys** (bool) - Sends bare foreign keys instead of URLs. 36 | """ 37 | # TODO make dotted keys work in the fetch parameter. 38 | 39 | def __call__(self, reference, results, **kwargs): 40 | 41 | # handle indent the same way python's json library does 42 | if 'indent' in kwargs: 43 | kwargs['indent'] = int(kwargs['indent']) 44 | 45 | if 'ordered' in kwargs: 46 | ordered = bool(kwargs.get('ordered', False)) 47 | del kwargs['ordered'] 48 | else: 49 | ordered = False 50 | 51 | # fetch a foreign key reference and append it as if it were part of the document. 52 | if 'fetch' in kwargs: 53 | fetch = kwargs['fetch'].split(',') 54 | del kwargs['fetch'] 55 | else: 56 | fetch = [] 57 | 58 | if 'bare_keys' in kwargs: 59 | bare_keys = bool(kwargs.get('bare_keys', False)) 60 | del kwargs['bare_keys'] 61 | else: 62 | bare_keys = False 63 | 64 | print(bare_keys) 65 | 66 | # note this is a closure around the fetch parameter. Consider before refactoring out of the method. 67 | def serialize(doc): 68 | if isinstance(doc, document.Document): 69 | ret = doc.json_repr(ordered=ordered, bare_keys=bare_keys) 70 | for f in fetch: 71 | if f in ret: 72 | if isinstance(doc[f], list): 73 | ret[f] = [d.json_repr(ordered=ordered, bare_keys=bare_keys) for d in doc[f]] 74 | elif isinstance(doc[f], dict): 75 | ret[f] = {k: v.json_repr(ordered=ordered, bare_keys=bare_keys) for k, v in doc[f].items()} 76 | else: 77 | ret[f] = doc[f].json_repr(ordered=ordered, bare_keys=bare_keys) 78 | return ret 79 | else: 80 | return doc 81 | 82 | result = mapjson(serialize, results) # make sure to serialize a full Document structure if we have one. 83 | 84 | if not (isinstance(result, dict) or isinstance(result, list)): 85 | result = {"_": result} 86 | 87 | return 'application/json', json.dumps(result, default=json_serial(bare_keys=bare_keys), **kwargs) -------------------------------------------------------------------------------- /sondra/formatters/schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sondra.api.expose import method_schema 4 | 5 | 6 | class Schema(object): 7 | """ 8 | Returns the schema of the target reference. 9 | 10 | Optional args: 11 | 12 | * indent (int) - If specified, the formatter pretty prints the JSON for human reading with indented lines. 13 | """ 14 | 15 | name = 'schema' 16 | 17 | def __call__(self, reference, result, **kwargs): 18 | if 'indent' in kwargs: 19 | kwargs['indent'] = int(kwargs['indent']) 20 | 21 | if 'method' in reference.kind: 22 | # ordered_schema = natural_order(method_schema(*reference.value)) 23 | return 'application/json', json.dumps(method_schema(*reference.value), **kwargs) 24 | else: 25 | # ordered_schema = natural_order(reference.value.schema) 26 | return 'application/json', json.dumps(reference.value.schema, **kwargs) 27 | 28 | -------------------------------------------------------------------------------- /sondra/lazy.py: -------------------------------------------------------------------------------- 1 | """Tools for creating JSON Schemas in Sondra. 2 | 3 | This module contains functions for making schemas a bit simpler and more foolproof to create. These include functions 4 | to create references between the schemas of Collections or Applications. 5 | """ 6 | 7 | import importlib 8 | from functools import partial 9 | 10 | FOREIGN_KEY = "fk" 11 | 12 | 13 | def _deferred_url_for(klass, context, fmt=None, fragment=None): 14 | from sondra.document import Document 15 | from sondra.suite import Suite 16 | from sondra.application import Application 17 | from sondra.collection import Collection 18 | 19 | fmt = ';'+fmt if fmt else '' 20 | 21 | if isinstance(klass, str): 22 | if "/" in klass or ('.' not in klass): 23 | slug = klass 24 | else: 25 | modulename, classname = klass.rsplit('.', 1) 26 | klass = getattr(importlib.import_module(modulename), classname) 27 | slug = klass.slug 28 | else: 29 | slug = klass.slug 30 | 31 | if isinstance(context, Suite): 32 | suite = context 33 | elif isinstance(context, Application): 34 | suite = context.suite 35 | elif isinstance(context, Collection): 36 | suite = context.application.suite 37 | elif isinstance(context, Document): 38 | suite = context.collection.application.suite 39 | elif context is None: 40 | suite = context 41 | else: 42 | raise ValueError("Context must be an instance of Application, Document, Collection, Suite, or None") 43 | 44 | ret = "" 45 | if issubclass(klass, Document): 46 | for app in suite.values(): 47 | for coll in app.values(): 48 | if coll.document_class is klass: 49 | ret = coll.url + fmt 50 | if fragment: 51 | return ret + fragment 52 | else: 53 | return ret + fmt 54 | else: 55 | raise KeyError("Cannot find document in a registered collection {0}".format(klass)) 56 | elif issubclass(klass, Collection): 57 | for app in suite.applications.values(): 58 | if slug in app: 59 | ret = app[slug].url 60 | break 61 | else: 62 | raise KeyError("Cannot find collection in a registered application {0}".format(klass)) 63 | elif issubclass(klass, Application): 64 | ret = suite[slug].url + fmt 65 | elif issubclass(klass, Suite): 66 | ret = suite.url + fmt 67 | else: 68 | raise ValueError("Target class must be an Application, Document, Collection, or Suite") 69 | 70 | if fragment: 71 | return ret + fragment 72 | else: 73 | return ret + fmt 74 | 75 | 76 | def url_for(klass, fmt=None): 77 | """Defer the calculation of the URL until the application has been initialized. 78 | 79 | Args: 80 | klass (str or type): The class whose URL to search for. Must be a Collection, Application, or Suite. 81 | formatters (str): The "formatters" portion of the API call. By default this is schema. 82 | 83 | Returns: 84 | callable: A function that takes a context. The context is an *instance* of Collection, Application, Document, or 85 | Suite. Optionally, the context can also be None, in which case the class's ``slug`` is returned. 86 | """ 87 | return partial(_deferred_url_for, klass=klass, fmt=fmt) 88 | 89 | 90 | def fk(klass, **kwargs): 91 | """ 92 | Create a schema fragment that lazily refers to the schema of a Collection, Application, or Suite. 93 | 94 | Args: 95 | klass (str or type): The class whose URL to search for. Must be a Collection, Application, or Suite. 96 | **kwargs: Additional properties to set on the schema fragment, often ``"description"``. 97 | 98 | Returns: 99 | dict: A schema fragment containing a callable returned by :py:func:`url_for` 100 | """ 101 | ret = { 102 | "type": "string", 103 | FOREIGN_KEY: url_for(klass) 104 | } 105 | if kwargs: 106 | ret.update(kwargs) 107 | return ret 108 | 109 | 110 | def lazy_definition(klass, name=None, **kwargs): 111 | """ 112 | Create a ``$ref`` that lazily refers to the schema definition 113 | 114 | Args: 115 | klass (str or type): The class whose URL to search for. Must be a Collection, Application, or Suite. 116 | name (str): the name of the definition to refer to in "definitions" 117 | 118 | Returns: 119 | callable: A function that takes a context and returns a JSON ref . The context is an *instance* of Collection, 120 | Application, Document, or Suite. Optionally, the context can also be None, in which case the class's ``slug`` is 121 | returned. 122 | """ 123 | ret = {} 124 | ret.update(kwargs) 125 | 126 | if name: 127 | ret.update({"$ref": partial(_deferred_url_for, klass=klass, fmt='schema', fragment="#/definitions/"+ name)}) 128 | else: 129 | ret.update({"$ref": url_for(klass)}) 130 | 131 | return ret 132 | 133 | 134 | def ref(klass='self', name=None, **kwargs): 135 | if klass != "self": 136 | return lazy_definition(klass, name, **kwargs) 137 | else: 138 | ret = {"$ref": ("#/definitions/" + name) if name else url_for(klass)} 139 | ret.update(**kwargs) 140 | return ret -------------------------------------------------------------------------------- /sondra/remote.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/sondra/remote.py -------------------------------------------------------------------------------- /sondra/schema.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from copy import copy, deepcopy 3 | from functools import partial 4 | import datetime 5 | 6 | def merge(a, b, path=None): 7 | "merges b into a" 8 | 9 | if path is None: path = [] 10 | for key in b: 11 | if key in a: 12 | if isinstance(a[key], dict) and isinstance(b[key], dict): 13 | merge(a[key], b[key], path + [str(key)]) 14 | elif a[key] == b[key]: 15 | pass # same leaf value 16 | else: 17 | a[key] = b[key] # prefer b to a 18 | else: 19 | a[key] = b[key] 20 | return a 21 | 22 | 23 | def list_meld(a, b): 24 | a_len = len(a) 25 | b_len = len(b) 26 | trunc_len = min(a_len, b_len) 27 | a_remainder = None if a_len < trunc_len else deepcopy(a[trunc_len:]) 28 | b_remainder = None if b_len < trunc_len else deepcopy(b[trunc_len:]) 29 | 30 | ret = [] 31 | for i in range(trunc_len): 32 | x = a[i] 33 | y = b[i] 34 | if isinstance(x, dict) and isinstance(y, dict): 35 | ret.append(deep_merge(x, y, 'meld')) 36 | elif isinstance(x, list) and isinstance(y, list): 37 | ret.append(list_meld(x, y)) 38 | else: 39 | ret.append(y) 40 | 41 | if a_remainder: 42 | ret.extend(a_remainder) 43 | if b_remainder: 44 | ret.extend(b_remainder) 45 | return ret 46 | 47 | 48 | def deep_merge(a, b, list_merge_method='set'): 49 | """ 50 | Merges dicts b into a, including expanding list items 51 | 52 | Args: 53 | a: dict 54 | b: dict 55 | list_merge_method: 'set', 'replace', 'extend', or 'meld' 56 | 57 | Returns: 58 | A deeply merged structure. 59 | 60 | """ 61 | 62 | 63 | a = deepcopy(a) 64 | 65 | for key in b: 66 | if key not in a: 67 | a[key] = deepcopy(b[key]) 68 | elif a[key] != b[key]: 69 | if isinstance(a[key], dict) and isinstance(b[key], dict): 70 | a[key] = deep_merge(a[key], b[key], list_merge_method) 71 | elif hasattr(a[key], '__iter__') and hasattr(b[key], '__iter__'): 72 | if list_merge_method == 'replace': 73 | a[key] = deepcopy(b[key]) 74 | if list_merge_method == 'set': 75 | a[key] = list(set(a[key]).union(set(b[key]))) 76 | elif list_merge_method == 'extend': 77 | a[key].extend(deepcopy(b[key])) 78 | elif list_merge_method == 'meld': 79 | a[key] = list_meld(a[key], b[key]) 80 | else: 81 | raise ValueError('list_expansion_method should be set, replace, extend. Was {0}'.format( 82 | list_merge_method)) 83 | else: 84 | a[key] = b[key] # prefer b to a 85 | return a 86 | 87 | def extend(proto, *values, **kwargs): 88 | ret = deepcopy(proto) if proto else OrderedDict() 89 | for v in values: 90 | ret.update(v) 91 | ret.update(kwargs) 92 | return ret 93 | 94 | 95 | def remove(proto, *keys): 96 | ret = deepcopy(proto) 97 | for key in keys: 98 | if key in ret: 99 | del ret[key] 100 | return ret 101 | 102 | 103 | # fragments to extend. 104 | 105 | class S(object): 106 | @staticmethod 107 | def object(properties=None, **kwargs): 108 | properties = properties or OrderedDict() 109 | for pname, pschema in properties.items(): 110 | if 'title' not in pschema: 111 | pschema['title'] = pname.replace('_', ' ').title() 112 | ret = extend(OrderedDict(), { 113 | "type": "object", 114 | "properties": properties, 115 | }, **kwargs) 116 | return ret 117 | 118 | string = partial(extend, {"type": "string"}) 119 | file = partial(extend, {"type": "string", "file": True}) 120 | image = partial(extend, {"type": "string", "image": True}) 121 | geo = partial(extend, { 122 | "type": "object", 123 | "geo": True, 124 | "properties": { 125 | "type": {"type": "string"}, 126 | "coordinates": {"type": "array", "items": {"type": "number"}}}}) 127 | array = partial(extend, {"type": "array"}) 128 | integer = partial(extend, {"type": "integer"}) 129 | number = partial(extend, {"type": "number"}) 130 | boolean = partial(extend, {"type": "boolean"}) 131 | date = partial(extend, {"type": "string", "format": "date-time"}) 132 | color = partial(extend, {"type": "string", "formatters": "color"}) 133 | datetime = partial(extend, {"type": "string", "format": "date-time"}) 134 | creation_timestamp = partial(extend, {"type": "string", "format": "date-time", "on_creation": True}) 135 | update_timestamp = partial(extend, {"type": "string", "format": "date-time", "on_update": True}) 136 | datetime_local = partial(extend, {"type": "string", "formatters": "datetime-local"}) 137 | email = partial(extend, {"type": "string", "formatters": "email"}) 138 | month = partial(extend, {"type": "string", "formatters": "month"}) 139 | range = partial(extend, {"type": "string", "formatters": "range"}) 140 | tel = partial(extend, {"type": "string", "formatters": "tel"}) 141 | text = partial(extend, {"type": "string", "formatters": "text"}) 142 | textarea = partial(extend, {"type": "string", "formatters": "textarea", "long": True}) 143 | time = partial(extend, {"type": "string", "formatters": "time"}) 144 | url = partial(extend, {"type": "string", "formatters": "url"}) 145 | week = partial(extend, {"type": "string", "formatters": "week"}) 146 | null = partial(extend, {"type": "null"}) 147 | 148 | @staticmethod 149 | def props(*args): 150 | properties = OrderedDict() 151 | for k, v in args: 152 | properties[k] = v 153 | 154 | return properties 155 | 156 | @staticmethod 157 | def fk(*args, **kwargs): 158 | if len(args) == 3: 159 | _, app, collection = args 160 | elif len(args) == 2: 161 | app, collection = args 162 | else: 163 | raise TypeError("Must provide at least app and collection to this function") 164 | return S.string({"type": "string", "fk": '/'.join([app, collection])}, **kwargs) 165 | 166 | @staticmethod 167 | def fk_array(*args, **kwargs): 168 | if len(args) == 3: 169 | _, app, collection = args 170 | elif len(args) == 2: 171 | app, collection = args 172 | else: 173 | raise TypeError("Must provide at least app and collection to this function") 174 | return S.array(items=S.fk(app, collection), **kwargs) 175 | 176 | @staticmethod 177 | def external_key(url, **kwargs): 178 | return S.string({"type": "string", "fk": url}, **kwargs) 179 | 180 | @staticmethod 181 | def ref(definition, **kwargs): 182 | url = "#/definitions/{definition}".format(**locals()) 183 | return extend({"$ref": url}, kwargs) 184 | 185 | @staticmethod 186 | def ref_array(definition, **kwargs): 187 | return S.array(items=S.ref(definition), **kwargs) 188 | 189 | @staticmethod 190 | def foreign_ref(suite, app, collection, definition, **kwargs): 191 | url = '/'.join((suite, app, collection, "#/definitions/{definition}".format(**locals()))) 192 | return extend({"$ref": url, "suite": suite, "app": app, "collection": collection}, kwargs) 193 | 194 | def external_ref(self, url, **kwargs): 195 | return extend({"$ref": url}, kwargs) 196 | 197 | @staticmethod 198 | def nullable(o): 199 | if isinstance(o.get('type', 'string'), list): 200 | o['type'].append('null') 201 | else: 202 | o['type'] = [o.get('type', 'string'), 'null'] 203 | 204 | return o 205 | 206 | @staticmethod 207 | def compose(*schemas): 208 | """ 209 | Composes schemas in order, with subsequent schemas taking precedence over earlier ones. 210 | 211 | Args: 212 | *schemas: A list of schemas. Definitions may be included 213 | 214 | Returns: 215 | A JSON schema 216 | """ 217 | result = OrderedDict() 218 | for s in schemas: 219 | result = deep_merge(result, s) 220 | return result 221 | 222 | -------------------------------------------------------------------------------- /sondra/static/css/flasky.css: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. Modifications by Kenneth Reitz. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | 10 | 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro'; 18 | font-size: 17px; 19 | background-color: white; 20 | color: #000; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.document { 26 | width: 940px; 27 | margin: 30px auto 0 auto; 28 | } 29 | 30 | div.documentwrapper { 31 | float: left; 32 | width: 100%; 33 | } 34 | 35 | div.bodywrapper { 36 | margin: 0 0 0 220px; 37 | } 38 | 39 | div.sphinxsidebar { 40 | width: 220px; 41 | } 42 | 43 | hr { 44 | border: 1px solid #B1B4B6; 45 | } 46 | 47 | div.body { 48 | background-color: #ffffff; 49 | color: #3E4349; 50 | padding: 0 30px 0 30px; 51 | } 52 | 53 | img.floatingflask { 54 | padding: 0 0 10px 10px; 55 | float: right; 56 | } 57 | 58 | div.footer { 59 | width: 940px; 60 | margin: 20px auto 30px auto; 61 | font-size: 14px; 62 | color: #888; 63 | text-align: right; 64 | } 65 | 66 | div.footer a { 67 | color: #888; 68 | } 69 | 70 | div.related { 71 | display: none; 72 | } 73 | 74 | div.sphinxsidebar a { 75 | color: #444; 76 | text-decoration: none; 77 | border-bottom: 1px dotted #999; 78 | } 79 | 80 | div.sphinxsidebar a:hover { 81 | border-bottom: 1px solid #999; 82 | } 83 | 84 | div.sphinxsidebar { 85 | font-size: 14px; 86 | line-height: 1.5; 87 | } 88 | 89 | div.sphinxsidebarwrapper { 90 | padding: 18px 10px; 91 | } 92 | 93 | div.sphinxsidebarwrapper p.logo { 94 | padding: 0; 95 | margin: -10px 0 0 -20px; 96 | text-align: center; 97 | } 98 | 99 | div.sphinxsidebar h3, 100 | div.sphinxsidebar h4 { 101 | font-family: 'Garamond', 'Georgia', serif; 102 | color: #444; 103 | font-size: 24px; 104 | font-weight: normal; 105 | margin: 0 0 5px 0; 106 | padding: 0; 107 | } 108 | 109 | div.sphinxsidebar h4 { 110 | font-size: 20px; 111 | } 112 | 113 | div.sphinxsidebar h3 a { 114 | color: #444; 115 | } 116 | 117 | div.sphinxsidebar p.logo a, 118 | div.sphinxsidebar h3 a, 119 | div.sphinxsidebar p.logo a:hover, 120 | div.sphinxsidebar h3 a:hover { 121 | border: none; 122 | } 123 | 124 | div.sphinxsidebar p { 125 | color: #555; 126 | margin: 10px 0; 127 | } 128 | 129 | div.sphinxsidebar ul { 130 | margin: 10px 0; 131 | padding: 0; 132 | color: #000; 133 | } 134 | 135 | div.sphinxsidebar input { 136 | border: 1px solid #ccc; 137 | font-family: 'Georgia', serif; 138 | font-size: 1em; 139 | } 140 | 141 | /* -- body styles ----------------------------------------------------------- */ 142 | 143 | a { 144 | color: #004B6B; 145 | text-decoration: underline; 146 | } 147 | 148 | a:hover { 149 | color: #6D4100; 150 | text-decoration: underline; 151 | } 152 | 153 | div.body h1, 154 | div.body h2, 155 | div.body h3, 156 | div.body h4, 157 | div.body h5, 158 | div.body h6 { 159 | font-family: 'Garamond', 'Georgia', serif; 160 | font-weight: normal; 161 | margin: 30px 0px 10px 0px; 162 | padding: 0; 163 | } 164 | 165 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } 166 | div.body h2 { font-size: 180%; } 167 | div.body h3 { font-size: 150%; } 168 | div.body h4 { font-size: 130%; } 169 | div.body h5 { font-size: 100%; } 170 | div.body h6 { font-size: 100%; } 171 | 172 | a.headerlink { 173 | color: #ddd; 174 | padding: 0 4px; 175 | text-decoration: none; 176 | } 177 | 178 | a.headerlink:hover { 179 | color: #444; 180 | background: #eaeaea; 181 | } 182 | 183 | div.body p, div.body dd, div.body li { 184 | line-height: 1.4em; 185 | } 186 | 187 | div.admonition { 188 | background: #fafafa; 189 | margin: 20px -30px; 190 | padding: 10px 30px; 191 | border-top: 1px solid #ccc; 192 | border-bottom: 1px solid #ccc; 193 | } 194 | 195 | div.admonition tt.xref, div.admonition a tt { 196 | border-bottom: 1px solid #fafafa; 197 | } 198 | 199 | dd div.admonition { 200 | margin-left: -60px; 201 | padding-left: 60px; 202 | } 203 | 204 | div.admonition p.admonition-title { 205 | font-family: 'Garamond', 'Georgia', serif; 206 | font-weight: normal; 207 | font-size: 24px; 208 | margin: 0 0 10px 0; 209 | padding: 0; 210 | line-height: 1; 211 | } 212 | 213 | div.admonition p.last { 214 | margin-bottom: 0; 215 | } 216 | 217 | div.highlight { 218 | background-color: white; 219 | } 220 | 221 | dt:target, .highlight { 222 | background: #FAF3E8; 223 | } 224 | 225 | div.note { 226 | background-color: #eee; 227 | border: 1px solid #ccc; 228 | } 229 | 230 | div.seealso { 231 | background-color: #ffc; 232 | border: 1px solid #ff6; 233 | } 234 | 235 | div.topic { 236 | background-color: #eee; 237 | } 238 | 239 | p.admonition-title { 240 | display: inline; 241 | } 242 | 243 | p.admonition-title:after { 244 | content: ":"; 245 | } 246 | 247 | pre, tt { 248 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 249 | font-size: 0.9em; 250 | } 251 | 252 | img.screenshot { 253 | } 254 | 255 | tt.descname, tt.descclassname { 256 | font-size: 0.95em; 257 | } 258 | 259 | tt.descname { 260 | padding-right: 0.08em; 261 | } 262 | 263 | img.screenshot { 264 | -moz-box-shadow: 2px 2px 4px #eee; 265 | -webkit-box-shadow: 2px 2px 4px #eee; 266 | box-shadow: 2px 2px 4px #eee; 267 | } 268 | 269 | table.docutils { 270 | border: 1px solid #888; 271 | -moz-box-shadow: 2px 2px 4px #eee; 272 | -webkit-box-shadow: 2px 2px 4px #eee; 273 | box-shadow: 2px 2px 4px #eee; 274 | } 275 | 276 | table.docutils td, table.docutils th { 277 | border: 1px solid #888; 278 | padding: 0.25em 0.7em; 279 | } 280 | 281 | table.field-list, table.footnote { 282 | border: none; 283 | -moz-box-shadow: none; 284 | -webkit-box-shadow: none; 285 | box-shadow: none; 286 | } 287 | 288 | table.footnote { 289 | margin: 15px 0; 290 | width: 100%; 291 | border: 1px solid #eee; 292 | background: #fdfdfd; 293 | font-size: 0.9em; 294 | } 295 | 296 | table.footnote + table.footnote { 297 | margin-top: -15px; 298 | border-top: none; 299 | } 300 | 301 | table.field-list th { 302 | padding: 0 0.8em 0 0; 303 | } 304 | 305 | table.field-list td { 306 | padding: 0; 307 | } 308 | 309 | table.footnote td.label { 310 | width: 0px; 311 | padding: 0.3em 0 0.3em 0.5em; 312 | } 313 | 314 | table.footnote td { 315 | padding: 0.3em 0.5em; 316 | } 317 | 318 | dl { 319 | margin: 0; 320 | padding: 0; 321 | } 322 | 323 | dl dd { 324 | margin-left: 30px; 325 | } 326 | 327 | blockquote { 328 | margin: 0 0 0 30px; 329 | padding: 0; 330 | } 331 | 332 | ul, ol { 333 | margin: 10px 0 10px 30px; 334 | padding: 0; 335 | } 336 | 337 | pre { 338 | background: #eee; 339 | padding: 7px 30px; 340 | margin: 15px -30px; 341 | line-height: 1.3em; 342 | } 343 | 344 | dl pre, blockquote pre, li pre { 345 | margin-left: -60px; 346 | padding-left: 60px; 347 | } 348 | 349 | dl dl pre { 350 | margin-left: -90px; 351 | padding-left: 90px; 352 | } 353 | 354 | tt { 355 | background-color: #ecf0f3; 356 | color: #222; 357 | /* padding: 1px 2px; */ 358 | } 359 | 360 | tt.xref, a tt { 361 | background-color: #FBFBFB; 362 | border-bottom: 1px solid white; 363 | } 364 | 365 | a.reference { 366 | text-decoration: none; 367 | border-bottom: 1px dotted #004B6B; 368 | } 369 | 370 | a.reference:hover { 371 | border-bottom: 1px solid #6D4100; 372 | } 373 | 374 | a.footnote-reference { 375 | text-decoration: none; 376 | font-size: 0.7em; 377 | vertical-align: top; 378 | border-bottom: 1px dotted #004B6B; 379 | } 380 | 381 | a.footnote-reference:hover { 382 | border-bottom: 1px solid #6D4100; 383 | } 384 | 385 | a:hover tt { 386 | background: #EEE; 387 | } 388 | 389 | 390 | @media screen and (max-width: 870px) { 391 | 392 | div.sphinxsidebar { 393 | display: none; 394 | } 395 | 396 | div.document { 397 | width: 100%; 398 | 399 | } 400 | 401 | div.documentwrapper { 402 | margin-left: 0; 403 | margin-top: 0; 404 | margin-right: 0; 405 | margin-bottom: 0; 406 | } 407 | 408 | div.bodywrapper { 409 | margin-top: 0; 410 | margin-right: 0; 411 | margin-bottom: 0; 412 | margin-left: 0; 413 | } 414 | 415 | ul { 416 | margin-left: 0; 417 | } 418 | 419 | .document { 420 | width: auto; 421 | } 422 | 423 | .footer { 424 | width: auto; 425 | } 426 | 427 | .bodywrapper { 428 | margin: 0; 429 | } 430 | 431 | .footer { 432 | width: auto; 433 | } 434 | 435 | .github { 436 | display: none; 437 | } 438 | 439 | 440 | 441 | } 442 | 443 | 444 | 445 | @media screen and (max-width: 875px) { 446 | 447 | body { 448 | margin: 0; 449 | padding: 20px 30px; 450 | } 451 | 452 | div.documentwrapper { 453 | float: none; 454 | background: white; 455 | } 456 | 457 | div.sphinxsidebar { 458 | display: block; 459 | float: none; 460 | width: 102.5%; 461 | margin: 50px -30px -20px -30px; 462 | padding: 10px 20px; 463 | background: #333; 464 | color: white; 465 | } 466 | 467 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, 468 | div.sphinxsidebar h3 a { 469 | color: white; 470 | } 471 | 472 | div.sphinxsidebar a { 473 | color: #aaa; 474 | } 475 | 476 | div.sphinxsidebar p.logo { 477 | display: none; 478 | } 479 | 480 | div.document { 481 | width: 100%; 482 | margin: 0; 483 | } 484 | 485 | div.related { 486 | display: block; 487 | margin: 0; 488 | padding: 10px 0 20px 0; 489 | } 490 | 491 | div.related ul, 492 | div.related ul li { 493 | margin: 0; 494 | padding: 0; 495 | } 496 | 497 | div.footer { 498 | display: none; 499 | } 500 | 501 | div.bodywrapper { 502 | margin: 0; 503 | } 504 | 505 | div.body { 506 | min-height: 0; 507 | padding: 0; 508 | } 509 | 510 | .rtd_doc_footer { 511 | display: none; 512 | } 513 | 514 | .document { 515 | width: auto; 516 | } 517 | 518 | .footer { 519 | width: auto; 520 | } 521 | 522 | .footer { 523 | width: auto; 524 | } 525 | 526 | .github { 527 | display: none; 528 | } 529 | } 530 | 531 | 532 | /* misc. */ 533 | 534 | .revsys-inline { 535 | display: none!important; 536 | } -------------------------------------------------------------------------------- /sondra/suite/signals.py: -------------------------------------------------------------------------------- 1 | from blinker import signal 2 | 3 | pre_init = signal('suite-pre-init') 4 | post_init = signal('suite-post-init') 5 | pre_app_registration = signal('suite-pre-app-registration') 6 | post_app_registration = signal('suite-post-app-registration') -------------------------------------------------------------------------------- /sondra/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/sondra/tests/__init__.py -------------------------------------------------------------------------------- /sondra/tests/data/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "http://some.site.somewhere/entry-schema#", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "description": "schema for an fstab entry", 5 | "type": "object", 6 | "required": [ "storage" ], 7 | "properties": { 8 | "storage": { 9 | "type": "object", 10 | "oneOf": [ 11 | { "$ref": "#/definitions/diskDevice" }, 12 | { "$ref": "#/definitions/diskUUID" }, 13 | { "$ref": "#/definitions/nfs" }, 14 | { "$ref": "#/definitions/tmpfs" } 15 | ] 16 | }, 17 | "fstype": { 18 | "enum": [ "ext3", "ext4", "btrfs" ] 19 | }, 20 | "options": { 21 | "type": "array", 22 | "minItems": 1, 23 | "items": { "type": "string" }, 24 | "uniqueItems": true 25 | }, 26 | "readonly": { "type": "boolean" } 27 | }, 28 | "definitions": { 29 | "diskDevice": { 30 | "properties": { 31 | "type": { "enum": [ "disk" ] }, 32 | "device": { 33 | "type": "string", 34 | "pattern": "^/dev/[^/]+(/[^/]+)*$" 35 | } 36 | }, 37 | "required": [ "type", "device" ], 38 | "additionalProperties": false 39 | }, 40 | "diskUUID": { 41 | "properties": { 42 | "type": { "enum": [ "disk" ] }, 43 | "label": { 44 | "type": "string", 45 | "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" 46 | } 47 | }, 48 | "required": [ "type", "label" ], 49 | "additionalProperties": false 50 | }, 51 | "nfs": { 52 | "properties": { 53 | "type": { "enum": [ "nfs" ] }, 54 | "remotePath": { 55 | "type": "string", 56 | "pattern": "^(/[^/]+)+$" 57 | }, 58 | "server": { 59 | "type": "string", 60 | "oneOf": [ 61 | { "format": "host-name" }, 62 | { "format": "ipv4" }, 63 | { "format": "ipv6" } 64 | ] 65 | } 66 | }, 67 | "required": [ "type", "server", "remotePath" ], 68 | "additionalProperties": false 69 | }, 70 | "tmpfs": { 71 | "properties": { 72 | "type": { "enum": [ "tmpfs" ] }, 73 | "sizeInMB": { 74 | "type": "integer", 75 | "minimum": 16, 76 | "maximum": 512 77 | } 78 | }, 79 | "required": [ "type", "sizeInMB" ], 80 | "additionalProperties": false 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /sondra/tests/test_applications.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .api import * 3 | from sondra.collection import Collection 4 | 5 | 6 | @pytest.fixture(scope='module') 7 | def s(request): 8 | v = ConcreteSuite() 9 | EmptyApp(v) 10 | DerivedApp(v) 11 | SimpleApp(v) 12 | SimpleApp(v, "Alt") 13 | return v 14 | 15 | 16 | def test_app_collections(s): 17 | "Make sure all collections are in the app, and that all contents of app are collections" 18 | assert 'simple-documents' in s['simple-app'] 19 | assert 'simple-points' in s['simple-app'] 20 | assert 'foreign-key-docs' in s['simple-app'] 21 | assert len(s['simple-app']) == 4 22 | assert all([isinstance(x, Collection) for x in s['simple-app'].values()]) 23 | 24 | assert 'simple-documents' not in s['empty-app'] 25 | 26 | 27 | def test_application_methods_local(s): 28 | """Make sure that all local application methods still act like methods""" 29 | assert s['simple-app'].simple_none_return() is None 30 | assert s['simple-app'].simple_int_return() == 1 31 | assert s['simple-app'].simple_number_return() == 1.0 32 | assert s['simple-app'].simple_str_return() == "String" 33 | assert s['simple-app'].list_return() == ["0", "1", "2", "3"] 34 | assert s['simple-app'].dict_return() == {'a': 0, 'b': 1, 'c': 2} 35 | assert s['simple-app'].operates_on_self() == s['simple-app'].title 36 | 37 | 38 | def test_derived_app_inheritance(s): 39 | """Make sure that inheritance never modifies the base class and that appropriate attributes are merged""" 40 | assert hasattr(s['derived-app'], 'simple_none_return') 41 | assert hasattr(s['derived-app'], 'derived_method') 42 | assert 'derived-collection' in s['derived-app'] 43 | 44 | assert not hasattr(s['simple-app'], 'derived_method') 45 | assert 'derived-collection' not in s['simple-app'] 46 | 47 | 48 | def test_app_construction(s): 49 | """Make sure that apps are consistent post-construction""" 50 | empty_app = s['empty-app'] 51 | simple_app = s['simple-app'] 52 | derived_app = s['derived-app'] 53 | 54 | assert hasattr(empty_app, "definitions") 55 | assert empty_app.definitions == {} 56 | assert 'appDef' in simple_app.definitions 57 | assert 'appDef' in derived_app.definitions 58 | assert 'derivedDef' in derived_app.definitions 59 | assert 'derivedDef' not in simple_app.definitions 60 | 61 | 62 | def test_app_properties(s): 63 | """Make sure that calculated properties of the app are correct""" 64 | simple_app = s['simple-app'] 65 | derived_app = s['derived-app'] 66 | 67 | assert simple_app.url == (s.url + '/simple-app') 68 | assert simple_app.title == "Simple App" 69 | assert derived_app.url == (s.url + '/derived-app') 70 | assert derived_app.title == "Derived App" 71 | 72 | 73 | def test_app_schema(s): 74 | """Make sure the schema contains everything it should""" 75 | simple_app = s['simple-app'] 76 | simple_app_schema = simple_app.schema 77 | 78 | assert 'definitions' in simple_app_schema 79 | assert 'appDef' in simple_app_schema['definitions'] 80 | 81 | assert simple_app_schema['type'] == 'object' 82 | assert 'methods' in simple_app_schema 83 | assert 'simple-none-return' in simple_app_schema['methods'] 84 | assert 'derived-method' not in simple_app_schema['methods'] 85 | assert simple_app_schema['id'] == (s.url + '/' + simple_app.slug + ';schema') 86 | assert simple_app_schema['type'] == 'object' 87 | assert all([x in simple_app_schema['collections'] for x in simple_app]) 88 | assert all([simple_app_schema['collections'][k] == v.url for k, v in simple_app.items()]) 89 | assert simple_app_schema['description'] == (simple_app.__doc__ or "*No description provided*") 90 | 91 | def test_help(s): 92 | """Make sure help returns something, even in edge cases""" 93 | assert isinstance(s['simple-app'].help(), str) 94 | assert isinstance(s['derived-app'].help(), str) 95 | assert isinstance(s['empty-app'].help(), str) -------------------------------------------------------------------------------- /sondra/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from sondra.auth import Auth, Credentials, User, Role 2 | import pytest 3 | 4 | from sondra import suite 5 | from sondra.api.ref import Reference 6 | 7 | class ConcreteSuite(suite.Suite): 8 | url = "http://localhost:5000/api" 9 | 10 | 11 | s = ConcreteSuite() 12 | 13 | auth = Auth(s) 14 | 15 | s.clear_databases() 16 | 17 | @pytest.fixture 18 | def calvin(request): 19 | r = s['auth']['roles'].create({ 20 | "title": "Calvin Role", 21 | "description": "Calvin Role Description", 22 | "permissions": [ 23 | { 24 | "application": "auth", 25 | "collection": "roles", 26 | "allowed": ["read","write","delete"] 27 | } 28 | ] 29 | }) 30 | u = s['auth']['users'].create_user( 31 | username='calvin', 32 | password='password', 33 | email='user@nowhere.com', 34 | family_name='Powers', 35 | given_name='Calvin', 36 | names=['S'], 37 | roles=[r] 38 | ) 39 | assert u == 'http://localhost:5000/api/auth/users/calvin' 40 | 41 | def teardown(): 42 | Reference(s, u).value.delete() 43 | r.delete() 44 | 45 | request.addfinalizer(teardown) 46 | 47 | return u 48 | 49 | @pytest.fixture 50 | def local_calvin(calvin): 51 | return s['auth']['users']['calvin'] 52 | 53 | 54 | @pytest.fixture 55 | def role(request): 56 | r = s['auth']['roles'].create({ 57 | "title": "Test Role", 58 | "description": "Test Role Description", 59 | "permissions": [ 60 | { 61 | "application": "auth", 62 | "collection": "roles", 63 | "allowed": ["read","write","delete"] 64 | } 65 | ] 66 | }) 67 | def teardown(): 68 | r.delete() 69 | request.addfinalizer(teardown) 70 | 71 | return s['auth']['roles']['test-role'] 72 | 73 | 74 | def test_credentials(local_calvin): 75 | creds = s['auth']['user-credentials'][local_calvin] 76 | assert isinstance(creds, Credentials) 77 | assert creds['password'] != 'password' 78 | assert creds['salt'] 79 | assert creds['secret'] 80 | assert creds['salt'] != creds['secret'] 81 | 82 | 83 | def test_role(role): 84 | assert role['slug'] == 'test-role' 85 | assert 'test-role' in s['auth']['roles'] 86 | assert (role.authorizes(s['auth']['roles'], 'write') == True) 87 | assert (role.authorizes(s['auth']['roles'], 'read') == True) 88 | 89 | 90 | def test_user_role(local_calvin): 91 | assert isinstance(local_calvin['roles'][0], Role) 92 | 93 | 94 | def test_login_local(local_calvin): 95 | # test login 96 | token = s['auth'].login(local_calvin['username'], 'password') 97 | assert isinstance(token, str) 98 | assert '.' in token 99 | assert isinstance(s['auth'].check(token, user=local_calvin['username']), User) 100 | 101 | # Test logout 102 | s['auth'].logout(token) 103 | creds = s['auth']['user-credentials'][local_calvin] 104 | assert creds['secret'] not in s['auth']['logged-in-users'] 105 | 106 | 107 | def test_renew(local_calvin): 108 | token = s['auth'].login(local_calvin['username'], 'password') 109 | 110 | # test renew 111 | try: 112 | token2 = s['auth'].renew(token) 113 | assert token2 != token 114 | print(token2) 115 | print(token) 116 | assert s['auth'].check(token2, user='calvin') 117 | assert s['auth']['logged-in-users'].for_token(token) is None 118 | assert s['auth']['logged-in-users'].for_token(token2) is not None 119 | finally: 120 | s['auth'].logout(token) 121 | 122 | 123 | -------------------------------------------------------------------------------- /sondra/tests/test_collections.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sondra.suite import SuiteException 4 | from .api import * 5 | from sondra.collection import Collection 6 | 7 | def _ignore_ex(f): 8 | try: 9 | f() 10 | except SuiteException: 11 | pass 12 | 13 | 14 | @pytest.fixture(scope='module') 15 | def s(request): 16 | v = ConcreteSuite() 17 | _ignore_ex(lambda: EmptyApp(v)) 18 | _ignore_ex(lambda: DerivedApp(v)) 19 | _ignore_ex(lambda: SimpleApp(v)) 20 | _ignore_ex(lambda: SimpleApp(v, "Alt")) 21 | v.ensure_database_objects() 22 | return v 23 | 24 | 25 | def test_collection_methods_local(s): 26 | assert s['simple-app']['simple-documents'].simple_none_return() is None 27 | assert s['simple-app']['simple-documents'].simple_int_return() == 1 28 | assert s['simple-app']['simple-documents'].simple_number_return() == 1.0 29 | assert s['simple-app']['simple-documents'].simple_str_return() == "String" 30 | assert s['simple-app']['simple-documents'].list_return() == ["0", "1", "2", "3"] 31 | assert s['simple-app']['simple-documents'].dict_return() == {'a': 0, 'b': 1, 'c': 2} 32 | assert s['simple-app']['simple-documents'].operates_on_self() == s['simple-app']['simple-documents'].title 33 | 34 | 35 | def test_derived_collection_inheritance(s): 36 | """Make sure that inheritance never modifies the base class and that appropriate attributes are merged""" 37 | base_coll = s['simple-app']['simple-documents'] 38 | derived_coll = s['derived-app']['derived-collection'] 39 | 40 | assert hasattr(derived_coll, 'simple_none_return') 41 | assert hasattr(derived_coll, 'derived_method') 42 | 43 | assert not hasattr(base_coll, 'derived_method') 44 | 45 | 46 | def test_collection_construction(s): 47 | coll = s['simple-app']['simple-documents'] 48 | 49 | 50 | def test_collection_properties(s): 51 | coll = s['simple-app']['simple-documents'] 52 | 53 | assert coll.suite 54 | assert coll.application 55 | assert coll.url == coll.application.url + '/' + coll.slug 56 | assert coll.table 57 | 58 | 59 | def test_collection_schema(s): 60 | assert 'id' in s['simple-app']['simple-documents'].schema 61 | assert s['simple-app']['simple-documents'].schema['id'].startswith(s['simple-app']['simple-documents'].url) 62 | 63 | 64 | def test_abstract_collection(s): 65 | class AbstractCollection(Collection): 66 | "An abstract collection" 67 | 68 | @expose_method 69 | def exposed_method(self) -> None: 70 | return None 71 | 72 | 73 | class ConcreteCollection(AbstractCollection): 74 | document_class = SimpleDocument 75 | 76 | 77 | assert AbstractCollection.abstract 78 | assert not ConcreteCollection.abstract 79 | 80 | 81 | def test_collection_help(s): 82 | assert s['simple-app']['simple-documents'].help() 83 | assert s['simple-app']['simple-points'].help() 84 | assert s['simple-app']['foreign-key-docs'].help() -------------------------------------------------------------------------------- /sondra/tests/test_documentprocessors.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/sondra/tests/test_documentprocessors.py -------------------------------------------------------------------------------- /sondra/tests/test_documents.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sondra.suite import SuiteException 4 | from .api import * 5 | from datetime import datetime 6 | 7 | 8 | def _ignore_ex(f): 9 | try: 10 | f() 11 | except SuiteException: 12 | pass 13 | 14 | 15 | @pytest.fixture(scope='module') 16 | def s(request): 17 | v = ConcreteSuite() 18 | _ignore_ex(lambda: SimpleApp(v)) 19 | _ignore_ex(lambda: DerivedApp(v)) 20 | v.clear_databases() 21 | return v 22 | 23 | 24 | @pytest.fixture() 25 | def simple_document(request, s): 26 | doc = s['simple-app']['simple-documents'].create({ 27 | 'name': "Document 1", 28 | 'date': datetime.now(), 29 | }) 30 | def teardown(): 31 | doc.delete() 32 | request.addfinalizer(teardown) 33 | 34 | return doc 35 | 36 | 37 | 38 | 39 | @pytest.fixture() 40 | def foreign_key_document(request, s, simple_document): 41 | doc = s['simple-app']['foreign-key-docs'].create({ 42 | 'name': 'FK Doc', 43 | 'simple_document': simple_document, 44 | 'rest': [simple_document, simple_document, simple_document] 45 | }) 46 | def teardown(): 47 | doc.delete() 48 | request.addfinalizer(teardown) 49 | 50 | return doc 51 | 52 | 53 | @pytest.fixture() 54 | def simple_point(request, s): 55 | doc = s['simple-app']['simple-points'].create({ 56 | 'name': "A Point", 57 | 'date': datetime.now(), 58 | 'geometry': {"type": "Point", "coordinates": [-85.1, 31.8]} 59 | }) 60 | def teardown(): 61 | doc.delete() 62 | request.addfinalizer(teardown) 63 | 64 | return doc 65 | 66 | 67 | def test_document_methods_local(s): 68 | assert s['simple-app']['simple-documents'].simple_none_return() is None 69 | assert s['simple-app']['simple-documents'].simple_int_return() == 1 70 | assert s['simple-app']['simple-documents'].simple_number_return() == 1.0 71 | assert s['simple-app']['simple-documents'].simple_str_return() == "String" 72 | assert s['simple-app']['simple-documents'].list_return() == ["0", "1", "2", "3"] 73 | assert s['simple-app']['simple-documents'].dict_return() == {'a': 0, 'b': 1, 'c': 2} 74 | assert s['simple-app']['simple-documents'].operates_on_self() == s['simple-app']['simple-documents'].title 75 | 76 | 77 | 78 | 79 | def test_derived_document_inheritance(s): 80 | """Make sure that inheritance never modifies the base class and that appropriate attributes are merged""" 81 | base_coll = s['simple-app']['simple-documents'] 82 | derived_coll = s['derived-app']['derived-collection'] 83 | 84 | assert hasattr(derived_coll, 'simple_none_return') 85 | assert hasattr(derived_coll, 'derived_method') 86 | 87 | assert not hasattr(base_coll, 'derived_method') 88 | 89 | 90 | def test_document_construction(s): 91 | coll = s['simple-app']['simple-documents'] 92 | 93 | 94 | def test_document_properties(s): 95 | coll = s['simple-app']['simple-documents'] 96 | 97 | assert coll.suite 98 | assert coll.application 99 | assert coll.url == coll.application.url + '/' + coll.slug 100 | assert coll.table 101 | 102 | 103 | def test_simple_document_creation(s, simple_document): 104 | assert simple_document.id 105 | assert simple_document.id == simple_document.slug 106 | assert simple_document.slug is not None 107 | assert simple_document.slug == 'document-1' 108 | assert 'document-1' in simple_document.collection 109 | assert simple_document['date'] is not None 110 | assert simple_document['timestamp'] is not None 111 | assert simple_document['value'] == 0 # make sure defaults work 112 | 113 | 114 | def test_document_update(s, simple_document): 115 | simple_document['value'] = 1024 116 | simple_document.save(conflict='replace') 117 | updated = s['simple-app']['simple-documents'][simple_document.id] 118 | assert updated['value'] == 1024 119 | 120 | 121 | def test_foreign_key_doc_creation(s, foreign_key_document): 122 | single = foreign_key_document.fetch('simple_document') 123 | multiple = foreign_key_document.fetch('rest') 124 | 125 | assert isinstance(single, SimpleDocument) 126 | assert isinstance(multiple, list) 127 | assert all([isinstance(x, SimpleDocument) for x in multiple]) 128 | assert isinstance(foreign_key_document['simple_document'], SimpleDocument) 129 | assert isinstance(foreign_key_document['rest'], list) 130 | assert all([isinstance(x, SimpleDocument) for x in foreign_key_document['rest']]) 131 | 132 | 133 | def test_simple_point_creation(s, simple_point): 134 | assert simple_point['geometry'] 135 | 136 | 137 | def test_document_help(s): 138 | assert s['simple-app']['simple-documents'].help() 139 | assert s['simple-app']['simple-points'].help() 140 | assert s['simple-app']['foreign-key-docs'].help() -------------------------------------------------------------------------------- /sondra/tests/test_exposed.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sondra import document, suite, collection, application 4 | from shapely.geometry import mapping, shape 5 | import sondra.collection 6 | 7 | 8 | class ConcreteSuite(suite.Suite): 9 | pass 10 | 11 | 12 | class SimpleDocument(document.Document): 13 | schema = { 14 | "type": "object", 15 | "required": ["name"], 16 | "properties": { 17 | "name": {"type": "string", "description": "The template name. Must be unique in the collection"}, 18 | 19 | } 20 | } 21 | 22 | 23 | class SimpleDocuments(collection.Collection): 24 | document_class = SimpleDocument 25 | primary_key = 'name' 26 | 27 | 28 | class BaseApp(application.Application): 29 | collections = ( 30 | SimpleDocument, 31 | ) 32 | 33 | 34 | @pytest.fixture(scope='module') 35 | def s(request): 36 | v = ConcreteSuite() 37 | return v 38 | 39 | @pytest.fixture(scope='module') 40 | def apps(request, s): 41 | customer_jeff = TerraHubBase(s, 'jeff') 42 | customer_steve = TerraHubBase(s, 'steve') 43 | s.clear_databases() 44 | return (customer_jeff, customer_steve) 45 | 46 | 47 | @pytest.fixture 48 | def tracked_item_templates(request, apps): 49 | customer_jeff, customer_steve = apps 50 | chint300 = customer_jeff["tracked-item-templates"].create({ 51 | "name": "CHINT-300.2014", 52 | "category": "module", 53 | "baseGeometry": { 54 | "type": "Polygon", 55 | "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.51], [0.0, 1.51], [0.0, 0.0]]] 56 | }, 57 | "properties": { 58 | "manufacturer": "CHINT", 59 | "wattage": 300, 60 | } 61 | }) 62 | ae_inverter = customer_steve["tracked-item-templates"].create({ 63 | "name": "AE2310", 64 | "category": "inverter", 65 | "baseGeometry": { 66 | "type": "Polygon", 67 | "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]]] 68 | }, 69 | "properties": { 70 | "manufacturer": "Advanced Energy", 71 | "wattage": 15000, 72 | "strings": 6 73 | } 74 | }) 75 | def fin(): 76 | ae_inverter.delete() 77 | chint300.delete() 78 | request.addfinalizer(fin) 79 | return chint300, ae_inverter 80 | 81 | 82 | @pytest.fixture 83 | def tracked_items(request, apps, tracked_item_templates): 84 | customer_jeff, customer_steve = apps 85 | chint300, ae_inverter = tracked_item_templates 86 | 87 | chint300_0001 = customer_jeff["tracked-items"].create({ 88 | "barcode": "0001", 89 | "template": chint300, 90 | "properties": { 91 | "batch": "2014.00", 92 | } 93 | }) 94 | chint300_0002 = customer_jeff["tracked-items"].create({ 95 | "barcode": "0002", 96 | "template": chint300, 97 | "properties": { 98 | "batch": "2014.00", 99 | } 100 | }) 101 | ae_inverter_0001 = customer_steve["tracked-items"].create({ 102 | "barcode": "0001", 103 | "template": ae_inverter, 104 | "properties": { 105 | "manufacturerTested": True, 106 | "thirdPartyTested": True, 107 | "thirdPartyTester": "CEA" 108 | } 109 | }) 110 | def fin(): 111 | ae_inverter_0001.delete() 112 | chint300_0001.delete() 113 | chint300_0002.delete() 114 | request.addfinalizer(fin) 115 | return ae_inverter_0001, chint300_0001, chint300_0002 -------------------------------------------------------------------------------- /sondra/tests/test_filestorage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | import json 4 | 5 | from .api import * 6 | 7 | BASE_URL = ConcreteSuite.url 8 | 9 | def _url(*args): 10 | return '/'.join((BASE_URL,) + args) 11 | 12 | @pytest.fixture() 13 | def file_document(): 14 | return { 15 | "name": "file document 1" 16 | } 17 | 18 | def test_add_delete_filedocument(file_document): 19 | destination = _url('simple-app', 'file-documents') 20 | 21 | # delete all documents in a collection 22 | confirmed_dangerous_delete = requests.delete(destination, params={'delete_all': True}) 23 | assert confirmed_dangerous_delete.ok 24 | 25 | # add an item to the collection 26 | with open("sondra/tests/data/test.json", 'rb') as post_file: 27 | post = requests.post(destination, data={"__objs": json.dumps(file_document)}, files={"file": post_file}) 28 | assert post.ok 29 | 30 | # get all the docs added (one) 31 | get_all = requests.get(destination + ';json') 32 | assert get_all.ok 33 | assert len(get_all.json()) == 1 34 | doc = get_all.json()[0] 35 | assert 'file' in doc 36 | assert doc['file'].startswith('http') 37 | 38 | get_file = requests.get(doc['file']) 39 | assert get_file.ok 40 | with open("sondra/tests/data/test.json") as input_file: 41 | assert json.load(input_file) == get_file.json() 42 | 43 | 44 | # delete all documents in a collection 45 | confirmed_dangerous_delete = requests.delete(destination, params={'delete_all': True}) 46 | assert confirmed_dangerous_delete.ok 47 | -------------------------------------------------------------------------------- /sondra/tests/test_suite.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sondra.suite import SuiteException 4 | from .api import * 5 | from sondra.application import Application 6 | 7 | 8 | def _ignore_ex(f): 9 | try: 10 | f() 11 | except SuiteException: 12 | pass 13 | 14 | 15 | @pytest.fixture() 16 | def s(request): 17 | v = ConcreteSuite() 18 | _ignore_ex(lambda: EmptyApp(v)) 19 | _ignore_ex(lambda: DerivedApp(v)) 20 | _ignore_ex(lambda: SimpleApp(v)) 21 | _ignore_ex(lambda: SimpleApp(v, "Alt")) 22 | return v 23 | 24 | 25 | def test_suite_apps(s): 26 | """Make sure apps were registered in the suite""" 27 | assert 'empty-app' in s 28 | assert 'simple-app' in s 29 | assert 'alt' in s 30 | assert len(s) == 4, "Wrong number of apps was {0} should be 4: {1}".format(len(s.keys()), [x for x in s.keys()]) 31 | assert all([isinstance(x, Application) for x in s.values()]) 32 | 33 | assert s['simple-app'].db == 'simple_app' 34 | assert s['alt'].db == 'alt' 35 | 36 | 37 | def test_suite_properties(s): 38 | """Make sure that properties are consistent after init""" 39 | assert s.slug == 'api' 40 | 41 | 42 | def test_suite_schema(s): 43 | """Make sure the schema is structured as expected""" 44 | assert isinstance(s.schema, dict) 45 | assert s.schema['title'] == "Sondra-Based API" 46 | assert s.schema['id'] == "http://localhost:5000/api;schema" 47 | assert s.schema['description'] == (s.__doc__ or "*No description provided.*") 48 | assert all([s.schema['applications'][k] == v.url for k, v in s.items()]) 49 | assert 'point' in s.schema['definitions'] 50 | assert 'concreteSuiteDefn' in s.schema['definitions'] 51 | 52 | 53 | def test_help(s): 54 | """Make sure that the help method returns something, even in edge cases""" 55 | assert isinstance(s.help(), str) -------------------------------------------------------------------------------- /sondra/tests/test_valuehandlers.py: -------------------------------------------------------------------------------- 1 | from sondra.document.valuehandlers import DateTime, Now 2 | from sondra.document.schema_parser import Geometry, DateTime, Now 3 | from shapely.geometry import Point 4 | from datetime import datetime 5 | import rethinkdb as r 6 | import pytest 7 | 8 | from sondra.tests.api import * 9 | from sondra.auth import Auth 10 | 11 | s = ConcreteSuite() 12 | 13 | api = SimpleApp(s) 14 | auth = Auth(s) 15 | AuthenticatedApp(s) 16 | AuthorizedApp(s) 17 | s.ensure_database_objects() 18 | 19 | 20 | @pytest.fixture(scope='module') 21 | def simple_doc(request): 22 | simple_doc = s['simple-app']['simple-documents'].create({ 23 | 'name': "valuehandler test", 24 | "date": datetime.now(), 25 | "value": 0 26 | }) 27 | def teardown(): 28 | simple_doc.delete() 29 | request.addfinalizer(teardown) 30 | return simple_doc 31 | 32 | 33 | @pytest.fixture(scope='module') 34 | def fk_doc(request, simple_doc): 35 | fk_doc = s['simple-app']['foreign-key-docs'].create({ 36 | 'name': "valuehandler test foreign key", 37 | 'simple_document': simple_doc, 38 | 'rest': [simple_doc] 39 | }) 40 | def teardown(): 41 | fk_doc.delete() 42 | request.addfinalizer(teardown) 43 | return fk_doc 44 | 45 | 46 | def test_foreignkey(fk_doc, simple_doc): 47 | retr_doc = s['simple-app']['foreign-key-docs']['valuehandler-test-foreign-key'] 48 | 49 | # make sure our object representation is the JSON one in the retrieved object. 50 | assert isinstance(fk_doc.obj['simple_document'], str) 51 | assert fk_doc.obj['simple_document'] == simple_doc.url 52 | 53 | # make sure our object representation is the JSON one in the retrieved object. 54 | assert isinstance(retr_doc.obj['simple_document'], str) 55 | assert retr_doc.obj['simple_document'] == simple_doc.url 56 | 57 | storage_repr = fk_doc.rql_repr() 58 | assert storage_repr['simple_document'] == simple_doc.id 59 | 60 | assert isinstance(fk_doc['simple_document'], SimpleDocument) -------------------------------------------------------------------------------- /sondra/tests/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffersonheard/sondra/da9159924824aeb2dd3db7b72cefa40c197bc7cb/sondra/tests/web/__init__.py -------------------------------------------------------------------------------- /sondra/tests/web/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, Response 2 | import os 3 | import rethinkdb as r 4 | 5 | from sondra.auth import Auth 6 | from sondra.flask import api_tree 7 | 8 | from sondra.tests.api import SimpleApp, ConcreteSuite, AuthenticatedApp, AuthorizedApp 9 | 10 | app = Flask(__name__) 11 | app.debug = True 12 | 13 | app.suite = suite = ConcreteSuite() 14 | 15 | api = SimpleApp(app.suite) 16 | auth = Auth(app.suite) 17 | AuthenticatedApp(app.suite) 18 | AuthorizedApp(app.suite) 19 | app.suite.ensure_database_objects() 20 | # app.suite.clear_databases() 21 | 22 | app.register_blueprint(api_tree, url_prefix='/api') 23 | 24 | @app.route('/uploads/') 25 | def serve_media(locator): 26 | print(locator) 27 | application, collection, filename = locator.split('/') 28 | uuid, orig_filename = filename.split(',', 1) 29 | file_record = r.db(application.replace('-','_')).table(collection.replace('-','_') + "__files").get(uuid).run(app.suite[application].connection) 30 | mimetype = file_record['metadata'].get('mimetype','application/x-octet-stream') 31 | 32 | def generator(): 33 | with open(file_record['filename']) as stream: 34 | while True: 35 | chunk = stream.read(65336) 36 | if chunk: 37 | yield chunk 38 | else: 39 | break 40 | 41 | return Response(generator(), mimetype=mimetype) 42 | 43 | 44 | if __name__ == '__main__': 45 | app.run() -------------------------------------------------------------------------------- /sondra/utils.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import re 4 | from collections import OrderedDict 5 | from copy import deepcopy 6 | import hashlib 7 | import random 8 | import time 9 | import sys 10 | from importlib import import_module 11 | import warnings 12 | import functools 13 | import pytz 14 | import datetime 15 | 16 | 17 | def split_camelcase(name): 18 | return re.sub('([A-Z]+)', r' \1', name).title().strip() 19 | 20 | 21 | def convert_camelcase(name): 22 | s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) 23 | return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() 24 | 25 | 26 | def camelcase_slugify(name): 27 | s1 = re.sub('(.)([A-Z][a-z]+)', r'\1-\2', name) 28 | return re.sub('([a-z0-9])([A-Z])', r'\1-\2', s1).lower() 29 | 30 | 31 | def mapjson(fun, doc): 32 | if isinstance(doc, OrderedDict): # preserve order 33 | ret = OrderedDict() 34 | for k, v in doc.items(): 35 | ret[k] = mapjson(fun, v) 36 | return ret 37 | elif isinstance(doc, dict): 38 | return {k: mapjson(fun, v) for k, v in doc.items()} 39 | elif isinstance(doc, list): 40 | return [mapjson(fun, v) for v in doc] 41 | else: 42 | return fun(doc) 43 | 44 | 45 | def apply_title(schema, parent_property_name=None): 46 | if 'title' not in schema and parent_property_name: 47 | schema['title'] = parent_property_name.replace('_', ' ').title() 48 | if schema['type'] == 'object': 49 | for property, schema in schema.get('properties', {}).items(): 50 | apply_title(schema, parent_property_name=property) 51 | if "definitions" in schema: 52 | for name, schema in schema['definitions'].items(): 53 | apply_title(schema, parent_property_name=name) 54 | 55 | 56 | def qiter(o): 57 | if o is not None: 58 | for x in o: 59 | yield x 60 | else: 61 | raise StopIteration 62 | 63 | 64 | def is_exposed(fun): 65 | return hasattr(fun, 'exposed') 66 | 67 | 68 | def schema_with_properties(original, **updates): 69 | new_schema = deepcopy(original) 70 | new_schema['properties'].update(updates) 71 | return new_schema 72 | 73 | 74 | def schema_sans_properties(original, *properties): 75 | new_schema = deepcopy(original) 76 | for property in (p for p in properties if p in new_schema['properties']): 77 | del new_schema['properties'][property] 78 | return new_schema 79 | 80 | 81 | def schema_with_definitions(original, **updates): 82 | new_schema = deepcopy(original) 83 | new_schema['definitions'].update(updates) 84 | return new_schema 85 | 86 | 87 | def schema_sans_definitions(original, *properties): 88 | new_schema = deepcopy(original) 89 | for property in (p for p in properties if p in new_schema['definitions']): 90 | del new_schema['definitions'][property] 91 | return new_schema 92 | 93 | 94 | def resolve_class(obj, required_superclass=object, required_metaclass=type): 95 | if isinstance(obj, str): 96 | modulename, classname = obj.rsplit('.', 1) 97 | module = importlib.import_module(modulename) 98 | klass = getattr(module, classname) 99 | else: 100 | klass = obj 101 | 102 | if not issubclass(klass, required_superclass): 103 | raise TypeError("{0} is not of type {1}".format( 104 | klass.__name__, 105 | required_superclass.__name__ 106 | )) 107 | 108 | if not isinstance(klass, required_metaclass): 109 | raise TypeError("{0} must use {1} metaclass".format( 110 | klass.__name__, 111 | required_metaclass.__name__ 112 | )) 113 | 114 | return obj 115 | 116 | 117 | # Use the system PRNG if possible 118 | try: 119 | random = random.SystemRandom() 120 | using_sysrandom = True 121 | except NotImplementedError: 122 | import warnings 123 | warnings.warn('A secure pseudo-random number generator is not available ' 124 | 'on your system. Falling back to Mersenne Twister.') 125 | using_sysrandom = False 126 | 127 | def get_random_string(length=12, 128 | allowed_chars='abcdefghijklmnopqrstuvwxyz' 129 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'): 130 | """ 131 | Adapted from Django. REMOVED SECRET_KEY FOR NOW 132 | 133 | Returns a securely generated random string. 134 | The default length of 12 with the a-z, A-Z, 0-9 character set returns 135 | a 71-bit value. log_2((26+26+10)^12) =~ 71 bits 136 | """ 137 | if not using_sysrandom: 138 | # This is ugly, and a hack, but it makes things better than 139 | # the alternative of predictability. This re-seeds the PRNG 140 | # using a value that is hard for an attacker to predict, every 141 | # time a random string is required. This may change the 142 | # properties of the chosen random sequence slightly, but this 143 | # is better than absolute predictability. 144 | random.seed( 145 | hashlib.sha256( 146 | ("%s%s" % ( 147 | random.getstate(), 148 | time.time()).encode('utf-8')) 149 | ).digest()) 150 | return ''.join(random.choice(allowed_chars) for i in range(length)) 151 | 152 | 153 | def import_string(dotted_path): 154 | """ 155 | Adapted from Django. 156 | 157 | Import a dotted module path and return the attribute/class designated by the 158 | last name in the path. Raise ImportError if the import failed. 159 | """ 160 | try: 161 | module_path, class_name = dotted_path.rsplit('.', 1) 162 | except ValueError: 163 | msg = "%s doesn't look like a module path" % dotted_path 164 | raise(ImportError, ImportError(msg), sys.exc_info()[2]) 165 | 166 | module = import_module(module_path) 167 | 168 | try: 169 | return getattr(module, class_name) 170 | except AttributeError: 171 | msg = 'Module "%s" does not define a "%s" attribute/class' % ( 172 | module_path, class_name) 173 | six.reraise(ImportError, ImportError(msg), sys.exc_info()[2]) 174 | 175 | 176 | def natural_order(json_repr, first=None): 177 | """ 178 | Dictionary sort order for JSON. Optionally prioritize keys. 179 | 180 | :param json_repr: A JSON-compatible data structure. 181 | :param first: An optional list of keys to list first, in order. 182 | 183 | :return: A JSON compatible data structure that will print keys in the right order. 184 | """ 185 | first = first or () 186 | 187 | if isinstance(json_repr, dict): 188 | od = OrderedDict() 189 | keys = set(json_repr.keys()) 190 | for k in first: 191 | if k in json_repr: 192 | od[k] = natural_order(json_repr[k]) 193 | keys.discard(k) 194 | for k in sorted(keys): 195 | od[k] = natural_order(json_repr[k]) 196 | return od 197 | elif isinstance(json_repr, list): 198 | return [natural_order(item, first) for item in json_repr] 199 | else: 200 | return json_repr 201 | 202 | 203 | def deprecated(func): 204 | '''This is a decorator which can be used to mark functions 205 | as deprecated. It will result in a warning being emitted 206 | when the function is used.''' 207 | 208 | @functools.wraps(func) 209 | def new_func(*args, **kwargs): 210 | try: 211 | filename = func.func_code.co_filename 212 | except: 213 | filename = 'unknown' 214 | 215 | try: 216 | lineno = func.func_code.co_firstlineno + 1 217 | except: 218 | lineno = 0 219 | 220 | warnings.warn_explicit( 221 | "Call to deprecated function {}.".format(func.__name__), 222 | category=DeprecationWarning, 223 | filename=filename, 224 | lineno=lineno 225 | ) 226 | return func(*args, **kwargs) 227 | return new_func 228 | 229 | 230 | def utc_timestamp(): 231 | now = datetime.datetime.utcnow() 232 | return now.replace(tzinfo=pytz.utc) 233 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from sondra.schema import S, deep_merge 2 | 3 | schema1 = S.object( 4 | title="Ticket", 5 | description='A work order for Pronto', 6 | required=['title', 'creator', 'status', 'open', 'price'], 7 | properties=S.props( 8 | ("asset", S.fk('api','core','assets', title="Asset")), 9 | ("location", S.geo(description="A copy of asset location, for efficient indexing purposes.", geometry_type='Point')), 10 | ("title", S.string(title="Title", description="A description of the title")), 11 | ("ticket_type", S.fk('api','core','ticket-types', title="Ticket Type")), 12 | ("narrative", S.string(title="Narrative", description="Details relevant to fixing the problem.")), 13 | ("confirm_before_dispatch", S.boolean(title="Confirm before dispatch", description="True if 365 pronto should confirm with the asset contact before a worker arrives on site", default=False)), 14 | ("clock_running", S.boolean(default=False)), 15 | ("next_response_due", S.datetime()), 16 | ("inconsistencies", S.integer(default=0, description="The number of inconsistencies reported in answers or status changes.")), 17 | ("flags", S.integer(default=0, description="A count of out of bounds values reported in worksheets.")), 18 | ("requires_review", S.boolean(default=False)), 19 | ("designated_reviewer", S.fk('api','auth','users')), 20 | ("related", S.array(items=S.string(), description='Any tickets whose body of work relates to the completion of this ticket.')), 21 | ("predecessor", S.string(description='The ticket this ticket was raised as a consequence of.')), 22 | ("antecedent", S.string(description='The ticket raised as a consequence of this one.')), 23 | ("required_professionals", S.integer(description="The number of people required on this ticket", default=1)), 24 | ("assigned_professionals", S.array(items=S.ref('assignee'))), 25 | ("creator", S.fk('api','auth','users', description="The person who created the ticket")), 26 | ("assignee", S.fk('api','auth','users', description="The person who currently is responsible for the ticket")), 27 | ("status", S.ref('ticket_status')), 28 | ("tech_support_token", S.string( 29 | description="Automatically generated. Send this token as part of a URL in email to allow a third party " 30 | "tech support access to view this ticket and communicate with the assigned professionals " 31 | "through the admin console or third-party app." 32 | )), 33 | ("open", S.boolean(default=False)), 34 | ("price", S.string()), 35 | ("currency", S.string(default='USD')), 36 | ("customer_billed", S.datetime()), 37 | ("customer_paid", S.datetime()), 38 | ("customer_paid_in_full", S.boolean(default=True)), 39 | ("contractor_paid", S.datetime()), 40 | ("work_requirements", S.array(items=S.ref('work_requirement'))), 41 | ("union", S.boolean(description="True if the asset requires a union contractor.")), 42 | ("prevailing_wage", S.boolean(description="True if the asset requires prevailing wage.")), 43 | ("worksheets", S.array(items=S.ref('worksheets_for_status'))), 44 | ("work_performed", S.array(items=S.ref('work'))), 45 | ('arbitration_required', S.boolean(default=False)), 46 | ('arbitration_complete', S.boolean()), 47 | ('result_of_arbitration', S.textarea()), 48 | ('linked', S.fk('core', 'tickets', description="This is set if this ticket is listed in another ticket's links")), 49 | ('linked_tickets', S.fk_array('core', 'tickets', 50 | description="Other tickets that must be complete for this one to be considered finished. These are " 51 | "often tickets that are co-located on the same site.")) 52 | )) 53 | 54 | 55 | schema2 = S.object( 56 | title="Ticket", 57 | description='A work order for Pronto', 58 | required=['owner', 'creator', 'created', 'updated'], 59 | properties=S.props( 60 | ("created", S.datetime()), 61 | ("updated", S.datetime()), 62 | ("owner", S.fk('api','core','customers', description="Customer who can administer this ticket")), 63 | ("creator", S.fk('api','auth','users', description="The person who created the ticket")), 64 | )) 65 | 66 | from pprint import PrettyPrinter 67 | pp = PrettyPrinter(indent=4) 68 | merged_schema = (deep_merge(schema1, schema2, 'set')) 69 | pp.pprint(sorted(merged_schema['properties'].keys())) 70 | pp.pprint(sorted(merged_schema['required'])) 71 | a = set(merged_schema['properties'].keys()) 72 | b = set(schema1['properties'].keys()) 73 | c = set(schema2['properties'].keys()) 74 | 75 | print(b.union(c).difference(a)) 76 | --------------------------------------------------------------------------------