├── .gitignore ├── pycnic ├── __init__.py ├── utils.py ├── errors.py ├── data.py ├── core.py └── cli.py ├── benchmark ├── hug_test.py ├── bottle_test.py ├── flask_test.py ├── muffin_test.py ├── bobo_test.py ├── falcon_test.py ├── horseman_test.py ├── cherrypy_test.py ├── pyramid_test.py ├── pycnic_test.py ├── tornado_test.py ├── runner.sh └── README.md ├── docs ├── source │ ├── support.md │ ├── thumb.tpl │ ├── benchmarks.md │ ├── download.md │ ├── response-class.md │ ├── index.md │ ├── example-validation.md │ ├── example-cors.md │ ├── example-auth-wrapper.md │ ├── wsgi-servers.md │ ├── request-class.md │ ├── wsgi-class.md │ ├── getting-started.md │ ├── cli-routes.md │ ├── example-request.md │ ├── example-cookies.md │ ├── example-errors.md │ ├── example-full-auth.md │ └── nodejs-compare.md ├── config.yaml └── links.yaml ├── examples ├── hello.py ├── validation.py ├── cors.py ├── json-encoder.py ├── models.py ├── auth-wrapper.py ├── test.py ├── cookies.py ├── overrides.py └── full-auth.py ├── README.md ├── setup.py └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /pycnic/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.9" 2 | -------------------------------------------------------------------------------- /benchmark/hug_test.py: -------------------------------------------------------------------------------- 1 | import hug 2 | 3 | @hug.get('/json') 4 | def json_get(): 5 | return { "message": "Hello, World!" } 6 | 7 | app = __hug_wsgi__ 8 | -------------------------------------------------------------------------------- /benchmark/bottle_test.py: -------------------------------------------------------------------------------- 1 | import bottle 2 | 3 | app = bottle.Bottle() 4 | 5 | @app.route('/json') 6 | def json(): 7 | return {"message":"Hello, world!"} 8 | -------------------------------------------------------------------------------- /benchmark/flask_test.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import json 3 | 4 | app = flask.Flask(__name__) 5 | 6 | @app.route('/json') 7 | def json(): 8 | return flask.jsonify(message="Hello, world!") 9 | -------------------------------------------------------------------------------- /benchmark/muffin_test.py: -------------------------------------------------------------------------------- 1 | import muffin 2 | 3 | app = muffin.Application('web') 4 | 5 | @app.register('/json') 6 | def json(request): 7 | return { 'message': 'Hello, World!' } 8 | 9 | -------------------------------------------------------------------------------- /benchmark/bobo_test.py: -------------------------------------------------------------------------------- 1 | import bobo 2 | 3 | @bobo.query('/json', content_type='application/json') 4 | def json(): 5 | return { "message":"Hello, world!" } 6 | 7 | app = bobo.Application(bobo_resources=__name__) 8 | -------------------------------------------------------------------------------- /docs/source/support.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Support 3 | ]] 4 | 5 | # Support 6 | 7 | Please file an issue on Github. 8 | 9 | New Support Issue 10 | -------------------------------------------------------------------------------- /benchmark/falcon_test.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | import json 3 | 4 | 5 | class Resource: 6 | 7 | def on_get(self, req, resp): 8 | resp.body = json.dumps({"message": "Hello, world!"}) 9 | 10 | 11 | app = falcon.API() 12 | app.add_route('/json', Resource()) 13 | -------------------------------------------------------------------------------- /benchmark/horseman_test.py: -------------------------------------------------------------------------------- 1 | from horseman.response import Response 2 | from roughrider.application import Application 3 | 4 | app = Application() 5 | 6 | 7 | @app.routes.register('/json') 8 | def json(request): 9 | return Response.to_json(body={'message': "Hello, world!"}) 10 | -------------------------------------------------------------------------------- /docs/source/thumb.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 |
7 |
8 | -------------------------------------------------------------------------------- /benchmark/cherrypy_test.py: -------------------------------------------------------------------------------- 1 | import cherrypy 2 | 3 | 4 | class Root(object): 5 | 6 | @cherrypy.expose 7 | @cherrypy.tools.json_out() 8 | def json(self): 9 | return {"message": "Hello, world!"} 10 | 11 | 12 | app = cherrypy.tree.mount(Root()) 13 | cherrypy.log.screen = False 14 | -------------------------------------------------------------------------------- /benchmark/pyramid_test.py: -------------------------------------------------------------------------------- 1 | from pyramid.view import view_config 2 | from pyramid.config import Configurator 3 | 4 | @view_config(route_name='json', renderer='json') 5 | def json(request): 6 | return {'message': 'Hello, World!'} 7 | 8 | config = Configurator() 9 | 10 | config.add_route('json', '/json') 11 | 12 | config.scan() 13 | app = config.make_wsgi_app() 14 | -------------------------------------------------------------------------------- /benchmark/pycnic_test.py: -------------------------------------------------------------------------------- 1 | import sys;sys.path.insert(0,'../') 2 | 3 | # TEST 4 | 5 | #from pycnic.core import WSGI, Handler 6 | import pycnic.core 7 | 8 | class JSONHandler(pycnic.core.Handler): 9 | 10 | def get(self): 11 | return { "message": "Hello, World!" } 12 | 13 | 14 | class app(pycnic.core.WSGI): 15 | routes = [ 16 | ("/json", JSONHandler()), 17 | ] 18 | 19 | -------------------------------------------------------------------------------- /benchmark/tornado_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import tornado.ioloop 3 | import tornado.web 4 | 5 | class JSONHandler(tornado.web.RequestHandler): 6 | def get(self): 7 | self.write({"message":"Hello, world!"}) 8 | 9 | app = tornado.web.Application([ 10 | (r"/json", JSONHandler), 11 | ]) 12 | 13 | if __name__ == "__main__": 14 | app.listen(8000) 15 | tornado.ioloop.IOLoop.current().start() 16 | -------------------------------------------------------------------------------- /docs/source/benchmarks.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Benchmarks 3 | ]] 4 | 5 | # Pycnic Bench (heh) 6 | 7 | Pycnic benchmarks were ran with two [Gunicorn](http://gunicorn.org) workers 8 | and hit with `ab -n 5000 -c 5` against a JSON endpoint (/json). 9 | 10 | *Note: the express.js benchmark can be located at [[nodejs-compare]].* 11 | 12 | * Source code 13 | 14 | 15 | {tpl:thumb} 16 | image: http://pycnic.nullism.com/images/pycnic-bench.png 17 | max_width: 736px 18 | {endtpl} 19 | -------------------------------------------------------------------------------- /docs/source/download.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Download 3 | tags: [configuration] 4 | ]] 5 | 6 | # Download Pycnic 7 | 8 | Pycnic is open source and licensed under [MIT](https://opensource.org/licenses/MIT). 9 | 10 | ## Source Code 11 | 12 | Via GitHub: [github.com/nullism/pycnic](https://github.com/nullism/pycnic). 13 | 14 | git clone https://github.com/nullism/pycnic.git 15 | 16 | Via PyPI: [pypi.python.org/pypi/pycnic](https://pypi.python.org/pypi/pycnic) 17 | 18 | ## Installation 19 | 20 | Via pip: 21 | 22 | pip install pycnic 23 | 24 | ## Upgrade 25 | 26 | Via pip: 27 | 28 | pip install pycnic --upgrade 29 | -------------------------------------------------------------------------------- /examples/hello.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from pycnic.core import WSGI, Handler 3 | 4 | class Hello(Handler): 5 | 6 | def get(self, name="World"): 7 | return { 8 | "message": "Hello, {name}!".format(name=name) 9 | } 10 | 11 | 12 | class app(WSGI): 13 | routes = [ 14 | ("/", Hello()), 15 | ("/([\w]+)", Hello()) 16 | ] 17 | 18 | if __name__ == "__main__": 19 | 20 | from wsgiref.simple_server import make_server 21 | try: 22 | print("Serving on 0.0.0.0:8080...") 23 | make_server('0.0.0.0', 8080, app).serve_forever() 24 | except KeyboardInterrupt: 25 | pass 26 | print("Done") 27 | 28 | -------------------------------------------------------------------------------- /benchmark/runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | output="Test results:\n" 4 | 5 | for app in bobo_test falcon_test pycnic_test cherrypy_test pyramid_test hug_test flask_test bottle_test tornado_test horseman_test; 6 | do 7 | echo "TEST: $app" 8 | if [ "$app" = "tornado_test" ]; then 9 | worker="tornado" 10 | else 11 | worker="sync" 12 | fi 13 | gunicorn -w 3 -k $worker $app:app & 14 | gunicorn_pid=$! 15 | sleep 2 16 | ab_out=`ab -n 5000 -c 5 http://127.0.0.1:8000/json` 17 | kill $gunicorn_pid 18 | rps=`echo "$ab_out" | grep "Requests per second"` 19 | crs=`echo "$ab_out" | grep "Complete requests"` 20 | output="$output\n$app:\n\t$rps\n\t$crs" 21 | done 22 | 23 | echo -e "$output" 24 | -------------------------------------------------------------------------------- /examples/validation.py: -------------------------------------------------------------------------------- 1 | from pycnic.core import Handler, WSGI 2 | from pycnic.utils import requires_validation 3 | 4 | 5 | def has_proper_name(data): 6 | if 'name' not in data or data['name'] != 'root': 7 | raise ValueError('Expected \'root\' as name') 8 | 9 | 10 | class NameHandler(Handler): 11 | 12 | @requires_validation(has_proper_name) 13 | def post(self): 14 | return {'status': 'ok'} 15 | 16 | 17 | class app(WSGI): 18 | routes = [('/name', NameHandler())] 19 | 20 | if __name__ == "__main__": 21 | 22 | from wsgiref.simple_server import make_server 23 | try: 24 | print("Serving on 0.0.0.0:8080...") 25 | make_server('0.0.0.0', 8080, app).serve_forever() 26 | except KeyboardInterrupt: 27 | pass 28 | print("Done") 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![HUG](http://pycnic.nullism.com/images/pycnic-head-small.png) 2 | ============================ 3 | 4 | # Docs 5 | 6 | [pycnic.nullism.com/docs](http://pycnic.nullism.com/docs) 7 | 8 | # Example 9 | 10 | ```python 11 | # example.py 12 | from pycnic.core import WSGI, Handler 13 | 14 | class Hello(Handler): 15 | def get(self, name="World"): 16 | return { "message":"Hello, %s!"%(name) } 17 | 18 | class app(WSGI): 19 | routes = [ 20 | ('/', Hello()), 21 | ('/([\w]+)', Hello()) 22 | ] 23 | ``` 24 | 25 | # Installation 26 | 27 | Now that Pycnic is available on PyPI, it may be installed with pip. 28 | 29 | pip install pycnic 30 | 31 | # Running 32 | 33 | Pycnic may be ran with any WSGI-compliant server, such as [Gunicorn](http://gunicorn.org). 34 | 35 | gunicorn file:app 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/config.yaml: -------------------------------------------------------------------------------- 1 | base_path: /home/nullism/github/pycnic/docs 2 | blurb_max: 50 3 | date_format: '%Y-%m-%d' 4 | home_page: index 5 | markdown_exts: 6 | - codehilite 7 | - toc 8 | - pykwiki.ext.tpl 9 | - pykwiki.ext.post 10 | postlist: 11 | max_pages: 5 12 | order_field: mtime 13 | order_type: descending 14 | per_page: 2 15 | post_type: preview 16 | rss_max_entries: 20 17 | site: 18 | author: Nullism 19 | base_url: http://pycnic.nullism.com 20 | description: Pycnic, a tinsy web framework for JSON APIs. 21 | keywords: pycnic, python web frameworks, microframework, API 22 | title: Pycnic Docs 23 | source_ext: .md 24 | style: dark 25 | theme: null 26 | time_format: '%H:%M:%S' 27 | timestamp_format: '%Y-%m-%d %H:%M' 28 | upload_exts: 29 | - .gif 30 | - .jpg 31 | - .jpeg 32 | - .png 33 | - .tiff 34 | - .pdf 35 | version: 2 36 | web_prefix: /docs 37 | -------------------------------------------------------------------------------- /examples/cors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from pycnic.core import WSGI, Handler 3 | 4 | class CorsTest(Handler): 5 | 6 | def get(self): 7 | return { 8 | "message": "Cross site works!", 9 | "origin": self.request.get_header("Origin") 10 | } 11 | 12 | def options(self): 13 | return { 14 | "GET": { 15 | "description": "Test cross site origin", 16 | "parameters": None 17 | } 18 | } 19 | 20 | 21 | class app(WSGI): 22 | 23 | # This allows * on all Handlers 24 | headers = [("Access-Control-Allow-Origin", "*")] 25 | 26 | routes = [ 27 | ("/", CorsTest()), 28 | ] 29 | 30 | if __name__ == "__main__": 31 | 32 | from wsgiref.simple_server import make_server 33 | try: 34 | print("Serving on 0.0.0.0:8080...") 35 | make_server('0.0.0.0', 8080, app).serve_forever() 36 | except KeyboardInterrupt: 37 | pass 38 | print("Done") 39 | 40 | -------------------------------------------------------------------------------- /docs/source/response-class.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Response Class 3 | tags: [classes] 4 | ]] 5 | 6 | # Response Class 7 | 8 | The Response class contains data to be sent back to the client. 9 | 10 | :::python 11 | # cookies: A dictionary of cookies to be sent to the client. 12 | cookies = {} 13 | 14 | # set_header: A method that sets headers 15 | # to be sent back to the client 16 | set_header(self, key, value) 17 | 18 | # set_cookie: A method that sets cookies 19 | # to be sent back to the client with 20 | # Set-Cookie 21 | set_cookie(self, key, value, expires='', path='/', domain='') 22 | 23 | # delete_cookie: A method that tells the client to 24 | # overwrite a cookie with a "bad" value. 25 | delete_cookie(self, key) 26 | 27 | # status_code: The integer HTTP code to return to the client 28 | status_code = 200 29 | 30 | # status: A read only property that returns a string 31 | # of the current self.status_code 32 | status = "200 OK" 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /examples/json-encoder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from pycnic.core import WSGI, Handler 3 | import datetime 4 | import json 5 | 6 | class DateTimeEncoder(json.JSONEncoder): 7 | def default(self, o): 8 | if isinstance(o, datetime.datetime): 9 | return o.isoformat() 10 | 11 | return json.JSONEncoder.default(self, o) 12 | 13 | class Hello(Handler): 14 | 15 | def get(self, name="World"): 16 | return { 17 | "message": "Hello, {name}!".format(name=name), 18 | "date": datetime.datetime.now() 19 | } 20 | 21 | 22 | class app(WSGI): 23 | debug = True 24 | json_cls = DateTimeEncoder 25 | routes = [ 26 | ("/", Hello()), 27 | ("/([\w]+)", Hello()) 28 | ] 29 | 30 | if __name__ == "__main__": 31 | from wsgiref.simple_server import make_server 32 | try: 33 | print("Serving on 0.0.0.0:8080...") 34 | make_server('0.0.0.0', 8080, app).serve_forever() 35 | except KeyboardInterrupt: 36 | pass 37 | print("Done") 38 | 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | import os 4 | import sys 5 | 6 | os.chdir(os.path.dirname(os.path.abspath(__file__))) 7 | sys.path.insert(0, os.path.dirname(__file__)) 8 | from pycnic import __version__ 9 | 10 | setup(name='pycnic', 11 | version = __version__, 12 | description = 'A simple, ultra light-weight, pure-python RESTful JSON API framework.', 13 | long_description = ( 14 | 'Pycnic offers a fully WSGI compliant JSON only web framework for ' 15 | 'quickly creating fast, modern web applications ' 16 | 'based on AJAX. Static files are served over a CDN or ' 17 | 'with a standard webserver, like Apache.'), 18 | author = 'Aaron Meier', 19 | author_email = 'webgovernor@gmail.com', 20 | packages = ['pycnic'], 21 | package_dir={'pycnic':'pycnic'}, 22 | url = 'http://pycnic.nullism.com', 23 | license = 'MIT', 24 | entry_points={"console_scripts": ["pycnic = pycnic.cli:main"]}, 25 | install_requires = [], 26 | provides = ['pycnic'] 27 | ) 28 | 29 | -------------------------------------------------------------------------------- /examples/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine, Column, Integer, String, ForeignKey 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker, relationship, backref 4 | import hashlib 5 | import time 6 | 7 | engine = create_engine('sqlite:///:memory:', echo=True) 8 | Base = declarative_base() 9 | Session = sessionmaker(bind=engine) 10 | 11 | def get_new_session_id(): 12 | """ Generates a sha1 session id """ 13 | 14 | to_hash = "%s_SECRET"%(time.time()) 15 | return hashlib.sha1(to_hash.encode('utf-8')).hexdigest() 16 | 17 | class UserSession(Base): 18 | 19 | __tablename__ = "user_sessions" 20 | id = Column(Integer, primary_key=True) 21 | session_id = Column(String, 22 | default=get_new_session_id, onupdate=get_new_session_id) 23 | user_id = Column(Integer, ForeignKey('users.id')) 24 | user = relationship("User", backref=backref("sessions"), order_by=id) 25 | 26 | class User(Base): 27 | __tablename__ = "users" 28 | id = Column(Integer, primary_key=True) 29 | username = Column(String) 30 | password = Column(String) 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Nullism 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Introduction 3 | description: Pycnic, the JSON API framework 4 | timestamp: 2015-10-01 20:22 5 | ]] 6 | 7 | # What it be? 8 | 9 | Pycnic is a small, fast, easy to use web framework for building JSON APIs. 10 | 11 | # What it do? 12 | 13 | Pycnic handles routing, JSON requests and responses, cookie magic, and provides 14 | jsonified error handling. 15 | 16 | # Get it 17 | 18 | GitHub: [github.com/nullism/pycnic](https://github.com/nullism/pycnic) 19 | 20 | PyPI: [pypy.python.org/pypi/pycnic](https://pypi.python.org/pypi/pycnic) 21 | 22 | Or with pip: `pip install pycnic` 23 | 24 | # Quick sample 25 | 26 | :::python 27 | # from hello.py 28 | from pycnic.core import WSGI, Handler 29 | 30 | class Hello(Handler): 31 | 32 | def get(self, name="World"): 33 | return { 34 | "message": "Hello, {name}!".format(name=name) 35 | } 36 | 37 | 38 | class app(WSGI): 39 | routes = [ 40 | ("/", Hello()), 41 | ("/([\w]+)", Hello()) 42 | ] 43 | 44 | 45 | To run the example, just point some WSGI server at it, like [Gunicorn](http://gunicorn.org). 46 | 47 | :::bash 48 | gunicorn hello:app 49 | 50 | -------------------------------------------------------------------------------- /docs/links.yaml: -------------------------------------------------------------------------------- 1 | - Intro: 2 | post: index 3 | - Getting Started: 4 | post: getting-started 5 | - Docs: 6 | children: 7 | - Examples: 8 | children: 9 | - Request Data: 10 | post: example-request 11 | - Error Handling: 12 | post: example-errors 13 | - Auth Wrapper: 14 | post: example-auth-wrapper 15 | - Full Authentication: 16 | post: example-full-auth 17 | - Cookies: 18 | post: example-cookies 19 | - Validation: 20 | post: example-validation 21 | - CORS: 22 | post: example-cors 23 | - Classes: 24 | children: 25 | - WSGI: 26 | post: wsgi-class 27 | - Request: 28 | post: request-class 29 | - Response: 30 | post: response-class 31 | - Serving: 32 | children: 33 | - WSGI Servers: 34 | post: wsgi-servers 35 | - CLI: 36 | children: 37 | - Routes: 38 | post: cli-routes 39 | - Download: 40 | post: download 41 | - Support: 42 | post: support 43 | - Benchmarks: 44 | post: benchmarks 45 | -------------------------------------------------------------------------------- /examples/auth-wrapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from functools import wraps 3 | from pycnic.core import WSGI, Handler 4 | from pycnic.errors import HTTP_401 5 | 6 | def get_user_role(request): 7 | # Normally you'd do something like 8 | # check for request.cookies["session_id"] 9 | # existing in memcache or a database, but 10 | # for now... everyone's an admin! 11 | return "admin" 12 | 13 | def requires_roles(*roles): 14 | def wrapper(f): 15 | @wraps(f) 16 | def wrapped(*args, **kwargs): 17 | if get_user_role(args[0].request) not in roles: 18 | raise HTTP_401("I can't let you do that") 19 | return f(*args, **kwargs) 20 | return wrapped 21 | return wrapper 22 | 23 | class UserHandler(Handler): 24 | 25 | @requires_roles("admin", "user") 26 | def get(self): 27 | return { "message":"Welcome, admin!" } 28 | 29 | @requires_roles("admin") 30 | def post(self): 31 | self.response.status_code = 201 32 | return { "message":"New user added!" } 33 | 34 | class app(WSGI): 35 | routes = [ ('/user', UserHandler()) ] 36 | 37 | if __name__ == "__main__": 38 | 39 | from wsgiref.simple_server import make_server 40 | try: 41 | print("Serving on 0.0.0.0:8080...") 42 | make_server('0.0.0.0', 8080, app).serve_forever() 43 | except KeyboardInterrupt: 44 | pass 45 | print("Done") 46 | -------------------------------------------------------------------------------- /examples/test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from pycnic.core import WSGI, Handler 3 | from pycnic.errors import HTTPError 4 | 5 | 6 | 7 | def before(handler): 8 | if handler.request.ip in ['1.2.3.4', '5.6.7.8']: 9 | raise HTTPError(401, "Hey! You're banned from here.") 10 | 11 | class IndexHandler(Handler): 12 | 13 | def get(self, name="Nobody"): 14 | return { 15 | "message":"How you is, %s?"%(name), 16 | "path":self.request.path, 17 | "requestBody":self.request.body, 18 | "status":self.response.status, 19 | "clientIp":self.request.ip, 20 | "cookies":self.request.cookies, 21 | "method":self.request.method, 22 | "args":self.request.args, 23 | "json_args":self.request.json_args, 24 | } 25 | 26 | def post(self, name="Nobody"): 27 | data = self.request.data 28 | self.response.status_code = 201 29 | return self.get(name) 30 | 31 | class application(WSGI): 32 | routes = [ 33 | ("/", IndexHandler()), 34 | ("/name/(.*)", IndexHandler()) 35 | ] 36 | debug = True 37 | before = before 38 | 39 | if __name__ == "__main__": 40 | 41 | from wsgiref.simple_server import make_server 42 | try: 43 | print("Serving on 0.0.0.0:8080...") 44 | make_server('0.0.0.0', 8080, application).serve_forever() 45 | except KeyboardInterrupt: 46 | pass 47 | print("Done") 48 | 49 | -------------------------------------------------------------------------------- /pycnic/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from functools import wraps 4 | 5 | from . import errors 6 | 7 | if sys.version_info >= (3, 0): 8 | from urllib.parse import parse_qsl 9 | else: 10 | from cgi import parse_qsl 11 | 12 | 13 | def query_string_to_dict(qs): 14 | """ Returns a dictionary from a QUERY_STRING """ 15 | 16 | pairs = parse_qsl(qs) 17 | if pairs: 18 | return dict(pairs) 19 | return {} 20 | 21 | 22 | def query_string_to_json(qs): 23 | """ Returns a dictionary from a QUERY_STRING with JSON in it """ 24 | 25 | if "json=" not in qs: 26 | return {} 27 | data = query_string_to_dict(qs) 28 | return json.loads(data.get("json", "{}")) 29 | 30 | 31 | def requires_validation(validator, with_route_params=False): 32 | """ Validates an incoming request over given validator. 33 | If with_route_params is set to True, validator is called with request 34 | data and args taken from route, otherwise only request data is 35 | passed to validator. If validator raises any Exception, HTTP_400 is raised. 36 | """ 37 | def wrapper(f): 38 | @wraps(f) 39 | def wrapped(*args, **kwargs): 40 | try: 41 | if with_route_params: 42 | validator(args[0].request.data, *args[1:]) 43 | else: 44 | validator(args[0].request.data) 45 | except Exception as e: 46 | raise errors.HTTP_400(str(e)) 47 | 48 | return f(*args, **kwargs) 49 | return wrapped 50 | return wrapper 51 | -------------------------------------------------------------------------------- /docs/source/example-validation.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Example validation 3 | timestamp: 2015-10-03 23:02 4 | tags: [examples] 5 | ]] 6 | 7 | # Example Validation 8 | 9 | ## Using before 10 | 11 | The traditional way to handle validation is implementing 12 | a `before` method on the handler. 13 | 14 | :::python 15 | from pycnic.core import Handler, WSGI 16 | from pycnic.errors import HTTP_400 17 | 18 | class NameHandler(Handler): 19 | 20 | def before(self): 21 | if 'name' not in self.request.data \ 22 | or self.request.data['name'] != 'root': 23 | raise HTTP_400("Expected 'root' as name") 24 | 25 | def post(self): 26 | return {"status": "ok"} 27 | 28 | class app(WSGI): 29 | routes = [('/name', NameHandler())] 30 | 31 | 32 | ## Using requires_validation decorator 33 | 34 | As of `Pycnic v0.1.0` a Validation decorator is included 35 | that accepts a validator function and re-raises an `HTTP_400` 36 | error is that function raises any errors. 37 | 38 | 39 | :::python 40 | from pycnic.core import Handler, WSGI 41 | from pycnic.utils import requires_validation 42 | 43 | 44 | def has_proper_name(data): 45 | if 'name' not in data or data['name'] != 'root': 46 | raise ValueError('Expected \'root\' as name') 47 | 48 | 49 | class NameHandler(Handler): 50 | 51 | @requires_validation(has_proper_name) 52 | def post(self): 53 | return {'status': 'ok'} 54 | 55 | 56 | class app(WSGI): 57 | routes = [('/name', NameHandler())] 58 | 59 | -------------------------------------------------------------------------------- /docs/source/example-cors.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Working with CORS 3 | timestamp: 2017-10-29 12:30 4 | tags: [examples] 5 | ]] 6 | 7 | # Working with CORS 8 | 9 | Traditionally, to enable CORS on a resource, you simply need to ensure that the 10 | `Access-Control-Allow-Origin` header is set for the domains that you would like to 11 | enable access from. 12 | 13 | See [MDN Cross-Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) for more information about the header. 14 | 15 | 16 | 17 | ## Full example 18 | 19 | :::python 20 | #!/usr/bin/env python3 21 | from pycnic.core import WSGI, Handler 22 | 23 | class CorsTest(Handler): 24 | 25 | def get(self): 26 | return { 27 | "message": "Cross site works!", 28 | "origin": self.request.get_header("Origin") 29 | } 30 | 31 | def options(self): 32 | return { 33 | "GET": { 34 | "description": "Test cross site origin", 35 | "parameters": None 36 | } 37 | } 38 | 39 | 40 | class app(WSGI): 41 | 42 | # This allows * on all Handlers 43 | headers = [("Access-Control-Allow-Origin", "*")] 44 | 45 | routes = [ 46 | ("/", CorsTest()), 47 | ] 48 | 49 | if __name__ == "__main__": 50 | 51 | from wsgiref.simple_server import make_server 52 | try: 53 | print("Serving on 0.0.0.0:8080...") 54 | make_server('0.0.0.0', 8080, app).serve_forever() 55 | except KeyboardInterrupt: 56 | pass 57 | print("Done") 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /pycnic/errors.py: -------------------------------------------------------------------------------- 1 | from .data import STATUSES 2 | 3 | 4 | class PycnicError(Exception): 5 | pass 6 | 7 | 8 | class HTTPError(PycnicError): 9 | status_code = 0 10 | status = None 11 | message = None 12 | data = None 13 | headers = None 14 | 15 | def __init__(self, status_code, message, data=None, headers=[]): 16 | if self.status_code: 17 | status_code = self.status_code 18 | self.status_code = status_code 19 | self.status = STATUSES[status_code] 20 | self.message = message 21 | self.data = data 22 | if headers: 23 | self.headers = headers 24 | 25 | def response(self): 26 | return { 27 | "status": self.status, 28 | "status_code": self.status_code, 29 | "error": self.message, 30 | "data": self.data 31 | } 32 | 33 | 34 | class HTTPNumeric(HTTPError): 35 | 36 | status_code = 0 37 | 38 | def __init__(self, message, data=None, headers=[]): 39 | super(HTTPError, self).__init__(self.status_code, message, data, headers) 40 | self.status = STATUSES[self.status_code] 41 | self.message = message 42 | self.data = data 43 | self.headers = headers 44 | 45 | 46 | class HTTP_400(HTTPNumeric): 47 | status_code = 400 48 | 49 | 50 | class HTTP_401(HTTPNumeric): 51 | status_code = 401 52 | 53 | 54 | class HTTP_403(HTTPNumeric): 55 | status_code = 403 56 | 57 | 58 | class HTTP_404(HTTPNumeric): 59 | status_code = 404 60 | 61 | 62 | class HTTP_405(HTTPNumeric): 63 | status_code = 405 64 | 65 | 66 | class HTTP_408(HTTPNumeric): 67 | status_code = 408 68 | 69 | 70 | class HTTP_500(HTTPNumeric): 71 | status_code = 500 72 | -------------------------------------------------------------------------------- /docs/source/example-auth-wrapper.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Example Authentication Wrapper 3 | timestamp: 2015-11-03 23:45 4 | tags: [examples] 5 | ]] 6 | 7 | # Example Authentication Wrapper 8 | 9 | Handling authentication with a wrapper just *feels* clean. 10 | 11 | :::python 12 | from functools import wraps 13 | from pycnic.core import WSGI, Handler 14 | from pycnic.errors import HTTP_401 15 | 16 | def get_user_role(request): 17 | # Normally you'd do something like 18 | # check for request.cookies["session_id"] 19 | # existing in memcache or a database, but 20 | # for now... everyone's an admin! 21 | return "admin" 22 | 23 | def requires_roles(*roles): 24 | def wrapper(f): 25 | @wraps(f) 26 | def wrapped(*args, **kwargs): 27 | if get_user_role(args[0].request) not in roles: 28 | raise HTTP_401("I can't let you do that") 29 | return f(*args, **kwargs) 30 | return wrapped 31 | return wrapper 32 | 33 | class UserHandler(Handler): 34 | 35 | @requires_roles("admin", "user") 36 | def get(self): 37 | return { "message":"Welcome, admin!" } 38 | 39 | @requires_roles("admin") 40 | def post(self): 41 | self.response.status_code = 201 42 | return { "message":"New user added!" } 43 | 44 | class app(WSGI): 45 | routes = [ ('/user', UserHandler()) ] 46 | 47 | As you can see from above, either a user or admin can perform a GET request 48 | against the `UserHandler`, but only an admin can POST to it. 49 | 50 | In that example, `get_user_role()` is a dummy function that blindly returns 51 | "admin" for each user. 52 | 53 | 54 | -------------------------------------------------------------------------------- /docs/source/wsgi-servers.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: WSGI Server Examples 3 | description: Webservers and running Pycnic 4 | timestamp: 2017-07-14 05:22 5 | ]] 6 | 7 | [TOC] 8 | 9 | # WSGI Server Examples 10 | 11 | Here's a collection of examples using various WSGI servers to run a Pycnic app. 12 | 13 | These examples assume you have an application in `hello.py` with a main WSGI class called `app`. 14 | 15 | :::python 16 | from pycnic.core import WSGI, Handler 17 | 18 | class Hello(Handler): 19 | 20 | def get(self, name="World"): 21 | return { 22 | "message": "Hello, {name}!".format(name=name) 23 | } 24 | 25 | class app(WSGI): 26 | routes = [ 27 | ("/", Hello()), 28 | ("/([\w]+)", Hello()) 29 | ] 30 | 31 | ## [Gunicorn](http://gunicorn.org) 32 | 33 | ### Install 34 | 35 | :::text 36 | pip install gunicorn 37 | 38 | ### Run 39 | 40 | :::text 41 | gunicorn -b 0.0.0.0:8080 hello:app 42 | 43 | ## [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/WSGIquickstart.html) 44 | 45 | 46 | ### Install 47 | 48 | :::text 49 | pip install uwsgi 50 | 51 | ### Run 52 | 53 | :::text 54 | uwsgi --http 0.0.0.0:8080 --module hello:app 55 | 56 | ## [wsgiref](https://docs.python.org/2/library/wsgiref.html) 57 | 58 | ### Install 59 | 60 | :::text 61 | pip install wsgiref 62 | 63 | ### Run 64 | 65 | First, in `hello.py` add 66 | 67 | :::python 68 | if __name__ == "__main__": 69 | from wsgiref.simple_server import make_server 70 | print("Serving on 0.0.0.0:8080...") 71 | make_server('0.0.0.0', 8080, app).serve_forever() 72 | 73 | Then execute that script 74 | 75 | :::text 76 | python ./hello.py 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /docs/source/request-class.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Request class 3 | tags: [classes] 4 | ]] 5 | 6 | # Request Class 7 | 8 | The request instance provided in self.request contains the following properties. 9 | 10 | :::python 11 | # cookies: A dictionary of client cookies 12 | # in the format of { cookie name: cookie value } 13 | cookies = {} 14 | 15 | # headers: A dictionary of title-case headers 16 | # in the format of { Header-Name: value } 17 | # Example: headers['Content-Length'] 18 | headers = {} 19 | 20 | # get_header: A method to return a header in self.headers 21 | # or default if it is not set. 22 | get_header(self, name, default=None) 23 | 24 | # method: The request method, upper case. 25 | # may be GET, POST, PUT, DELETE, OPTIONS 26 | method = None 27 | 28 | # ip: The IP address of the client. If a X-Forwarded-For IP exists, 29 | # it will use that instead (for proxies) 30 | ip = None 31 | 32 | # body: The raw request body as read from wsgi.input 33 | # This relies on a content-length header being present. 34 | body = None 35 | 36 | # data: A dictionary built from JSON in the request body. 37 | # If the body does not contain valid json when this property 38 | # is accessed it will raise an HTTP_400 error. 39 | data = {} 40 | 41 | # args: A dictionary built from the query string parameters. 42 | args = {} 43 | 44 | # json_args: A dictionary built from "json=" in the query string. 45 | # If the query string does not contain "json=" then this will be {}. 46 | # If the query string contains "json=" but it is invalid JSON, then 47 | # this will raise an HTTP_400 error. 48 | json_args = {} 49 | 50 | # environ: The WSGI environ dictionary 51 | environ = wsgi.environ 52 | 53 | 54 | -------------------------------------------------------------------------------- /docs/source/wsgi-class.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: WSGI Class 3 | tags: [configuration, classes] 4 | ]] 5 | 6 | # WSGI Class 7 | 8 | ## Configuration 9 | 10 | The WSGI class contains several configuration properties for use in your `app` subclass. 11 | 12 | :::python 13 | class app(pycnic.core.WSGI): 14 | 15 | # logger: A Python logging Logger instance 16 | logger = logging.Logger(__name__) 17 | 18 | # before: A function that accepts a WSGI instance 19 | # as its first argument. Executed before dispatching 20 | # the request. Does not need to return. 21 | before = None 22 | 23 | # after: A function that accepts a WSGI instance 24 | # as its first argument. Executed after dispatching 25 | # the request if no errors are raised. Does not need to return. 26 | after = None 27 | 28 | # teardown: A function that accepts a WSGI instance 29 | # as its argument. Always executed at the end of the request 30 | # even if errors are raised. 31 | # New in v0.1.1 32 | teardown = None 33 | 34 | # headers: default headers to add to the response 35 | # on every successful request, or when an HTTP* error 36 | # is raised. In the format of [("key", "value"),("key", "value")] 37 | # New in v0.1.1 38 | headers = None 39 | 40 | # debug: Boolean. If true, will return error stacktraces 41 | # for uncaught exceptions and json will be pretty-formatted. 42 | debug = False 43 | 44 | # strip_path: Boolean. If true, paths with a trailing 45 | # slash (/foo/bar/) will have the trailing slash removed 46 | # (/foo/bar) before routing the request 47 | strip_path = True 48 | 49 | # routes: A list of (path, Handler()) tuples. 50 | routes = [] 51 | 52 | # json_cls: An optional custom json response encoder, new in v0.1.3 53 | json_cls = None 54 | 55 | 56 | -------------------------------------------------------------------------------- /examples/cookies.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys; sys.path.insert(0, '../') 3 | from pycnic.core import WSGI, Handler 4 | from pycnic.errors import HTTP_400, HTTP_401 5 | 6 | class Login(Handler): 7 | 8 | def get(self): 9 | sess_id = self.request.cookies.get("session_id") 10 | return { "session_id":sess_id } 11 | 12 | def post(self): 13 | 14 | username = self.request.data.get("username") 15 | password = self.request.data.get("password") 16 | 17 | if not username or not password: 18 | raise HTTP_400("Username and password are required") 19 | 20 | if username != "foo" or password != "foo": 21 | raise HTTP_401("Username or password are incorrect") 22 | 23 | # Set a session ID for lookup in a DB or memcache 24 | self.response.set_cookie("session_id", "1234567890abcdefg") 25 | return { "message":"Logged in" } 26 | 27 | class Logout(Handler): 28 | 29 | def post(self): 30 | 31 | if self.request.cookies.get("session_id"): 32 | # Clear the cookie 33 | self.response.delete_cookie("session_id") 34 | return { "message":"Logged out" } 35 | 36 | return { 37 | "message":"Already logged out", 38 | "cookies":self.request.cookies 39 | } 40 | 41 | class TestSetCookie(Handler): 42 | 43 | def get(self): 44 | self.response.set_cookie( 45 | "TestCookie", "12345", path="/test", flags=["HttpOnly", "ExtraFlag"]) 46 | return { 47 | "message": "This is a set_cookie test. Check developer tools to see the Set-Cookie string", 48 | } 49 | 50 | class app(WSGI): 51 | routes = [ 52 | ("/login", Login()), 53 | ("/logout", Logout()), 54 | ("/test", TestSetCookie()) 55 | ] 56 | 57 | if __name__ == "__main__": 58 | 59 | from wsgiref.simple_server import make_server 60 | try: 61 | print("Serving on 0.0.0.0:8080...") 62 | make_server('0.0.0.0', 8080, app).serve_forever() 63 | except KeyboardInterrupt: 64 | pass 65 | print("Done") 66 | 67 | -------------------------------------------------------------------------------- /docs/source/getting-started.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Getting started 3 | timestamp: 2015-10-03 22:19 4 | ]] 5 | 6 | [TOC] 7 | 8 | # Getting Started 9 | 10 | ## Install 11 | 12 | The easiest way to install Pycnic is though `pip`. 13 | 14 | pip install pycnic 15 | 16 | Or, for Python 3: 17 | 18 | pip3 install pycnic 19 | 20 | Now Pycnic is ready to be used. 21 | 22 | ## Making an App 23 | 24 | Let's create a new file called `quote.py` 25 | 26 | :::python 27 | # quote.py 28 | from pycnic.core import WSGI, Handler 29 | 30 | class QuoteRes(Handler): 31 | def get(self): 32 | return { 33 | "quote":"Cool URIs don't change", 34 | "author":"Tim Berners-Lee" 35 | } 36 | 37 | class app(WSGI): 38 | routes = [('/', QuoteRes())] 39 | 40 | The basic structure of this app is as follows: 41 | 42 | 1. The `QuoteRes` subclass of `Handler`. This exposes `get, post, put, delete`, and `options` methods 43 | of your subclass to the client if those methods are implemented. For now, we only care about `get`. 44 | 2. The `app` subclass of `WSGI`. This is a wsgi class with some configuration options. 45 | For now, we only care about routing '/' to `QuoteRes`. 46 | 47 | ## Running an App 48 | 49 | Since Pycnic is WSGI compliant, running this app can be done a number of ways. 50 | 51 | For this example, let's use [Gunicorn](http://gunicorn.org). 52 | 53 | ### Installing Gunicorn 54 | 55 | Gunicorn is available in the Python Package Index, so it can be installed with 56 | 57 | pip install gunicorn 58 | 59 | Or, for Python 3 60 | 61 | pip3 install gunicorn 62 | 63 | ### Hosting with Gunicorn 64 | 65 | In the same directory as `quote.py`, run 66 | 67 | gunicorn quote:app 68 | 69 | Your app should now be hosted. 70 | 71 | :::text 72 | [2015-11-03 13:03:09 -0500] [7292] [INFO] Starting gunicorn 19.3.0 73 | [2015-11-03 13:03:09 -0500] [7292] [INFO] Listening at: http://127.0.0.1:8000 (7292) 74 | ... 75 | 76 | If you visit [http://localhost:8000/](http://localhost:8000/) you should see a response like the following: 77 | 78 | :::json 79 | {"quote": "Cool URIs don't change", "author": "Tim Berners-Lee"} 80 | -------------------------------------------------------------------------------- /pycnic/data.py: -------------------------------------------------------------------------------- 1 | STATUSES = { 2 | 200: "200 OK", 3 | 201: "201 Created", 4 | 202: "202 Accepted", 5 | 204: "204 No Content", 6 | 206: "206 Partial Content", 7 | 207: "207 Multi-Status", 8 | 208: "208 Already Reported", 9 | 226: "226 IM Used", 10 | 300: "300 Multiple Choices", 11 | 301: "301 Moved Permanently", 12 | 302: "302 Found", 13 | 303: "303 See Other", 14 | 304: "304 Not Modified", 15 | 305: "305 Use Proxy", 16 | 307: "307 Temporary Redirect", 17 | 308: "308 Permanent Redirect", 18 | 400: "400 Bad Request", 19 | 401: "401 Unauthorized", 20 | 402: "402 Payment Required", 21 | 403: "403 Forbidden", 22 | 404: "404 Not Found", 23 | 405: "405 Method Not Allowed", 24 | 406: "406 Not Acceptable", 25 | 407: "407 Proxy Authentication Required", 26 | 408: "408 Request Timeout", 27 | 409: "409 Conflict", 28 | 410: "410 Gone", 29 | 411: "411 Length Required", 30 | 412: "412 Precondition Failed", 31 | 413: "413 Payload Too Large", 32 | 414: "414 URI Too Long", 33 | 415: "415 Unsupported Media Type", 34 | 416: "416 Range Not Satisfiable", 35 | 417: "417 Expectation Failed", 36 | 421: "421 Misdirected Request", 37 | 422: "422 Unprocessable Entity", 38 | 423: "423 Locked", 39 | 424: "424 Failed Dependency", 40 | 425: "425 Too Early", 41 | 426: "426 Upgrade Required", 42 | 428: "428 Precondition Required", 43 | 429: "429 Too Many Requests", 44 | 431: "431 Request Header Fields Too Large", 45 | 451: "451 Unavailable For Legal Reasons", 46 | 500: "500 Internal Server Error", 47 | 501: "501 Not Implemented", 48 | 502: "502 Bad Gateway", 49 | 503: "503 Service Unavailable", 50 | 504: "504 Gateway Timeout", 51 | 505: "505 HTTP Version Not Supported", 52 | 506: "506 Variant Also Negotiates", 53 | 507: "507 Insufficient Storage", 54 | 508: "508 Loop Detected", 55 | 510: "510 Not Extended", 56 | 577: "577 Unknown Status", 57 | } 58 | -------------------------------------------------------------------------------- /examples/overrides.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from pycnic.core import WSGI, Handler 3 | import logging 4 | import sys 5 | 6 | my_logger = logging.Logger(__name__) 7 | my_logger.setLevel(logging.DEBUG) 8 | hnd = logging.StreamHandler(sys.stdout) 9 | my_logger.addHandler(hnd) 10 | 11 | """ 12 | overrides.py 13 | 14 | This example includes special methods, functions, and properties 15 | with example usages. 16 | """ 17 | 18 | def my_before(handler): 19 | 20 | my_logger.info("Before, request IP is %s"%(handler.request.ip)) 21 | 22 | def my_after(handler): 23 | 24 | my_logger.info("After, headers are %s"%(handler.response.headers)) 25 | 26 | class Howdy(Handler): 27 | 28 | def before(self): 29 | """ Called before the request is routed """ 30 | 31 | my_logger.info("Howdy before called") 32 | 33 | def after(self): 34 | """ Called after the request is routed """ 35 | 36 | my_logger.info("Howdy after called") 37 | 38 | def get(self): 39 | 40 | assert self.request.method == "GET" 41 | return {} 42 | 43 | def post(self): 44 | 45 | assert self.request.method == "POST" 46 | return {} 47 | 48 | def put(self): 49 | 50 | assert self.request.method == "PUT" 51 | return {} 52 | 53 | def delete(self): 54 | 55 | assert self.request.method == "DELETE" 56 | return {} 57 | 58 | class app(WSGI): 59 | 60 | # A method name to call before the request is routed 61 | # default: None 62 | before = my_before 63 | 64 | # A method name to call after the request is routed 65 | # default: None 66 | after = my_after 67 | 68 | # Assign a custom logger, default is logging.Logger 69 | logger = my_logger 70 | 71 | # Set debug mode, default False 72 | debug = True 73 | 74 | # Remove the trailing slash for routing purposes 75 | # default: True 76 | strip_path = True 77 | 78 | # A list of routes, handler instances 79 | routes = [ 80 | ('/', Howdy()), 81 | ] 82 | 83 | if __name__ == "__main__": 84 | 85 | from wsgiref.simple_server import make_server 86 | try: 87 | print("Serving on 0.0.0.0:8080...") 88 | make_server('0.0.0.0', 8080, app).serve_forever() 89 | except KeyboardInterrupt: 90 | pass 91 | print("Done") 92 | 93 | -------------------------------------------------------------------------------- /docs/source/cli-routes.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: CLI Route Introspection 3 | tags: [cli, examples] 4 | ]] 5 | 6 | # CLI Routes 7 | 8 | ## Purpose 9 | 10 | The `pycnic routes` CLI tool can be used for route introspection for pycnic 11 | applications. It lists all the routes that it can identify from the 12 | `pycnic.core.WSGI.routes` list, as well as the methods that they support, and 13 | the fully qualified handler object names. 14 | 15 | ## Usage 16 | 17 | `pycnic routes [-sort {alpha,class,method}] [--verbose] routes [path]` 18 | 19 | -sort {alpha,class,method} Sorting method to use, if any. 20 | Alpha sorts by route regex, alphanumerically. 21 | Class sorts by handler object names, alphanu- 22 | merically. 23 | Method groups supported request methods to- 24 | gether. 25 | 26 | --verbose, -v Provides verbose output about routes by dis- 27 | playing their handler's docstring, if it has 28 | one. 29 | 30 | path Python module-like path to describe what class 31 | to print the routes of. Formatted as 32 | path.to.file:class. For example, api.main:app 33 | will access the app class in ./api/main.py. 34 | Defaults to main:app. 35 | 36 | ## Example 37 | 38 | pycnic@pycnic:~/git/my_app $ ls 39 | LICENSE.md README.md main.py routes.py util.py 40 | pycnic@pycnic:~/git/my_app $ 41 | pycnic@pycnic:~/git/my_app $ pycnic routes main:app 42 | Route Method Class 43 | 44 | / GET main.Index 45 | /ping GET main.Ping 46 | /login POST routes.Login 47 | /logout POST routes.Logout 48 | pycnic@pycnic:~/git/my_app $ 49 | pycnic@pycnic:~/git/my_app $ pycnic routes -sort method main:app 50 | Method Route Class 51 | 52 | GET / main.Index 53 | GET /ping main.Ping 54 | 55 | POST /login routes.Login 56 | POST /logout routes.Logout 57 | pycnic@pycnic:~/git/my_app $ 58 | 59 | ## Requirements 60 | 61 | In order for route introspection to work, the application class being inspected 62 | must be a subclass of `pycnic.core.WSGI`. -------------------------------------------------------------------------------- /docs/source/example-request.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Example request data 3 | timestamp: 2015-10-03 23:02 4 | tags: [examples] 5 | ]] 6 | 7 | # Example of the request object 8 | 9 | The requests instance of the Request class contains information about... requests. 10 | 11 | :::python 12 | from pycnic.core import Handler, WSGI 13 | from pycnic.errors import HTTP_400 14 | 15 | class UsersHandler(Handler): 16 | 17 | def post(self): 18 | 19 | if not self.request.data.get("username"): 20 | raise HTTP_400("Yo dawg, you need to provide a username") 21 | 22 | return { 23 | "username":self.request.data["username"], 24 | "authID":self.request.cookies.get("auth_id"), 25 | "yourIp":self.request.ip, 26 | "rawBody":self.request.body, 27 | "method":self.request.method, 28 | "json":self.request.data, 29 | "args":self.request.args, 30 | "jsonArgs":self.request.json_args, 31 | # Alternatively, self.request.environ.get("HTTP_X_FORWARDED_FOR") 32 | "xForwardedFor":self.request.get_header("X-Forwarded-For") 33 | } 34 | 35 | class app(WSGI): 36 | routes = [ ("/user", UserHandler()) ] 37 | 38 | In the above example, several request properties are accessed. 39 | 40 | 1. `self.request.method` - One of GET, POST, PUT, DELETE, OPTIONS. 41 | 2. `self.request.data` - When this property is read, it attempts to read the request body into a Python dictionary by way of JSON. 42 | If a body exists and it can't be read as JSON, then this sends an HTTP 400 JSON error to the client. 43 | 3. `self.request.cookies` - A dictionary of client cookies available for this domain. 44 | 4. `self.request.ip` - The IP address of the user. Pycnic attempts to use HTTP_X_FORWARDED_FOR if it's available, otherwise it settles 45 | on REMOTE_ADDR. 46 | 5. `self.request.body` - This is the raw deal. The body contains everything that's not a header, read from wsgi.input. 47 | This assumes CONTENT_LENGTH header was set. 48 | 6. `self.request.args` - This is a dictionary of query string parameters. 49 | 7. `self.request.json_args` - This is a dictionary of a loaded json string from the query parameters. 50 | For example, a request to `/myurl?json=%7B%22foo%22%3A%22bar%22%7D` would populate `json_args` with `{"foo": "bar"}`. 51 | -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | # Framework Benchmarks 2 | 3 | **UPDATE** - Further research has helped me figure out how to run 4 | more of the tests in Gunicorn. Given that, I've decided to use 5 | two workers instead of just one for these tests. 6 | 7 | **UPDATE 2** - These benchmarks helped the author of Hug find a bug. 8 | As of version 1.8.2 it's *way* faster. 9 | 10 | The benchmarks perform the following framework-specific tasks. 11 | 12 | 1. Route '/json' to a method. 13 | 2. Encode the '/json' result as JSON. 14 | 3. Return JSON in the response body with "Content-Type: application/json". 15 | 16 | ## Requirements 17 | 18 | 1. The app MUST work under Python3. 19 | 20 | ## Running the tests 21 | 22 | You'll need to install all specified frameworks. Python 3 was used. 23 | 24 | pip3 install bobo bottle cherrypy hug falcon flask pycnic pyramid gunicorn roughrider.application 25 | 26 | Then: 27 | 28 | $ cd /path/to/pycnic/benchmarks 29 | $ ./runner.sh 30 | 31 | ## Methods 32 | 33 | All WSGI frameworks are hosted with Gunicorn (single worker). 34 | 35 | gunicorn -w 2 _test:app 36 | 37 | The actual testing is performed with `ab`. 38 | 39 | ab -n 5000 -c 5 http://localhost:8000/json 40 | 41 | ### Exceptions 42 | 43 | * **tornado** was hosted using the built-in IOLoop. 44 | * **muffin** was hosted with `muffin muffin_test run` due to various errors attempting to get it working with Gunicorn. 45 | * **morepath** was disqualified for numerous errors and complexity issues. Interestingly enough, it installs through pip but does not uninstall. 46 | 47 | ## Results 48 | 49 | [Chart](http://pycnic.nullism.com/images/pycnic-bench.png) 50 | 51 | Output from `runner.sh` (for tests working with WSGI): 52 | 53 | *Note: Falcon is running with Cython enabled, which provides a slight advantage* 54 | 55 | 56 | Test results: 57 | 58 | bobo_test: 59 | Requests per second: 2879.91 [#/sec] (mean) 60 | Complete requests: 5000 61 | falcon_test: 62 | Requests per second: 3354.99 [#/sec] (mean) 63 | Complete requests: 5000 64 | pycnic_test: 65 | Requests per second: 3183.22 [#/sec] (mean) 66 | Complete requests: 5000 67 | cherrypy_test: 68 | Requests per second: 1696.93 [#/sec] (mean) 69 | Complete requests: 5000 70 | pyramid_test: 71 | Requests per second: 2785.36 [#/sec] (mean) 72 | Complete requests: 5000 73 | flask_test: 74 | Requests per second: 2372.21 [#/sec] (mean) 75 | Complete requests: 5000 76 | bottle_test: 77 | Requests per second: 3084.96 [#/sec] (mean) 78 | Complete requests: 5000 79 | hug_test (1.8.2): 80 | Requests per second: 3146.27 [#/sec] (mean) 81 | Complete requests: 5000 82 | 83 | 84 | Manually running tests for the others: 85 | 86 | * **tornado** - 1341.86/sec, 3.762s 87 | * **muffin** - 1080.41/sec, 4.628s 88 | 89 | 90 | -------------------------------------------------------------------------------- /docs/source/example-cookies.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Working with Cookies 3 | timestamp: 2015-11-07 09:00 4 | tags: [examples] 5 | ]] 6 | 7 | # Working with Cookies 8 | 9 | Cookies can be accessed in the `request` and `response` objects. 10 | 11 | ## Read 12 | 13 | To read a cookie: 14 | 15 | :::python 16 | self.request.cookies # Dictionary of all client-cookies 17 | self.request.cookies.get("Cookie name") # get a specific cookie 18 | 19 | ## Set 20 | 21 | Setting a cookie only requires a key and value for the cookie. 22 | 23 | To set a cookie: 24 | 25 | :::python 26 | self.response.set_cookie( 27 | "Cookie name", 28 | "Cookie Value", 29 | expires="Optional time string, default: empty", 30 | domain="Optional domain, default: empty", 31 | path="Optional path, default: /", 32 | flags=["List", "OfFlags", "default: []"]) 33 | 34 | *Note: `flags` is available as of v0.1.2* 35 | 36 | If the expiration date is not specified then most browsers will 37 | treat this as a Session cookie, which lasts until "removed" by the server. 38 | 39 | ## Delete 40 | 41 | When a cookie is deleted, its value is replaced with **DELETED** and 42 | the expiration is set to *Jan 1st, 1970*. Pycnic ignores cookies 43 | that contain *DELETED* in the value. 44 | 45 | To delete a cookie: 46 | 47 | :::python 48 | self.response.delete_cookie("Cookie name") 49 | 50 | ## Full example 51 | 52 | :::python 53 | from pycnic.core import WSGI, Handler 54 | from pycnic.errors import HTTP_400, HTTP_401 55 | 56 | class Login(Handler): 57 | 58 | def get(self): 59 | 60 | sess_id = self.request.cookies.get("session_id") 61 | return { "session_id":sess_id } 62 | 63 | def post(self): 64 | 65 | username = self.request.data.get("username") 66 | password = self.request.data.get("password") 67 | 68 | if not username or not password: 69 | raise HTTP_400("Username and password are required") 70 | 71 | if username != "foo" or password != "foo": 72 | raise HTTP_401("Username or password are incorrect") 73 | 74 | # Set a session ID for lookup in a DB or memcache 75 | self.response.set_cookie("session_id", "1234567890abcdefg") 76 | return { "message":"Logged in" } 77 | 78 | class Logout(Handler): 79 | 80 | def post(self): 81 | 82 | if self.request.cookies.get("session_id"): 83 | # Clear the cookie 84 | self.response.delete_cookie("session_id") 85 | return { "message":"Logged out" } 86 | 87 | return { 88 | "message":"Already logged out", 89 | "cookies":self.request.cookies 90 | } 91 | 92 | class app(WSGI): 93 | routes = [ 94 | ("/login", Login()), 95 | ("/logout", Logout()), 96 | ] 97 | 98 | -------------------------------------------------------------------------------- /docs/source/example-errors.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Example Error Handling 3 | timestamp: 2015-11-05 22:21 4 | tags: [examples] 5 | ]] 6 | 7 | # Example error handling 8 | 9 | `pycnic.errors` provides several exception classes. 10 | 11 | * HTTPError - The parent error. 12 | * HTTP_400/401/403/404/405 - For 4xx client errors. 13 | * HTTP_500 - For 5xx server errors. 14 | 15 | The Pycnic framework catches these built-in exceptions and returns json describing the error (defined in the exception's `response` method). 16 | The `response` method returns a dictionary which is then encoded into JSON and sent back to the client. 17 | 18 | By default, any HTTPError subclass sends the following response to the client when the exception is caught: 19 | 20 | :::python 21 | { 22 | "status": self.status, 23 | "status_code": self.status_code, 24 | "error":self.message, 25 | "data":self.data 26 | } 27 | 28 | * `status` is a string like "404 Not Found". 29 | * `status_code` is the integer representation, like 404. 30 | * `error` is the error message from the exception. 31 | * `data` is a custom attribute that defaults to null. 32 | 33 | ## Using Exceptions 34 | 35 | Exceptions are wonderful and easy to use. 36 | Let's say you have a Handler that requires a message from the user on POST. 37 | If that message isn't present, you could manually set the status and 38 | return your own message, or you could do something like below: 39 | 40 | :::python 41 | class MessageHandler(Handler): 42 | def post(self): 43 | if not self.request.data.get("message"): 44 | raise HTTP_400("Message is required") 45 | return { 46 | "youSaid":self.request.data["message"] 47 | } 48 | 49 | ## Custom Exceptions 50 | 51 | Pycnic makes it easy to write and use your own exceptions. 52 | 53 | Let's say your application checks for authorization frequently. 54 | Instead of doing `raise HTTP_401("You can't do that, please login", data={"loginURI":"/login"})` each 55 | time a user isn't authorized, you can create your own exception class. 56 | 57 | :::python 58 | class AuthError(pycnic.errors.HTTPError): 59 | 60 | status_code = 401 # Required 61 | message = "You can't do that, please login" 62 | 63 | def __init__(self): 64 | pass 65 | 66 | def response(self): 67 | return { 68 | "error":self.message, 69 | "data": { "loginURI":"/login" }, 70 | "status_code":self.status_code, 71 | "status":"401 Not Authorized" 72 | } 73 | 74 | You can then raise that exception when appropriate. 75 | 76 | :::python 77 | class SomeHandler(Handler): 78 | def get(self): 79 | if not logged_in(self.request): 80 | raise AuthError() 81 | ... 82 | 83 | 84 | Which, if the user is not logged in, will return a 401 response with a JSON body of: 85 | 86 | :::json 87 | { 88 | "data": {"loginURI": "/login"}, 89 | "status": "401 Not Authorized", 90 | "error": "You can't do that, please login", 91 | "status_code": 401 92 | } 93 | -------------------------------------------------------------------------------- /examples/full-auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from functools import wraps 3 | from pycnic.core import WSGI, Handler 4 | from pycnic.errors import HTTP_401, HTTP_400 5 | # Import our SQLAlchemy stuff 6 | from models import User, UserSession, Session, Base, engine 7 | 8 | # Start the :memory: sqlite session 9 | db = Session() 10 | 11 | def get_user(request): 12 | """ Lookup a user session or return None if one doesn't exist """ 13 | 14 | sess_id = request.cookies.get("session_id") 15 | if not sess_id: 16 | return None 17 | sess = db.query(UserSession).filter( 18 | UserSession.session_id == sess_id).first() 19 | if not sess: 20 | return None 21 | if not sess.user: 22 | return None 23 | return { "username": sess.user.username } 24 | 25 | def requires_login(): 26 | """ Wrapper for methods that require login """ 27 | 28 | def wrapper(f): 29 | @wraps(f) 30 | def wrapped(*args, **kwargs): 31 | if not get_user(args[0].request): 32 | raise HTTP_401("I can't let you do that") 33 | return f(*args, **kwargs) 34 | return wrapped 35 | return wrapper 36 | 37 | class Home(Handler): 38 | """ Handler for a message with the user's name """ 39 | 40 | @requires_login() 41 | def get(self): 42 | user = get_user(self.request) 43 | return { "message":"Hello, %s"%(user.get("username")) } 44 | 45 | @requires_login() 46 | def post(self): 47 | return self.get() 48 | 49 | class Login(Handler): 50 | """ Create a session for a user """ 51 | 52 | def post(self): 53 | 54 | username = self.request.data.get("username") 55 | password = self.request.data.get("password") 56 | 57 | if not username or not password: 58 | raise HTTP_400("Please specify username and password") 59 | 60 | # See if a user exists with those params 61 | user = db.query(User).filter( 62 | User.username==username, 63 | User.password==password).first() 64 | if not user: 65 | raise HTTP_401("Invalid username or password") 66 | 67 | # Create a new session 68 | sess = UserSession( 69 | user_id=user.id) 70 | db.add(sess) 71 | self.response.set_cookie("session_id", sess.session_id) 72 | return { "message":"Logged in", "session_id":sess.session_id } 73 | 74 | class Logout(Handler): 75 | """ Clears a user's session """ 76 | 77 | def post(self): 78 | 79 | sess_id = self.request.cookies.get("session_id") 80 | if sess_id: 81 | # query to user sessions table 82 | sess = db.query(UserSession).filter( 83 | UserSession.session_id==sess_id).first() 84 | if sess: 85 | db.delete(sess) 86 | self.response.delete_cookie("session_id") 87 | return { "message":"logged out" } 88 | return { "message":"Not logged in" } 89 | 90 | class app(WSGI): 91 | routes = [ 92 | ('/home', Home()), 93 | ('/login', Login()), 94 | ('/logout', Logout()) 95 | ] 96 | 97 | if __name__ == "__main__": 98 | 99 | print("DB: Creating users table in memory...") 100 | Base.metadata.create_all(engine) 101 | 102 | print("DB: Adding users...") 103 | db.add_all([ 104 | User(username="foo", password="foo") 105 | ]) 106 | 107 | from wsgiref.simple_server import make_server 108 | try: 109 | print("Serving on 0.0.0.0:8080...") 110 | make_server('0.0.0.0', 8080, app).serve_forever() 111 | except KeyboardInterrupt: 112 | pass 113 | print("Done") 114 | -------------------------------------------------------------------------------- /docs/source/example-full-auth.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Full authentication example 3 | timestamp: 2015-11-08 21:00 4 | tags: [examples] 5 | ]] 6 | 7 | [TOC] 8 | 9 | # Full Authentication Example 10 | 11 | This example handles the following operations: 12 | 13 | 1. Create a database in memory with `users` and `user_sessions` tables. 14 | 2. Provides a `/login` handler which sets up a session. 15 | 3. Provides a `/home` handler which checks for a session. 16 | 4. Provides a `/logout` handler which deletes a session. 17 | 18 | ## Running the example 19 | 20 | This example uses SQLAlchemy, but the principles are the same with most ORMs. 21 | To run this specific example you will need SQLAlchemy installed, which 22 | can be done with `pip(3) install sqlalchemy` 23 | 24 | 1. Place `app.py` and `models.py` in the same directory. 25 | 2. Run `app.py` with your `python` command (2.7/3 should work). 26 | 3. Use a JSON API tool, such as Advanced Rest for Chrome, to perform the following calls: 27 | 1. `POST /login` with a payload of `{ "username":"foo", "password":"foo" }`. 28 | 2. `GET or POST /home`. You should see a message of "Hello, foo". 29 | 3. `POST /logout` 30 | 4. `GET or POST /home`. You should now see a 401 error. 31 | 32 | ## app.py 33 | 34 | :::python 35 | #!/usr/bin/env python3 36 | from functools import wraps 37 | from pycnic.core import WSGI, Handler 38 | from pycnic.errors import HTTP_401, HTTP_400 39 | # Import our SQLAlchemy stuff 40 | from models import User, UserSession, Session, Base, engine 41 | 42 | # Start the :memory: sqlite session 43 | db = Session() 44 | 45 | def get_user(request): 46 | """ Lookup a user session or return None if one doesn't exist """ 47 | 48 | sess_id = request.cookies.get("session_id") 49 | if not sess_id: 50 | return None 51 | sess = db.query(UserSession).filter( 52 | UserSession.session_id == sess_id).first() 53 | if not sess: 54 | return None 55 | if not sess.user: 56 | return None 57 | return { "username": sess.user.username } 58 | 59 | def requires_login(): 60 | """ Wrapper for methods that require login """ 61 | 62 | def wrapper(f): 63 | @wraps(f) 64 | def wrapped(*args, **kwargs): 65 | if not get_user(args[0].request): 66 | raise HTTP_401("I can't let you do that") 67 | return f(*args, **kwargs) 68 | return wrapped 69 | return wrapper 70 | 71 | class Home(Handler): 72 | """ Handler for a message with the user's name """ 73 | 74 | @requires_login() 75 | def get(self): 76 | user = get_user(self.request) 77 | return { "message":"Hello, %s"%(user.get("username")) } 78 | 79 | @requires_login() 80 | def post(self): 81 | return self.get() 82 | 83 | class Login(Handler): 84 | """ Create a session for a user """ 85 | 86 | def post(self): 87 | 88 | username = self.request.data.get("username") 89 | password = self.request.data.get("password") 90 | 91 | if not username or not password: 92 | raise HTTP_400("Please specify username and password") 93 | 94 | # See if a user exists with those params 95 | user = db.query(User).filter( 96 | User.username==username, 97 | User.password==password).first() 98 | if not user: 99 | raise HTTP_401("Invalid username or password") 100 | 101 | # Create a new session 102 | sess = UserSession( 103 | user_id=user.id) 104 | db.add(sess) 105 | self.response.set_cookie("session_id", sess.session_id) 106 | return { "message":"Logged in", "session_id":sess.session_id } 107 | 108 | class Logout(Handler): 109 | """ Clears a user's session """ 110 | 111 | def post(self): 112 | 113 | sess_id = self.request.cookies.get("session_id") 114 | if sess_id: 115 | # query to user sessions table 116 | sess = db.query(UserSession).filter( 117 | UserSession.session_id==sess_id).first() 118 | if sess: 119 | db.delete(sess) 120 | self.response.delete_cookie("session_id") 121 | return { "message":"logged out" } 122 | return { "message":"Not logged in" } 123 | 124 | class app(WSGI): 125 | routes = [ 126 | ('/home', Home()), 127 | ('/login', Login()), 128 | ('/logout', Logout()) 129 | ] 130 | 131 | if __name__ == "__main__": 132 | 133 | print("DB: Creating users table in memory...") 134 | Base.metadata.create_all(engine) 135 | 136 | print("DB: Adding users...") 137 | db.add_all([ 138 | User(username="foo", password="foo") 139 | ]) 140 | 141 | from wsgiref.simple_server import make_server 142 | try: 143 | print("Serving on 0.0.0.0:8080...") 144 | make_server('0.0.0.0', 8080, app).serve_forever() 145 | except KeyboardInterrupt: 146 | pass 147 | print("Done") 148 | 149 | ## models.py 150 | 151 | This file contains the SQLAlchemy database models as well 152 | as a method to generate session ids in SHA1 format. 153 | 154 | :::python 155 | from sqlalchemy import create_engine, Column, Integer, String, ForeignKey 156 | from sqlalchemy.ext.declarative import declarative_base 157 | from sqlalchemy.orm import sessionmaker, relationship, backref 158 | import hashlib 159 | import time 160 | 161 | engine = create_engine('sqlite:///:memory:', echo=True) 162 | Base = declarative_base() 163 | Session = sessionmaker(bind=engine) 164 | 165 | def get_new_session_id(): 166 | """ Generates a sha1 session id """ 167 | 168 | to_hash = "%s_SECRET"%(time.time()) 169 | return hashlib.sha1(to_hash.encode('utf-8')).hexdigest() 170 | 171 | class UserSession(Base): 172 | 173 | __tablename__ = "user_sessions" 174 | id = Column(Integer, primary_key=True) 175 | session_id = Column(String, 176 | default=get_new_session_id, onupdate=get_new_session_id) 177 | user_id = Column(Integer, ForeignKey('users.id')) 178 | user = relationship("User", backref=backref("sessions"), order_by=id) 179 | 180 | class User(Base): 181 | __tablename__ = "users" 182 | id = Column(Integer, primary_key=True) 183 | username = Column(String) 184 | password = Column(String) 185 | 186 | -------------------------------------------------------------------------------- /docs/source/nodejs-compare.md: -------------------------------------------------------------------------------- 1 | [[ 2 | title: Node.js Comparison 3 | ]] 4 | 5 | [TOC] 6 | 7 | # Async NodeJS compared to Pycnic 8 | 9 | The purpose of these benchmarks was to test the claim 10 | that node.js's non-blocking nature is faster than other 11 | production webservers. The most popular web framework for Node.js 12 | is Express.js, by at least one order of magnitude (github stars). 13 | 14 | While Pycnic is definitely not the most popular Python framework, 15 | this is still the Pycnic documentation. 16 | 17 | 18 | ## Methods 19 | 20 | Both tests were deployed in a production-ready fashion. Though there 21 | may be optimizations to both (using Nginx over Gunicorn, or PM2 instead of `node`) the 22 | goal was to reasonably simulate a small-scale deployment. 23 | 24 | 1. Both tests were ran with `ab -c 5000 -n 5 127.0.0.1:/` 25 | 2. Both tests read a .json file and returned its contents. 26 | 3. The Express.js test uses asynchronous code and is served by `node`. 27 | 4. The Pycnic test uses synchronous code, and is served by `gunicorn -w 2`. 28 | 29 | ## Express.js Source 30 | 31 | :::javascript 32 | var express = require("express") 33 | var fs = require("fs") 34 | 35 | var app = express() 36 | 37 | function getFileData() { 38 | let p = new Promise( 39 | function(resolve, reject) { 40 | fs.readFile('/home/nullism/foo.json', 'utf8', function(err, data) { 41 | resolve(data) 42 | }) 43 | } 44 | ) 45 | return p 46 | } 47 | 48 | app.get("/", function(req, res) { 49 | getFileData().then( 50 | function(val) { 51 | res.end(val) 52 | } 53 | ) 54 | }) 55 | 56 | app.listen(3000, function() { 57 | console.log("Listening on port 3000") 58 | }) 59 | 60 | 61 | ## Pycnic Source 62 | 63 | :::python 64 | from pycnic.core import WSGI, Handler 65 | 66 | def get_file_data(): 67 | 68 | with open("/home/nullism/foo.json") as fh: 69 | return fh.read() 70 | 71 | class Root(Handler): 72 | 73 | def get(self): 74 | return get_file_data() 75 | 76 | class app(WSGI): 77 | routes = [("/", Root())] 78 | 79 | 80 | ## Results 81 | 82 | ### Express.js 83 | 84 | :::text 85 | This is ApacheBench, Version 2.3 <$Revision: 1706008 $> 86 | Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ 87 | Licensed to The Apache Software Foundation, http://www.apache.org/ 88 | 89 | Benchmarking 127.0.0.1 (be patient) 90 | Completed 500 requests 91 | Completed 1000 requests 92 | Completed 1500 requests 93 | Completed 2000 requests 94 | Completed 2500 requests 95 | Completed 3000 requests 96 | Completed 3500 requests 97 | Completed 4000 requests 98 | Completed 4500 requests 99 | Completed 5000 requests 100 | Finished 5000 requests 101 | 102 | 103 | Server Software: 104 | Server Hostname: 127.0.0.1 105 | Server Port: 3000 106 | 107 | Document Path: / 108 | Document Length: 61 bytes 109 | 110 | Concurrency Level: 5 111 | Time taken for tests: 2.278 seconds 112 | Complete requests: 5000 113 | Failed requests: 0 114 | Total transferred: 795000 bytes 115 | HTML transferred: 305000 bytes 116 | Requests per second: 2195.30 [#/sec] (mean) 117 | Time per request: 2.278 [ms] (mean) 118 | Time per request: 0.456 [ms] (mean, across all concurrent requests) 119 | Transfer rate: 340.87 [Kbytes/sec] received 120 | 121 | Connection Times (ms) 122 | min mean[+/-sd] median max 123 | Connect: 0 0 0.1 0 2 124 | Processing: 1 2 11.3 2 358 125 | Waiting: 0 2 11.3 2 358 126 | Total: 1 2 11.3 2 358 127 | 128 | Percentage of the requests served within a certain time (ms) 129 | 50% 2 130 | 66% 2 131 | 75% 2 132 | 80% 2 133 | 90% 2 134 | 95% 2 135 | 98% 3 136 | 99% 4 137 | 100% 358 (longest request) 138 | 139 | 140 | ### Pycnic 141 | 142 | 143 | :::text 144 | This is ApacheBench, Version 2.3 <$Revision: 1706008 $> 145 | Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ 146 | Licensed to The Apache Software Foundation, http://www.apache.org/ 147 | 148 | Benchmarking 127.0.0.1 (be patient) 149 | Completed 500 requests 150 | Completed 1000 requests 151 | Completed 1500 requests 152 | Completed 2000 requests 153 | Completed 2500 requests 154 | Completed 3000 requests 155 | Completed 3500 requests 156 | Completed 4000 requests 157 | Completed 4500 requests 158 | Completed 5000 requests 159 | Finished 5000 requests 160 | 161 | 162 | Server Software: gunicorn/19.6.0 163 | Server Hostname: 127.0.0.1 164 | Server Port: 8000 165 | 166 | Document Path: / 167 | Document Length: 61 bytes 168 | 169 | Concurrency Level: 5 170 | Time taken for tests: 1.588 seconds 171 | Complete requests: 5000 172 | Failed requests: 0 173 | Total transferred: 965000 bytes 174 | HTML transferred: 305000 bytes 175 | Requests per second: 3148.74 [#/sec] (mean) 176 | Time per request: 1.588 [ms] (mean) 177 | Time per request: 0.318 [ms] (mean, across all concurrent requests) 178 | Transfer rate: 593.46 [Kbytes/sec] received 179 | 180 | Connection Times (ms) 181 | min mean[+/-sd] median max 182 | Connect: 0 0 0.0 0 0 183 | Processing: 0 2 0.3 1 11 184 | Waiting: 0 1 0.2 1 11 185 | Total: 1 2 0.3 1 11 186 | ERROR: The median and mean for the processing time are more than twice the standard 187 | deviation apart. These results are NOT reliable. 188 | ERROR: The median and mean for the total time are more than twice the standard 189 | deviation apart. These results are NOT reliable. 190 | 191 | Percentage of the requests served within a certain time (ms) 192 | 50% 1 193 | 66% 2 194 | 75% 2 195 | 80% 2 196 | 90% 2 197 | 95% 2 198 | 98% 2 199 | 99% 2 200 | 100% 11 (longest request) 201 | 202 | 203 | 204 | 205 | ## Conclusion 206 | 207 | Though Express.js may outperform Pycnic in a single-worker scenario, 208 | this is not the case when using a production Python webserver (gunicorn with two workers in this case). 209 | 210 | Multi-worker Pycnic outperforms Express.js, and it may be difficult to justify the additional verbosity 211 | of Node.js server-side programming. 212 | 213 | 214 | :::text 215 | # Express.js 216 | Requests per second: 2195.30 [#/sec] (mean) 217 | Time per request: 2.278 [ms] (mean) 218 | Time per request: 0.456 [ms] (mean, across all concurrent requests) 219 | Transfer rate: 340.87 [Kbytes/sec] received 220 | 221 | # Pycnic 222 | Requests per second: 3148.74 [#/sec] (mean) 223 | Time per request: 1.588 [ms] (mean) 224 | Time per request: 0.318 [ms] (mean, across all concurrent requests) 225 | Transfer rate: 593.46 [Kbytes/sec] received 226 | 227 | ## Questions or Comments? 228 | 229 | Feel free to open an issue on the Pycnic [Github page](https://github.com/nullism/pycnic/issues). 230 | -------------------------------------------------------------------------------- /pycnic/core.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import traceback 4 | import json 5 | import logging 6 | 7 | from . import utils 8 | from .data import STATUSES 9 | from . import errors 10 | 11 | 12 | class Handler(object): 13 | 14 | request = None 15 | response = None 16 | 17 | def options(self, *args, **kwargs): 18 | """Default implementation of OPTIONS method. 19 | 20 | Checks which methods are implemented, and sets the Allow header 21 | accordingly. 22 | """ 23 | # Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods 24 | http_methods = [ 25 | 'get', 26 | 'head', 27 | 'post', 28 | 'put', 29 | 'delete', 30 | 'connect', 31 | 'options', 32 | 'trace', 33 | 'patch' 34 | ] 35 | 36 | supported_methods = [] 37 | 38 | for method in http_methods: 39 | if method in dir(self): 40 | supported_methods.append(method.upper()) 41 | 42 | self.response.set_header('Allow', ', '.join(supported_methods)) 43 | return {} 44 | 45 | 46 | class Request(object): 47 | 48 | def __init__(self, path, method, environ): 49 | self._headers = None 50 | self._args = None 51 | self._json_args = None 52 | self._cookies = None 53 | self._body = None 54 | self._data = None 55 | 56 | self.path = path 57 | self.method = method.upper() 58 | self.environ = environ 59 | 60 | def get_header(self, name, default=None): 61 | return self.headers.get(name.title(), default) 62 | 63 | @property 64 | def headers(self): 65 | if self._headers is not None: 66 | return self._headers 67 | self._headers = {} 68 | for key, value in self.environ.items(): 69 | if key == "CONTENT_TYPE" or key == "CONTENT_LENGTH": 70 | header = key.title().replace("_", "-") 71 | self._headers[header] = value 72 | elif key.startswith("HTTP_"): 73 | header = key[5:].title().replace("_", "-") 74 | self._headers[header] = value 75 | return self._headers 76 | 77 | @property 78 | def args(self): 79 | if self._args is not None: 80 | return self._args 81 | qs = self.environ.get("QUERY_STRING", "") 82 | self._args = utils.query_string_to_dict(qs) 83 | return self._args 84 | 85 | @property 86 | def json_args(self): 87 | if self._json_args is not None: 88 | return self._json_args 89 | 90 | try: 91 | qs = self.environ.get("QUERY_STRING", "") 92 | self._json_args = utils.query_string_to_json(qs) 93 | except Exception: 94 | raise errors.HTTP_400("Invalid JSON in request query string") 95 | 96 | return self._json_args 97 | 98 | @property 99 | def body(self): 100 | if self._body is not None: 101 | return self._body 102 | try: 103 | length = int(self.environ.get("CONTENT_LENGTH", "0")) 104 | except ValueError: 105 | length = 0 106 | if length > 0: 107 | self._body = self.environ['wsgi.input'].read(length) 108 | else: 109 | self._body = '' 110 | 111 | return self._body 112 | 113 | @property 114 | def cookies(self): 115 | if self._cookies is not None: 116 | return self._cookies 117 | 118 | self._cookies = {} 119 | 120 | if 'HTTP_COOKIE' in self.environ: 121 | for cookie_line in self.environ['HTTP_COOKIE'].split(';'): 122 | if "=" not in cookie_line: 123 | continue # malformed cookie 124 | if "DELETED" in cookie_line: 125 | continue 126 | cname, cvalue = cookie_line.strip().split('=', 1) 127 | self._cookies[cname] = cvalue 128 | 129 | return self._cookies 130 | 131 | @property 132 | def data(self): 133 | if self._data is not None: 134 | return self._data 135 | if self.body: 136 | try: 137 | self._data = json.loads(self.body.decode('utf-8')) 138 | except Exception: 139 | raise errors.HTTP_400("Expected JSON in request body") 140 | else: 141 | self._data = {} 142 | 143 | return self._data 144 | 145 | @property 146 | def ip(self): 147 | try: 148 | return self.environ['HTTP_X_FORWARDED_FOR'].split(',')[-1].strip() 149 | except KeyError: 150 | return self.environ['REMOTE_ADDR'] 151 | 152 | 153 | class Response(object): 154 | 155 | def __init__(self, status_code): 156 | self.header_dict = { 157 | "Content-Type": "application/json" 158 | } 159 | self._headers = [] 160 | self.cookie_dict = {} 161 | self.status_code = status_code 162 | 163 | def set_header(self, key, value): 164 | self.header_dict[key] = value 165 | 166 | def set_cookie( 167 | self, key, value, expires="", path='/', domain="", flags=[]): 168 | value = value.replace(";", "") # ; not allowed 169 | if expires: 170 | expires = "expires=%s; " % (expires) 171 | if domain: 172 | domain = "Domain=%s; " % (domain) 173 | 174 | self.cookie_dict[key] = "%s;%s%s path=%s; %s"\ 175 | % (value, domain, expires, path, "; ".join(flags)) 176 | 177 | def delete_cookie(self, key): 178 | expiry_string = 'Thu, 01 Jan 1970 00:00:00 GMT' 179 | self.set_cookie(key, "DELETED", expires=expiry_string) 180 | 181 | @property 182 | def headers(self): 183 | self._headers = [] 184 | for k, v in self.header_dict.items(): 185 | self._headers.append((k, v)) 186 | for k, v in self.cookie_dict.items(): 187 | self._headers.append(('Set-Cookie', '%s=%s' % (k, v))) 188 | return self._headers 189 | 190 | @property 191 | def status(self): 192 | if self.status_code in STATUSES: 193 | return STATUSES[self.status_code] 194 | print("Warning! Status %s does not exist!" % (self.status_code)) 195 | return STATUSES[577] 196 | 197 | 198 | class WSGI: 199 | 200 | routes = [] 201 | debug = False 202 | logger = None 203 | before = None 204 | teardown = None 205 | after = None 206 | headers = None 207 | strip_path = True 208 | json_cls = None 209 | 210 | def __init__(self, environ, start_response): 211 | 212 | if not self.logger: 213 | self.logger = logging.Logger(__name__) 214 | 215 | self.logger.debug("WSGI __init__ called") 216 | 217 | path = environ["PATH_INFO"] 218 | if self.strip_path and len(path) > 1 and path.endswith("/"): 219 | path = path[0:-1] 220 | 221 | self.request = Request( 222 | path=path, 223 | method=environ["REQUEST_METHOD"], 224 | environ=environ 225 | ) 226 | 227 | self.response = Response(status_code=200) 228 | 229 | self.environ = environ 230 | self.start = start_response 231 | self.response.set_header("Content-Type", "application/json") 232 | 233 | def __iter__(self): 234 | try: 235 | if self.before: 236 | self.before() 237 | resp = self.delegate() 238 | if self.after: 239 | self.after() 240 | headers = self.response.headers 241 | if self.headers: 242 | headers += self.headers 243 | self.start(self.response.status, headers) 244 | 245 | except errors.HTTPError as err: 246 | self.response.status_code = err.status_code 247 | headers = [("Content-Type", "application/json")] 248 | if self.headers: 249 | headers += self.headers 250 | if err.headers: 251 | headers += err.headers 252 | self.start(self.response.status, headers) 253 | resp = err.response() 254 | 255 | except Exception as err: 256 | self.logger.exception(err) 257 | headers = [("Content-Type", "application/json")] 258 | self.start(STATUSES[500], headers) 259 | if self.debug: 260 | resp = {"error": traceback.format_exc().split("\n")} 261 | else: 262 | resp = {"error": "Internal server error encountered."} 263 | 264 | if self.teardown: 265 | self.teardown() 266 | 267 | if isinstance(resp, (dict, list)): 268 | try: 269 | if self.debug: 270 | jresp = json.dumps(resp, indent=4, cls=self.json_cls) 271 | else: 272 | jresp = json.dumps(resp, cls=self.json_cls) 273 | except Exception: 274 | if self.debug: 275 | msg = traceback.format_exc().split("\n") 276 | jresp = json.dumps({"error": msg}, indent=4) 277 | else: 278 | msg = "An unhandled exception occured during response" 279 | jresp = json.dumps({"error": msg}) 280 | self.logger.debug("Sending JSON response: %s", jresp) 281 | return iter([jresp.encode('utf-8')]) 282 | elif isinstance(resp, str): 283 | self.logger.debug("Sending string response: %s", resp) 284 | return iter([resp.encode('utf-8')]) 285 | else: 286 | self.logger.debug("Sending unknown response: %s", resp) 287 | return iter(resp) 288 | 289 | def delegate(self): 290 | path = self.request.path 291 | method = self.request.method 292 | 293 | for pattern, handler in self.routes: 294 | # Set defaults for handler 295 | handler.request = self.request 296 | handler.response = self.response 297 | 298 | m = re.match('^' + pattern + '$', path) 299 | if m: 300 | if hasattr(handler, 'before'): 301 | handler.before() 302 | 303 | args = m.groups() 304 | funcname = method.lower() 305 | try: 306 | func = getattr(handler, funcname) 307 | except AttributeError: 308 | raise errors.HTTP_405("%s not allowed" % (method.upper())) 309 | 310 | output = func(*args) 311 | 312 | if hasattr(handler, 'after'): 313 | handler.after() 314 | 315 | return output 316 | 317 | raise errors.HTTP_404("Path %s not found" % (path)) 318 | -------------------------------------------------------------------------------- /pycnic/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import argparse 4 | import inspect 5 | import os 6 | import sys 7 | 8 | # Local imports 9 | from pycnic.core import WSGI 10 | 11 | 12 | class DummyFile(object): 13 | """Class that will no-op writes to stdout and stderr.""" 14 | def write(self, x): pass 15 | 16 | 17 | class nostdout(object): 18 | """Capture any stdout and stderr from importing the files. 19 | 20 | During the import of the user-specified files, sometimes code will 21 | get run. This silences whatever would print during that in order to 22 | keep the console output of the Pycnic CLI clean. 23 | """ 24 | 25 | def __enter__(self): 26 | self.old_stdout = sys.stdout 27 | self.old_stderr = sys.stderr 28 | sys.stdout = DummyFile() 29 | sys.stderr = DummyFile() 30 | return self 31 | 32 | def __exit__(self, exc_type, exc_val, traceback): 33 | sys.stdout = self.old_stdout 34 | sys.stderr = self.old_stderr 35 | 36 | 37 | def print_description(cls, max_route_length, max_method_length): 38 | """Extract the docstring from the class and print it. 39 | 40 | Formatted to fit underneath the class name in all output formats.""" 41 | if not cls.__doc__: 42 | return 43 | 44 | doc_string = cls.__doc__ 45 | doc_lines = doc_string.split('\n') 46 | right_justify = max_route_length+2+max_method_length 47 | print('Description:'.rjust(right_justify) + ' ' + doc_lines[0].strip()) 48 | for line in doc_lines[1:]: 49 | print(''.rjust(max_route_length+4+max_method_length) + line.strip()) 50 | print('') 51 | 52 | 53 | def alpha_sort(routes, verbose=False): 54 | """Sort routes alphabetically by their regex pattern. 55 | 56 | Regexes are not inherently sortable by the patterns they match, 57 | so this treates the pattern as a string and sorts accordingly. 58 | """ 59 | routes.sort(key=lambda x: x[0]) 60 | max_route_length = 5 # Length of the word "route" 61 | max_method_length = 6 # Length of the word "method" 62 | # Determine justified string lengths 63 | for route in routes: 64 | methods_str = ', '.join(route[1]) 65 | max_route_length = max(max_route_length, len(route[0])) 66 | max_method_length = max(max_method_length, len(methods_str)) 67 | 68 | ljust_method_word = 'Method'.ljust(max_method_length) 69 | ljust_route_word = 'Route'.ljust(max_route_length) 70 | print(ljust_route_word + ' ' + ljust_method_word + ' Class') 71 | print('') 72 | 73 | # Print justified strings 74 | for route in routes: 75 | ljust_route = route[0].ljust(max_route_length) 76 | methods_str = ', '.join(route[1]).upper() 77 | ljust_methods = methods_str.ljust(max_method_length) 78 | route_cls_name = full_class_name(route[2]) 79 | print(' '.join([ljust_route, ljust_methods, route_cls_name])) 80 | if verbose: 81 | print_description(route[2], max_route_length, max_method_length) 82 | 83 | 84 | def class_sort(routes, verbose=False): 85 | """Sort routes alphabetically by their class name.""" 86 | routes.sort(key=lambda x: full_class_name(x[2])) 87 | max_route_length = 5 # Length of the word "route" 88 | max_method_length = 6 # Length of the word "method" 89 | # Determine justified string lengths 90 | for route in routes: 91 | methods_str = ', '.join(route[1]) 92 | max_route_length = max(max_route_length, len(route[0])) 93 | max_method_length = max(max_method_length, len(methods_str)) 94 | 95 | ljust_method_word = 'Method'.ljust(max_method_length) 96 | ljust_route_word = 'Route'.ljust(max_route_length) 97 | print(ljust_route_word + ' ' + ljust_method_word + ' Class') 98 | print('') 99 | 100 | # Print justified strings 101 | for route in routes: 102 | ljust_route = route[0].ljust(max_route_length) 103 | methods_str = ', '.join(route[1]).upper() 104 | ljust_methods = methods_str.ljust(max_method_length) 105 | route_cls_name = full_class_name(route[2]) 106 | print(' '.join([ljust_route, ljust_methods, route_cls_name])) 107 | if verbose: 108 | print_description(route[2], max_route_length, max_method_length) 109 | 110 | 111 | def no_sort(routes, verbose=False): 112 | """Don't process the list at all, simply format it.""" 113 | max_route_length = 5 # Length of the word "route" 114 | max_method_length = 6 # Length of the word "method" 115 | # Determine justified string lengths 116 | for route in routes: 117 | methods_str = ', '.join(route[1]) 118 | max_route_length = max(max_route_length, len(route[0])) 119 | max_method_length = max(max_method_length, len(methods_str)) 120 | 121 | ljust_method_word = 'Method'.ljust(max_method_length) 122 | ljust_route_word = 'Route'.ljust(max_route_length) 123 | print(ljust_route_word + ' ' + ljust_method_word + ' Class') 124 | print('') 125 | 126 | # Print justified strings 127 | for route in routes: 128 | ljust_route = route[0].ljust(max_route_length) 129 | methods_str = ', '.join(route[1]).upper() 130 | ljust_methods = methods_str.ljust(max_method_length) 131 | route_cls_name = full_class_name(route[2]) 132 | print(' '.join([ljust_route, ljust_methods, route_cls_name])) 133 | if verbose: 134 | print_description(route[2], max_route_length, max_method_length) 135 | 136 | 137 | def method_sort(routes, verbose=False): 138 | """Group by methods, but do not sort.""" 139 | method_routes = { 140 | 'get': [], 141 | 'head': [], 142 | 'post': [], 143 | 'put': [], 144 | 'delete': [], 145 | 'connect': [], 146 | 'options': [], 147 | 'trace': [], 148 | 'patch': [], 149 | } 150 | 151 | # Find which routes support which methods, and place into dict accordingly 152 | for route, methods, route_class in routes: 153 | for m in methods: 154 | method_routes[m].append((route, route_class)) 155 | 156 | # Start at length of word "METHOD" 157 | max_method_length = 6 # Length of the word "method" 158 | for key in method_routes: 159 | if len(method_routes[key]) > 0: 160 | max_method_length = max(max_method_length, len(key)) 161 | 162 | max_route_length = 5 # Length of the word "route" 163 | for route in routes: 164 | max_route_length = max(max_route_length, len(route[0])) 165 | 166 | # Print out the information. 167 | ljust_method_word = 'Method'.ljust(max_method_length) 168 | ljust_route_word = 'Route'.ljust(max_route_length) 169 | print(ljust_method_word + ' ' + ljust_route_word + ' Class') 170 | for key in method_routes: 171 | if len(method_routes[key]) <= 0: 172 | continue 173 | print('') 174 | for a in method_routes[key]: 175 | ljust_route = a[0].ljust(max_route_length) 176 | ljust_method = key.upper().ljust(max_method_length) 177 | route_cls_name = full_class_name(a[1]) 178 | print(' '.join([ljust_method, ljust_route, route_cls_name])) 179 | if verbose: 180 | print_description(a[1], max_route_length, max_method_length) 181 | 182 | 183 | def full_class_name(cls): 184 | """Get the fully qualified class name of an object class. 185 | 186 | Reference: https://stackoverflow.com/a/2020083 187 | """ 188 | module = cls.__class__.__module__ 189 | if module is None or module == str.__class__.__module__: 190 | return cls.__class__.__module__ # Avoid reporting __builtin__ 191 | else: 192 | return module + '.' + cls.__class__.__name__ 193 | 194 | 195 | def find_routes(): 196 | """Find the routes in a specified class. 197 | 198 | This is done by spawning an instance of the class, and inspecting the 199 | routes attribute of that class. 200 | """ 201 | # Need to deep copy to preserve original system path 202 | starting_sys_path = [el for el in sys.path] 203 | 204 | # Parse command-line arguments 205 | parser = argparse.ArgumentParser(prog='pycnic routes', 206 | description='Pycnic Routes') 207 | parser.add_argument('-sort', required=False, 208 | help='Sorting method to use, if any.', 209 | choices=('alpha', 'class', 'method')) 210 | parser.add_argument('--verbose', '-v', 211 | action='store_true', 212 | help='Verbose route output. \ 213 | Prints route class docstring.') 214 | parser.add_argument('path', type=str, 215 | nargs='?', default='main:app', 216 | help='Path to the specified application. If none \ 217 | given, looks in main.py for a class called app. \ 218 | Example: pycnic.pycnic:app') 219 | 220 | # Since we've already handled the command argument, discard it. 221 | args = parser.parse_args(sys.argv[2:]) 222 | 223 | # Extract class name from path 224 | module_path, class_name = args.path.split(':') 225 | 226 | # Determine module name, for dynamic import 227 | module_name = module_path.split('.')[-1] 228 | 229 | # Convert module path-type to os path-type 230 | file_path = os.path.join(os.getcwd(), *module_path.split('.')) + '.py' 231 | 232 | # Make sure that the file specified actually exists 233 | if not os.path.isfile(file_path): 234 | print('File ' + file_path + ' does not exist.') 235 | exit(1) 236 | 237 | # Add the current working directory to the path so that imports will 238 | # function properly in the imported file. 239 | sys.path.append(os.getcwd()) 240 | 241 | with nostdout() as _: 242 | # Dynamically load the file in order to assess the class 243 | if sys.version_info[0] < 3: 244 | # For python 2.X, we use the imp library 245 | # Reference: https://stackoverflow.com/a/67692 246 | import imp 247 | 248 | loaded_src = imp.load_source(module_name, file_path) 249 | wsgi_class = getattr(loaded_src, class_name) 250 | else: 251 | # For python 3.X, we use importlib 252 | # Reference: https://stackoverflow.com/a/67692 253 | if sys.version_info[1] >= 5: 254 | # For 3.5+, use importlib.util for dynamic module loading 255 | from importlib.util import spec_from_file_location 256 | import importlib.util 257 | spec = spec_from_file_location(module_name, file_path) 258 | loaded_src = importlib.util.module_from_spec(spec) 259 | spec.loader.exec_module(loaded_src) 260 | wsgi_class = getattr(loaded_src, class_name) 261 | elif sys.version_info[1] in [3, 4]: 262 | # For 3.3 and 3.4, SourceFileLoader is the tool to use 263 | from importlib.machinery import SourceFileLoader 264 | loaded_src = SourceFileLoader(module_name, file_path) 265 | wsgi_class = getattr(loaded_src, class_name) 266 | else: 267 | raise ImportError('Pycnic routes is not supported for Python \ 268 | versions 3.0.X through 3.2.X') 269 | 270 | # Restore the system PATH to its original state 271 | # now that we've loaded the input project. 272 | sys.path = starting_sys_path 273 | 274 | # Make sure that the specified element is actually a subclass of the 275 | # WSGI class provided in pycnic.core 276 | if not inspect.isclass(wsgi_class): 277 | print(class_name + ' is not a class in ' + file_path) 278 | exit(1) 279 | if not issubclass(wsgi_class, WSGI): 280 | print(class_name + ' is not a subclass of pycnic.core.WSGI.') 281 | exit(1) 282 | 283 | # Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods 284 | http_methods = [ 285 | 'get', 286 | 'head', 287 | 'post', 288 | 'put', 289 | 'delete', 290 | 'connect', 291 | 'options', 292 | 'trace', 293 | 'patch' 294 | ] 295 | 296 | # Start with empty list of routes 297 | # This will ultimately be a list of tuples of routes and methods 298 | all_routes = [] 299 | 300 | for route_regex, route_class in wsgi_class.routes: 301 | # Old-style python2 classes require you to access 302 | # object.__class__.__name__. 303 | class_name = route_class.__class__.__name__ 304 | 305 | # Get the intersection of http_methods and class methods to find 306 | # what http methods are handled by the class 307 | class_http_methods = [ 308 | method for method in http_methods if method in dir(route_class) 309 | ] 310 | 311 | all_routes.append((route_regex, class_http_methods, route_class)) 312 | 313 | if args.sort: 314 | if args.sort == 'method': 315 | method_sort(all_routes, args.verbose) 316 | elif args.sort == 'alpha': 317 | alpha_sort(all_routes, args.verbose) 318 | elif args.sort == 'class': 319 | class_sort(all_routes, args.verbose) 320 | else: 321 | no_sort(all_routes, args.verbose) 322 | 323 | 324 | def usage(): 325 | """Print known CLI commands. 326 | 327 | Emulates the style of argparse's output. 328 | """ 329 | allowed_commands = [ 330 | 'routes' 331 | ] 332 | allowed_commands_list_str = '{' + ', '.join(allowed_commands) + '}' 333 | script_name = os.path.basename(sys.argv[0]) 334 | print('usage: ' + script_name + ' [-h] ' + allowed_commands_list_str) 335 | print('') 336 | print('Pycnic CLI') 337 | print('') 338 | print('positional arguments:') 339 | print(' ' + allowed_commands_list_str + ' Subcommand to run.') 340 | print('') 341 | print('optional arguments:') 342 | print(' -h, --help show this help message and exit') 343 | 344 | 345 | def main(): 346 | """Main function referenced by entry_points in setup.py""" 347 | if len(sys.argv) < 2: 348 | usage() 349 | exit() 350 | run_type = sys.argv[1] 351 | if run_type == '-h': 352 | usage() 353 | elif run_type.lower() == 'routes': 354 | find_routes() 355 | else: 356 | usage() 357 | 358 | 359 | if __name__ == "__main__": 360 | main() 361 | --------------------------------------------------------------------------------