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