├── .gitignore
├── .travis.yml
├── CHANGELOG.rst
├── DESCRIPTION.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── __init__.py
├── docs
└── source
│ ├── Makefile
│ ├── make.bat
│ └── source
│ ├── conf.py
│ └── index.rst
├── pypermedia
├── __init__.py
├── client.py
├── gzip_requests.py
└── siren.py
├── setup.py
├── tests
├── __init__.py
├── test_siren.py
└── unit
│ ├── __init__.py
│ ├── test_client.py
│ └── test_siren.py
└── tox.ini
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | *.egg-info/
23 | .installed.cfg
24 | *.egg
25 |
26 | # PyInstaller
27 | # Usually these files are written by a python script from a template
28 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
29 | *.manifest
30 | *.spec
31 |
32 | # Installer logs
33 | pip-log.txt
34 | pip-delete-this-directory.txt
35 |
36 | # Unit test / coverage reports
37 | htmlcov/
38 | .tox/
39 | .coverage
40 | .coverage.*
41 | .cache
42 | nosetests.xml
43 | coverage.xml
44 | *,cover
45 |
46 | # Translations
47 | *.mo
48 | *.pot
49 |
50 | # Django stuff:
51 | *.log
52 |
53 | # Sphinx documentation
54 | docs/_build/
55 | docs/build
56 |
57 | # PyBuilder
58 | target/
59 |
60 | # PyCharm files
61 | .idea
62 |
63 | # Passwords will get stored in setup.cfg Dont include taht
64 | setup.cfg
65 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - "3.4"
4 | - "3.3"
5 | - "2.7"
6 | - "2.6"
7 | - "pypy"
8 | - "pypy3"
9 | install:
10 | - pip install coveralls
11 | - python setup.py -q install
12 | script:
13 | coverage run --source=pypermedia setup.py test
14 | after_success:
15 | coveralls
16 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | CHANGELOG
2 | =========
3 |
4 | 0.4.2 (unreleased)
5 | ------------------
6 |
7 | - Updated init import so that README example works.
8 | - Minor cleanup of the setup.py
9 | - Miscellaneous code cleanup.
10 |
11 |
12 | 0.4.1 (2015-12-08)
13 | ------------------
14 |
15 | - Added rel to Siren Entities.
16 |
17 |
18 | 0.4.0 (2015-06-30)
19 | ------------------
20 |
21 | - Added SIREN sub-entity support
22 | - Defaulted to Requests instead of GzipRequests (outgoing data is not compressed by default now)
23 | - Test cases added
24 |
25 |
26 |
--------------------------------------------------------------------------------
/DESCRIPTION.rst:
--------------------------------------------------------------------------------
1 | Hypermedia Client
2 | =================
3 |
4 | Hypermedia client is a proxy for direct URL-access to a hypermedia resource. It
5 | currently allows a python developer to interact with a SIREN-producing service
6 | as though the objects were local to the application.
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 2, June 1991
3 |
4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
6 | Everyone is permitted to copy and distribute verbatim copies
7 | of this license document, but changing it is not allowed.
8 |
9 | Preamble
10 |
11 | The licenses for most software are designed to take away your
12 | freedom to share and change it. By contrast, the GNU General Public
13 | License is intended to guarantee your freedom to share and change free
14 | software--to make sure the software is free for all its users. This
15 | General Public License applies to most of the Free Software
16 | Foundation's software and to any other program whose authors commit to
17 | using it. (Some other Free Software Foundation software is covered by
18 | the GNU Lesser General Public License instead.) You can apply it to
19 | your programs, too.
20 |
21 | When we speak of free software, we are referring to freedom, not
22 | price. Our General Public Licenses are designed to make sure that you
23 | have the freedom to distribute copies of free software (and charge for
24 | this service if you wish), that you receive source code or can get it
25 | if you want it, that you can change the software or use pieces of it
26 | in new free programs; and that you know you can do these things.
27 |
28 | To protect your rights, we need to make restrictions that forbid
29 | anyone to deny you these rights or to ask you to surrender the rights.
30 | These restrictions translate to certain responsibilities for you if you
31 | distribute copies of the software, or if you modify it.
32 |
33 | For example, if you distribute copies of such a program, whether
34 | gratis or for a fee, you must give the recipients all the rights that
35 | you have. You must make sure that they, too, receive or can get the
36 | source code. And you must show them these terms so they know their
37 | rights.
38 |
39 | We protect your rights with two steps: (1) copyright the software, and
40 | (2) offer you this license which gives you legal permission to copy,
41 | distribute and/or modify the software.
42 |
43 | Also, for each author's protection and ours, we want to make certain
44 | that everyone understands that there is no warranty for this free
45 | software. If the software is modified by someone else and passed on, we
46 | want its recipients to know that what they have is not the original, so
47 | that any problems introduced by others will not reflect on the original
48 | authors' reputations.
49 |
50 | Finally, any free program is threatened constantly by software
51 | patents. We wish to avoid the danger that redistributors of a free
52 | program will individually obtain patent licenses, in effect making the
53 | program proprietary. To prevent this, we have made it clear that any
54 | patent must be licensed for everyone's free use or not licensed at all.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | GNU GENERAL PUBLIC LICENSE
60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
61 |
62 | 0. This License applies to any program or other work which contains
63 | a notice placed by the copyright holder saying it may be distributed
64 | under the terms of this General Public License. The "Program", below,
65 | refers to any such program or work, and a "work based on the Program"
66 | means either the Program or any derivative work under copyright law:
67 | that is to say, a work containing the Program or a portion of it,
68 | either verbatim or with modifications and/or translated into another
69 | language. (Hereinafter, translation is included without limitation in
70 | the term "modification".) Each licensee is addressed as "you".
71 |
72 | Activities other than copying, distribution and modification are not
73 | covered by this License; they are outside its scope. The act of
74 | running the Program is not restricted, and the output from the Program
75 | is covered only if its contents constitute a work based on the
76 | Program (independent of having been made by running the Program).
77 | Whether that is true depends on what the Program does.
78 |
79 | 1. You may copy and distribute verbatim copies of the Program's
80 | source code as you receive it, in any medium, provided that you
81 | conspicuously and appropriately publish on each copy an appropriate
82 | copyright notice and disclaimer of warranty; keep intact all the
83 | notices that refer to this License and to the absence of any warranty;
84 | and give any other recipients of the Program a copy of this License
85 | along with the Program.
86 |
87 | You may charge a fee for the physical act of transferring a copy, and
88 | you may at your option offer warranty protection in exchange for a fee.
89 |
90 | 2. You may modify your copy or copies of the Program or any portion
91 | of it, thus forming a work based on the Program, and copy and
92 | distribute such modifications or work under the terms of Section 1
93 | above, provided that you also meet all of these conditions:
94 |
95 | a) You must cause the modified files to carry prominent notices
96 | stating that you changed the files and the date of any change.
97 |
98 | b) You must cause any work that you distribute or publish, that in
99 | whole or in part contains or is derived from the Program or any
100 | part thereof, to be licensed as a whole at no charge to all third
101 | parties under the terms of this License.
102 |
103 | c) If the modified program normally reads commands interactively
104 | when run, you must cause it, when started running for such
105 | interactive use in the most ordinary way, to print or display an
106 | announcement including an appropriate copyright notice and a
107 | notice that there is no warranty (or else, saying that you provide
108 | a warranty) and that users may redistribute the program under
109 | these conditions, and telling the user how to view a copy of this
110 | License. (Exception: if the Program itself is interactive but
111 | does not normally print such an announcement, your work based on
112 | the Program is not required to print an announcement.)
113 |
114 | These requirements apply to the modified work as a whole. If
115 | identifiable sections of that work are not derived from the Program,
116 | and can be reasonably considered independent and separate works in
117 | themselves, then this License, and its terms, do not apply to those
118 | sections when you distribute them as separate works. But when you
119 | distribute the same sections as part of a whole which is a work based
120 | on the Program, the distribution of the whole must be on the terms of
121 | this License, whose permissions for other licensees extend to the
122 | entire whole, and thus to each and every part regardless of who wrote it.
123 |
124 | Thus, it is not the intent of this section to claim rights or contest
125 | your rights to work written entirely by you; rather, the intent is to
126 | exercise the right to control the distribution of derivative or
127 | collective works based on the Program.
128 |
129 | In addition, mere aggregation of another work not based on the Program
130 | with the Program (or with a work based on the Program) on a volume of
131 | a storage or distribution medium does not bring the other work under
132 | the scope of this License.
133 |
134 | 3. You may copy and distribute the Program (or a work based on it,
135 | under Section 2) in object code or executable form under the terms of
136 | Sections 1 and 2 above provided that you also do one of the following:
137 |
138 | a) Accompany it with the complete corresponding machine-readable
139 | source code, which must be distributed under the terms of Sections
140 | 1 and 2 above on a medium customarily used for software interchange; or,
141 |
142 | b) Accompany it with a written offer, valid for at least three
143 | years, to give any third party, for a charge no more than your
144 | cost of physically performing source distribution, a complete
145 | machine-readable copy of the corresponding source code, to be
146 | distributed under the terms of Sections 1 and 2 above on a medium
147 | customarily used for software interchange; or,
148 |
149 | c) Accompany it with the information you received as to the offer
150 | to distribute corresponding source code. (This alternative is
151 | allowed only for noncommercial distribution and only if you
152 | received the program in object code or executable form with such
153 | an offer, in accord with Subsection b above.)
154 |
155 | The source code for a work means the preferred form of the work for
156 | making modifications to it. For an executable work, complete source
157 | code means all the source code for all modules it contains, plus any
158 | associated interface definition files, plus the scripts used to
159 | control compilation and installation of the executable. However, as a
160 | special exception, the source code distributed need not include
161 | anything that is normally distributed (in either source or binary
162 | form) with the major components (compiler, kernel, and so on) of the
163 | operating system on which the executable runs, unless that component
164 | itself accompanies the executable.
165 |
166 | If distribution of executable or object code is made by offering
167 | access to copy from a designated place, then offering equivalent
168 | access to copy the source code from the same place counts as
169 | distribution of the source code, even though third parties are not
170 | compelled to copy the source along with the object code.
171 |
172 | 4. You may not copy, modify, sublicense, or distribute the Program
173 | except as expressly provided under this License. Any attempt
174 | otherwise to copy, modify, sublicense or distribute the Program is
175 | void, and will automatically terminate your rights under this License.
176 | However, parties who have received copies, or rights, from you under
177 | this License will not have their licenses terminated so long as such
178 | parties remain in full compliance.
179 |
180 | 5. You are not required to accept this License, since you have not
181 | signed it. However, nothing else grants you permission to modify or
182 | distribute the Program or its derivative works. These actions are
183 | prohibited by law if you do not accept this License. Therefore, by
184 | modifying or distributing the Program (or any work based on the
185 | Program), you indicate your acceptance of this License to do so, and
186 | all its terms and conditions for copying, distributing or modifying
187 | the Program or works based on it.
188 |
189 | 6. Each time you redistribute the Program (or any work based on the
190 | Program), the recipient automatically receives a license from the
191 | original licensor to copy, distribute or modify the Program subject to
192 | these terms and conditions. You may not impose any further
193 | restrictions on the recipients' exercise of the rights granted herein.
194 | You are not responsible for enforcing compliance by third parties to
195 | this License.
196 |
197 | 7. If, as a consequence of a court judgment or allegation of patent
198 | infringement or for any other reason (not limited to patent issues),
199 | conditions are imposed on you (whether by court order, agreement or
200 | otherwise) that contradict the conditions of this License, they do not
201 | excuse you from the conditions of this License. If you cannot
202 | distribute so as to satisfy simultaneously your obligations under this
203 | License and any other pertinent obligations, then as a consequence you
204 | may not distribute the Program at all. For example, if a patent
205 | license would not permit royalty-free redistribution of the Program by
206 | all those who receive copies directly or indirectly through you, then
207 | the only way you could satisfy both it and this License would be to
208 | refrain entirely from distribution of the Program.
209 |
210 | If any portion of this section is held invalid or unenforceable under
211 | any particular circumstance, the balance of the section is intended to
212 | apply and the section as a whole is intended to apply in other
213 | circumstances.
214 |
215 | It is not the purpose of this section to induce you to infringe any
216 | patents or other property right claims or to contest validity of any
217 | such claims; this section has the sole purpose of protecting the
218 | integrity of the free software distribution system, which is
219 | implemented by public license practices. Many people have made
220 | generous contributions to the wide range of software distributed
221 | through that system in reliance on consistent application of that
222 | system; it is up to the author/donor to decide if he or she is willing
223 | to distribute software through any other system and a licensee cannot
224 | impose that choice.
225 |
226 | This section is intended to make thoroughly clear what is believed to
227 | be a consequence of the rest of this License.
228 |
229 | 8. If the distribution and/or use of the Program is restricted in
230 | certain countries either by patents or by copyrighted interfaces, the
231 | original copyright holder who places the Program under this License
232 | may add an explicit geographical distribution limitation excluding
233 | those countries, so that distribution is permitted only in or among
234 | countries not thus excluded. In such case, this License incorporates
235 | the limitation as if written in the body of this License.
236 |
237 | 9. The Free Software Foundation may publish revised and/or new versions
238 | of the General Public License from time to time. Such new versions will
239 | be similar in spirit to the present version, but may differ in detail to
240 | address new problems or concerns.
241 |
242 | Each version is given a distinguishing version number. If the Program
243 | specifies a version number of this License which applies to it and "any
244 | later version", you have the option of following the terms and conditions
245 | either of that version or of any later version published by the Free
246 | Software Foundation. If the Program does not specify a version number of
247 | this License, you may choose any version ever published by the Free Software
248 | Foundation.
249 |
250 | 10. If you wish to incorporate parts of the Program into other free
251 | programs whose distribution conditions are different, write to the author
252 | to ask for permission. For software which is copyrighted by the Free
253 | Software Foundation, write to the Free Software Foundation; we sometimes
254 | make exceptions for this. Our decision will be guided by the two goals
255 | of preserving the free status of all derivatives of our free software and
256 | of promoting the sharing and reuse of software generally.
257 |
258 | NO WARRANTY
259 |
260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
268 | REPAIR OR CORRECTION.
269 |
270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
278 | POSSIBILITY OF SUCH DAMAGES.
279 |
280 | END OF TERMS AND CONDITIONS
281 |
282 | How to Apply These Terms to Your New Programs
283 |
284 | If you develop a new program, and you want it to be of the greatest
285 | possible use to the public, the best way to achieve this is to make it
286 | free software which everyone can redistribute and change under these terms.
287 |
288 | To do so, attach the following notices to the program. It is safest
289 | to attach them to the start of each source file to most effectively
290 | convey the exclusion of warranty; and each file should have at least
291 | the "copyright" line and a pointer to where the full notice is found.
292 |
293 | {description}
294 | Copyright (C) {year} {fullname}
295 |
296 | This program is free software; you can redistribute it and/or modify
297 | it under the terms of the GNU General Public License as published by
298 | the Free Software Foundation; either version 2 of the License, or
299 | (at your option) any later version.
300 |
301 | This program is distributed in the hope that it will be useful,
302 | but WITHOUT ANY WARRANTY; without even the implied warranty of
303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
304 | GNU General Public License for more details.
305 |
306 | You should have received a copy of the GNU General Public License along
307 | with this program; if not, write to the Free Software Foundation, Inc.,
308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
309 |
310 | Also add information on how to contact you by electronic and paper mail.
311 |
312 | If the program is interactive, make it output a short notice like this
313 | when it starts in an interactive mode:
314 |
315 | Gnomovision version 69, Copyright (C) year name of author
316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
317 | This is free software, and you are welcome to redistribute it
318 | under certain conditions; type `show c' for details.
319 |
320 | The hypothetical commands `show w' and `show c' should show the appropriate
321 | parts of the General Public License. Of course, the commands you use may
322 | be called something other than `show w' and `show c'; they could even be
323 | mouse-clicks or menu items--whatever suits your program.
324 |
325 | You should also get your employer (if you work as a programmer) or your
326 | school, if any, to sign a "copyright disclaimer" for the program, if
327 | necessary. Here is a sample; alter the names:
328 |
329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program
330 | `Gnomovision' (which makes passes at compilers) written by James Hacker.
331 |
332 | {signature of Ty Coon}, 1 April 1989
333 | Ty Coon, President of Vice
334 |
335 | This General Public License does not permit incorporating your program into
336 | proprietary programs. If your program is a subroutine library, you may
337 | consider it more useful to permit linking proprietary applications with the
338 | library. If this is what you want to do, use the GNU Lesser General
339 | Public License instead of this License.
340 |
341 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include DESCRIPTION.rst
2 |
3 | # Include the test suite (FIXME: does not work yet)
4 | # recursive-include tests *
5 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Pypermedia
2 | =================
3 |
4 |
5 |
6 | .. image:: https://travis-ci.org/vertical-knowledge/pypermedia.svg?branch=master&style=flat
7 | :target: https://travis-ci.org/vertical-knowledge/pypermedia
8 | :alt: test status
9 |
10 | .. image:: https://coveralls.io/repos/vertical-knowledge/pypermedia/badge.svg?branch=master&style=flat
11 | :target: https://coveralls.io/r/vertical-knowledge/pypermedia?branch=master
12 | :alt: test coverage
13 |
14 | .. image:: https://readthedocs.org/projects/pypermedia/badge/?version=latest
15 | :target: https://pypermedia.readthedocs.org/
16 | :alt: Documentation Status
17 |
18 | ..
19 | .. image:: https://pypip.in/version/pypermedia/badge.svg?style=flat
20 | :target: https://pypi.python.org/pypi/pypermedia/
21 | :alt: current version
22 |
23 | ..
24 | .. image:: https://pypip.in/download/pypermedia/badge.png?style=flat
25 | :target: https://pypi.python.org/pypi/pypermedia/
26 | :alt: PyPI downloads
27 |
28 | .. image:: https://img.shields.io/pypi/dm/pypermedia.svg?style=flat
29 | :target: https://pypi.python.org/pypi/pypermedia/
30 | :alt: python versions
31 |
32 |
33 | .. image:: https://img.shields.io/github/stars/vertical-knowledge/pypermedia.svg?style=flat
34 | :target: https://github.com/vertical-knowledge/pypermedia/
35 | :alt: stars
36 |
37 |
38 |
39 | Pypermedia is a hypermedia client serves as a python developer's API for access to services which return certain
40 | hypermedia mimetypes. The client self-discovers services from the endpoint and relies entirely upon responses from the
41 | server for operation. It can be considered a proxy for the REST service and allows developers to program against a
42 | hypermedia provider without need for understanding HTTP or network conceits aside from the root API URL.
43 |
44 | Currently supports SIREN.
45 |
46 | SIREN
47 | -----
48 |
49 | For information on the protocol see the
50 | `specification. `_
51 |
52 | The client allows you to interact with a SIREN protocol web api
53 | as if it were a python object. It automatically generates python
54 | objects with attributes corresponding to the SIREN properties and
55 | methods to the SIREN actions. Additionally, it gives you access to
56 | SIREN links and subentities.
57 |
58 | To connect the client you simply need to provide an initial
59 | url.
60 |
61 | .. code-block:: python
62 |
63 | >>> from pypermedia import HypermediaClient
64 | >>> siren_obj = HypermediaClient('http://myapp.io/api/my_resource/')
65 |
66 | Now suppose the endpoint returned the following SIREN response.
67 |
68 | .. code-block:: javascript
69 |
70 | {
71 | "class": [ "order" ],
72 | "properties": {
73 | "orderNumber": 42,
74 | "itemCount": 3,
75 | "status": "pending"
76 | },
77 | "entities": [
78 | {
79 | "class": [ "items", "collection" ],
80 | "rel": [ "http://x.io/rels/order-items" ],
81 | "href": "http://api.x.io/orders/42/items"
82 | },
83 | {
84 | "class": [ "info", "customer" ],
85 | "rel": [ "http://x.io/rels/customer" ],
86 | "properties": {
87 | "customerId": "pj123",
88 | "name": "Peter Joseph"
89 | },
90 | "links": [
91 | { "rel": [ "self" ], "href": "http://api.x.io/customers/pj123" }
92 | ]
93 | }
94 | ],
95 | "actions": [
96 | {
97 | "name": "add-item",
98 | "title": "Add Item",
99 | "method": "POST",
100 | "href": "http://api.x.io/orders/42/items",
101 | "type": "application/x-www-form-urlencoded",
102 | "fields": [
103 | { "name": "orderNumber", "type": "number" },
104 | { "name": "productCode", "type": "text" },
105 | { "name": "quantity", "type": "number" }
106 | ]
107 | }
108 | ],
109 | "links": [
110 | { "rel": [ "self" ], "href": "http://api.x.io/orders/42" },
111 | { "rel": [ "previous" ], "href": "http://api.x.io/orders/41" },
112 | { "rel": [ "next" ], "href": "http://api.x.io/orders/43" }
113 | ]
114 | }
115 |
116 | .. testsetup:: siren
117 |
118 | from pypermedia.siren import SirenBuilder
119 |
120 | response = {
121 | "class": [ "order" ],
122 | "properties": {
123 | "orderNumber": 42,
124 | "itemCount": 3,
125 | "status": "pending"
126 | },
127 | "entities": [
128 | {
129 | "class": [ "items", "collection" ],
130 | "rel": [ "http://x.io/rels/order-items" ],
131 | "href": "http://api.x.io/orders/42/items"
132 | },
133 | {
134 | "class": [ "info", "customer" ],
135 | "rel": [ "http://x.io/rels/customer" ],
136 | "properties": {
137 | "customerId": "pj123",
138 | "name": "Peter Joseph"
139 | },
140 | "links": [
141 | { "rel": [ "self" ], "href": "http://api.x.io/customers/pj123" }
142 | ]
143 | }
144 | ],
145 | "actions": [
146 | {
147 | "name": "add-item",
148 | "title": "Add Item",
149 | "method": "POST",
150 | "href": "http://api.x.io/orders/42/items",
151 | "type": "application/x-www-form-urlencoded",
152 | "fields": [
153 | { "name": "productCode", "type": "text" },
154 | { "name": "quantity", "type": "number" }
155 | ]
156 | }
157 | ],
158 | "links": [
159 | { "rel": [ "self" ], "href": "http://api.x.io/orders/42" },
160 | { "rel": [ "previous" ], "href": "http://api.x.io/orders/41" },
161 | { "rel": [ "next" ], "href": "http://api.x.io/orders/43" }
162 | ]
163 | }
164 | siren_builder = SirenBuilder()
165 | siren_obj = siren_builder.from_api_response(response)
166 |
167 | We could then access the various properties on the
168 | object.
169 |
170 | .. code-block:: python
171 |
172 | >>> print(siren_obj.orderNumber)
173 | 42
174 | >>> print(siren_obj.itemCount)
175 | 3
176 | >>> print(siren_obj.status)
177 | pending
178 |
179 | Additionally, we could see that the class name was indeed order
180 |
181 | .. code-block:: python
182 |
183 | >>> print(siren_obj.__class__.__name__)
184 | order
185 |
186 | Where you can really see the power of the SIREN protocol is
187 | when you go to perform actions. In this case, we can see that
188 | there is an action called add-item. We can simply call that
189 | on the siren_obj and we will get a new SIREN object representing
190 | the response from the server for adding an item.
191 |
192 | .. code-block:: python
193 |
194 | >>> new_item = siren_obj.add_item(productCode=15, quantity=2)
195 |
196 | And now we have the new item that was added to the orders items!
197 |
198 | Additionally, we can access links and entities
199 |
200 | .. code-block:: python
201 |
202 | >>> next_obj = siren_obj.get_links('next')[0].as_python_object()
203 | >>> customer = next(siren_obj.get_entity('customer'))
204 |
205 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vertical-knowledge/pypermedia/97c7f9acba37b36514019f8372cc46eb90a07f96/__init__.py
--------------------------------------------------------------------------------
/docs/source/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = build
9 |
10 | # User-friendly check for sphinx-build
11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
13 | endif
14 |
15 | # Internal variables.
16 | PAPEROPT_a4 = -D latex_paper_size=a4
17 | PAPEROPT_letter = -D latex_paper_size=letter
18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
19 | # the i18n builder cannot share the environment and doctrees with the others
20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
21 |
22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
23 |
24 | help:
25 | @echo "Please use \`make ' where is one of"
26 | @echo " html to make standalone HTML files"
27 | @echo " dirhtml to make HTML files named index.html in directories"
28 | @echo " singlehtml to make a single large HTML file"
29 | @echo " pickle to make pickle files"
30 | @echo " json to make JSON files"
31 | @echo " htmlhelp to make HTML files and a HTML help project"
32 | @echo " qthelp to make HTML files and a qthelp project"
33 | @echo " applehelp to make an Apple Help Book"
34 | @echo " devhelp to make HTML files and a Devhelp project"
35 | @echo " epub to make an epub"
36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
37 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
39 | @echo " text to make text files"
40 | @echo " man to make manual pages"
41 | @echo " texinfo to make Texinfo files"
42 | @echo " info to make Texinfo files and run them through makeinfo"
43 | @echo " gettext to make PO message catalogs"
44 | @echo " changes to make an overview of all changed/added/deprecated items"
45 | @echo " xml to make Docutils-native XML files"
46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
47 | @echo " linkcheck to check all external links for integrity"
48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
49 | @echo " coverage to run coverage check of the documentation (if enabled)"
50 |
51 | clean:
52 | rm -rf $(BUILDDIR)/*
53 |
54 | html:
55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
56 | @echo
57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
58 |
59 | dirhtml:
60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
61 | @echo
62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
63 |
64 | singlehtml:
65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
66 | @echo
67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
68 |
69 | pickle:
70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
71 | @echo
72 | @echo "Build finished; now you can process the pickle files."
73 |
74 | json:
75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
76 | @echo
77 | @echo "Build finished; now you can process the JSON files."
78 |
79 | htmlhelp:
80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
81 | @echo
82 | @echo "Build finished; now you can run HTML Help Workshop with the" \
83 | ".hhp project file in $(BUILDDIR)/htmlhelp."
84 |
85 | qthelp:
86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
87 | @echo
88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pypermedia.qhcp"
91 | @echo "To view the help file:"
92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pypermedia.qhc"
93 |
94 | applehelp:
95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
96 | @echo
97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
98 | @echo "N.B. You won't be able to view it unless you put it in" \
99 | "~/Library/Documentation/Help or install it in your application" \
100 | "bundle."
101 |
102 | devhelp:
103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
104 | @echo
105 | @echo "Build finished."
106 | @echo "To view the help file:"
107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pypermedia"
108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pypermedia"
109 | @echo "# devhelp"
110 |
111 | epub:
112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
113 | @echo
114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
115 |
116 | latex:
117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
118 | @echo
119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
121 | "(use \`make latexpdf' here to do that automatically)."
122 |
123 | latexpdf:
124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
125 | @echo "Running LaTeX files through pdflatex..."
126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
128 |
129 | latexpdfja:
130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
131 | @echo "Running LaTeX files through platex and dvipdfmx..."
132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
134 |
135 | text:
136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
137 | @echo
138 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
139 |
140 | man:
141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
142 | @echo
143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
144 |
145 | texinfo:
146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
147 | @echo
148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
149 | @echo "Run \`make' in that directory to run these through makeinfo" \
150 | "(use \`make info' here to do that automatically)."
151 |
152 | info:
153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
154 | @echo "Running Texinfo files through makeinfo..."
155 | make -C $(BUILDDIR)/texinfo info
156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
157 |
158 | gettext:
159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
160 | @echo
161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
162 |
163 | changes:
164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
165 | @echo
166 | @echo "The overview file is in $(BUILDDIR)/changes."
167 |
168 | linkcheck:
169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
170 | @echo
171 | @echo "Link check complete; look for any errors in the above output " \
172 | "or in $(BUILDDIR)/linkcheck/output.txt."
173 |
174 | doctest:
175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
176 | @echo "Testing of doctests in the sources finished, look at the " \
177 | "results in $(BUILDDIR)/doctest/output.txt."
178 |
179 | coverage:
180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
181 | @echo "Testing of coverage in the sources finished, look at the " \
182 | "results in $(BUILDDIR)/coverage/python.txt."
183 |
184 | xml:
185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
186 | @echo
187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
188 |
189 | pseudoxml:
190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
191 | @echo
192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
193 |
--------------------------------------------------------------------------------
/docs/source/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | REM Command file for Sphinx documentation
4 |
5 | if "%SPHINXBUILD%" == "" (
6 | set SPHINXBUILD=sphinx-build
7 | )
8 | set BUILDDIR=build
9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source
10 | set I18NSPHINXOPTS=%SPHINXOPTS% source
11 | if NOT "%PAPER%" == "" (
12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
14 | )
15 |
16 | if "%1" == "" goto help
17 |
18 | if "%1" == "help" (
19 | :help
20 | echo.Please use `make ^` where ^ is one of
21 | echo. html to make standalone HTML files
22 | echo. dirhtml to make HTML files named index.html in directories
23 | echo. singlehtml to make a single large HTML file
24 | echo. pickle to make pickle files
25 | echo. json to make JSON files
26 | echo. htmlhelp to make HTML files and a HTML help project
27 | echo. qthelp to make HTML files and a qthelp project
28 | echo. devhelp to make HTML files and a Devhelp project
29 | echo. epub to make an epub
30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
31 | echo. text to make text files
32 | echo. man to make manual pages
33 | echo. texinfo to make Texinfo files
34 | echo. gettext to make PO message catalogs
35 | echo. changes to make an overview over all changed/added/deprecated items
36 | echo. xml to make Docutils-native XML files
37 | echo. pseudoxml to make pseudoxml-XML files for display purposes
38 | echo. linkcheck to check all external links for integrity
39 | echo. doctest to run all doctests embedded in the documentation if enabled
40 | echo. coverage to run coverage check of the documentation if enabled
41 | goto end
42 | )
43 |
44 | if "%1" == "clean" (
45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
46 | del /q /s %BUILDDIR%\*
47 | goto end
48 | )
49 |
50 |
51 | REM Check if sphinx-build is available and fallback to Python version if any
52 | %SPHINXBUILD% 2> nul
53 | if errorlevel 9009 goto sphinx_python
54 | goto sphinx_ok
55 |
56 | :sphinx_python
57 |
58 | set SPHINXBUILD=python -m sphinx.__init__
59 | %SPHINXBUILD% 2> nul
60 | if errorlevel 9009 (
61 | echo.
62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
63 | echo.installed, then set the SPHINXBUILD environment variable to point
64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
65 | echo.may add the Sphinx directory to PATH.
66 | echo.
67 | echo.If you don't have Sphinx installed, grab it from
68 | echo.http://sphinx-doc.org/
69 | exit /b 1
70 | )
71 |
72 | :sphinx_ok
73 |
74 |
75 | if "%1" == "html" (
76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
77 | if errorlevel 1 exit /b 1
78 | echo.
79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html.
80 | goto end
81 | )
82 |
83 | if "%1" == "dirhtml" (
84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
85 | if errorlevel 1 exit /b 1
86 | echo.
87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
88 | goto end
89 | )
90 |
91 | if "%1" == "singlehtml" (
92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
93 | if errorlevel 1 exit /b 1
94 | echo.
95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
96 | goto end
97 | )
98 |
99 | if "%1" == "pickle" (
100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
101 | if errorlevel 1 exit /b 1
102 | echo.
103 | echo.Build finished; now you can process the pickle files.
104 | goto end
105 | )
106 |
107 | if "%1" == "json" (
108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
109 | if errorlevel 1 exit /b 1
110 | echo.
111 | echo.Build finished; now you can process the JSON files.
112 | goto end
113 | )
114 |
115 | if "%1" == "htmlhelp" (
116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
117 | if errorlevel 1 exit /b 1
118 | echo.
119 | echo.Build finished; now you can run HTML Help Workshop with the ^
120 | .hhp project file in %BUILDDIR%/htmlhelp.
121 | goto end
122 | )
123 |
124 | if "%1" == "qthelp" (
125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
126 | if errorlevel 1 exit /b 1
127 | echo.
128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^
129 | .qhcp project file in %BUILDDIR%/qthelp, like this:
130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\pypermedia.qhcp
131 | echo.To view the help file:
132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\pypermedia.ghc
133 | goto end
134 | )
135 |
136 | if "%1" == "devhelp" (
137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
138 | if errorlevel 1 exit /b 1
139 | echo.
140 | echo.Build finished.
141 | goto end
142 | )
143 |
144 | if "%1" == "epub" (
145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
146 | if errorlevel 1 exit /b 1
147 | echo.
148 | echo.Build finished. The epub file is in %BUILDDIR%/epub.
149 | goto end
150 | )
151 |
152 | if "%1" == "latex" (
153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
154 | if errorlevel 1 exit /b 1
155 | echo.
156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
157 | goto end
158 | )
159 |
160 | if "%1" == "latexpdf" (
161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
162 | cd %BUILDDIR%/latex
163 | make all-pdf
164 | cd %~dp0
165 | echo.
166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex.
167 | goto end
168 | )
169 |
170 | if "%1" == "latexpdfja" (
171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
172 | cd %BUILDDIR%/latex
173 | make all-pdf-ja
174 | cd %~dp0
175 | echo.
176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex.
177 | goto end
178 | )
179 |
180 | if "%1" == "text" (
181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
182 | if errorlevel 1 exit /b 1
183 | echo.
184 | echo.Build finished. The text files are in %BUILDDIR%/text.
185 | goto end
186 | )
187 |
188 | if "%1" == "man" (
189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
190 | if errorlevel 1 exit /b 1
191 | echo.
192 | echo.Build finished. The manual pages are in %BUILDDIR%/man.
193 | goto end
194 | )
195 |
196 | if "%1" == "texinfo" (
197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
198 | if errorlevel 1 exit /b 1
199 | echo.
200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
201 | goto end
202 | )
203 |
204 | if "%1" == "gettext" (
205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
206 | if errorlevel 1 exit /b 1
207 | echo.
208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
209 | goto end
210 | )
211 |
212 | if "%1" == "changes" (
213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
214 | if errorlevel 1 exit /b 1
215 | echo.
216 | echo.The overview file is in %BUILDDIR%/changes.
217 | goto end
218 | )
219 |
220 | if "%1" == "linkcheck" (
221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
222 | if errorlevel 1 exit /b 1
223 | echo.
224 | echo.Link check complete; look for any errors in the above output ^
225 | or in %BUILDDIR%/linkcheck/output.txt.
226 | goto end
227 | )
228 |
229 | if "%1" == "doctest" (
230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
231 | if errorlevel 1 exit /b 1
232 | echo.
233 | echo.Testing of doctests in the sources finished, look at the ^
234 | results in %BUILDDIR%/doctest/output.txt.
235 | goto end
236 | )
237 |
238 | if "%1" == "coverage" (
239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage
240 | if errorlevel 1 exit /b 1
241 | echo.
242 | echo.Testing of coverage in the sources finished, look at the ^
243 | results in %BUILDDIR%/coverage/python.txt.
244 | goto end
245 | )
246 |
247 | if "%1" == "xml" (
248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
249 | if errorlevel 1 exit /b 1
250 | echo.
251 | echo.Build finished. The XML files are in %BUILDDIR%/xml.
252 | goto end
253 | )
254 |
255 | if "%1" == "pseudoxml" (
256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
257 | if errorlevel 1 exit /b 1
258 | echo.
259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
260 | goto end
261 | )
262 |
263 | :end
264 |
--------------------------------------------------------------------------------
/docs/source/source/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # pypermedia documentation build configuration file, created by
4 | # sphinx-quickstart on Wed Jul 1 16:10:38 2015.
5 | #
6 | # This file is execfile()d with the current directory set to its
7 | # containing dir.
8 | #
9 | # Note that not all possible configuration values are present in this
10 | # autogenerated file.
11 | #
12 | # All configuration values have a default; values that are commented out
13 | # serve to show the default.
14 |
15 | import sys
16 | import os
17 | import shlex
18 |
19 | # If extensions (or modules to document with autodoc) are in another directory,
20 | # add these directories to sys.path here. If the directory is relative to the
21 | # documentation root, use os.path.abspath to make it absolute, like shown here.
22 | #sys.path.insert(0, os.path.abspath('.'))
23 |
24 | # -- General configuration ------------------------------------------------
25 |
26 | # If your documentation needs a minimal Sphinx version, state it here.
27 | #needs_sphinx = '1.0'
28 |
29 | # Add any Sphinx extension module names here, as strings. They can be
30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
31 | # ones.
32 | extensions = [
33 | 'sphinx.ext.autodoc',
34 | 'sphinx.ext.doctest',
35 | 'sphinx.ext.intersphinx',
36 | 'sphinx.ext.todo',
37 | 'sphinx.ext.coverage',
38 | 'sphinx.ext.ifconfig',
39 | 'sphinx.ext.viewcode',
40 | ]
41 |
42 | # Add any paths that contain templates here, relative to this directory.
43 | templates_path = ['_templates']
44 |
45 | # The suffix(es) of source filenames.
46 | # You can specify multiple suffix as a list of string:
47 | # source_suffix = ['.rst', '.md']
48 | source_suffix = '.rst'
49 |
50 | # The encoding of source files.
51 | #source_encoding = 'utf-8-sig'
52 |
53 | # The master toctree document.
54 | master_doc = 'index'
55 |
56 | # General information about the project.
57 | project = u'pypermedia'
58 | copyright = u'2015, Alex Maskovyak'
59 | author = u'Alex Maskovyak'
60 |
61 | # The version info for the project you're documenting, acts as replacement for
62 | # |version| and |release|, also used in various other places throughout the
63 | # built documents.
64 | #
65 | # The short X.Y version.
66 | version = '0.4.0'
67 | # The full version, including alpha/beta/rc tags.
68 | release = '0.4.0'
69 |
70 | # The language for content autogenerated by Sphinx. Refer to documentation
71 | # for a list of supported languages.
72 | #
73 | # This is also used if you do content translation via gettext catalogs.
74 | # Usually you set "language" from the command line for these cases.
75 | language = None
76 |
77 | # There are two options for replacing |today|: either, you set today to some
78 | # non-false value, then it is used:
79 | #today = ''
80 | # Else, today_fmt is used as the format for a strftime call.
81 | #today_fmt = '%B %d, %Y'
82 |
83 | # List of patterns, relative to source directory, that match files and
84 | # directories to ignore when looking for source files.
85 | exclude_patterns = []
86 |
87 | # The reST default role (used for this markup: `text`) to use for all
88 | # documents.
89 | #default_role = None
90 |
91 | # If true, '()' will be appended to :func: etc. cross-reference text.
92 | #add_function_parentheses = True
93 |
94 | # If true, the current module name will be prepended to all description
95 | # unit titles (such as .. function::).
96 | #add_module_names = True
97 |
98 | # If true, sectionauthor and moduleauthor directives will be shown in the
99 | # output. They are ignored by default.
100 | #show_authors = False
101 |
102 | # The name of the Pygments (syntax highlighting) style to use.
103 | pygments_style = 'sphinx'
104 |
105 | # A list of ignored prefixes for module index sorting.
106 | #modindex_common_prefix = []
107 |
108 | # If true, keep warnings as "system message" paragraphs in the built documents.
109 | #keep_warnings = False
110 |
111 | # If true, `todo` and `todoList` produce output, else they produce nothing.
112 | todo_include_todos = True
113 |
114 |
115 | # -- Options for HTML output ----------------------------------------------
116 |
117 | # The theme to use for HTML and HTML Help pages. See the documentation for
118 | # a list of builtin themes.
119 | html_theme = 'alabaster'
120 |
121 | # Theme options are theme-specific and customize the look and feel of a theme
122 | # further. For a list of options available for each theme, see the
123 | # documentation.
124 | #html_theme_options = {}
125 |
126 | # Add any paths that contain custom themes here, relative to this directory.
127 | #html_theme_path = []
128 |
129 | # The name for this set of Sphinx documents. If None, it defaults to
130 | # " v documentation".
131 | #html_title = None
132 |
133 | # A shorter title for the navigation bar. Default is the same as html_title.
134 | #html_short_title = None
135 |
136 | # The name of an image file (relative to this directory) to place at the top
137 | # of the sidebar.
138 | #html_logo = None
139 |
140 | # The name of an image file (within the static path) to use as favicon of the
141 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
142 | # pixels large.
143 | #html_favicon = None
144 |
145 | # Add any paths that contain custom static files (such as style sheets) here,
146 | # relative to this directory. They are copied after the builtin static files,
147 | # so a file named "default.css" will overwrite the builtin "default.css".
148 | html_static_path = ['_static']
149 |
150 | # Add any extra paths that contain custom files (such as robots.txt or
151 | # .htaccess) here, relative to this directory. These files are copied
152 | # directly to the root of the documentation.
153 | #html_extra_path = []
154 |
155 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
156 | # using the given strftime format.
157 | #html_last_updated_fmt = '%b %d, %Y'
158 |
159 | # If true, SmartyPants will be used to convert quotes and dashes to
160 | # typographically correct entities.
161 | #html_use_smartypants = True
162 |
163 | # Custom sidebar templates, maps document names to template names.
164 | #html_sidebars = {}
165 |
166 | # Additional templates that should be rendered to pages, maps page names to
167 | # template names.
168 | #html_additional_pages = {}
169 |
170 | # If false, no module index is generated.
171 | #html_domain_indices = True
172 |
173 | # If false, no index is generated.
174 | #html_use_index = True
175 |
176 | # If true, the index is split into individual pages for each letter.
177 | #html_split_index = False
178 |
179 | # If true, links to the reST sources are added to the pages.
180 | #html_show_sourcelink = True
181 |
182 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
183 | #html_show_sphinx = True
184 |
185 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
186 | #html_show_copyright = True
187 |
188 | # If true, an OpenSearch description file will be output, and all pages will
189 | # contain a tag referring to it. The value of this option must be the
190 | # base URL from which the finished HTML is served.
191 | #html_use_opensearch = ''
192 |
193 | # This is the file name suffix for HTML files (e.g. ".xhtml").
194 | #html_file_suffix = None
195 |
196 | # Language to be used for generating the HTML full-text search index.
197 | # Sphinx supports the following languages:
198 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja'
199 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr'
200 | #html_search_language = 'en'
201 |
202 | # A dictionary with options for the search language support, empty by default.
203 | # Now only 'ja' uses this config value
204 | #html_search_options = {'type': 'default'}
205 |
206 | # The name of a javascript file (relative to the configuration directory) that
207 | # implements a search results scorer. If empty, the default will be used.
208 | #html_search_scorer = 'scorer.js'
209 |
210 | # Output file base name for HTML help builder.
211 | htmlhelp_basename = 'pypermediadoc'
212 |
213 | # -- Options for LaTeX output ---------------------------------------------
214 |
215 | latex_elements = {
216 | # The paper size ('letterpaper' or 'a4paper').
217 | #'papersize': 'letterpaper',
218 |
219 | # The font size ('10pt', '11pt' or '12pt').
220 | #'pointsize': '10pt',
221 |
222 | # Additional stuff for the LaTeX preamble.
223 | #'preamble': '',
224 |
225 | # Latex figure (float) alignment
226 | #'figure_align': 'htbp',
227 | }
228 |
229 | # Grouping the document tree into LaTeX files. List of tuples
230 | # (source start file, target name, title,
231 | # author, documentclass [howto, manual, or own class]).
232 | latex_documents = [
233 | (master_doc, 'pypermedia.tex', u'pypermedia Documentation',
234 | u'Alex Maskovyak', 'manual'),
235 | ]
236 |
237 | # The name of an image file (relative to this directory) to place at the top of
238 | # the title page.
239 | #latex_logo = None
240 |
241 | # For "manual" documents, if this is true, then toplevel headings are parts,
242 | # not chapters.
243 | #latex_use_parts = False
244 |
245 | # If true, show page references after internal links.
246 | #latex_show_pagerefs = False
247 |
248 | # If true, show URL addresses after external links.
249 | #latex_show_urls = False
250 |
251 | # Documents to append as an appendix to all manuals.
252 | #latex_appendices = []
253 |
254 | # If false, no module index is generated.
255 | #latex_domain_indices = True
256 |
257 |
258 | # -- Options for manual page output ---------------------------------------
259 |
260 | # One entry per manual page. List of tuples
261 | # (source start file, name, description, authors, manual section).
262 | man_pages = [
263 | (master_doc, 'pypermedia', u'pypermedia Documentation',
264 | [author], 1)
265 | ]
266 |
267 | # If true, show URL addresses after external links.
268 | #man_show_urls = False
269 |
270 |
271 | # -- Options for Texinfo output -------------------------------------------
272 |
273 | # Grouping the document tree into Texinfo files. List of tuples
274 | # (source start file, target name, title, author,
275 | # dir menu entry, description, category)
276 | texinfo_documents = [
277 | (master_doc, 'pypermedia', u'pypermedia Documentation',
278 | author, 'pypermedia', 'One line description of project.',
279 | 'Miscellaneous'),
280 | ]
281 |
282 | # Documents to append as an appendix to all manuals.
283 | #texinfo_appendices = []
284 |
285 | # If false, no module index is generated.
286 | #texinfo_domain_indices = True
287 |
288 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
289 | #texinfo_show_urls = 'footnote'
290 |
291 | # If true, do not generate a @detailmenu in the "Top" node's menu.
292 | #texinfo_no_detailmenu = False
293 |
294 |
295 | # Example configuration for intersphinx: refer to the Python standard library.
296 | intersphinx_mapping = {'https://docs.python.org/': None}
297 |
--------------------------------------------------------------------------------
/docs/source/source/index.rst:
--------------------------------------------------------------------------------
1 | .. pypermedia documentation master file, created by
2 | sphinx-quickstart on Wed Jul 1 16:10:38 2015.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | Welcome to pypermedia's documentation!
7 | ======================================
8 |
9 | Contents:
10 |
11 | .. toctree::
12 | :maxdepth: 2
13 |
14 |
15 | .. include:: ../../README.rst
16 |
17 | .. include:: ../../CHANGELOG.rst
18 |
19 |
20 | Indices and tables
21 | ==================
22 |
23 | * :ref:`genindex`
24 | * :ref:`modindex`
25 | * :ref:`search`
26 |
27 |
--------------------------------------------------------------------------------
/pypermedia/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import division
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | from pypermedia.client import HypermediaClient
7 |
8 | __author__ = 'alexmaskovyak'
9 |
10 |
--------------------------------------------------------------------------------
/pypermedia/client.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import division
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | import requests
7 | import requests.exceptions
8 |
9 | from pypermedia.siren import SirenBuilder
10 |
11 |
12 | class HypermediaClient(object):
13 | """
14 | Entry-point and helper methods for using the codex service. This performs the initial setup, all other client calls
15 | are created dynamically from service responses.
16 | """
17 |
18 | @staticmethod
19 | def connect(root_url, session=None, verify=False, request_factory=requests.Request, builder=SirenBuilder):
20 | """
21 | Creates a client by connecting to the root api url. Pointing to other urls is possible so long as their
22 | responses correspond to standard siren-json.
23 |
24 | :param str|unicode root_url: root api url
25 | :param bool verify: whether to verify ssl certificates from the server or ignore them (should be false for
26 | local dev)
27 | :param type|function request_factory: constructor of request objects
28 | :return: codex client generated from root url
29 | :rtype: object
30 | """
31 | # connect to server and get json
32 | # convert to siren
33 | # get as python object
34 | request = request_factory('GET', root_url)
35 | p = request.prepare()
36 | return HypermediaClient.send_and_construct(p, session=session, verify=verify,
37 | request_factory=request_factory, builder=builder)
38 |
39 | @staticmethod
40 | def send_and_construct(prepared_request, session=None, verify=False, request_factory=requests.Request,
41 | builder=SirenBuilder):
42 | """
43 | Takes a PreparedRequest object and sends it and then constructs the SirenObject from the response.
44 |
45 | :param requests.PreparedRequest prepared_request: The initial request to send.
46 | :param requests.Session session : Existing session to use for requests.
47 | :param bool verify: whether to verify ssl certificates from the server or ignore them (should be false for
48 | local dev)
49 | :param type|function request_factory: constructor of request object
50 | :param builder: The object to build the hypermedia object
51 | :return: The object representing the siren object returned from the server.
52 | :rtype: object
53 | :raises: ConnectError
54 | """
55 | session = session or requests.Session()
56 | try:
57 | response = session.send(prepared_request, verify=verify)
58 | except requests.exceptions.ConnectionError as e:
59 | # this is the deprecated form but it preserves the stack trace so let's use this
60 | # it's not like this is going to be a big problem when porting to Python 3 in the future
61 | raise ConnectError('Unable to connect to server! Unable to construct client. root_url="{0}" verify="{1}"'.
62 | format(prepared_request.url, verify), e)
63 |
64 | builder = builder(verify=verify, request_factory=request_factory)
65 | obj = builder.from_api_response(response)
66 | return obj.as_python_object()
67 |
68 |
69 | class ConnectError(Exception):
70 | """Standard error for an inability to connect to the server."""
71 | pass
72 |
73 |
74 | class APIError(Exception):
75 | """Bucket for errors related to server responses."""
76 | pass
77 |
--------------------------------------------------------------------------------
/pypermedia/gzip_requests.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import division
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | from requests import Request
7 | import zlib
8 |
9 |
10 | class GzipRequest(Request):
11 | """Encapsulates gzip requests. Currently just adds a header but may be extended in the future to do more."""
12 | def __init__(self, *args, **kwargs):
13 | """
14 | Constructor.
15 |
16 | :param args: all of request's normal positional arguments, unused by GzipRequest itself
17 | :param kwargs: all of request's normal kwargs, unused by GzipRequest itself
18 | """
19 | super(GzipRequest, self).__init__(*args, **kwargs) # delegate up
20 |
21 | # add acceptance of gzip
22 | self.headers['Accept-Encoding'] = 'gzip, deflate' # always specify this since the requests library implicitly understands compression we might as well always request/use it
23 |
24 | def prepare(self):
25 | """
26 | Constructs a prepared request and compresses its contents.
27 | :return: prepared request with compressed payload
28 | :rtype: requests.PreparedRequest
29 | """
30 | p = super(GzipRequest, self).prepare() # delegate up
31 |
32 | # modify payload when present
33 | if p.body and (self.method == 'POST' or self.method == 'PUT' or self.method == 'PATCH'):
34 | p.method = p.method.encode('utf-8') # we have a byte-based message-body so we need bytes in the message header, harmless if already encoded properly
35 |
36 | # modify body and update headers
37 | p.body = self.gzip_compress(p.body)
38 | p.headers['Content-Length'] = len(p.body)
39 | p.headers['Content-Encoding'] = 'gzip'
40 | return p
41 |
42 | @staticmethod
43 | def gzip_compress(data):
44 | """
45 | Gzip compresses the data.
46 |
47 | :param data: data to compress
48 | :type data: str
49 | :return: compressed data
50 | :rtype: str
51 | """
52 | gzip_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16)
53 | return gzip_compress.compress(data) + gzip_compress.flush()
54 |
--------------------------------------------------------------------------------
/pypermedia/siren.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import division
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | import json
7 | import logging
8 | import re
9 | import six
10 | from requests import Response, Session, Request
11 |
12 |
13 | # =====================================
14 | # Siren element->object representations
15 | # =====================================
16 |
17 |
18 | def _check_and_decode_response(response):
19 | """
20 | Checks if the response is valid. If it is, it returns the response body. Otherwise it raises an exception or
21 | returns None if the status_code is 404.
22 |
23 | :param Response response: The response to check
24 | :return: The response body if appropriate.
25 | :rtype: unicode
26 | """
27 | # not found is equivalent to none
28 | if response.status_code == 404:
29 | return None
30 |
31 | # return none when the code is errant, we should log this as well
32 | if response.status_code > 299 or response.status_code < 200:
33 | raise UnexpectedStatusError(message='Received an unexpected status code of "{0}"! Unable to construct siren objects.'.format(response.status_code))
34 |
35 | response = response.text
36 | if not response:
37 | raise MalformedSirenError(message='Parameter "response" object had empty response content. Unable to construct siren objects.')
38 | return response
39 |
40 |
41 | class RequestMixin(object):
42 | """Values for any request creating object."""
43 |
44 | def __init__(self, request_factory=Request, verify=False):
45 | """
46 | :param type|function request_factory: constructor for request objects
47 | :param bool verify: whether ssl certificate validation should occur
48 | """
49 | self.request_factory = request_factory
50 | self.verify = verify
51 |
52 |
53 | class SirenBuilder(RequestMixin):
54 | """Responsible for constructing Siren hierarchy objects."""
55 |
56 | def from_api_response(self, response):
57 | """
58 | Creates a SirenEntity and related siren object graph.
59 |
60 | :param response: response item containing siren construction information
61 | :type response: str or unicode or requests.Response
62 | :return: siren entity graph
63 | :rtype: SirenEntity
64 | :raises: MalformedSirenError
65 | :raises: TypeError
66 | """
67 | # get string
68 | if isinstance(response, Response):
69 | response = _check_and_decode_response(response)
70 | if response is None:
71 | return None
72 |
73 | # convert to dict
74 | if isinstance(response, six.string_types):
75 | try:
76 | response = json.loads(response)
77 | except ValueError as e:
78 | raise MalformedSirenError(
79 | message='Parameter "response" must be valid json. Unable to construct siren objects.',
80 | errors=e)
81 |
82 | # check preferred dict type
83 | if type(response) is not dict:
84 | raise TypeError('Siren object construction requires a valid response, json, or dict object.')
85 |
86 | try:
87 | return self._construct_entity(response)
88 | except Exception as e:
89 | raise MalformedSirenError(
90 | message='Siren response is malformed and is missing one or more required values. '
91 | 'Unable to create python object representation.',
92 | errors=e)
93 |
94 | def _construct_entity(self, entity_dict):
95 | """
96 | Constructs an entity from a dictionary. Used
97 | for both entities and embedded sub-entities.
98 |
99 | :param dict entity_dict:
100 | :return: The SirenEntity representing the object
101 | :rtype: SirenEntity
102 | :raises KeyError
103 | """
104 | classname = entity_dict['class']
105 | properties = entity_dict.get('properties', {})
106 | rel = entity_dict.get('rel', [])
107 |
108 | actions = [] # odd that multiple actions can have the same name, is this for overloading? it will break python!
109 | for action_dict in entity_dict.get('actions', []):
110 | siren_action = SirenAction(request_factory=self.request_factory, verify=self.verify, **action_dict)
111 | actions.append(siren_action)
112 |
113 | links = [] # odd that multiple links can have the same relationship & that because this is a list we could have overloading?? this will break python!
114 | for links_dict in entity_dict.get('links', []):
115 | link = self._construct_link(links_dict)
116 | links.append(link)
117 |
118 | entities = []
119 | for entities_dict in entity_dict.get('entities', []):
120 | try: # Try it as a link style subentity
121 | entity = self._construct_link(entities_dict)
122 | except KeyError: # otherwise assume it is a full subentity
123 | entity = self._construct_entity(entities_dict)
124 | entities.append(entity)
125 |
126 | siren_entity = SirenEntity(classnames=classname, properties=properties, actions=actions,
127 | links=links, entities=entities, rel=rel, verify=self.verify,
128 | request_factory=self.request_factory)
129 | return siren_entity
130 |
131 | def _construct_link(self, links_dict):
132 | """
133 | Constructs a link from the links dictionary.
134 |
135 | :param dict links_dict: A dictionary include a {key: list, href: unicode}
136 | :return: A SirenLink representing the link
137 | :rtype: SirenLink
138 | :raises: KeyError
139 | """
140 | rel = links_dict['rel']
141 | href = links_dict['href']
142 | link = SirenLink(rel=rel, href=href, verify=self.verify, request_factory=self.request_factory)
143 | return link
144 |
145 |
146 | class SirenEntity(RequestMixin):
147 | """
148 | Represents a siren-entity object. This is the highest-level/root item used by Siren. These represent
149 | instances/classes.
150 | """
151 |
152 | log = logging.getLogger(__name__)
153 |
154 | def __init__(self, classnames, links, properties=None, actions=None, entities=None, rel=None, **kwargs):
155 | """
156 | Constructor.
157 |
158 | :param classnames: root classnames of the response, currently these do nothing, in the future they will be used
159 | to add expanded functionality to an object.
160 | :type classnames: str or list[str]
161 | :param links: link relations to self and related but non owned items
162 | :type links: str or list[str]
163 | :param properties: fields/properties of the instance
164 | :type properties:
165 | :param actions: actions that can be performed on an instance or object class
166 | :type actions:
167 | :raises: ValueError
168 | """
169 | super(SirenEntity, self).__init__(**kwargs)
170 | if not classnames or len(classnames) == 0:
171 | raise ValueError('Parameter "classnames" must have at least one element.')
172 | self.classnames = classnames
173 | self.rel = rel
174 |
175 | self.properties = properties if properties else {}
176 | self.actions = actions if actions else []
177 |
178 | # links are supposed to be of size 0 or more because they should contain at least a link to self
179 | # this is not the case for error messages currently so I'm removing this check
180 | #if not links or len(links) == 0:
181 | # raise ValueError('Parameter "links" must have at least one element.')
182 | self.links = links or [] # store this as a dictionary of rel->siren, also ensure that rels are not duplicated
183 | self.entities = entities or []
184 |
185 | def get_links(self, rel):
186 | """
187 | Obtains a link based upon relationship value.
188 |
189 | :param rel: relationship between this entity and the linked resource
190 | :type rel: str
191 | :return: link to the resource with the specified relationship
192 | :rtype: SirenEntity
193 | """
194 | if not self.links:
195 | return None
196 |
197 | return [x for x in self.links if rel in x.rel] # should change this so that links are added to an internal dictionary? this seems like a flaw in siren
198 |
199 | def get_entities(self, rel):
200 | """
201 | Obtains an entity based upon the relationship value.
202 |
203 | :param rel: relationship between this entity and the linked resource
204 | :type rel: str
205 | :return: link to the resource with the specified relationship
206 | :rtype: list
207 | """
208 | if not self.entities:
209 | return []
210 | return [x for x in self.entities if rel in x.rel]
211 |
212 | def get_primary_classname(self):
213 | """
214 | Obtains the primary classname associated with this entity. This is assumed to be the first classname in the list
215 | of classnames associated with this entity.
216 |
217 | :return: primary classname
218 | :rtype: str
219 | """
220 | return self.classnames[0]
221 |
222 | def get_base_classnames(self):
223 | """
224 | Obtains the base classnames associated with this entity. This is assumed to be all values following the
225 | first/primary classname.
226 |
227 | :return: base classnames
228 | :rtype: str
229 | """
230 | return self.classnames[1:] if len(self.classnames) > 1 else []
231 |
232 | def as_siren(self):
233 | """
234 | Python dictionary/array representation of this entity graph.
235 |
236 | :return: dictionary representation of this siren entity
237 | :rtype: dict[str]
238 | """
239 | new_dict = {'class': self.classnames, 'properties': self.properties}
240 | new_dict['actions'] = [action.as_siren() for action in self.actions]
241 | new_dict['entities'] = [entity.as_siren() for entity in self.entities]
242 | new_dict['links'] = [link.as_siren() for link in self.links]
243 | return new_dict
244 |
245 | def as_json(self):
246 | """
247 | Json-string representation of this entity graph.
248 |
249 | :return: json-string representation of this siren entity
250 | :rtype: str
251 | """
252 | new_dict = self.as_siren()
253 | return json.dumps(new_dict)
254 |
255 | def as_python_object(self):
256 | """
257 | Programmatically create a python object for this siren entity.
258 |
259 | :return: dynamically created object based upon the siren response, type is based upon the classname(s) of this
260 | siren entity
261 | :rtype: object
262 | """
263 | ModelClass = type(str(self.get_primary_classname()), (), self.properties)
264 |
265 | # NOTE: there is no checking to ensure that over-writing of methods will not occur
266 | siren_builder = SirenBuilder(verify=self.verify, request_factory=self.request_factory)
267 | # add actions as methods
268 | for action in self.actions:
269 | method_name = SirenEntity._create_python_method_name(action.name)
270 | method_def = _create_action_fn(action, siren_builder)
271 | setattr(ModelClass, method_name, method_def)
272 |
273 | # add links as methods
274 | for link in self.links:
275 | for rel in link.rel:
276 | method_name = SirenEntity._create_python_method_name(rel)
277 | siren_builder = SirenBuilder(verify=self.verify, request_factory=self.request_factory)
278 | method_def = _create_action_fn(link, siren_builder)
279 |
280 | setattr(ModelClass, method_name, method_def)
281 |
282 | def get_entity(obj, rel):
283 | matching_entities = self.get_entities(rel) or []
284 | for x in matching_entities:
285 | yield x.as_python_object()
286 | setattr(ModelClass, 'get_entities', get_entity)
287 |
288 | return ModelClass()
289 |
290 | @staticmethod
291 | def _create_python_method_name(base_name):
292 | """
293 | Creates a valid python method name from a non-normalized base name.
294 |
295 | :param base_name: base string/name
296 | :type base_name: str
297 | :return: valid python method name
298 | :rtype: str|unicode
299 | """
300 | name = six.text_type(base_name) # coerce argument
301 |
302 | # normalize value
303 | name = name.lower()
304 | name = re.sub(r'-', '_', name)
305 | name = re.sub(r'[^a-zA-Z0-9_]', '', name)
306 |
307 | # confirm the name is valid
308 | matcher = re.compile(r'[a-zA-Z_][a-zA-Z0-9_]*') # see https://docs.python.org/2/reference/lexical_analysis.html#grammar-token-identifier
309 | if matcher.match(name):
310 | return name
311 |
312 | raise ValueError('Unable to create normalized python method name! Base method name="{}". Attempted normalized name="{}"'.format(base_name, name))
313 |
314 |
315 | class SirenAction(RequestMixin):
316 | """Representation of a Siren Action element. Actions are operations on a hypermedia instance or class level."""
317 |
318 | def __init__(self, name, href, type='application/json', fields=None, title=None, method='GET', verify=False, request_factory=Request, **kwargs):
319 | """
320 | Constructor.
321 |
322 | :param name: method name for this action
323 | :type name: str|unicode
324 | :param href: url associated with the method
325 | :type href: str|unicode
326 | :param type: content-type of the payload
327 | :type type: str|unicode
328 | :param fields: list of fields to send with this action/request (parameters, either post or query)
329 | :type fields: list[dict]
330 | :param title: descriptive title/in-line documentation for the method
331 | :type title: str|unicode
332 | :param method: HTTP verb to use for this action (GET, PUT, POST, PATCH, HEAD, etc.)
333 | :type method: str|unicode
334 | :param request_factory: constructor for request objects
335 | :type type or function
336 | :param dict kwargs: Extra stuff to ignore for now.
337 | """
338 | self.name = name
339 | self.title = title
340 | self.method = method
341 | self.href = href
342 | self.type = type
343 | self.fields = fields if fields else []
344 | super(SirenAction, self).__init__(request_factory=request_factory, verify=verify, **kwargs)
345 |
346 | @staticmethod
347 | def create_field(name, type=None, value=None):
348 | """
349 | Convenience method for creating a field dictionary.
350 |
351 | :param name: name of the field/property for this method
352 | :type name: str
353 | :param type: object type for the field (optional)
354 | :type type: str
355 | :param value: value assigned to the field (optional)
356 | :type value: object
357 | :return: dictionary with field definition
358 | :rtype: dict
359 | """
360 | return {'name': name, 'type': type, 'value': value}
361 |
362 | def add_field(self, name, type=None, value=None):
363 | """
364 | Convenience method for adding a field.
365 |
366 | :param name: name of the field/property for this method
367 | :type name: str
368 | :param type: object type for the field (optional)
369 | :type type: str
370 | :param value: value assigned to the field (optional)
371 | :type value: object
372 | """
373 | field = self.create_field(name, type, value)
374 | self.fields.append(field)
375 |
376 | def get_fields_as_dict(self):
377 | """
378 | Gets the fields of this object as a dictionary of key/value pairs.
379 |
380 | :return: dictionary of field key/value pairs that will be sent with this action.
381 | :rtype: dict[str, object]
382 | """
383 | fields_dict = {}
384 | for f in self.fields:
385 | fields_dict[f['name']] = f.get('value', None)
386 | return fields_dict
387 |
388 | def _get_bound_href(self, template_class, **kwfields):
389 | """
390 | Gets the bound href and the
391 | remaining variables
392 |
393 | :param dict kwargs:
394 | :return: The templated string representing
395 | the href and the remaining variables
396 | to place in the query or request body.
397 | :rtype: str|unicode, dict
398 | """
399 | # bind template variables
400 | # bind and remove these the fields so that they do not get passed on
401 | templated_href = template_class(self.href)
402 | url_params = dict(kwfields)
403 | bound_href = templated_href.bind(**url_params)
404 | if bound_href.has_unbound_variables():
405 | raise ValueError('Unbound template parameters in url detected! All variables must be specified! Unbound variables: {}'.format(bound_href.unbound_variables()))
406 | bound_href = bound_href.as_string()
407 |
408 | url_variables = templated_href.unbound_variables()
409 | request_fields = {}
410 | for k, v in kwfields.items():
411 | if k not in url_variables: # remove template variables
412 | request_fields[k] = v
413 | return bound_href, request_fields
414 |
415 | def as_siren(self):
416 | """
417 | Returns a siren-compatible dictionary representation of this object.
418 |
419 | :return: siren dictionary representation of the action
420 | :rtype: dict
421 | """
422 | new_dict = dict(name=self.name, title=self.name, method=self.method,
423 | href=self.href, type=self.type, fields=self.fields)
424 | return new_dict
425 |
426 | def as_json(self):
427 | """
428 | Returns as a json string a siren-compatible representation of this object.
429 |
430 | :return: json-siren
431 | :rtype: str
432 | """
433 | new_dict = self.as_siren()
434 | return json.dumps(new_dict)
435 |
436 | def as_request(self, **kwfields):
437 | """
438 | Creates a Request object from this action. This Request object may be used to call-out and retrieve data from an external source.
439 |
440 | :param kwfields: query/post parameters to add to the request, parameter type depends upon HTTP verb in use # limitation of siren
441 | :return: Request object representation of this action
442 | :rtype: Request
443 | """
444 | bound_href, request_fields = self._get_bound_href(TemplatedString, **kwfields)
445 |
446 | # update query/post parameters specified from sirenaction with remaining arg values
447 | # (we ignore anything not specified for the action)
448 | fields = self.get_fields_as_dict()
449 | fields.update(request_fields)
450 |
451 | # prepare the parameters for serialization
452 | fields = self.prepare_payload_parameters(**fields)
453 |
454 | # depending upon the method we need to use params or data for field transmission
455 | if self.method == 'GET':
456 | req = self.request_factory(self.method, bound_href, params=fields)
457 | elif self.method in ['PUT', 'POST', 'PATCH']:
458 | req = self.request_factory(self.method, bound_href, data=fields)
459 | else:
460 | req = self.request_factory(self.method, bound_href)
461 |
462 | return req.prepare()
463 |
464 | def make_request(self, _session=None, **kwfields):
465 | """
466 | Performs the request.
467 |
468 | :param kwfields: additional items to add to the underlying request object
469 | :return: response from the server
470 | :rtype: Response
471 | """
472 | s = _session or Session()
473 | return s.send(self.as_request(**kwfields), verify=self.verify)
474 |
475 | @staticmethod
476 | def prepare_payload_parameters(**params):
477 | """
478 | Prepares parameters for their serialized json representation.
479 |
480 | :param params: query/post parameters
481 | :return: dictionary of prepared parameters
482 | :rtype: dict[str, str]
483 | """
484 | result = {}
485 | for k, v in params.items():
486 | if not v:
487 | continue
488 |
489 | if not isinstance(v, six.string_types):
490 | v = json.dumps(v)
491 |
492 | result[k] = v
493 | return result
494 |
495 |
496 | class SirenLink(SirenBuilder):
497 | """
498 | Representation of a Link in Siren. Links are traversals to related objects that exist outside of normal entity
499 | (parent-child) ownership.
500 | """
501 |
502 | def __init__(self, rel, href, verify=False, request_factory=Request):
503 | """
504 | Constructor.
505 |
506 | :param rel: relationship or list relationships associated with the link
507 | :type rel: list[str] or str
508 | :param href: href
509 | :type href: str
510 | :param request_factory: constructor for request objects
511 | :type type or function
512 | :raises: ValueError
513 | """
514 | if not rel:
515 | raise ValueError('Parameter "rel" is required and must be a string or list of at least one element..')
516 |
517 | if isinstance(rel, six.string_types):
518 | rel = [rel, ]
519 | self.rel = list(rel)
520 |
521 | if not href or not isinstance(href, six.string_types):
522 | raise ValueError('Parameter "href" must be a string.')
523 | self.href = href
524 |
525 | self.verify = verify
526 | self.request_factory = request_factory
527 |
528 | def add_rel(self, new_rel):
529 | """
530 | Adds a new relationship to this link.
531 |
532 | :param new_rel: additional relationship to assign to this link (note that duplicate relationships will not be added)
533 | :type new_rel: str
534 | """
535 | if new_rel not in self.rel:
536 | self.rel.append(new_rel)
537 |
538 | def rem_rel(self, cur_rel):
539 | """
540 | Removes a relationship from this link.
541 |
542 | :param cur_rel: pre-existing relationship to remove (note that removing relationships not assigned to this link is a no-op)
543 | :type cur_rel: str|unicode
544 | """
545 | if cur_rel in self.rel:
546 | self.rel.remove(cur_rel)
547 |
548 | def as_siren(self):
549 | """
550 | Returns a siren-compatible dictionary representation of this object.
551 |
552 | :return: siren dictionary representation of the link
553 | :rtype: dict
554 | """
555 | return dict(rel=self.rel, href=self.href)
556 |
557 | def as_json(self):
558 | """
559 | Returns as a json string a siren-compatible representation of this object.
560 |
561 | :return: json-siren
562 | :rtype: unicode
563 | """
564 | new_dict = self.as_siren()
565 | return json.dumps(new_dict)
566 |
567 | def as_request(self, **kwfields):
568 | """
569 | Returns this link as a request.
570 |
571 | :param kwfields: optional and not currently used for standard links, retained for method-signature compatibility with actions
572 | :return: request object representing the link
573 | :rtype: Request
574 | """
575 | req = self.request_factory('GET', self.href)
576 | return req.prepare()
577 |
578 | def as_python_object(self, _session=None, **kwargs):
579 | """
580 | Constructs the link as a python object by
581 | first making a request and then constructing the
582 | corresponding object.
583 |
584 | :param kwfields: query/post parameters to add to the request, parameter type depends upon HTTP verb in use # limitation of siren
585 | :return: The SirenEntity constructed from the respons from the api.
586 | :rtype: SirenEntity
587 | """
588 | resp = self.make_request(_session=_session)
589 | siren_entity = self.from_api_response(resp)
590 | return siren_entity.as_python_object()
591 |
592 | def make_request(self, _session=None, **kwfields):
593 | """
594 | Performs retrieval of the link from the external server.
595 |
596 | :param kwfields: query/post parameters to add to the request, parameter type depends upon HTTP verb in use # limitation of siren
597 | :return: Request object representation of this action
598 | :rtype: Request
599 | """
600 | s = _session or Session()
601 | return s.send(self.as_request(**kwfields), verify=self.verify)
602 |
603 |
604 | # ==============
605 | # Helper Classes
606 | # ==============
607 |
608 | class MalformedSirenError(Exception):
609 | """
610 | siren-json representation is errant.
611 | """
612 | def __init__(self, message, errors=None):
613 | Exception.__init__(self, message)
614 |
615 |
616 | class UnexpectedStatusError(Exception):
617 | """
618 | Unexpected status was returned from the service. These are errant statuses from which the library cannot recover to
619 | create an object.
620 | """
621 | def __init__(self, message, errors=None):
622 | Exception.__init__(self, message)
623 |
624 |
625 | class TemplatedString(object):
626 | """
627 | Helper class for handling templated strings and allows for partial templating.
628 | """
629 |
630 | def __init__(self, base):
631 | """
632 | Constructor. Creates template dict.
633 |
634 | :param base: base string which template placeholders
635 | :type base: str or unicode
636 | """
637 | # assign
638 | self.base = str(base)
639 |
640 | # locate parameters
641 | param_locator = re.compile('\{[^}]+\}')
642 | params = param_locator.findall(self.base)
643 | self.param_dict = {}
644 | for p in params:
645 | self.param_dict[p.replace('{', '').replace('}', '')] = p
646 |
647 | def items(self):
648 | """
649 | Unbound template variables and their literal string value in the list.
650 |
651 | :return: iterator on template dictionary
652 | :rtype: dict[str, str]
653 | """
654 | return self.param_dict.items()
655 |
656 | def unbound_variables(self):
657 | """
658 | Gets the unbound template variables.
659 |
660 | :return: unbound variables
661 | :rtype: list[str]
662 | """
663 | return self.param_dict.keys()
664 |
665 | def bind(self, **kwargs):
666 | """
667 | Binds the keyword arguments against the template variables. Partial binding is permitted. Later rebinding is not
668 | possible.
669 |
670 | :param kwargs: parameters and binding values
671 | :type kwargs: dict[str, str]
672 | :return: templated string with bound variables
673 | :rtype: TemplatedString
674 | """
675 | #TODO: allow rebinding, don't permanently replace values
676 | bound_string = self.base
677 | for param_key, param_val in kwargs.items():
678 | template = self.param_dict.get(param_key, None) # locate in dict, let's play nice and not explode
679 | if not template:
680 | continue
681 |
682 | bound_string = bound_string.replace(template, six.text_type(param_val)) # use value to perform replacement
683 |
684 | return TemplatedString(bound_string)
685 |
686 | def has_unbound_variables(self):
687 | """
688 | Checks whether there are unmet variable assignments.
689 |
690 | :return: True if there are unbound variables, False otherwise
691 | :rtype: bool
692 | """
693 | return len(self.param_dict) != 0
694 |
695 | def as_string(self):
696 | """
697 | Provides the string representation.
698 |
699 | :return: string representation.
700 | :rtype: str
701 | """
702 | return self.base
703 |
704 |
705 | # ================
706 | # Helper functions
707 | # ================
708 |
709 | def _create_action_fn(action, siren_builder, **kwargs):
710 | """Creates an action function which will make a web request, retrieve content, and create a python object.
711 |
712 | :param action: action object capable of making a request
713 | :type action: SirenAction or SirenLink
714 | :param kwargs: keyword arguments for passage into the underlying requests library object
715 | :return: action function capable of requesting data from the server and creating a new proxy object
716 | :rtype: function
717 | """
718 | def _action_fn(self, **kwargs):
719 | response = action.make_request(verify=siren_builder.verify, **kwargs) # create request and obtain response
720 | siren = siren_builder.from_api_response(response=response) # interpret response as a siren object
721 | if not siren:
722 | return None
723 | return siren.as_python_object() # represent this as a legitimate python object (proxy to the service)
724 |
725 | return _action_fn
726 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import division
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | from setuptools import setup, find_packages # Always prefer setuptools over distutils
7 | from codecs import open # To use a consistent encoding
8 | from os import path
9 |
10 | __author__ = 'Alex Maskovyak'
11 | __pkg_name__ = 'pypermedia'
12 |
13 |
14 | here = path.abspath(path.dirname(__file__))
15 |
16 | # Get the long description from the relevant file
17 | with open(path.join(here, 'DESCRIPTION.rst'), encoding='utf-8') as f:
18 | long_description = f.read()
19 |
20 | version = '0.4.2'
21 |
22 | # run-time dependencies, listed here so that they can be shared with test requirements
23 | install_requirements = [
24 | 'requests>=2.3.0',
25 | 'six'
26 | ]
27 |
28 | test_requirements = [
29 | 'mock',
30 | 'pytest',
31 | 'unittest2'
32 | ] + install_requirements
33 |
34 |
35 | # setuptool packaging info
36 | setup(
37 | name=__pkg_name__,
38 | version=version,
39 | description='Python client for hypermedia APIs.',
40 | long_description=long_description,
41 |
42 | author=__author__,
43 | author_email='alex.maskovyak@vertical-knowledge.com',
44 |
45 | license='GPLv2',
46 |
47 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers
48 | classifiers=[
49 | # 2 - Pre-Alpha, 3 - Alpha, 4 - Beta, 5 - Production/Stable
50 | 'Development Status :: 4 - Beta',
51 |
52 | 'Intended Audience :: Developers',
53 | 'Topic :: Software Development :: Libraries :: Python Modules',
54 |
55 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)',
56 |
57 | 'Programming Language :: Python :: 2.7',
58 | 'Programming Language :: Python :: 3.3',
59 | ],
60 |
61 | keywords='client rest hypermedia http proxy siren api hateoas',
62 |
63 | packages=find_packages(include=['pypermedia', 'pypermedia.*', 'tests', 'tests.*']),
64 |
65 | install_requires=install_requirements,
66 | tests_require=test_requirements,
67 | test_suite='tests'
68 |
69 | # Although 'package_data' is the preferred approach, in some case you may
70 | # need to place model files outside of your packages.
71 | # see http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files
72 | # In this case, 'data_file' will be installed into '/my_data'
73 | # data_files=[('my_data', ['model/data_file'])],
74 |
75 | # To provide executable scripts, use entry points in preference to the
76 | # "scripts" keyword. Entry points provide cross-platform support and allow
77 | # pip to create the appropriate form of executable for the target platform.
78 | # entry_points={
79 | # 'console_scripts': [
80 | # 'sample=sample:main',
81 | # ],
82 | # },
83 | )
84 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | __author__ = 'alexmaskovyak'
2 |
--------------------------------------------------------------------------------
/tests/test_siren.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | import pytest
3 | from pypermedia.siren import SirenBuilder, SirenEntity, SirenAction, SirenLink
4 |
5 | unit_test = pytest.mark.unit_test
6 |
7 | @unit_test
8 | def test_from_api_response():
9 | """Test the construction of objects from a json string."""
10 | json = """
11 | {
12 | "links": [
13 | {
14 | "href": "/resources?url=http://slashdot.org",
15 | "rel": [
16 | "self", "double-self"
17 | ]
18 | },
19 | {
20 | "href": "/views/5e88ecafcfe0520766cede7ef76dc16b2d869f5f6ce37141fde4224780a839c5dac26014336a22fabcf475873a5d254245e954fd9646e84c8e6ab087934eb873",
21 | "rel": [
22 | "view"
23 | ]
24 | }
25 | ],
26 | "class": [
27 | "Resource"
28 | ],
29 | "actions": [
30 | {
31 | "name": "get_with_url",
32 | "title": "get resource with url",
33 | "fields": [
34 | {
35 | "type": "text",
36 | "name": "url"
37 | }
38 | ],
39 | "href": "/resources",
40 | "type": "application/json",
41 | "method": "GET"
42 | }
43 | ],
44 | "properties": {
45 | "url": "http://slashdot.org",
46 | "time_fetched": 1409067477,
47 | "view_id": "5e88ecafcfe0520766cede7ef76dc16b2d869f5f6ce37141fde4224780a839c5dac26014336a22fabcf475873a5d254245e954fd9646e84c8e6ab087934eb873",
48 | "body_hash": "a3bf7ed65f40731cc33eb806476f3810883fc609b7dab609802fc844aaf06a4ec1836c0b21969acabe3c66b7d5dbd75fa664efad355eaf67d1055aa388f8b989"
49 | }
50 | }"""
51 |
52 | sb = SirenBuilder()
53 | sb.verify = False
54 | sb.request_factory = None
55 | so = sb.from_api_response(json)
56 | assert 'Resource' in so.classnames
57 | assert so.properties['url']
58 | assert so.properties['time_fetched']
59 | assert so.properties['view_id']
60 | assert so.properties['body_hash']
61 |
62 | links = so.links
63 | assert links and len(links) == 2
64 | assert so.get_links('self').href == '/resources?url=http://slashdot.org'
65 | assert so.get_links('double-self').href == '/resources?url=http://slashdot.org'
66 | assert so.get_links('view').href == '/views/5e88ecafcfe0520766cede7ef76dc16b2d869f5f6ce37141fde4224780a839c5dac26014336a22fabcf475873a5d254245e954fd9646e84c8e6ab087934eb873'
67 |
68 | actions = so.actions
69 | assert actions and len(actions) == 1
70 |
71 | action = actions[0]
72 | assert action.name
73 | assert action.href
74 | assert action.type
75 | assert action.method
76 |
77 | fields = action.fields
78 | assert fields and len(fields) == 1
79 |
80 | for f in fields:
81 | assert f['name']
82 | assert f['type']
83 |
84 |
85 | def test_as_python_object():
86 | """Integration test of using hard-coded siren to create the hypermedia rest-client. This will attempt to contact the url."""
87 | base_url = 'http://127.0.0.1:5000/codex/views'
88 | classnames = ['view']
89 | properties = {'view_id': '1', 'url': 'http://slashdot.org', 'time_fetched': '1409067477'}
90 | actions = [SirenAction(name='update-view',
91 | href='{}/1/'.format(base_url),
92 | type='application/json',
93 | fields=[{'type': 'text', 'name': 'url'}, {"type": "text","name": "time_fetched"},],
94 | title=None,
95 | method='PUT'),
96 | SirenAction(name='create-view',
97 | href=base_url,
98 | type='application/json',
99 | fields=[{'type': 'text', 'name': 'url'}, {"type": "text", "name": "time_fetched"},],
100 | title=None,
101 | method='POST'),]
102 | links = [SirenLink(rel=['self'], href='http://127.0.0.1/views/1')]
103 | so = SirenEntity(classnames=classnames, properties=properties, actions=actions, links=links)
104 |
105 | view = so.as_python_object()
106 | assert type(view).__name__ == 'view'
107 | assert view.view_id == '1'
108 | assert view.url == 'http://slashdot.org'
109 | assert view.time_fetched == '1409067477'
110 | view.update_view(url='blank', time_fetched='2014-08-26 14:05:26', body='TEST')
111 |
--------------------------------------------------------------------------------
/tests/unit/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import division
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | __author__ = 'Tim Martin'
7 |
--------------------------------------------------------------------------------
/tests/unit/test_client.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import division
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | from pypermedia.client import HypermediaClient, ConnectError
7 |
8 | import mock
9 | import requests
10 | import unittest2
11 |
12 |
13 | class TestClient(unittest2.TestCase):
14 | """
15 | This is kinda shit since it really
16 | needs to be integration tested.
17 | """
18 |
19 | def test_connect(self):
20 | builder = mock.MagicMock()
21 | request_factory = mock.MagicMock()
22 | session = mock.MagicMock()
23 | resp = HypermediaClient.connect('blah', session=session, request_factory=request_factory, builder=builder)
24 | self.assertEqual(builder.return_value.from_api_response.return_value.as_python_object.return_value, resp)
25 |
26 | def test_send_and_construct(self):
27 | builder = mock.MagicMock()
28 | request_factory = mock.MagicMock()
29 | session = mock.MagicMock()
30 | request = mock.Mock(url='url')
31 | resp = HypermediaClient.send_and_construct(request, session=session, request_factory=request_factory, builder=builder)
32 | self.assertEqual(builder.return_value.from_api_response.return_value.as_python_object.return_value, resp)
33 |
34 | def test_send_and_construct_error(self):
35 | request = mock.Mock(url='url')
36 | session = mock.Mock(send=mock.Mock(side_effect=requests.exceptions.ConnectionError))
37 | self.assertRaises(ConnectError, HypermediaClient.send_and_construct, request, session=session)
38 |
--------------------------------------------------------------------------------
/tests/unit/test_siren.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import division
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | from pypermedia.siren import _check_and_decode_response, SirenBuilder, UnexpectedStatusError, \
7 | MalformedSirenError, SirenLink, SirenEntity, SirenAction, TemplatedString, \
8 | _create_action_fn
9 |
10 | from requests import Response, PreparedRequest
11 |
12 | import json
13 | import mock
14 | import six
15 | import types
16 | import unittest2
17 |
18 |
19 | class TestSirenBuilder(unittest2.TestCase):
20 | def test_check_and_decode_response_404(self):
21 | """
22 | Tests the private _check_and_decode_response method
23 | """
24 | resp = mock.Mock(status_code=404)
25 | self.assertIsNone(_check_and_decode_response(resp))
26 |
27 | def test_check_and_decode_bad_status_code(self):
28 | """
29 | Tests that an exception is raised for non-200
30 | status codes.
31 | """
32 | resp = mock.Mock(status_code=400)
33 | self.assertRaises(UnexpectedStatusError, _check_and_decode_response, resp)
34 |
35 | def test_check_and_decode_empty_text(self):
36 | """
37 | Tests that an exception is raised when
38 | the body is empty.
39 | """
40 | resp = mock.Mock(status_code=200, text='')
41 | self.assertRaises(MalformedSirenError, _check_and_decode_response, resp)
42 |
43 | def test_construct_link(self):
44 | builder = SirenBuilder()
45 | link = builder._construct_link(dict(rel=['rel'], href='whocares'))
46 | self.assertIsInstance(link, SirenLink)
47 |
48 | def test_construct_link_bad(self):
49 | """
50 | Tests constructing a link.
51 | """
52 | builder = SirenBuilder()
53 | self.assertRaises(KeyError, builder._construct_link, dict(rel=['blah']))
54 |
55 | def test_construct_entity_missing_class(self):
56 | entity = dict(properties={}, actions=[], links=[], entities=[])
57 | builder = SirenBuilder()
58 | self.assertRaises(KeyError, builder._construct_entity, entity)
59 |
60 | def test_construct_entity_missing_non_essential(self):
61 | """Tests that non-essential pieces are ignored."""
62 | entity = {'class': ['blah']}
63 | builder = SirenBuilder()
64 | resp = builder._construct_entity(entity)
65 | self.assertIsInstance(resp, SirenEntity)
66 |
67 | def test_from_api_response(self):
68 | """
69 | Tests for a requests.Response object.
70 | """
71 | entity = {'class': ['blah']}
72 | resp = Response()
73 | resp.status_code = 200
74 | resp._content = six.binary_type(json.dumps(entity).encode('utf8'))
75 | builder = SirenBuilder()
76 | siren = builder.from_api_response(resp)
77 | self.assertIsInstance(siren, SirenEntity)
78 |
79 | def test_bad_text_from_api_response(self):
80 | builder = SirenBuilder()
81 | self.assertRaises(MalformedSirenError, builder.from_api_response, 'asdfgsjdfg')
82 |
83 | def test_from_api_response_bad_type(self):
84 | builder = SirenBuilder()
85 | self.assertRaises(TypeError, builder.from_api_response, [])
86 |
87 |
88 | class TestSirenEntity(unittest2.TestCase):
89 | def test_init_no_classnames(self):
90 | self.assertRaises(ValueError, SirenEntity, None, None)
91 | self.assertRaises(ValueError, SirenEntity, [], None)
92 |
93 | def test_get_link_no_links(self):
94 | entity = SirenEntity(['blah'], None)
95 | self.assertIsNone(entity.get_links('sakdf'))
96 |
97 | def test_get_link(self):
98 | link = mock.Mock(rel=['myrel'])
99 | entity = SirenEntity(['blah'], [link])
100 | resp = entity.get_links('myrel')
101 | self.assertEqual([link], resp)
102 | self.assertListEqual(entity.get_links('badrel'), [])
103 |
104 | def test_get_entity_no_entities(self):
105 | entity = SirenEntity(['blah'], None)
106 | self.assertEqual(entity.get_entities('sakdf'), [])
107 |
108 | def test_get_entities(self):
109 | ent = SirenEntity(['blah'], [], rel=['myrel'])
110 | entity = SirenEntity(['builderah'], [], entities=[ent])
111 | resp = entity.get_entities('myrel')
112 | self.assertEqual([ent], resp)
113 | self.assertEqual(entity.get_entities('badrel'), [])
114 |
115 | def test_get_primary_classname(self):
116 | entity = SirenEntity(['blah'], None)
117 | self.assertEqual(entity.get_primary_classname(), 'blah')
118 |
119 | def test_get_base_classnames(self):
120 | entity = SirenEntity(['blah'], None)
121 | self.assertListEqual(entity.get_base_classnames(), [])
122 | entity = SirenEntity(['blah', 'second'], None)
123 | self.assertListEqual(entity.get_base_classnames(), ['second'])
124 |
125 | def test_as_siren(self):
126 | entity = SirenEntity(['blah'], [])
127 | siren_dict = entity.as_siren()
128 | self.assertIsInstance(siren_dict, dict)
129 | self.assertDictEqual(siren_dict, {'class': ['blah'], 'links': [], 'entities': [], 'actions': [], 'properties': {}})
130 |
131 | def test_as_json(self):
132 | entity = SirenEntity(['blah'], [])
133 | json_string = entity.as_json()
134 | self.assertIsInstance(json_string, six.string_types)
135 |
136 | def test_as_python_object(self):
137 | entity = SirenEntity(['blah'], [])
138 | siren_class = entity.as_python_object()
139 | self.assertTrue(hasattr(siren_class, 'get_entities'))
140 | # TODO we definitely need some more tests for this part.
141 |
142 | def test_create_python_method_name(self):
143 | original_expected = [
144 | ('original', 'original',),
145 | ('original func', 'originalfunc',),
146 | ('original-func', 'original_func',),
147 | ('%bd#$%#$)@c', 'bdc'),
148 | ]
149 | for original, expected in original_expected:
150 | actual = SirenEntity._create_python_method_name(original)
151 | self.assertEqual(actual, expected)
152 |
153 | def test_create_python_method_name_invalid(self):
154 | bad = ('#$%^#$%&', '', '09345asda',)
155 | for name in bad:
156 | self.assertRaises(ValueError, SirenEntity._create_python_method_name, name)
157 |
158 |
159 | class TestSirenAction(unittest2.TestCase):
160 | def test_add_field(self):
161 | action = SirenAction('action', 'blah', 'application/json')
162 | self.assertEqual(action.fields, [])
163 | action.add_field('field')
164 | self.assertEqual(len(action.fields), 1)
165 | self.assertDictEqual(action.fields[0], dict(name='field', type=None, value=None))
166 |
167 | def test_get_fields_dict(self):
168 | action = SirenAction('action', 'blah', 'application/json',
169 | fields=[dict(name='field', type=None, value='whocares')])
170 | field_dict = action.get_fields_as_dict()
171 | self.assertDictEqual(dict(field='whocares'), field_dict)
172 |
173 | def test_as_siren(self):
174 | action = SirenAction('action', 'blah', 'application/json')
175 | siren_action = action.as_siren()
176 | expected = {'href': u'blah', 'name': u'action', 'title': u'action',
177 | 'fields': [], 'type': u'application/json', 'method': u'GET'}
178 | self.assertDictEqual(siren_action, expected)
179 |
180 | def test_as_json(self):
181 | action = SirenAction('action', 'blah', 'application/json')
182 | siren_action = action.as_json()
183 | self.assertIsInstance(siren_action, six.string_types)
184 |
185 | def test_get_bound_href(self):
186 | action = SirenAction('action', 'blah', 'application/json')
187 | bound_href, request_fields = action._get_bound_href(TemplatedString, x=1, y=2)
188 | self.assertEqual(bound_href, 'blah')
189 | self.assertDictEqual(request_fields, dict(x=1, y=2))
190 |
191 | def test_get_bound_href_with_template(self):
192 | action = SirenAction('action', 'http://host.com/{id}/{id}', 'application/json')
193 | bound_href, request_fields = action._get_bound_href(TemplatedString, x=1, y=2, id=3)
194 | self.assertEqual(bound_href, 'http://host.com/3/3')
195 | self.assertDictEqual(dict(x=1, y=2), request_fields)
196 |
197 | def test_get_bound_href_unboud_variables(self):
198 | action = SirenAction('action', 'http://host.com/{id}/{id}', 'application/json')
199 | self.assertRaises(ValueError, action._get_bound_href, TemplatedString, x=1, y=2)
200 |
201 | def test_as_request_get(self):
202 | action = SirenAction('action', 'http://blah.com', 'application/json')
203 | resp = action.as_request(x=1, y=2)
204 | self.assertIsInstance(resp, PreparedRequest)
205 | self.assertEqual(resp.method, 'GET')
206 | self.assertIn('y=2', resp.path_url)
207 | self.assertIn('x=1', resp.path_url)
208 |
209 | def test_as_request_post(self):
210 | action = SirenAction('action', 'http://blah.com', 'application/json', method='POST')
211 | resp = action.as_request(x=1, y=2)
212 | self.assertIsInstance(resp, PreparedRequest)
213 | self.assertEqual(resp.method, 'POST')
214 | self.assertEqual('/', resp.path_url)
215 |
216 | def test_as_request_delete(self):
217 | action = SirenAction('action', 'http://blah.com', 'application/json', method='DELETE')
218 | resp = action.as_request(x=1, y=2)
219 | self.assertIsInstance(resp, PreparedRequest)
220 | self.assertEqual(resp.method, 'DELETE')
221 | self.assertEqual('/', resp.path_url)
222 |
223 | def test_make_request(self):
224 | action = SirenAction('action', 'http://blah.com', 'application/json')
225 | mck = mock.Mock(send=mock.Mock(return_value=True))
226 | resp = action.make_request(_session=mck, x=1, y=2)
227 | self.assertTrue(resp)
228 | self.assertEqual(mck.send.call_count, 1)
229 | self.assertIsInstance(mck.send.call_args[0][0], PreparedRequest)
230 |
231 |
232 | class TestSirenLink(unittest2.TestCase):
233 | def test_init_errors(self):
234 | self.assertRaises(ValueError, SirenLink, [], 'href')
235 | self.assertRaises(ValueError, SirenLink, None, 'href')
236 | self.assertRaises(ValueError, SirenLink, ['blah'], '')
237 |
238 | def test_init_rel_string(self):
239 | siren_link = SirenLink('blah', 'href')
240 | self.assertEqual(['blah'], siren_link.rel)
241 |
242 | def test_add_rel(self):
243 | link = SirenLink(['blah'], 'blah')
244 | self.assertListEqual(link.rel, ['blah'])
245 | link.add_rel('two')
246 | self.assertListEqual(['blah', 'two'], link.rel)
247 | link.add_rel('two')
248 | self.assertListEqual(['blah', 'two'], link.rel)
249 |
250 | def test_rem_rel(self):
251 | link = SirenLink(['blah'], 'blah')
252 | link.rem_rel('notreal')
253 | self.assertListEqual(link.rel, ['blah'])
254 | link.rem_rel('blah')
255 | self.assertListEqual(link.rel, [])
256 |
257 | def test_as_siren(self):
258 | link = SirenLink(['blah'], 'href')
259 | self.assertDictEqual(link.as_siren(), dict(rel=['blah'], href='href'))
260 |
261 | def test_as_json(self):
262 | link = SirenLink(['blah'], 'href')
263 | self.assertIsInstance(link.as_json(), six.string_types)
264 |
265 | def test_as_request(self):
266 | href = 'http://notreal.com/'
267 | link = SirenLink(['blah'], 'http://notreal.com')
268 | req = link.as_request()
269 | self.assertIsInstance(req, PreparedRequest)
270 | self.assertEqual(href, req.url)
271 |
272 | def test_make_request(self):
273 | link = SirenLink(['blah'], 'http://notreal.com')
274 | session = mock.MagicMock()
275 | resp = link.make_request(_session=session)
276 | self.assertEqual(session.send.call_count, 1)
277 |
278 | def test_as_python_object(self):
279 | """
280 | Mostly just an explosion test.
281 | """
282 | link = SirenLink('blah', 'blah')
283 | with mock.patch.object(link, 'make_request') as make_request:
284 | with mock.patch.object(link, 'from_api_response') as from_api_respons:
285 | resp = link.as_python_object()
286 | self.assertEqual(make_request.call_count, 1)
287 | self.assertEqual(from_api_respons.call_count, 1)
288 |
289 |
290 | class TestTemplatedString(unittest2.TestCase):
291 | def test_init(self):
292 | base = '/blah/'
293 | template = TemplatedString(base)
294 | self.assertEqual(len(template.param_dict), 0)
295 | base = '/{id}/{pk}/sdf'
296 | template = TemplatedString(base)
297 | self.assertEqual(len(template.param_dict), 2)
298 | self.assertEqual(template.param_dict['id'], '{id}')
299 | self.assertEqual(template.param_dict['pk'], '{pk}')
300 |
301 | def test_items(self):
302 | base = '/{id}/{pk}/sdf'
303 | template = TemplatedString(base)
304 | for p in [('id', '{id}'), ('pk', '{pk}')]:
305 | self.assertIn(p, template.items())
306 |
307 | def test_unbound_variables(self):
308 | base = '/{id}/{pk}/sdf'
309 | template = TemplatedString(base)
310 | for p in ['id', 'pk']:
311 | self.assertIn(p, template.unbound_variables())
312 |
313 | def test_bind(self):
314 | base = '/{id}/{pk}/sdf'
315 | template = TemplatedString(base)
316 | template2 = template.bind(id=1)
317 | self.assertEqual(template2.base, '/1/{pk}/sdf')
318 | self.assertDictEqual(template2.param_dict, {'pk': '{pk}'})
319 |
320 | template3 = template2.bind(id=1)
321 | self.assertEqual(template3.base, '/1/{pk}/sdf')
322 | self.assertDictEqual(template3.param_dict, {'pk': '{pk}'})
323 |
324 | template4 = template3.bind(pk=2)
325 | self.assertEqual(template4.base, '/1/2/sdf')
326 | self.assertDictEqual(template4.param_dict, {})
327 |
328 | template5 = template4.bind(who='asdknf', cares=23)
329 | self.assertEqual(template5.base, '/1/2/sdf')
330 | self.assertDictEqual(template5.param_dict, {})
331 |
332 | def test_has_unbound_variables(self):
333 | base = '/{id}/{pk}/sdf'
334 | template = TemplatedString(base)
335 | self.assertTrue(template.has_unbound_variables())
336 |
337 | base = '/sdf'
338 | template = TemplatedString(base)
339 | self.assertFalse(template.has_unbound_variables())
340 |
341 | def test_as_string(self):
342 | base = '/{id}/{pk}/sdf'
343 | template = TemplatedString(base)
344 | self.assertEqual(base, template.as_string())
345 |
346 |
347 | class TestMiscellaneousSiren(unittest2.TestCase):
348 | def test_create_action_function(self):
349 | action = mock.MagicMock()
350 | siren = mock.MagicMock()
351 | func = _create_action_fn(action, siren)
352 | self.assertIsInstance(func, types.FunctionType)
353 | slf = mock.MagicMock()
354 | resp = func(slf, blah='ha')
355 | self.assertEqual(siren.from_api_response.return_value.as_python_object.return_value, resp)
356 | self.assertEqual(action.make_request.return_value, siren.from_api_response.call_args[1]['response'])
357 |
358 | def test_create_action_function_none_response(self):
359 | action = mock.MagicMock()
360 | siren = mock.MagicMock()
361 | siren.from_api_response.return_value = None
362 | func = _create_action_fn(action, siren)
363 | slf = mock.MagicMock()
364 | resp = func(slf, blah='ha')
365 | self.assertIsNone(resp)
366 |
367 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py26,py27,py33,py34,pypy,pypy3
3 |
4 | [testenv]
5 | commands =
6 | pip install -U ripozo-tests
7 | python setup.py install
8 | python setup.py test
9 |
--------------------------------------------------------------------------------