├── .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 | --------------------------------------------------------------------------------