├── .gitignore ├── GPL-LICENSE.txt ├── MANIFEST.in ├── MIT-LICENSE.txt ├── README.md ├── VERSION ├── bootstrap ├── dev-req.txt ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat └── selector.rst ├── fabfile.py ├── selector.py ├── setup.py └── tests ├── __init__.py ├── functional ├── __init__.py ├── conftest.py ├── test_selector.py └── wsgiapps.py ├── regression ├── __init__.py └── test_issue_12_build_installs.py ├── test_harness.py └── unit ├── __init__.py ├── mocks.py ├── path-expression-expectations.csv ├── path-expressions.csv ├── test_by_method.py ├── test_default_handlers.py ├── test_environ_dispatcher.py ├── test_middleware_composer.py ├── test_naked.py ├── test_pliant.py ├── test_selector_add.py ├── test_selector_call.py ├── test_selector_init.py ├── test_selector_mapping_format.py ├── test_selector_select.py ├── test_selector_slurp.py └── test_simple_parser.py /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.xml 2 | build 3 | dist 4 | *.pyc 5 | .virt 6 | .coverage 7 | .tox 8 | *.egg-info 9 | trash 10 | htmlcov 11 | docs/_build 12 | .buildtest 13 | -------------------------------------------------------------------------------- /GPL-LICENSE.txt: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION 2 | -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006 Luke Arno, http://lukearno.com/ 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Selector 2 | 3 | WSGI request delegation. (AKA routing.) 4 | 5 | ```bash 6 | $ pip install selector 7 | ``` 8 | 9 | ## Overview 10 | 11 | This distribution provides WSGI middleware 12 | for "RESTful" dispatch of requests to WSGI applications 13 | by URL path and HTTP request method. 14 | Selector now also comes with components for environ-based 15 | dispatch and on-the-fly middleware composition. 16 | There is a very simple optional mini-language for 17 | path matching expressions. Alternately we can easily use 18 | regular expressions directly or even create our own 19 | mini-language. There is a simple "mapping file" format 20 | that can be used. There are no architecture specific 21 | features (to MVC or whatever). Neither are there any 22 | framework specific features. 23 | 24 | ## Quick Start 25 | 26 | ```python 27 | import selector 28 | 29 | app = selector.Selector() 30 | app.add('/resource/{id}', GET=wsgi_handler) 31 | ``` 32 | 33 | If you have ever designed a REST protocol you have probably made a table that 34 | looks something like this: 35 | 36 | | `/foos/{id}` | | 37 | | ------------- | --------------------------------- | 38 | | `POST` | Create a new foo with id == {id}. | 39 | | `GET` | Retrieve the foo with id == {id}. | 40 | | `PUT` | Update the foo with id == {id}. | 41 | | `DELETE` | Delete the foo with id == {id}. | 42 | 43 | Selector was designed to fit mappings of this kind. 44 | 45 | Lets suppose that we are creating a very simple app. The only requirement is 46 | that `http://example.com/myapp/hello/Guido` responds with a simple page that 47 | says hello to Guido (where "Guido" can actually be any name at all). 48 | The interface of this extremely useful service looks like: 49 | 50 | | `/myapp/hello/{name}` | | 51 | | --------------------- | --------------------- | 52 | | `GET` | Say hello to {name}. | 53 | 54 | Here's the code for `myapp.py`: 55 | 56 | ```python 57 | from selector import Selector 58 | 59 | def say_hello(environ, start_response): 60 | args, kwargs = environ['wsgiorg.routing_args'] 61 | start_response("200 OK", [('Content-type', 'text/plain')]) 62 | return ["Hello, %s!" % kwargs['name']] 63 | 64 | app = Selector() 65 | app.add('/myapp/hello/{name}', GET=say_hello) 66 | ``` 67 | 68 | Run it with [Green Unicorn](http://gunicorn.org/): 69 | 70 | ```bash 71 | $ gunicorn myapp:app 72 | ``` 73 | 74 | Of course, you can use Selector in any WSGI environment. 75 | 76 | ## How It Works 77 | 78 | When a route is added, the **path expression** is converted into a regular 79 | expression. (You can also use regexes directly.) When the `Selector` 80 | instance receives a request, it checks each regex until a 81 | match is found. If no match is found, the request is passed to 82 | `Selector.status404`. Otherwise, it modifies the `environ` to store some 83 | information about the match and looks up the `dict` of HTTP request methods 84 | associated with the regex. If the HTTP method is not found in the `dict`, 85 | the request is passed to `Selector.status405`. Otherwise, 86 | the request is passed to the WSGI handler associated with the HTTP method. 87 | 88 | ## Path Expressions 89 | 90 | As you probably noticed, you can capture named portions of the path into 91 | `environ['wsgiorg.routing_args']`. (They also get put into 92 | ~~`environ['selector.vars']`~~, but that is *deprecated* in favor of a 93 | [routing args standard](http://www.wsgi.org/en/latest/specifications/routing_args.html).) 94 | 95 | You can also capture things positionally:. 96 | 97 | ```python 98 | def show_tag(environ, start_response): 99 | args, kwargs = environ['wsgiorg.routing_args'] 100 | user = kwargs['user'] 101 | tag = args[0] 102 | # ... 103 | 104 | s.add('/myapp/{user}/tags/{}', GET=show_tag) 105 | ``` 106 | 107 | Selector supports a number of datatypes for your routing args, specified 108 | like this: `{VARNAME:DATATYPE}` or just `{:DATATYPE}`. 109 | 110 | | type | regex | 111 | | --------- | ----------- | 112 | | `word` | `\w+` | 113 | | `alpha` | `[a-zA-Z]+` | 114 | | `digits` | `\d+` | 115 | | `number` | `\d*.?\d+` | 116 | | `chunk` | `[^/^.]+` | 117 | | `segment` | `[^/]+` | 118 | | `any` | `.+` | 119 | 120 | These types work for both named and positional routing args: 121 | 122 | ```python 123 | s.add('/collection/{:digits}/{docname:chunk}.{filetype:chunk}', GET=foo) 124 | ``` 125 | 126 | (You can even add your own types with just a name and a regex, 127 | but we will get to that in a moment.) 128 | 129 | Parts of the URL path can also be made optional using `[`square brackets.`]` 130 | 131 | ```python 132 | s.add("/required-part[/optional-part]", GET=any_wsgi) 133 | ``` 134 | 135 | Optional portions in path expressions can be nested. 136 | 137 | ```python 138 | s.add("/recent-articles[/{topic}[/{subtopic}]][/]", GET=recent_articles) 139 | ``` 140 | 141 | By default, selector does **path consumption**, which means the matched portion 142 | of the path information is moved from `environ['PATH_INFO']` to 143 | `environ['SCRIPT_NAME']` when routing a request. 144 | The matched portion of the path is also appended to a list found or created 145 | in `environ['selector.matches']`, where it is is available 146 | to upstack consumers. 147 | It's useful in conjunction with open ended path expressions 148 | (using the pipe character, `|`) for recursive dispatch: 149 | 150 | ```python 151 | def load_book(environ, start_response): 152 | args, kwargs = environ['wsgiorg.routing_args'] 153 | # load book 154 | environ['com.example.book'] = db.get_book(kwargs['book_id']) 155 | return s(environ, start_response) 156 | 157 | def load_chapter(environ, start_response): 158 | book = environ['com.example.book'] 159 | args, kwargs = environ['wsgiorg.routing_args'] 160 | chapter = book.chapters[kwargs['chapter_id']) 161 | # ... send some response 162 | 163 | s.add("/book/{book_id}|", GET=load_book) 164 | s.add("/chapter/{chapter_id}", GET=load_chapter) 165 | ``` 166 | 167 | ## Plain Regexes, Custom Types and Custom Parsers 168 | 169 | You can create your own parser and your own path expression 170 | syntax, or use none at all. All you need is a callable that 171 | takes the path expression and returns a regex string. 172 | 173 | ```python 174 | s.parser = lambda x: x 175 | s.add('^\/somepath\/$', GET=foo) 176 | ``` 177 | 178 | You can add a custom type to the default parser when you instantiate 179 | it or by modifying it in place. 180 | 181 | ```python 182 | parser = selector.SimpleParser(patterns={'mytype': 'MYREGEX'}) 183 | assert parser('/{foo:mytype}') == r'^\/(?PMYREGEX)$' 184 | ``` 185 | 186 | ```python 187 | s.parser.patterns['othertype'] = 'OTHERREGEX' 188 | assert parser('/{foo:othertype}') == r'^\/(?POTHERREGEX)$' 189 | ``` 190 | 191 | ## Prefix and Wrap 192 | 193 | Often you have some common prefix you would like appended to your 194 | path expressions automatically when you add routes. 195 | You can set that when instantiating selector and change it as you 196 | go. 197 | 198 | ```python 199 | # Add the same page under three prefixes: 200 | s = Selector(prefix='/myapp') 201 | s.add('/somepage', GET=get_page) 202 | s.prefix = '/otherapp' 203 | s.add('/somepage', GET=get_page) 204 | s.add('/somepage', GET=get_page, prefix='/app3') 205 | ``` 206 | 207 | Selector can automatically wrap the callables you route to. 208 | I often use [Yaro](http://lukearno.com/projects/yaro), 209 | which puts WSGI behind a pretty request object. 210 | 211 | ```python 212 | import selector, yaro 213 | 214 | def say_hello(req): 215 | return "Hello, World!" 216 | 217 | s = selector.Selector(wrap=yaro.Yaro) 218 | s.add('/hello', GET=say_hello) 219 | ``` 220 | 221 | ## Adding Routes 222 | 223 | There are basically three ways to add routes. 224 | 225 | ### One at a Time 226 | 227 | So far we have been adding routes with `.add()` 228 | 229 | ```python 230 | foo_handlers = {'GET': get_foo, 'POST': create_foo} 231 | 232 | s.add('/foo', method_dict=foo_handlers) 233 | s.add('/bar', GET=bar_handler) 234 | s.add('/read-only-foo', 235 | method_dict=foo_handlers, 236 | POST=sorry_charlie) 237 | ) 238 | ``` 239 | 240 | Notice how `POST` was overridden for `/read-only-foo`. 241 | 242 | `.add()` also takes a `prefix` key word arg. 243 | 244 | ### Slurping up a List 245 | 246 | `.slurp()` will load mapping from a list of tuples, which turns out 247 | to be pretty ugly, so you would probably only do this if you were building 248 | the list programmatically. (... like, if parsing your own URL mapping file 249 | format, for instance.) 250 | 251 | ```python 252 | routes = [('/foo', {'GET': foo}), 253 | ('/bar', {'GET': bar})] 254 | s = Selector(mappings=routes) 255 | # or 256 | s.slurp(routes) 257 | ``` 258 | 259 | `.slurp()` takes the keyword args `prefix`, `parser` and `wrap`... 260 | 261 | ### Mapping Files 262 | 263 | Selector supports a sweet URL mapping file format. 264 | 265 | ``` 266 | /foo/{id}[/] 267 | GET somemodule:some_wsgi_app 268 | POST pak.subpak.mod:other_wsgi_app 269 | 270 | @prefix /myapp 271 | @wrap yaro:Yaro 272 | 273 | /path[/] 274 | GET module:app 275 | POST package.module:get_app('foo') 276 | PUT package.module:FooApp('hello', resolve('module:setting')) 277 | 278 | @parser :lambda x: x 279 | @wrap :lambda x: x 280 | 281 | @prefix 282 | ^/spam/eggs[/]$ 283 | GET mod:regex_mapped_app 284 | ``` 285 | 286 | This format is read line by line. 287 | 288 | * Blank lines and lines starting with `#` as their first 289 | non-whitespace characters are ignored. 290 | * Directives start with `@` 291 | and modulate route adding behavior. 292 | * Path expressions come on their own line and 293 | have no leading whitespace 294 | * HTTP method -> handler mappings are indented 295 | 296 | There are three directives: `@prefix`, `@parser` and `@wrap`, 297 | and they do what you think they do. 298 | The `@parser` and `@wrap` directives take 299 | [resolver](http://lukearno.com/projects/resolver/) 300 | statements. 301 | Handlers are resolver statements too. 302 | HTTP method to handler mappings are applied to the preceding 303 | path expression. 304 | 305 | Files of this format can be used in the following ways. 306 | 307 | ```python 308 | s = Selector(mapfile='map1.urls') 309 | s.slurp_file('map2.urls') 310 | ``` 311 | 312 | `Selector.slurp_file()` supports optional `prefix`, `parser` and `wrap` 313 | keyword arguments, too. 314 | 315 | ## Initializing a Selector 316 | 317 | All the functionality is covered above, but, to summarize the init 318 | signature: 319 | 320 | ```python 321 | def __init__(self, 322 | mappings=None, 323 | prefix="", 324 | parser=None, 325 | wrap=None, 326 | mapfile=None, 327 | consume_path=True): 328 | ``` 329 | 330 | ## Customizing 404s and 405s and Chain Dispatchers 331 | 332 | You can replace Selector's 404 and 405 handlers. They're just WSGI. 333 | 334 | ```python 335 | s = Selector() 336 | s.status404 = my_404_wsgi 337 | s.status405 = my_405_wsgi 338 | ``` 339 | 340 | You could chain Selector instances together, or fall through to other 341 | types of dispachers or any handler at all really. 342 | 343 | ```python 344 | s1 = Selector(mapfile='map1.urls') 345 | s2 = Selector(mapfile='map2.urls') 346 | s1.status404 = s2 347 | ``` 348 | 349 | ## Environ Dispatcher 350 | 351 | `EnvironDispatcher` routes a request based on the `environ`. It's 352 | instantiated with a list of `(predicate, wsgi_app)` pairs. Each predicate is a 353 | callable that takes one argument (`environ`) and returns True or False. When 354 | called, the instance iterates through the pairs until it finds a predicate that 355 | returns True and runs the app paired with it. 356 | 357 | ```python 358 | is_admin = lambda env: 'admin' in env['session']['roles'] 359 | is_user = lambda env: 'user' in env['session']['roles'] 360 | default = lambda env: True 361 | 362 | rules = [(is_admin, admin_screen), (is_user, user_screen), (default, 363 | access_denied)] 364 | 365 | envdis = EnvironDispatcher(rules) 366 | 367 | s = Selector() 368 | s.add('/user-info/{username}[/]', GET=envdis) 369 | ``` 370 | 371 | ## Middleware Composer 372 | 373 | Another WSGI middleware included in selector allows us compose middleware on 374 | the fly (compose as in function composition) in a similar way. 375 | `MiddlewareComposer` also is instantiated with a list of rules, only instead 376 | of WSGI apps you have WSGI middleware. When called, the instance applies all 377 | the middlewares whose predicates are true for environ in reverse order, and 378 | calls the resulting app. 379 | 380 | ```python 381 | lambda x: True; f = lambda x: False 382 | rules = [(t, a), (f, b), (t, c), (f, d), (t, e)] 383 | 384 | composed = MiddlewareComposer(app, rules) 385 | 386 | s = Selector() 387 | s.add('/endpoint[/]', GET=composed) 388 | ``` 389 | 390 | is equivalent to 391 | 392 | ```python 393 | a(c(e(app))) 394 | ``` 395 | 396 | ## Routing Args in Callable Signatures 397 | 398 | There are some experimental, somewhat old decorators in Selector 399 | that facilitate putting your routing args into the signatures 400 | of your callables. 401 | 402 | ```python 403 | from selector import pliant, opliant 404 | 405 | @pliant 406 | def app(environ, start_response, arg1, arg2, foo='bar'): 407 | ... 408 | 409 | class App(object): 410 | @opliant 411 | def __call__(self, environ, start_response, arg1, arg2, foo='bar'): 412 | ... 413 | ``` 414 | 415 | ## Exposing Callables 416 | 417 | Selector now provides classes for naked object and HTTP method to object method 418 | based dispatch, for completeness. 419 | 420 | ```python 421 | from selector import expose, Naked, ByMethod 422 | 423 | class Nude(Naked): 424 | # If this were True we would not need expose 425 | _expose_all = False 426 | 427 | @expose 428 | list(self, environ, start_response): 429 | ... 430 | 431 | class Methodical(ByMethod): 432 | def GET(self, environ, start_response): 433 | ... 434 | def POST(self, environ, start_response): 435 | ... 436 | ``` 437 | 438 | ## API Docs 439 | 440 | Read [Selector's API Docs](http://readthedocs.org/docs/selector/) 441 | on [Read the Docs](http://readthedocs.org/). 442 | 443 | ## Tests 444 | 445 | Selector has 100% unit test coverage, as well as some basic functional tests. 446 | 447 | Here is output from a recent run. 448 | 449 | ```bash 450 | luke$ fab test 451 | [localhost] local: which python 452 | Running unit tests with coverage... 453 | [localhost] local: py.test -x --doctest-modules selector.py --cov selector 454 | tests/ 455 | =============================================================================== 456 | test session starts 457 | =============================================================================== 458 | platform darwin -- Python 2.6.1 -- pytest-2.1.3 459 | collected 72 items 460 | 461 | selector.py . 462 | tests/__init__.py . 463 | tests/test_harness.py ... 464 | tests/util.py . 465 | tests/wsgiapps.py . 466 | tests/functional/__init__.py . 467 | tests/functional/conftest.py . 468 | tests/functional/test_simple_routes.py ..... 469 | tests/unit/__init__.py . 470 | tests/unit/mocks.py . 471 | tests/unit/test_by_method.py .... 472 | tests/unit/test_default_handlers.py ... 473 | tests/unit/test_environ_dispatcher.py .. 474 | tests/unit/test_middleware_composer.py .. 475 | tests/unit/test_naked.py ............ 476 | tests/unit/test_pliant.py ... 477 | tests/unit/test_selector_add.py ...... 478 | tests/unit/test_selector_call.py .. 479 | tests/unit/test_selector_init.py ... 480 | tests/unit/test_selector_mapping_format.py ....... 481 | tests/unit/test_selector_select.py ..... 482 | tests/unit/test_selector_slurp.py ... 483 | tests/unit/test_simple_parser.py .... 484 | ----------------------------------------------------------------- coverage: 485 | platform darwin, python 2.6.1-final-0 486 | ----------------------------------------------------------------- 487 | Name Stmts Miss Cover 488 | ------------------------------ 489 | selector 261 0 100% 490 | 491 | ============================================================================ 72 492 | passed in 1.13 seconds 493 | ============================================================================ 494 | [localhost] local: which python 495 | Running PEP8 checker 496 | No PEP8 violations found! W00t! 497 | ``` 498 | 499 | ## Release Management Policy and Versioning 500 | 501 | Selector is [SemVer](http://semver.org/) compliant. 502 | 503 | Release management is codified in the `fabfile.py` in the `release` task. 504 | 505 | ## Hack! 506 | 507 | Fork it. 508 | 509 | ```bash 510 | $ git clone http://github.com/lukearno/selector.git 511 | ``` 512 | 513 | Set yourself up in a virtualenv and list the fab tasks 514 | at your disposal. 515 | (Requires [Virtualenv](http://pypi.python.org/pypi/virtualenv).) 516 | 517 | ```bash 518 | $ . bootstrap 519 | ``` 520 | 521 | Run the tests. 522 | 523 | ```bash 524 | (.virt/)$ fab test 525 | ``` 526 | 527 | ## Licenses 528 | 529 | Use under MIT or GPL. 530 | 531 | Copyright (c) 2006 Luke Arno, http://lukearno.com/ 532 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.10.1 2 | -------------------------------------------------------------------------------- /bootstrap: -------------------------------------------------------------------------------- 1 | 2 | # Set up to build, test or hack this project. 3 | # Requires Virtualenv http://pypi.python.org/pypi/virtualenv 4 | 5 | # If the virtualenv does not already exist at .virt/ 6 | if ! test -d .virt/; then 7 | # Install a virtualenv and install fabric 8 | # Install selector in development mode 9 | virtualenv --no-site-packages --distribute .virt/ 10 | . .virt/bin/activate 11 | pip install fabric 12 | python setup.py develop 13 | fab devdeps 14 | fi 15 | 16 | # Activate the virtualenv in .virt/ 17 | . .virt/bin/activate 18 | 19 | # List the available fab tasks 20 | fab -l 21 | -------------------------------------------------------------------------------- /dev-req.txt: -------------------------------------------------------------------------------- 1 | webob 2 | pep8 3 | pyflakes 4 | pytest 5 | pytest-cov 6 | twill 7 | flexmock 8 | sphinx 9 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 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 " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Selector.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Selector.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Selector" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Selector" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Selector documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Nov 17 00:16:10 2011. 5 | # 6 | # This file is execfile()d 7 | # with the current directory set to its 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 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ---------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx.ext.doctest', 33 | 'sphinx.ext.intersphinx', 34 | 'sphinx.ext.coverage', 35 | 'sphinx.ext.ifconfig', 36 | 'sphinx.ext.viewcode' 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # The suffix of source filenames. 43 | source_suffix = '.rst' 44 | 45 | # The encoding of source files. 46 | #source_encoding = 'utf-8-sig' 47 | 48 | # The master toctree document. 49 | master_doc = 'index' 50 | 51 | # General information about the project. 52 | project = u'Selector' 53 | copyright = u'2011, Luke Arno' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = '0.9.0' 61 | # The full version, including alpha/beta/rc tags. 62 | release = '0.9.0' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | #language = None 67 | 68 | # There are two options for replacing |today|: either, you set today to some 69 | # non-false value, then it is used: 70 | #today = '' 71 | # Else, today_fmt is used as the format for a strftime call. 72 | #today_fmt = '%B %d, %Y' 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | exclude_patterns = ['_build'] 77 | 78 | # The reST default role (used for this markup: `text`) 79 | # to use for all documents. 80 | #default_role = None 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | #add_function_parentheses = True 84 | 85 | # If true, the current module name will be prepended to all description 86 | # unit titles (such as .. function::). 87 | #add_module_names = True 88 | 89 | # If true, sectionauthor and moduleauthor directives will be shown in the 90 | # output. They are ignored by default. 91 | #show_authors = False 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = 'sphinx' 95 | 96 | # A list of ignored prefixes for module index sorting. 97 | #modindex_common_prefix = [] 98 | 99 | 100 | # -- Options for HTML output -------------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = 'default' 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | #html_theme_options = {} 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | #html_theme_path = [] 113 | 114 | # The name for this set of Sphinx documents. If None, it defaults to 115 | # " v documentation". 116 | #html_title = None 117 | 118 | # A shorter title for the navigation bar. Default is the same as html_title. 119 | #html_short_title = None 120 | 121 | # The name of an image file (relative to this directory) to place at the top 122 | # of the sidebar. 123 | #html_logo = None 124 | 125 | # The name of an image file (within the static path) to use as favicon of the 126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 127 | # pixels large. 128 | #html_favicon = None 129 | 130 | # Add any paths that contain custom static files (such as style sheets) here, 131 | # relative to this directory. They are copied after the builtin static files, 132 | # so a file named "default.css" will overwrite the builtin "default.css". 133 | html_static_path = ['_static'] 134 | 135 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 136 | # using the given strftime format. 137 | #html_last_updated_fmt = '%b %d, %Y' 138 | 139 | # If true, SmartyPants will be used to convert quotes and dashes to 140 | # typographically correct entities. 141 | #html_use_smartypants = True 142 | 143 | # Custom sidebar templates, maps document names to template names. 144 | #html_sidebars = {} 145 | 146 | # Additional templates that should be rendered to pages, maps page names to 147 | # template names. 148 | #html_additional_pages = {} 149 | 150 | # If false, no module index is generated. 151 | #html_domain_indices = True 152 | 153 | # If false, no index is generated. 154 | #html_use_index = True 155 | 156 | # If true, the index is split into individual pages for each letter. 157 | #html_split_index = False 158 | 159 | # If true, links to the reST sources are added to the pages. 160 | #html_show_sourcelink = True 161 | 162 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 163 | #html_show_sphinx = True 164 | 165 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 166 | #html_show_copyright = True 167 | 168 | # If true, an OpenSearch description file will be output, and all pages will 169 | # contain a tag referring to it. The value of this option must be the 170 | # base URL from which the finished HTML is served. 171 | #html_use_opensearch = '' 172 | 173 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 174 | #html_file_suffix = None 175 | 176 | # Output file base name for HTML help builder. 177 | htmlhelp_basename = 'Selectordoc' 178 | 179 | 180 | # -- Options for LaTeX output ------------------------------------------------- 181 | 182 | latex_elements = { 183 | # The paper size ('letterpaper' or 'a4paper'). 184 | #'papersize': 'letterpaper', 185 | 186 | # The font size ('10pt', '11pt' or '12pt'). 187 | #'pointsize': '10pt', 188 | 189 | # Additional stuff for the LaTeX preamble. 190 | #'preamble': '', 191 | } 192 | 193 | # Grouping the document tree into LaTeX files. List of tuples 194 | # (source start file, target name, 195 | # title, author, documentclass [howto/manual]). 196 | latex_documents = [ 197 | ('index', 'Selector.tex', u'Selector Documentation', 198 | u'Luke Arno', 'manual'), 199 | ] 200 | 201 | # The name of an image file (relative to this directory) to place at the top of 202 | # the title page. 203 | #latex_logo = None 204 | 205 | # For "manual" documents, if this is true, then toplevel headings are parts, 206 | # not chapters. 207 | #latex_use_parts = False 208 | 209 | # If true, show page references after internal links. 210 | #latex_show_pagerefs = False 211 | 212 | # If true, show URL addresses after external links. 213 | #latex_show_urls = False 214 | 215 | # Documents to append as an appendix to all manuals. 216 | #latex_appendices = [] 217 | 218 | # If false, no module index is generated. 219 | #latex_domain_indices = True 220 | 221 | 222 | # -- Options for manual page output ------------------------------------------- 223 | 224 | # One entry per manual page. List of tuples 225 | # (source start file, name, description, authors, manual section). 226 | man_pages = [ 227 | ('index', 'selector', u'Selector Documentation', 228 | [u'Luke Arno'], 1) 229 | ] 230 | 231 | # If true, show URL addresses after external links. 232 | #man_show_urls = False 233 | 234 | 235 | # -- Options for Texinfo output ----------------------------------------------- 236 | 237 | # Grouping the document tree into Texinfo files. List of tuples 238 | # (source start file, target name, title, author, 239 | # dir menu entry, description, category) 240 | texinfo_documents = [ 241 | ('index', 'Selector', u'Selector Documentation', 242 | u'Luke Arno', 'Selector', 'One line description of project.', 243 | 'Miscellaneous'), 244 | ] 245 | 246 | # Documents to append as an appendix to all manuals. 247 | #texinfo_appendices = [] 248 | 249 | # If false, no module index is generated. 250 | #texinfo_domain_indices = True 251 | 252 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 253 | #texinfo_show_urls = 'footnote' 254 | 255 | 256 | # Example configuration for intersphinx: refer to the Python standard library. 257 | intersphinx_mapping = {'http://docs.python.org/': None} 258 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Selector documentation master file, created by 2 | sphinx-quickstart on Thu Nov 17 00:16:10 2011. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Selector's Generated API documentation! 7 | ==================================== 8 | 9 | Narrative documentation available from 10 | http://github.com/lukearno/selector 11 | 12 | Contents: 13 | 14 | .. toctree:: 15 | :maxdepth: 4 16 | 17 | selector 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Selector.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Selector.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/selector.rst: -------------------------------------------------------------------------------- 1 | selector Module 2 | =============== 3 | 4 | .. automodule:: selector 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | """Project automation for Selector. 2 | 3 | fab -l - list available tasks 4 | fab -d TASK - describe a task in detail 5 | """ 6 | 7 | import contextlib 8 | import csv 9 | import sys 10 | 11 | from os.path import abspath as _abspath 12 | 13 | import pkg_resources 14 | 15 | from fabric.api import local, puts, settings, hide, abort, lcd, prefix 16 | from fabric import colors as c 17 | from fabric.contrib.console import confirm 18 | 19 | 20 | import selector 21 | 22 | 23 | def _invirt(): 24 | """Make sure we have bootstrapped.""" 25 | with settings(hide('running', 'stdout', 'stderr')): 26 | current_py = local('which python', capture=True) 27 | virt_py = _abspath('.virt/bin/python') 28 | if not current_py == virt_py: 29 | puts(c.red('Not in virtualenv! Please run ". bootstrap".')) 30 | sys.exit(1) 31 | 32 | 33 | def pep8(): 34 | """Check for pep8 style compliance.""" 35 | _invirt() 36 | puts(c.magenta("Running PEP8 style checker...")) 37 | with settings(hide('warnings', 'stdout', 'stderr', 'running'), 38 | warn_only=True): 39 | violations = local('pep8 *.py */*.py */*/*.py', capture=True) 40 | if violations.strip(): 41 | violations_count = len(violations.split('\n')) 42 | puts(c.red("%s PEP8 violations! Oh nooooes!" % violations_count)) 43 | puts(c.cyan(violations)) 44 | else: 45 | violations_count = 0 46 | puts(c.green("No PEP8 violations found! W00t!")) 47 | return violations_count 48 | 49 | 50 | def pyflakes(): 51 | """Check for pyflakes warnings.""" 52 | _invirt() 53 | puts(c.magenta("Running PyFlakes style checker...")) 54 | with settings(hide('warnings', 'stdout', 'stderr', 'running'), 55 | warn_only=True): 56 | warnings = local('pyflakes *.py */*.py */*/*.py', capture=True) 57 | if warnings.strip(): 58 | warnings_count = len(warnings.split('\n')) 59 | puts(c.red("%s Pyflakes warningss! Oh nooooes!" % warnings_count)) 60 | puts(c.cyan(warnings)) 61 | else: 62 | warnings_count = 0 63 | puts(c.green("No Pyflakes warnings! W00t!")) 64 | return warnings_count 65 | 66 | 67 | def stylechecks(): 68 | """Do all style checks and abort if there are warnings.""" 69 | if pep8() or pyflakes(): 70 | abort(c.red("Style checks failed!")) 71 | 72 | 73 | def test(): 74 | """Run the all tests and show coverage.""" 75 | _invirt() 76 | puts(c.magenta("Running unit tests with coverage...")) 77 | local('py.test -x' 78 | ' --doctest-modules selector.py' 79 | ' --cov selector' 80 | ' tests/') 81 | stylechecks() 82 | 83 | 84 | def html_coverage(): 85 | """Run the all tests and write HTML coverage report.""" 86 | _invirt() 87 | puts(c.magenta("Running unit tests with coverage...")) 88 | local('py.test -x' 89 | ' --doctest-modules selector.py' 90 | ' --cov selector' 91 | ' --cov-report=html' 92 | ' tests/') 93 | stylechecks() 94 | 95 | 96 | def unittest(): 97 | """Run the unit tests only, with coverage.""" 98 | _invirt() 99 | puts(c.magenta("Running unit tests with coverage...")) 100 | local('py.test -x' 101 | ' --doctest-modules selector.py' 102 | ' --cov selector' 103 | #' --cov-report=html' 104 | ' tests/unit') 105 | stylechecks() 106 | 107 | 108 | def autotest(): 109 | """Run all tests with coverage and junit XML output.""" 110 | _invirt() 111 | puts(c.magenta("Running tests with coverage")) 112 | with settings(hide('running', 'stdout')): 113 | local('mkdir -p build/') 114 | local('py.test --junitxml build/junit.xml' 115 | ' --doctest-modules selector.py' 116 | ' --cov-report xml' 117 | ' --cov selector tests/') 118 | violations_count = pep8() 119 | puts(c.blue("Writing count to build/pep8-violations.txt")) 120 | with settings(hide('running')): 121 | local("echo %s > build/pep8-violations.txt" % violations_count) 122 | warnings_count = pyflakes() 123 | puts(c.blue("Writing count to build/pyflakes-warnings.txt")) 124 | with settings(hide('running')): 125 | local("echo %s > build/pyflakes-warnings.txt" % warnings_count) 126 | if violations_count or warnings_count: 127 | abort(c.red("Style check failed!")) 128 | 129 | 130 | @contextlib.contextmanager 131 | def _freshvirt(virt): 132 | puts(virt) 133 | local('rm -rf %s' % virt) 134 | local('virtualenv --no-site-packages --distribute %s' % virt) 135 | with prefix('. %s/bin/activate' % virt): 136 | with settings(hide('stdout', 'stderr', 'warnings', 'running'), 137 | warn_only=True): 138 | result = local('python -c "import selector"') 139 | if result.return_code != 1: 140 | abort(c.red("Failed to create clean virt without selector.")) 141 | yield 142 | 143 | 144 | def buildtest(): 145 | """Try installing from egg and importing in a clean virt. 146 | 147 | Builds and does `easy_install` of tarball. 148 | """ 149 | puts(c.blue("Running build/install test...")) 150 | with _freshvirt('.buildtest'): 151 | version = local('cat VERSION', capture=True) 152 | local('rm -rf dist/*') 153 | local('python setup.py sdist bdist_egg') 154 | local('easy_install dist/selector-%s.tar.gz' % version) 155 | local('python -c "import selector"') 156 | puts(c.magenta("Built and installed successfully")) 157 | 158 | 159 | def post_release_install_verification(): 160 | """Try installing from pypi and importing in a clean virt. 161 | 162 | Does `pip` and `easy_install`. 163 | """ 164 | puts(c.blue("Running post-release install verification...")) 165 | with _freshvirt('.buildtest'): 166 | local('pip install selector') 167 | local('python -c "import selector"') 168 | with _freshvirt('.buildtest'): 169 | local('easy_install selector') 170 | local('python -c "import selector"') 171 | puts(c.magenta("Release verification successful!")) 172 | 173 | 174 | def devdeps(): 175 | """Install the development dependencies..""" 176 | _invirt() 177 | puts(c.magenta("Installing dev dependencies...")) 178 | with settings(hide('stdout')): 179 | local('pip install -r dev-req.txt') 180 | 181 | 182 | def regenerate_api_docs(): 183 | """Regenerate the generated API docs with sphynx.""" 184 | if confirm('Are you sure you want to overwrite .rst files?'): 185 | puts(c.red("Regenerating .rst files with sphinx-apidoc...")) 186 | with settings(hide('running', 'stdout')): 187 | local('sphinx-apidoc -f -o docs/ . setup.py') 188 | local('rm -f docs/modules.rst') 189 | local('rm -f docs/setup.rst') 190 | local('rm -f docs/fabfile.rst') 191 | local('rm -f docs/tests.rst') 192 | local('rm -f docs/tests.functional.rst') 193 | local('rm -f docs/tests.unit.rst') 194 | 195 | 196 | def regenerate_expectations(): 197 | """Regenerate the expections for the SimpleParser unit tests.""" 198 | if not confirm(c.red('Are you _really_ sure you have a working parser??')): 199 | abort(c.blue("Ok, check that out and come back when you are sure.")) 200 | parser = selector.SimpleParser() 201 | with open('tests/unit/path-expressions.csv', 'r') as expressions: 202 | with open('tests/unit/path-expression-expectations.csv', 'w') as out: 203 | reader = csv.reader(expressions) 204 | writer = csv.writer(out) 205 | for (pathexpression,) in reader: 206 | writer.writerow([pathexpression, parser(pathexpression)]) 207 | 208 | 209 | def build_api_docs(): 210 | """Build the HTML API docs.""" 211 | puts(c.magenta("Building HTML API docs...")) 212 | with settings(hide('running', 'stdout', 'stderr')): 213 | with lcd('docs'): 214 | local('make html') 215 | 216 | 217 | def clean(deep=False): 218 | """Kill the virtual env and all files generated by build and test. 219 | 220 | [:deep=False] 221 | 222 | If deep is True, removes the virtualenv and all. 223 | """ 224 | _invirt() 225 | if confirm('This will delete stuff. You sure?'): 226 | puts(c.red("Cleaning up...")) 227 | with settings(hide('running', 'stdout')): 228 | local('rm -rf build/') 229 | local('rm -f coverage.xml') 230 | local('rm -rf selector.egg-*') 231 | local('rm -rf *.pyc') 232 | local('rm -rf __pycache__/') 233 | local('rm -rf tests/__pycache__/') 234 | local('rm -rf tests/*.pyc') 235 | local('rm -rf tests/*/__pycache__/') 236 | local('rm -rf tests/*/*.pyc') 237 | local('rm -rf htmlcov') 238 | with lcd('docs'): 239 | local('make clean') 240 | if deep: 241 | puts(c.red("Removing virtualenv .virt/")) 242 | puts(c.red("You will need to `. bootstrap` again.")) 243 | local('rm -rf .virt/') 244 | else: 245 | puts(c.red("Not cleaning.")) 246 | 247 | 248 | def _abort_if_not_valid_release_type(release_type): 249 | """Make sure we have a valid release type.""" 250 | release_type = release_type.lower() 251 | if release_type not in ("major", "minor", "patch"): 252 | abort(c.red("Not a valid release type, see `fab -d release`")) 253 | return release_type 254 | 255 | 256 | def compute_version(release_type, rc=False): 257 | """Compute a semver compliant version number. 258 | 259 | :release_type[,rc=False] 260 | 261 | Release types: MAJOR - non-backwards compatible feature(s) 262 | MINOR - backwards compatible feature(s) addition 263 | PATCH - backwards compatible bug fix(es) 264 | "rc" indicates a Release Candidate. 265 | """ 266 | # Make sure it is a valid type of release. 267 | release_type = _abort_if_not_valid_release_type(release_type) 268 | # Find the latest version. 269 | tags = (t.strip() for t in local('git tag', capture=True).split('\n')) 270 | versions = [t[1:] for t in tags if t.startswith('v')] 271 | versions.sort(key=pkg_resources.parse_version) 272 | final_versions = [v for v in versions if 'rc' not in v] 273 | latest_final = final_versions and final_versions[-1] or '0.0.0' 274 | # Bump the version number. 275 | major, minor, patch = map(int, latest_final.split('.')) 276 | if release_type == 'major': 277 | major += 1 278 | minor = 0 279 | patch = 0 280 | if release_type == 'minor': 281 | minor += 1 282 | patch = 0 283 | if release_type == 'patch': 284 | patch += 1 285 | new_version = ".".join(map(str, [major, minor, patch])) 286 | # Append a release candidate number to the version? 287 | if rc: 288 | candidate_number = 0 289 | while 1: 290 | candidate_number += 1 291 | candidate_version = "%src%s" % (new_version, candidate_number) 292 | if candidate_version not in versions: 293 | break 294 | new_version = candidate_version 295 | 296 | puts(c.magenta("Calculated version: %s" % new_version)) 297 | return new_version 298 | 299 | 300 | def _sync_and_preflight_check(branch, release_type): 301 | """Sync up repository and check that things are in order for release.""" 302 | # Sync. 303 | puts(c.blue("Git fetching origin...")) 304 | local("git fetch origin", capture=True) 305 | puts(c.blue("Running preflight checks...")) 306 | # Make release type is valid. 307 | _abort_if_not_valid_release_type(release_type) 308 | # Make sure we don't have any outstanding edits hanging around. 309 | if (local("git diff", capture=True).strip() 310 | or local("git status -s", capture=True)): 311 | abort(c.red("It seems you have unstaged local changes.")) 312 | if local("git diff --staged", capture=True).strip(): 313 | abort(c.red("It seems you have changes in the staging area.")) 314 | # Local master and origin/master must be up-to-date with each other. 315 | if (local("git diff origin/master...master", capture=True).strip() 316 | or local("git diff master...origin/master", capture=True).strip()): 317 | abort(c.red("master is out of sync with origin!.")) 318 | # Local branch and origin/branch must be up-to-date with each other. 319 | if (local("git diff origin/%s...%s" % (branch, branch), 320 | capture=True).strip() 321 | or local("git diff %s...origin/%s" % (branch, branch), 322 | capture=True).strip()): 323 | abort(c.red("master is out of sync with origin!.")) 324 | # Make sure our branch has all the latest from master. 325 | changes_from_master = local("git diff %s...master" % branch, capture=True) 326 | if changes_from_master: 327 | abort(c.red("%s is out of sync with master and needs to be merged.")) 328 | # See what changes are in the branch and make sure there is something to 329 | # release. 330 | changes = local("git diff master...%s" % branch, capture=True) 331 | if not changes: 332 | abort(c.red("No changes there to release. Hmm.")) 333 | # Compute the new semver compliant version number. 334 | version = compute_version(release_type) 335 | # Ask user to verify version and changesets. 336 | puts(c.blue("Changes to release:")) 337 | puts(c.cyan(changes)) 338 | if not confirm("Are the changes and version correct?"): 339 | abort(c.red("Aborting release")) 340 | 341 | return version, changes 342 | 343 | 344 | def release(branch, release_type): 345 | """Release a new version. 346 | 347 | :branch,release_type 348 | 349 | branch to be released 350 | release_type: see fab -d compute_version 351 | 352 | Preflight, runs tests, bumps version number, tags repo and uploads to pypi. 353 | """ 354 | _invirt() 355 | with settings(hide('stderr', 'stdout', 'running')): 356 | # Preflight checks. 357 | version, changes = _sync_and_preflight_check(branch, release_type) 358 | puts(c.blue("Testing...")) 359 | # Lets check out this branch and test it. 360 | local("git checkout %s" % branch, capture=True) 361 | test() 362 | puts(c.green("Tests passed!")) 363 | puts(c.blue("Build, package and publish...")) 364 | # Commit to the version file. 365 | local('echo "%s" > VERSION' % version) 366 | # Build 367 | local("python setup.py register sdist bdist_egg upload") 368 | puts(c.green("Uploaded to PyPI!")) 369 | # Commit the version change and tag the release. 370 | puts(c.blue("Commit, tag, merge, prune and push.")) 371 | local('git commit -m"Bumped version to v%s" -a' % version) 372 | local('git tag -a "v%s" -m "Release version %s"' % (version, version)) 373 | # Merge the branch into master and push them both to origin 374 | # Conflicts should never occur, due to preflight checks. 375 | local('git checkout master', capture=True) 376 | local('git merge %s' % branch, capture=True) 377 | local('git branch -d %s' % branch) 378 | local('git push origin :%s' % branch) # This deletes remote branch. 379 | local('git push --tags origin master') 380 | puts(c.magenta("Released branch %s as v%s!" % (branch, version))) 381 | post_release_install_verification() 382 | -------------------------------------------------------------------------------- /selector.py: -------------------------------------------------------------------------------- 1 | """selector - WSGI handler delegation. (AKA routing.)""" 2 | 3 | import re 4 | 5 | from itertools import starmap 6 | from wsgiref.util import shift_path_info 7 | 8 | import resolver 9 | 10 | 11 | class MappingFileError(Exception): 12 | """Raised to signal a syntax error in a mapping file.""" 13 | 14 | 15 | class PathExpressionParserError(Exception): 16 | """Raised to signal a syntax error in a path expression.""" 17 | 18 | 19 | def method_not_allowed(environ, start_response): 20 | """Default WSGI 405 app.""" 21 | start_response("405 Method Not Allowed", 22 | [('Allow', ', '.join(environ['selector.methods'])), 23 | ('Content-Type', 'text/plain')]) 24 | return ["405 Method Not Allowed\n\n" 25 | "The method specified in the Request-Line is not allowed " 26 | "for the resource identified by the Request-URI."] 27 | 28 | 29 | def not_found(environ, start_response): 30 | """Default WSGI 404 app.""" 31 | start_response("404 Not Found", [('Content-Type', 'text/plain')]) 32 | return ["404 Not Found\n\n" 33 | "The server has not found anything matching the Request-URI."] 34 | 35 | 36 | class Selector(object): 37 | """WSGI middleware for URL paths and HTTP method based delegation.""" 38 | 39 | status405 = staticmethod(method_not_allowed) 40 | status404 = staticmethod(not_found) 41 | 42 | def __init__(self, 43 | mappings=None, 44 | prefix="", 45 | parser=None, 46 | wrap=None, 47 | mapfile=None, 48 | consume_path=True): 49 | """Initialize selector.""" 50 | self.mappings = [] 51 | self.prefix = prefix 52 | if parser is None: 53 | self.parser = SimpleParser() 54 | else: 55 | self.parser = parser 56 | self.wrap = wrap 57 | if mapfile is not None: 58 | self.slurp_file(mapfile) 59 | if mappings is not None: 60 | self.slurp(mappings) 61 | self.consume_path = consume_path 62 | 63 | def slurp(self, mappings, prefix=None, parser=None, wrap=None): 64 | """Slurp in a whole list (or any iterable) of mappings. 65 | 66 | Mappings take the form of 67 | 68 | .. code-block:: python 69 | 70 | (PATH_EXPRESSION, HTTP_METHOD_TO_WSGI_APP_DICT) 71 | 72 | """ 73 | if prefix is not None: 74 | oldprefix = self.prefix 75 | self.prefix = prefix 76 | if parser is not None: 77 | oldparser = self.parser 78 | self.parser = parser 79 | if wrap is not None: 80 | oldwrap = self.wrap 81 | self.wrap = wrap 82 | list(starmap(self.add, mappings)) 83 | if wrap is not None: 84 | self.wrap = oldwrap 85 | if parser is not None: 86 | self.parser = oldparser 87 | if prefix is not None: 88 | self.prefix = oldprefix 89 | 90 | def add(self, path, method_dict=None, prefix=None, **http_methods): 91 | """Add a mapping. 92 | 93 | HTTP methods can be specified in a dict or using key word args, 94 | but kwargs will override if both are given. 95 | """ 96 | # Thanks to Sebastien Pierre 97 | # for suggesting that this accept keyword args. 98 | if method_dict is None: 99 | method_dict = {} 100 | if prefix is None: 101 | prefix = self.prefix 102 | method_dict = dict(method_dict) 103 | method_dict.update(http_methods) 104 | if self.wrap is not None: 105 | for meth, cbl in list(method_dict.items()): 106 | method_dict[meth] = self.wrap(cbl) 107 | regex = self.parser(prefix + path) 108 | compiled_regex = re.compile(regex) 109 | mapping = (compiled_regex, method_dict) 110 | self.mappings.append(mapping) 111 | return mapping 112 | 113 | def __call__(self, environ, start_response): 114 | """Delegate request to the appropriate WSGI app.""" 115 | app, svars, methods, matched = \ 116 | self.select(environ['PATH_INFO'], environ['REQUEST_METHOD']) 117 | unnamed, named = [], {} 118 | for k, v in svars.items(): 119 | if k.startswith('__pos'): 120 | k = k[5:] 121 | named[k] = v 122 | environ['selector.vars'] = dict(named) 123 | for k in list(named.keys()): 124 | if k.isdigit(): 125 | unnamed.append((k, named.pop(k))) 126 | unnamed.sort() 127 | unnamed = [v for k, v in unnamed] 128 | cur_unnamed, cur_named = environ.get('wsgiorg.routing_args', ([], {})) 129 | unnamed = cur_unnamed + unnamed 130 | named.update(cur_named) 131 | environ['wsgiorg.routing_args'] = unnamed, named 132 | environ['selector.methods'] = methods 133 | environ.setdefault('selector.matches', []).append(matched) 134 | if self.consume_path: 135 | environ['SCRIPT_NAME'] = environ.get('SCRIPT_NAME', '') + matched 136 | environ['PATH_INFO'] = environ['PATH_INFO'][len(matched):] 137 | return app(environ, start_response) 138 | 139 | def select(self, path, method): 140 | """Figure out which app to delegate to or send 404 or 405. 141 | 142 | """ 143 | response = (self.status404, {}, [], '') 144 | 145 | for regex, method_dict in self.mappings: 146 | match = regex.search(path) 147 | if match: 148 | methods = list(method_dict.keys()) 149 | if method in method_dict: 150 | return (method_dict[method], 151 | match.groupdict(), 152 | methods, 153 | match.group(0)) 154 | elif '_ANY_' in method_dict: 155 | return (method_dict['_ANY_'], 156 | match.groupdict(), 157 | methods, 158 | match.group(0)) 159 | else: 160 | # Do not return the 405 response right away, there could 161 | # still be a match in mappings we haven't tried yet. 162 | response = (self.status405, {}, methods, '') 163 | 164 | return response 165 | 166 | def slurp_file(self, filename, prefix=None, parser=None, wrap=None): 167 | """Read mappings from a simple text file. (See README.md.)""" 168 | with open(filename, 'rb') as the_file: 169 | oldprefix = self.prefix 170 | if prefix is not None: 171 | self.prefix = prefix 172 | oldparser = self.parser 173 | if parser is not None: 174 | self.parser = parser 175 | oldwrap = self.wrap 176 | if parser is not None: 177 | self.wrap = wrap 178 | path = methods = None 179 | lineno = 0 180 | try: 181 | try: 182 | for line in the_file: 183 | line = line.decode() 184 | lineno += 1 185 | path, methods = self._parse_line(line, path, methods) 186 | if path and methods: 187 | self.add(path, methods) 188 | except MappingFileError as mfe: 189 | raise MappingFileError("line %s: %s" % (lineno, mfe)) 190 | finally: 191 | self.wrap = oldwrap 192 | self.parser = oldparser 193 | self.prefix = oldprefix 194 | 195 | def _parse_line(self, line, path, methods): 196 | """Parse one line of a mapping file. 197 | 198 | This method is for the use of selector.slurp_file. 199 | """ 200 | # Comment or line: 201 | if not line.strip() or line.strip()[0] == '#': 202 | pass 203 | # Directive: 204 | elif line.strip()[0] == '@': 205 | parts = line.strip()[1:].split(' ', 1) 206 | if len(parts) == 2: 207 | directive, rest = parts 208 | else: 209 | directive = parts[0] 210 | rest = '' 211 | if directive == 'prefix': 212 | self.prefix = rest.strip() 213 | if directive == 'parser': 214 | self.parser = resolver.resolve(rest.strip()) 215 | if directive == 'wrap': 216 | self.wrap = resolver.resolve(rest.strip()) 217 | # HTTP Method -> Handler: 218 | elif line[0] in ' \t': 219 | if path is None: 220 | raise MappingFileError( 221 | "Specify a path expression first.") 222 | meth, app = line.strip().split(' ', 1) 223 | methods[meth.strip()] = resolver.resolve(app) 224 | # Path Expression: 225 | else: 226 | if path and methods: 227 | self.add(path, methods) 228 | path = line.strip() 229 | methods = {} 230 | return path, methods 231 | 232 | 233 | class SimpleParser(object): 234 | """Callable to turn path expressions into regexes with named groups. 235 | 236 | .. code-block:: python 237 | 238 | SimpleParser()("/hello/{name}") == r"^\/hello\/(?P[^\^.]+)$" 239 | 240 | See README.md for details. 241 | """ 242 | 243 | start, end = '{}' 244 | ostart, oend = '[]' 245 | _patterns = {'word': r'\w+', 246 | 'alpha': r'[a-zA-Z]+', 247 | 'digits': r'\d+', 248 | 'number': r'\d*.?\d+', 249 | 'chunk': r'[^/^.]+', 250 | 'segment': r'[^/]+', 251 | 'any': r'.+'} 252 | default_pattern = 'chunk' 253 | 254 | def __init__(self, patterns=None): 255 | """Initialize with character class mappings.""" 256 | self.patterns = dict(self._patterns) 257 | if patterns is not None: 258 | self.patterns.update(patterns) 259 | 260 | def _lookup(self, name): 261 | """Return the replacement for the name found.""" 262 | if ':' in name: 263 | name, pattern = name.split(':') 264 | pattern = self.patterns[pattern] 265 | else: 266 | pattern = self.patterns[self.default_pattern] 267 | if name == '': 268 | name = '__pos%s' % self._pos 269 | self._pos += 1 270 | return '(?P<%s>%s)' % (name, pattern) 271 | 272 | def _lastly(self, regex): 273 | """Process the result of __call__ right before it returns. 274 | 275 | Adds the ^ and the $ to the beginning and the end, respectively. 276 | """ 277 | return "^%s$" % regex 278 | 279 | def _openended(self, regex): 280 | """Process the result of __call__ right before it returns. 281 | 282 | Adds the ^ to the beginning but no $ to the end. 283 | Called as a special alternative to _lastly. 284 | """ 285 | return "^%s" % regex 286 | 287 | def _outermost_optionals_split(self, text): 288 | """Split out optional portions by outermost matching delims.""" 289 | parts = [] 290 | buffer = "" 291 | starts = ends = 0 292 | for c in text: 293 | if c == self.ostart: 294 | if starts == 0: 295 | parts.append(buffer) 296 | buffer = "" 297 | else: 298 | buffer += c 299 | starts += 1 300 | elif c == self.oend: 301 | ends += 1 302 | if starts == ends: 303 | parts.append(buffer) 304 | buffer = "" 305 | starts = ends = 0 306 | else: 307 | buffer += c 308 | else: 309 | buffer += c 310 | if not starts == ends == 0: 311 | raise PathExpressionParserError( 312 | "Mismatch of optional portion delimiters." 313 | ) 314 | parts.append(buffer) 315 | return parts 316 | 317 | def _parse(self, text): 318 | """Turn a path expression into regex.""" 319 | if self.ostart in text: 320 | parts = self._outermost_optionals_split(text) 321 | parts = list(map(self._parse, parts)) 322 | parts[1::2] = ["(%s)?" % p for p in parts[1::2]] 323 | else: 324 | parts = [part.split(self.end) 325 | for part in text.split(self.start)] 326 | parts = [y for x in parts for y in x] 327 | parts[::2] = list(map(re.escape, parts[::2])) 328 | parts[1::2] = list(map(self._lookup, parts[1::2])) 329 | return ''.join(parts) 330 | 331 | def __call__(self, url_pattern): 332 | """Turn a path expression into a regex.""" 333 | self._pos = 0 334 | if url_pattern.endswith('|'): 335 | return self._openended(self._parse(url_pattern[:-1])) 336 | else: 337 | return self._lastly(self._parse(url_pattern)) 338 | 339 | 340 | class EnvironDispatcher(object): 341 | """Dispatch based on list of rules.""" 342 | 343 | def __init__(self, rules): 344 | """Instantiate with a list of (predicate, wsgiapp) rules.""" 345 | self.rules = rules 346 | 347 | def __call__(self, environ, start_response): 348 | """Call the first app whose predicate is true. 349 | 350 | Each predicate is passes the environ to evaluate. 351 | """ 352 | for predicate, app in self.rules: 353 | if predicate(environ): 354 | return app(environ, start_response) 355 | 356 | 357 | class MiddlewareComposer(object): 358 | """Compose middleware based on list of rules.""" 359 | 360 | def __init__(self, app, rules): 361 | """Instantiate with an app and a list of rules.""" 362 | self.app = app 363 | self.rules = rules 364 | 365 | def __call__(self, environ, start_response): 366 | """Apply each middleware whose predicate is true. 367 | 368 | Each predicate is passes the environ to evaluate. 369 | 370 | Given this set of rules: 371 | 372 | t = lambda x: True; f = lambda x: False 373 | [(t, a), (f, b), (t, c), (f, d), (t, e)] 374 | 375 | The app composed would be equivalent to this: 376 | 377 | a(c(e(app))) 378 | """ 379 | app = self.app 380 | for predicate, middleware in reversed(self.rules): 381 | if predicate(environ): 382 | app = middleware(app) 383 | return app(environ, start_response) 384 | 385 | 386 | def expose(obj): 387 | """Set obj._exposed = True and return obj.""" 388 | obj._exposed = True 389 | return obj 390 | 391 | 392 | class Naked(object): 393 | """Naked object style dispatch base class.""" 394 | 395 | _not_found = staticmethod(not_found) 396 | _expose_all = True 397 | _exposed = True 398 | 399 | def _is_exposed(self, obj): 400 | """Determine if obj should be exposed. 401 | 402 | If self._expose_all is True, always return True. 403 | Otherwise, look at obj._exposed. 404 | """ 405 | return self._expose_all or getattr(obj, '_exposed', False) 406 | 407 | def __call__(self, environ, start_response): 408 | """Dispatch to the method named by the next bit of PATH_INFO.""" 409 | # Predict the path shift to get the callable name. 410 | name = shift_path_info(dict(SCRIPT_NAME=environ['SCRIPT_NAME'], 411 | PATH_INFO=environ['PATH_INFO'])) 412 | # If there is such a callable and it is exposed... 413 | callable = getattr(self, name or 'index', None) 414 | if callable is not None and self._is_exposed(callable): 415 | # ... shift the path and call the callable. 416 | shift_path_info(environ) 417 | return callable(environ, start_response) 418 | else: 419 | # ... or call self._not_found ( 420 | return self._not_found(environ, start_response) 421 | 422 | 423 | class ByMethod(object): 424 | """Base class for dispatching to method named by REQUEST_METHOD.""" 425 | 426 | _method_not_allowed = staticmethod(method_not_allowed) 427 | 428 | def __call__(self, environ, start_response): 429 | """Dispatch based on REQUEST_METHOD.""" 430 | environ['selector.methods'] = \ 431 | [m for m in dir(self) if not m.startswith('_')] 432 | return getattr(self, 433 | environ['REQUEST_METHOD'], 434 | self._method_not_allowed)(environ, start_response) 435 | 436 | 437 | def pliant(func): 438 | """Decorate an unbound wsgi callable taking args from wsgiorg.routing_args. 439 | 440 | .. code-block:: python 441 | 442 | @pliant 443 | def app(environ, start_response, arg1, arg2, foo='bar'): 444 | ... 445 | """ 446 | def wsgi_func(environ, start_response): 447 | args, kwargs = environ.get('wsgiorg.routing_args', ([], {})) 448 | args = list(args) 449 | args.insert(0, start_response) 450 | args.insert(0, environ) 451 | return func(*args, **dict(kwargs)) 452 | return wsgi_func 453 | 454 | 455 | def opliant(meth): 456 | """Decorate a bound wsgi callable taking args from wsgiorg.routing_args. 457 | 458 | .. code-block:: python 459 | 460 | class App(object): 461 | @opliant 462 | def __call__(self, environ, start_response, arg1, arg2, foo='bar'): 463 | ... 464 | """ 465 | def wsgi_meth(self, environ, start_response): 466 | args, kwargs = environ.get('wsgiorg.routing_args', ([], {})) 467 | args = list(args) 468 | args.insert(0, start_response) 469 | args.insert(0, environ) 470 | args.insert(0, self) 471 | return meth(*args, **dict(kwargs)) 472 | return wsgi_meth 473 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """setup - setuptools based setup for selector""" 2 | 3 | from setuptools import setup 4 | 5 | 6 | with open('VERSION', 'r') as version_file: 7 | version = version_file.read().strip() 8 | 9 | setup(name='selector', 10 | version=version, 11 | description='WSGI request delegation. (AKA routing.)', 12 | long_description=" ".join(""" 13 | This distribution provides WSGI middleware 14 | for "RESTful" mapping of URL paths to WSGI applications. 15 | Selector now also comes with components for environ based 16 | dispatch and on-the-fly middleware composition. 17 | There is a very simple optional mini-language for 18 | path expressions. Alternately we can easily use 19 | regular expressions directly or even create our own 20 | mini-language. There is a simple "mapping file" format 21 | that can be used. There are no architecture specific 22 | features (to MVC or whatever). Neither are there any 23 | framework specific features.""".split()), 24 | author='Luke Arno', 25 | author_email='luke.arno@gmail.com', 26 | url='http://github.com/lukearno/selector/', 27 | license="MIT", 28 | py_modules=['selector'], 29 | packages=[], 30 | install_requires=['resolver'], 31 | keywords="wsgi delegation routing web http rest webapps", 32 | classifiers=[ 33 | 'Development Status :: 3 - Alpha', 34 | 'Environment :: Web Environment', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: MIT License', 37 | 'License :: OSI Approved :: GNU General Public License (GPL)', 38 | 'Natural Language :: English', 39 | 'Operating System :: OS Independent', 40 | 'Programming Language :: Python', 41 | 'Topic :: Software Development :: Libraries', 42 | 'Topic :: Utilities']) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /tests/functional/conftest.py: -------------------------------------------------------------------------------- 1 | """Py.test plumbing.""" 2 | 3 | import twill 4 | 5 | from selector import Selector 6 | 7 | 8 | def _twill_selector_browser(id_): 9 | """Uses host to avoid collisions in the globalness of twill.""" 10 | host = "testhost-%s" % id_ 11 | s = Selector() 12 | twill.add_wsgi_intercept(host, 80, lambda: s) 13 | b = twill.get_browser() 14 | 15 | def go(path): 16 | b.go('http://%s%s' % (host, path)) 17 | headers = b._browser._response._headers.headers 18 | print s 19 | return (b.result.http_code, 20 | headers, 21 | b.result.page) 22 | 23 | return dict(selector=s, browser=b, go=go) 24 | 25 | 26 | def generate_twill_selector_browsers(): 27 | """Get selector, browser and go function for tests. 28 | 29 | Comes in a dict for funcargs. 30 | Feeds methods with signature `(selector, browser, go)`. 31 | """ 32 | for i in range(100): 33 | yield _twill_selector_browser(i) 34 | 35 | 36 | twill_selector_browsers = generate_twill_selector_browsers() 37 | 38 | 39 | def pytest_generate_tests(metafunc): 40 | """Populate test arguments.""" 41 | if 'selector' in metafunc.funcargnames: 42 | # Expects signature to be (s, b, go) 43 | metafunc.addcall(funcargs=twill_selector_browsers.next()) 44 | -------------------------------------------------------------------------------- /tests/functional/test_selector.py: -------------------------------------------------------------------------------- 1 | """Functional tests for `Selector()`.""" 2 | 3 | from webob import Request, Response 4 | from .wsgiapps import say_hello, here_i_am, say_hello_positional 5 | 6 | 7 | def test_simple_route(selector, browser, go): 8 | """Call the hello app..""" 9 | selector.add('/myapp/hello/{name}', GET=say_hello) 10 | code, headers, page = go('/myapp/hello/Guido') 11 | assert code == 200 12 | assert page.startswith("Hello Guido!") 13 | 14 | 15 | def test_simple_route_positional(selector, browser, go): 16 | """Call the hello app with positional arg in path.""" 17 | selector.add('/myapp/hello/{}', GET=say_hello_positional) 18 | code, headers, page = go('/myapp/hello/Guido') 19 | assert code == 200 20 | assert page.startswith("Hello Guido!") 21 | 22 | 23 | def tesiiit_custom_parser(selector, browser, go): 24 | """Use a plain regex with a do-nothing parser.""" 25 | orig = selector.parser 26 | selector.parser = lambda x: x 27 | selector.add(r'^\/here-i-am$', GET=here_i_am) 28 | selector.parser = orig 29 | code, headers, page = go('/here-i-am') 30 | assert code == 200 31 | assert page.startswith("Here I am.") 32 | 33 | 34 | def test_404_and_405(selector, browser, go): 35 | """Selector responds with 404s and 405s appropriately.""" 36 | selector.add('/post-only', POST=here_i_am) 37 | code, headers, page = go('/doesnt-exist') 38 | assert code == 404 39 | assert page.startswith("404 Not Found") 40 | code, headers, page = go('/post-only') 41 | assert code == 405 42 | assert page.startswith("405 Method Not Allowed") 43 | assert "Allow: POST\n" in list(headers) 44 | 45 | 46 | def test_seperate_http_method_paths(selector, browser, go): 47 | selector.add('/ws/hello', GET=here_i_am) 48 | selector.add('/ws/{slug}', POST=here_i_am) 49 | 50 | request = Request.blank('/ws/hello', method='GET') 51 | response = request.send (selector) 52 | 53 | assert response.status_code == 200 54 | assert response.body.startswith("Here I am.") 55 | 56 | request = Request.blank('/ws/hello', method='POST') 57 | response = request.send (selector) 58 | 59 | assert response.status_code == 200 60 | assert response.body.startswith("Here I am.") 61 | 62 | 63 | -------------------------------------------------------------------------------- /tests/functional/wsgiapps.py: -------------------------------------------------------------------------------- 1 | """A few simple WSGI apps for testing.""" 2 | 3 | 4 | def here_i_am(environ, start_response): 5 | """Just says "Here I am.".""" 6 | start_response("200 OK", [('Content-type', 'text/plain')]) 7 | return ["Here I am."] 8 | 9 | 10 | def say_hello(environ, start_response): 11 | """Say hello to named arg "name".""" 12 | args, kwargs = environ['wsgiorg.routing_args'] 13 | start_response("200 OK", [('Content-type', 'text/plain')]) 14 | return ["Hello %s!" % kwargs['name']] 15 | 16 | 17 | def say_hello_positional(environ, start_response): 18 | """Say hello to first positional arg.""" 19 | args, kwargs = environ['wsgiorg.routing_args'] 20 | start_response("200 OK", [('Content-type', 'text/plain')]) 21 | return ["Hello %s!" % args[0]] 22 | -------------------------------------------------------------------------------- /tests/regression/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /tests/regression/test_issue_12_build_installs.py: -------------------------------------------------------------------------------- 1 | 2 | import subprocess 3 | 4 | 5 | def test_issue_12_build_installs(): 6 | """.""" 7 | returncode = subprocess.call("fab buildtest", shell=True) 8 | assert returncode == 0 9 | -------------------------------------------------------------------------------- /tests/test_harness.py: -------------------------------------------------------------------------------- 1 | """Just verify that the test harness itself is working.""" 2 | 3 | from flexmock import flexmock 4 | 5 | from .unit import mocks 6 | 7 | 8 | def test_harness(): 9 | """This is a do-nothing test to verify the harness itself.""" 10 | assert 1 == 1 11 | 12 | 13 | def test_mock_open(): 14 | """Test mock for builtin `open()`.""" 15 | mocks.mock_open('test_mock_open', ['a', 'b', 'c'], 'rb') 16 | m = flexmock(calls=lambda x: x) 17 | m.should_receive('calls').times(3) 18 | with open('test_mock_open', 'rb') as foofile: 19 | for line in foofile: 20 | m.calls(line) 21 | for line in foofile: 22 | m.calls("Again %s" % line) 23 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /tests/unit/mocks.py: -------------------------------------------------------------------------------- 1 | """Mock object support for unit tests.""" 2 | 3 | import sys 4 | 5 | from flexmock import flexmock 6 | 7 | 8 | def mock_open(filename, lines, mode='rb'): 9 | """An open() call for filename returns lines.""" 10 | mock = flexmock(sys.modules['__builtin__']) 11 | mock.should_call('open') # set the fall-through 12 | mock.should_receive('open').with_args(filename, mode).and_return( 13 | flexmock(__enter__=lambda: lines.__iter__(), 14 | __exit__=lambda x, y, z: None)) 15 | 16 | 17 | def mock_regex(matched=None, groupdict={}): 18 | """Mocks a regex, of course. 19 | 20 | If you give it `matched`, it will claimed to have matched what you passed 21 | it and to have extracted named groups according to `groupdict`. Otherwise 22 | it will claim to have failed. 23 | """ 24 | if matched: 25 | match = flexmock(groupdict=lambda: groupdict, 26 | group=lambda x: matched) 27 | else: 28 | match = None 29 | return flexmock(search=lambda x: match) 30 | 31 | 32 | slurpables = [ 33 | ('/and-one', dict(GET=11)), 34 | ('/and-two', dict(GET=22)), 35 | ] 36 | 37 | 38 | def checkadd(s, prefix, parser, wrap): 39 | def myadd(a, method_dict=None): 40 | print a, method_dict 41 | assert s.prefix == prefix 42 | assert s.parser == parser 43 | assert s.wrap == wrap 44 | return myadd 45 | -------------------------------------------------------------------------------- /tests/unit/path-expression-expectations.csv: -------------------------------------------------------------------------------- 1 | /endpoint/,^\/endpoint\/$ 2 | /endpoint[/],^\/endpoint(\/)?$ 3 | /endpoint/{},^\/endpoint\/(?P<__pos0>[^/^.]+)$ 4 | /endpoint/{name},^\/endpoint\/(?P[^/^.]+)$ 5 | /endpoint/{name}/{},^\/endpoint\/(?P[^/^.]+)\/(?P<__pos0>[^/^.]+)$ 6 | /endpoint/{name}/{}|,^\/endpoint\/(?P[^/^.]+)\/(?P<__pos0>[^/^.]+) 7 | /endpoint/{name}[/],^\/endpoint\/(?P[^/^.]+)(\/)?$ 8 | /endpoint/[{name}[/]],^\/endpoint\/((?P[^/^.]+)(\/)?)?$ 9 | /endpoint[/[{name}[/]]],^\/endpoint(\/((?P[^/^.]+)(\/)?)?)?$ 10 | /endpoint[/[{name}[/]]]/foo[.{ext}],^\/endpoint(\/((?P[^/^.]+)(\/)?)?)?\/foo(\.(?P[^/^.]+))?$ 11 | /endpoint/{name:word}/,^\/endpoint\/(?P\w+)\/$ 12 | /endpoint/{name:alpha}/,^\/endpoint\/(?P[a-zA-Z]+)\/$ 13 | /endpoint/{name:digits}/,^\/endpoint\/(?P\d+)\/$ 14 | /endpoint/{name:number}/,^\/endpoint\/(?P\d*.?\d+)\/$ 15 | /endpoint/{name:chunk}/,^\/endpoint\/(?P[^/^.]+)\/$ 16 | /endpoint/{name:segment}/,^\/endpoint\/(?P[^/]+)\/$ 17 | /endpoint/{name:any}/,^\/endpoint\/(?P.+)\/$ 18 | /endpoint/{:word}/,^\/endpoint\/(?P<__pos0>\w+)\/$ 19 | /endpoint/{:alpha}/,^\/endpoint\/(?P<__pos0>[a-zA-Z]+)\/$ 20 | /endpoint/{:digits}/,^\/endpoint\/(?P<__pos0>\d+)\/$ 21 | /endpoint/{:number}/,^\/endpoint\/(?P<__pos0>\d*.?\d+)\/$ 22 | /endpoint/{:chunk}/,^\/endpoint\/(?P<__pos0>[^/^.]+)\/$ 23 | /endpoint/{:segment}/,^\/endpoint\/(?P<__pos0>[^/]+)\/$ 24 | /endpoint/{:any}/,^\/endpoint\/(?P<__pos0>.+)\/$ 25 | -------------------------------------------------------------------------------- /tests/unit/path-expressions.csv: -------------------------------------------------------------------------------- 1 | /endpoint/ 2 | /endpoint[/] 3 | /endpoint/{} 4 | /endpoint/{name} 5 | /endpoint/{name}/{} 6 | /endpoint/{name}/{}| 7 | /endpoint/{name}[/] 8 | /endpoint/[{name}[/]] 9 | /endpoint[/[{name}[/]]] 10 | /endpoint[/[{name}[/]]]/foo[.{ext}] 11 | /endpoint/{name:word}/ 12 | /endpoint/{name:alpha}/ 13 | /endpoint/{name:digits}/ 14 | /endpoint/{name:number}/ 15 | /endpoint/{name:chunk}/ 16 | /endpoint/{name:segment}/ 17 | /endpoint/{name:any}/ 18 | /endpoint/{:word}/ 19 | /endpoint/{:alpha}/ 20 | /endpoint/{:digits}/ 21 | /endpoint/{:number}/ 22 | /endpoint/{:chunk}/ 23 | /endpoint/{:segment}/ 24 | /endpoint/{:any}/ 25 | -------------------------------------------------------------------------------- /tests/unit/test_by_method.py: -------------------------------------------------------------------------------- 1 | """Unit tests for `ByMethod()`.""" 2 | 3 | from flexmock import flexmock 4 | 5 | import selector 6 | 7 | 8 | class Methodical(selector.ByMethod): 9 | """Mock subclass of `ByMethod`.""" 10 | 11 | def GET(self, environ, start_response): 12 | """Do Nothing.""" 13 | 14 | def POST(self, environ, start_response): 15 | """Do Nothing.""" 16 | 17 | 18 | def test_by_method_exists(): 19 | """Route by HTTP method to corresponding method.""" 20 | methodical = Methodical() 21 | flexmock(methodical).should_receive('GET').once 22 | environ = {'REQUEST_METHOD': 'GET'} 23 | methodical(environ, None) 24 | print environ 25 | assert 'GET' in environ['selector.methods'] 26 | assert 'POST' in environ['selector.methods'] 27 | 28 | 29 | def test_by_method_doesnt_exist(): 30 | """Route by HTTP method should call it's 405 fallback.""" 31 | methodical = Methodical() 32 | (flexmock(methodical).should_receive('_method_not_allowed').once 33 | .replace_with(lambda x, y: None)) 34 | environ = {'REQUEST_METHOD': 'PUT'} 35 | methodical(environ, None) 36 | assert 'GET' in environ['selector.methods'] 37 | assert 'POST' in environ['selector.methods'] 38 | 39 | 40 | def test_method_not_allowed_uses_selector(): 41 | """We are using the default 405 handler.""" 42 | assert selector.ByMethod._method_not_allowed is selector.method_not_allowed 43 | -------------------------------------------------------------------------------- /tests/unit/test_default_handlers.py: -------------------------------------------------------------------------------- 1 | """Test the default handlers for 404s and 405s.""" 2 | 3 | from selector import method_not_allowed, not_found 4 | import twill 5 | 6 | 7 | def test_not_found(): 8 | """The "not found" handler return a 404.""" 9 | twill.add_wsgi_intercept('not-found-host', 80, lambda: not_found) 10 | browser = twill.get_browser() 11 | browser.go('http://not-found-host/') 12 | assert browser.result.page.startswith("404 Not Found") 13 | assert browser.result.http_code == 404 14 | 15 | 16 | def test_method_not_allowed(): 17 | """The "method not allowed" handler return a 405.""" 18 | def app(environ, start_response): 19 | environ['selector.methods'] = ['GET', 'PUT'] 20 | return method_not_allowed(environ, start_response) 21 | twill.add_wsgi_intercept('not-found-host', 80, lambda: app) 22 | browser = twill.get_browser() 23 | browser.go('http://not-found-host/') 24 | assert browser.result.page.startswith("405 Method Not Allowed") 25 | assert browser.result.http_code == 405 26 | -------------------------------------------------------------------------------- /tests/unit/test_environ_dispatcher.py: -------------------------------------------------------------------------------- 1 | """Unit test `EnvironDispatcher()`.""" 2 | 3 | import twill 4 | 5 | import selector 6 | 7 | 8 | def test_environ_dispatcher(): 9 | """Dispatcher chooses an app basen on env.""" 10 | def make_app(name): 11 | def app(environ, start_response): 12 | start_response("200 OK", [('Content-type', 'text/plain')]) 13 | return [name] 14 | return app 15 | 16 | t = lambda env: env['PATH_INFO'] == '/foo' 17 | f = lambda env: env['PATH_INFO'] == '/bar' 18 | rules = [(f, make_app('a')), 19 | (f, make_app('b')), 20 | (t, make_app('c')), 21 | (f, make_app('d')), 22 | (t, make_app('e'))] 23 | 24 | dispatcher = selector.EnvironDispatcher(rules) 25 | twill.add_wsgi_intercept('simple-host', 80, lambda: dispatcher) 26 | browser = twill.get_browser() 27 | browser.go('http://simple-host/foo') 28 | assert browser.result.page.startswith("c") 29 | assert browser.result.http_code == 200 30 | -------------------------------------------------------------------------------- /tests/unit/test_middleware_composer.py: -------------------------------------------------------------------------------- 1 | """Unit test `MiddlewareComposer()`.""" 2 | 3 | import twill 4 | 5 | import selector 6 | 7 | 8 | def test_middleware_composer(): 9 | """Middleware stack should alter return in order..""" 10 | 11 | def make_middleware(txt): 12 | def middleware(app): 13 | def wrappedapp(environ, start_response): 14 | res = app(environ, start_response) 15 | res.append(txt) 16 | return res 17 | return wrappedapp 18 | return middleware 19 | 20 | # Environ predicates 21 | t = lambda x: True 22 | f = lambda x: False 23 | rules = [(t, make_middleware('a')), 24 | (f, make_middleware('b')), 25 | (t, make_middleware('c')), 26 | (f, make_middleware('d')), 27 | (t, make_middleware('e'))] 28 | 29 | def app(environ, start_response): 30 | start_response("200 OK", [('Content-type', 'text/plain')]) 31 | return ["ok "] 32 | 33 | composed = selector.MiddlewareComposer(app, rules) 34 | twill.add_wsgi_intercept('simple-host', 80, lambda: composed) 35 | browser = twill.get_browser() 36 | browser.go('http://simple-host/endpoint') 37 | assert browser.result.page.startswith("ok eca") 38 | assert browser.result.http_code == 200 39 | -------------------------------------------------------------------------------- /tests/unit/test_naked.py: -------------------------------------------------------------------------------- 1 | """Unit test naked object stuff.""" 2 | 3 | from flexmock import flexmock 4 | 5 | import selector 6 | 7 | 8 | class NakedMock(selector.Naked): 9 | """Base class for classes for testing `Naked`.""" 10 | 11 | def tell(self, name): 12 | """For replacement by flexmock. 13 | 14 | It looks like flexmock breaks the expose decorator, 15 | so use `tell` to get around mocking exposed methods. 16 | """ 17 | 18 | @selector.expose 19 | def index(self, environ, start_response): 20 | self.tell('index') 21 | 22 | def _not_found(self, environ, start_response): 23 | self.tell('_not_found') 24 | 25 | 26 | class NotNestable(NakedMock): 27 | """Not nestable and only index exposed.""" 28 | 29 | _expose_all = False 30 | _exposed = False 31 | 32 | 33 | class AllExposed(NakedMock): 34 | """Totally exposed and nestable.""" 35 | 36 | _expose_all = True 37 | 38 | def info(self, environ, start_response): 39 | self.tell('info') 40 | 41 | def nonsecrets(self, environ, start_response): 42 | self.tell('nonsecrets') 43 | 44 | 45 | class SomeExposed(NakedMock): 46 | """Selectively exposed.""" 47 | 48 | _expose_all = False 49 | 50 | # For testing nesting... 51 | not_nestable = NotNestable() 52 | nestable = AllExposed() 53 | 54 | @selector.expose 55 | def info(self, environ, start_response): 56 | self.tell('info') 57 | 58 | def secrets(self, environ, start_response): 59 | self.tell('secrets') 60 | 61 | 62 | def test_expose(): 63 | """Mark a callable as "exposed".""" 64 | class Obj(object): 65 | pass 66 | o = Obj() 67 | selector.expose(o) 68 | assert o._exposed 69 | 70 | 71 | def test_exposure(): 72 | """Method is exposed by decorator.""" 73 | some_exposed = SomeExposed() 74 | all_exposed = AllExposed() 75 | not_nestable = NotNestable() 76 | # Methods are exposed or not as we expected. 77 | assert some_exposed._is_exposed(some_exposed.info) 78 | assert not some_exposed._is_exposed(some_exposed.secrets) 79 | assert all_exposed._is_exposed(all_exposed.info) 80 | assert all_exposed._is_exposed(all_exposed.nonsecrets) 81 | assert not not_nestable._is_exposed(all_exposed.info) 82 | # Nestables are exposed, non-nestables are not 83 | assert all_exposed._is_exposed(all_exposed) 84 | assert some_exposed._is_exposed(some_exposed) 85 | assert not not_nestable._is_exposed(not_nestable) 86 | 87 | 88 | def naked_test_call(NakedClass, 89 | script_name_before, 90 | path_info_before, 91 | callable_name, 92 | script_name_after, 93 | path_info_after): 94 | 95 | def generated_test(): 96 | environ = dict( 97 | SCRIPT_NAME=script_name_before, 98 | PATH_INFO=path_info_before 99 | ) 100 | naked = NakedClass() 101 | (flexmock(naked) 102 | .should_receive('tell') 103 | .with_args(callable_name) 104 | .once 105 | .replace_with(lambda y: None)) 106 | naked(environ, None) 107 | assert environ['SCRIPT_NAME'] == script_name_after 108 | assert environ['PATH_INFO'] == path_info_after 109 | 110 | return generated_test 111 | 112 | 113 | test_call_index_slash = naked_test_call( 114 | AllExposed, 115 | '/foo/bar', 116 | '/', 117 | 'index', 118 | '/foo/bar/', 119 | '') 120 | 121 | 122 | test_call_index_noslash = naked_test_call( 123 | AllExposed, 124 | '/foo/bar', 125 | '', 126 | 'index', 127 | '/foo/bar', 128 | '') 129 | 130 | 131 | test_call_index_slash_on_script_name = naked_test_call( 132 | AllExposed, 133 | '/foo/bar/', 134 | '', 135 | 'index', 136 | '/foo/bar/', 137 | '') 138 | 139 | 140 | test_call_an_implicitly_exposed_method = naked_test_call( 141 | AllExposed, 142 | '/foo/bar', 143 | '/info/remainder', 144 | 'info', 145 | '/foo/bar/info', 146 | '/remainder') 147 | 148 | 149 | test_call_an_explicitly_exposed_method = naked_test_call( 150 | SomeExposed, 151 | '/foo/bar', 152 | '/info/remainder', 153 | 'info', 154 | '/foo/bar/info', 155 | '/remainder') 156 | 157 | 158 | test_call_an_unexposed_method = naked_test_call( 159 | SomeExposed, 160 | '/foo/bar', 161 | '/secrets', 162 | '_not_found', 163 | '/foo/bar', 164 | '/secrets') 165 | 166 | 167 | def test_call_a_nested_unnestable(): 168 | environ = dict( 169 | SCRIPT_NAME='/foo/bar', 170 | PATH_INFO='/nestable/info' 171 | ) 172 | some_exposed = SomeExposed() 173 | (flexmock(some_exposed.nestable) 174 | .should_receive('tell') 175 | .with_args('info') 176 | .once 177 | .replace_with(lambda y: None)) 178 | some_exposed(environ, None) 179 | assert environ['SCRIPT_NAME'] == '/foo/bar/nestable/info' 180 | assert environ['PATH_INFO'] == '' 181 | 182 | 183 | test_call_ = naked_test_call( 184 | SomeExposed, 185 | '/foo/bar', 186 | '/unnestable/info', 187 | '_not_found', 188 | '/foo/bar', 189 | '/unnestable/info') 190 | 191 | 192 | def test_notfound(): 193 | """Not found is tested elsewhere.""" 194 | assert selector.Naked._not_found is selector.not_found 195 | -------------------------------------------------------------------------------- /tests/unit/test_pliant.py: -------------------------------------------------------------------------------- 1 | """Unit test `pliant` and `opliant`.""" 2 | 3 | import selector 4 | 5 | 6 | def test_pliant(): 7 | """Transform the WSGI call for a function.""" 8 | outer_environ = {'wsgiorg.routing_args': [ 9 | ['xxx', 'yyy'], 10 | {'z': 'zzz'}]} 11 | 12 | def checker(environ, start_response, x, y, z=None): 13 | assert environ is outer_environ 14 | assert start_response == 1 15 | assert x == 'xxx' 16 | assert y == 'yyy' 17 | assert z == 'zzz' 18 | return 'output' 19 | 20 | product = selector.pliant(checker) 21 | returned = product(outer_environ, 1) 22 | assert returned == 'output' 23 | 24 | 25 | def test_opliant(): 26 | """Transform the WSGI call for an instance method.""" 27 | outer_environ = {'wsgiorg.routing_args': [ 28 | ['xxx', 'yyy'], 29 | {'z': 'zzz'}]} 30 | 31 | def checker(self, environ, start_response, x, y, z=None): 32 | assert self == 99 33 | assert environ is outer_environ 34 | assert start_response == 1 35 | assert x == 'xxx' 36 | assert y == 'yyy' 37 | assert z == 'zzz' 38 | return 'output' 39 | 40 | product = selector.opliant(checker) 41 | returned = product(99, outer_environ, 1) 42 | assert returned == 'output' 43 | -------------------------------------------------------------------------------- /tests/unit/test_selector_add.py: -------------------------------------------------------------------------------- 1 | """Unit tests for Selector.add()..""" 2 | 3 | import re 4 | 5 | from flexmock import flexmock 6 | 7 | import selector 8 | 9 | 10 | def test_add_keyword(): 11 | """Add mapping using keyword for HTTP method.""" 12 | s = selector.Selector(parser=lambda x: x + 'p') 13 | (flexmock(re).should_receive('compile') 14 | .once.with_args('/foop') 15 | .replace_with(lambda x: x + "c")) 16 | result = s.add('/foo', GET=33) 17 | assert result[0] == '/foopc' # 'pc' as in "parsed and compiled" 18 | assert result[1].keys() == ['GET'] 19 | assert result[1].values() == [33] 20 | assert len(s.mappings) == 1 21 | 22 | 23 | def test_add_dict(): 24 | """Add mapping using dict for HTTP method.""" 25 | s = selector.Selector(parser=lambda x: x + 'p') 26 | (flexmock(re).should_receive('compile') 27 | .once.with_args('/foop') 28 | .replace_with(lambda x: x + "c")) 29 | result = s.add('/foo', method_dict=dict(GET=33)) 30 | assert result[0] == '/foopc' # 'pc' as in "parsed and compiled" 31 | assert result[1].keys() == ['GET'] 32 | assert result[1].values() == [33] 33 | assert len(s.mappings) == 1 34 | 35 | 36 | def test_add_dict_override(): 37 | """Add mapping using keyword and dict (keyword wins).""" 38 | s = selector.Selector(parser=lambda x: x + 'p') 39 | (flexmock(re).should_receive('compile') 40 | .once.with_args('/foop') 41 | .replace_with(lambda x: x + "c")) 42 | result = s.add('/foo', method_dict=dict(GET=33), GET=34) 43 | assert result[0] == '/foopc' # 'pc' as in "parsed and compiled" 44 | assert result[1].keys() == ['GET'] 45 | assert result[1].values() == [34] 46 | assert len(s.mappings) == 1 47 | 48 | 49 | def test_add_prefix(): 50 | """Add mapping using prefix option.""" 51 | s = selector.Selector(parser=lambda x: x + 'p') 52 | (flexmock(re).should_receive('compile') 53 | .once.with_args('/pre/foop') 54 | .replace_with(lambda x: x + "c")) 55 | assert s.prefix == '' 56 | result = s.add('/foo', GET=33, prefix='/pre') 57 | assert s.prefix == '' 58 | assert result[0] == '/pre/foopc' # 'pc' as in "parsed and compiled" 59 | assert result[1].keys() == ['GET'] 60 | assert result[1].values() == [33] 61 | assert len(s.mappings) == 1 62 | 63 | 64 | def test_add_wrap(): 65 | """Add mapping using keyword for.""" 66 | s = selector.Selector(parser=lambda x: x + 'p', 67 | wrap=lambda x: x + 1) 68 | (flexmock(re).should_receive('compile') 69 | .once.with_args('/foop') 70 | .replace_with(lambda x: x + "c")) 71 | result = s.add('/foo', GET=33) 72 | assert result[0] == '/foopc' # 'pc' as in "parsed and compiled" 73 | assert result[1].keys() == ['GET'] 74 | assert result[1].values() == [34] 75 | assert len(s.mappings) == 1 76 | -------------------------------------------------------------------------------- /tests/unit/test_selector_call.py: -------------------------------------------------------------------------------- 1 | """Unit test `Selector.__call__()`.""" 2 | 3 | from flexmock import flexmock 4 | 5 | import selector 6 | 7 | 8 | def test_selector_call(): 9 | """Call should call select, munge environ and call app.""" 10 | s = selector.Selector() 11 | 12 | def checker(environ, start_response): 13 | print environ 14 | assert len(environ.keys()) == 7 15 | assert start_response == 1 16 | assert environ['selector.methods'] == ['GET', 'POST'] 17 | assert environ['selector.vars'] == {'1': 'two', 18 | '0': 'one', 19 | 'name': 'luke'} 20 | assert environ['wsgiorg.routing_args'][0] == ['one', 'two'] 21 | assert environ['wsgiorg.routing_args'][1] == {'name': 'luke'} 22 | assert environ['PATH_INFO'] == '' 23 | assert environ['SCRIPT_NAME'] == '/path' 24 | assert environ['REQUEST_METHOD'] == 'GET' 25 | return 'output' 26 | 27 | # Mock select 28 | (flexmock(s) 29 | .should_receive('select') 30 | .once 31 | .with_args('/path', 'GET') 32 | .replace_with(lambda x, y: ( 33 | checker, 34 | {'name': 'luke', 35 | '__pos0': 'one', 36 | '__pos1': 'two'}, 37 | ['GET', 'POST'], 38 | '/path'))) 39 | returned = s({'PATH_INFO': '/path', 'REQUEST_METHOD': 'GET'}, 1) 40 | assert returned == 'output' 41 | -------------------------------------------------------------------------------- /tests/unit/test_selector_init.py: -------------------------------------------------------------------------------- 1 | """Unit test `Selector()` instantiation.""" 2 | 3 | from flexmock import flexmock 4 | 5 | import selector 6 | 7 | from . import mocks 8 | 9 | 10 | def test_instantiate_selector_args(): 11 | """Instantiate a `Selector()` with the works.""" 12 | myparser = lambda x: x 13 | mywrap = lambda x: x + 1 14 | (flexmock(selector.Selector).should_receive('slurp') 15 | .with_args(mocks.slurpables) 16 | .once.ordered) 17 | (flexmock(selector.Selector).should_receive('slurp_file') 18 | .with_args('some.urls') 19 | .once.ordered) 20 | s = selector.Selector( 21 | mappings=mocks.slurpables, 22 | prefix='/aprefix', 23 | parser=myparser, 24 | wrap=mywrap, 25 | mapfile='some.urls', 26 | consume_path=False) 27 | assert s.prefix == '/aprefix' 28 | assert s.parser is myparser 29 | assert s.wrap is mywrap 30 | assert s.consume_path is False 31 | 32 | 33 | def test_instantiate_selector_defaults(): 34 | """Instantiate a `Selector()`, hold-the-mayo please.""" 35 | s = selector.Selector() 36 | s.mappings = [] 37 | s.prefix is None 38 | s.parser.__class__ is selector.SimpleParser 39 | s.wrap is None 40 | s.consume_path is True 41 | -------------------------------------------------------------------------------- /tests/unit/test_selector_mapping_format.py: -------------------------------------------------------------------------------- 1 | """Unit test `Selector` mapping file handling.""" 2 | 3 | import pytest 4 | import resolver 5 | 6 | from flexmock import flexmock 7 | 8 | import selector 9 | 10 | from . import mocks 11 | 12 | 13 | def test__line_parse_comments_and_blanks_ignored(): 14 | """Blank and are ignored by the parser.""" 15 | s = flexmock(selector.Selector()) 16 | assert s._parse_line("", None, None) == (None, None) 17 | assert s._parse_line(" # ", None, None) == (None, None) 18 | assert s._parse_line("", 1, 2) == (1, 2) 19 | assert s._parse_line(" # ", 3, 4) == (3, 4) 20 | 21 | 22 | def test__line_parse_processing_directives(): 23 | """Route adding is modulated by `@` directives.""" 24 | flexmock(resolver).should_receive('resolve').replace_with(lambda x: 1) 25 | s = flexmock(selector.Selector(), add=None) 26 | assert s.prefix == '' 27 | assert s._parse_line("@prefix /pre", None, None) == (None, None) 28 | assert s.prefix == '/pre' 29 | assert s._parse_line("@prefix", None, None) == (None, None) 30 | assert s.prefix == '' 31 | assert s.parser.__class__ is selector.SimpleParser 32 | assert s._parse_line("@parser whatever", None, None) == (None, None) 33 | assert s.parser == 1 34 | assert s.wrap == None 35 | assert s._parse_line("@wrap whatever", None, None) == (None, None) 36 | assert s.wrap == 1 37 | 38 | 39 | def test__line_parse_path_expressions_and_http_methods(): 40 | """Path expressions collect HTTP method -> handler maps.""" 41 | flexmock(resolver).should_receive('resolve').replace_with(lambda x: 1) 42 | s = flexmock(selector.Selector(), add=None) 43 | s.should_receive('add').once 44 | assert s._parse_line("/path", None, None) == ('/path', {}) 45 | assert (s._parse_line(" GET foo:Bar", '/path', {}) 46 | == ('/path', {'GET': 1})) 47 | assert (s._parse_line(" PUT foo:Put", '/path', {'GET': 1}) 48 | == ('/path', {'PUT': 1, 'GET': 1})) 49 | assert (s._parse_line("/bar", '/path', {'GET': 1}) 50 | == ('/bar', {})) 51 | print dir(s) 52 | 53 | 54 | def test_selector_slurp_file(): 55 | """Slurping a file adds mappings..""" 56 | flexmock(resolver).should_receive('resolve').replace_with(lambda x: 1) 57 | mocks.mock_open('test_selector_slurp_file', [ 58 | "/some-url/", 59 | " GET whatever.foo:app"]) 60 | s = selector.Selector() 61 | (flexmock(s) 62 | .should_receive('add') 63 | .once.with_args('/some-url/', {'GET': 1}) 64 | .replace_with(mocks.checkadd(s, '', s.parser, None))) 65 | s.slurp_file('test_selector_slurp_file') 66 | 67 | 68 | def test_selector_slurp_file_with_options(): 69 | """Slurp in a file with the works..""" 70 | flexmock(resolver).should_receive('resolve').replace_with(lambda x: 1) 71 | mocks.mock_open('test_selector_slurp_file', [ 72 | "/some-url/", 73 | " GET whatever.foo:app"]) 74 | s = selector.Selector() 75 | (flexmock(s) 76 | .should_receive('add') 77 | .once.with_args('/some-url/', {'GET': 1}) 78 | .replace_with(mocks.checkadd(s, '/pre', 2, 3))) 79 | assert s.prefix == '' 80 | assert s.parser.__class__ == selector.SimpleParser 81 | assert s.wrap is None 82 | s.slurp_file('test_selector_slurp_file', 83 | prefix='/pre', parser=2, wrap=3) 84 | assert s.prefix == '' 85 | assert s.parser.__class__ == selector.SimpleParser 86 | assert s.wrap is None 87 | 88 | 89 | def test_selector_slurp_file_exception(): 90 | """Slurping a file adds mappings..""" 91 | flexmock(resolver).should_receive('resolve').replace_with(lambda x: 1) 92 | mocks.mock_open('test_selector_slurp_file', [ 93 | " GET whatever.foo:app", 94 | "/some-url/"]) 95 | s = selector.Selector() 96 | flexmock(s).should_receive('add').times(0) 97 | with pytest.raises(selector.MappingFileError): 98 | s.slurp_file('test_selector_slurp_file') 99 | -------------------------------------------------------------------------------- /tests/unit/test_selector_select.py: -------------------------------------------------------------------------------- 1 | """Unit test Selector.select().""" 2 | 3 | import selector 4 | 5 | from . import mocks 6 | 7 | 8 | def test_select_first_match(): 9 | """Select the first matching regex.""" 10 | s = selector.Selector() 11 | s.mappings = [ 12 | (mocks.mock_regex(), {'GET': 1}), 13 | (mocks.mock_regex('/foo'), {'GET': 2}), 14 | (mocks.mock_regex(), {'GET': 3}), 15 | (mocks.mock_regex('/foo', {'name': 'bar'}), {'GET': 4}), 16 | ] 17 | app, svars, methods, matched = s.select('/foo', 'GET') 18 | assert app == 2 19 | assert svars == {} 20 | assert methods == ['GET'] 21 | assert matched == "/foo" 22 | 23 | 24 | def test_select_match_any_http_method(): 25 | """Select the first matching regex.""" 26 | s = selector.Selector() 27 | s.mappings = [ 28 | (mocks.mock_regex(), {'GET': 1}), 29 | (mocks.mock_regex('/foo'), {'_ANY_': 2}), 30 | (mocks.mock_regex(), {'GET': 3}), 31 | (mocks.mock_regex('/foo', {'name': 'bar'}), {'GET': 4}), 32 | ] 33 | app, svars, methods, matched = s.select('/foo', 'GET') 34 | assert app == 2 35 | assert svars == {} 36 | assert methods == ['_ANY_'] 37 | assert matched == "/foo" 38 | 39 | 40 | def test_select_405(): 41 | """The path matches but the method is not supported.""" 42 | s = selector.Selector() 43 | s.mappings = [ 44 | (mocks.mock_regex(), {'GET': 1}), 45 | (mocks.mock_regex('/foo'), {'POST': 2}), 46 | ] 47 | app, svars, methods, matched = s.select('/foo', 'GET') 48 | assert app is s.status405 49 | assert svars == {} 50 | assert methods == ['POST'] 51 | assert matched == "" 52 | 53 | 54 | def test_select_404(): 55 | """The path was not matched.""" 56 | s = selector.Selector() 57 | s.mappings = [ 58 | (mocks.mock_regex(), {'GET': 1}), 59 | ] 60 | app, svars, methods, matched = s.select('/foo', 'GET') 61 | assert app is s.status404 62 | assert svars == {} 63 | assert methods == [] 64 | assert matched == "" 65 | -------------------------------------------------------------------------------- /tests/unit/test_selector_slurp.py: -------------------------------------------------------------------------------- 1 | """Unit test `Selector.slurp()`.""" 2 | 3 | from flexmock import flexmock 4 | 5 | import selector 6 | 7 | from . import mocks 8 | 9 | 10 | def test_slurp_no_mayo(): 11 | """Just slurp up some mappings in a list.""" 12 | s = selector.Selector() 13 | flexmock(s).should_receive('add').times(2) 14 | s.slurp(mocks.slurpables) 15 | 16 | 17 | def test_slurp_the_works(): 18 | """Slurp with prefix, parser and wrap.""" 19 | myparser = lambda x: x 20 | mywrap = lambda x: x 21 | s = selector.Selector() 22 | assert s.prefix == '' 23 | assert s.parser.__class__ == selector.SimpleParser 24 | assert s.wrap is None 25 | flexmock(s).should_receive('add').times(2).replace_with( 26 | mocks.checkadd(s, '/pre', myparser, mywrap)) 27 | s.slurp(mocks.slurpables, prefix='/pre', parser=myparser, wrap=mywrap) 28 | assert s.prefix == '' 29 | assert s.parser.__class__ == selector.SimpleParser 30 | assert s.wrap is None 31 | -------------------------------------------------------------------------------- /tests/unit/test_simple_parser.py: -------------------------------------------------------------------------------- 1 | """Unit Test `SimpleParser()`.""" 2 | 3 | import csv 4 | 5 | import pytest 6 | 7 | import selector 8 | 9 | 10 | def test_parser_expectations(): 11 | """Just a basic test of the builtin parser.""" 12 | parser = selector.SimpleParser() 13 | with open('tests/unit/path-expression-expectations.csv', 'r') as pex: 14 | reader = csv.reader(pex) 15 | for pe, re in reader: 16 | print pe 17 | assert parser(pe) == re 18 | 19 | 20 | def test_parser_raises_exception(): 21 | """Malformed path expression raises error.""" 22 | parser = selector.SimpleParser() 23 | with pytest.raises(selector.PathExpressionParserError): 24 | parser("/this/is/a/[broken/path/expression") 25 | 26 | 27 | def test_parser_with_custom_type(): 28 | """Custom types can be provided to the parser or modifying .patterns.""" 29 | parser = selector.SimpleParser(patterns={'mytype': 'MYREGEX'}) 30 | assert parser('/{foo:mytype}') == r'^\/(?PMYREGEX)$' 31 | parser.patterns['othertype'] = 'OTHERREGEX' 32 | assert parser('/{foo:othertype}') == r'^\/(?POTHERREGEX)$' 33 | --------------------------------------------------------------------------------