├── .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 |
4 |
5 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
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 |
9 |
10 |
11 |
12 |
17 |
18 |
19 |
20 |
21 |
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…</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…</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…</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…</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 |
--------------------------------------------------------------------------------
/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('- ')
44 | self.format(x, buf=buf)
45 | buf.write('
')
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('- ')
65 | self.format(x, buf=buf)
66 | buf.write('
')
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 |
--------------------------------------------------------------------------------