Skip to content

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
toastdriven committed Nov 23, 2019
2 parents 810d3f5 + 05454f8 commit f1da0cc
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 14 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ exclude_lines =
def __str__
if __name__ == .__main__.:
def run
def reset_logging
if self.debug
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,79 @@
# CHANGELOG


## 1.0.0

Whoo! :tada:

After over 10 years since the initial `itty` (Python 2) [commit](https://github.com/toastdriven/itty/commit/e8ec06096ed70179a7d4c0fea89ac95246604c3b),
I finally did the re-write I've been wanting to do for 4+ years.

`itty3` has reached `1.0.0` status & is in active production use. It is not
a perfect 1:1 match for the original codebase (a couple missing features),
but it has been completely rewritten for Python 3. It also features:

* A more extensible codebase & less global-dependence
* Better WSGI server compatibility
* Documentation!
* 100% test coverage!

It's not an end-all Python web framework (nor should it be), but if you need
to solve a small problem fast, it'd be great if you considered using `itty3`!

Enjoy!


### Features

* Added logging support

`itty3` now ships with support for Python's `logging` module. By default,
this is configured to ship logs to a `NullHandler` (no logs), unless you're
using `App.run`.

You can customize/extend this using all the typical `logging` configuration
options, including adding your own handlers, changing the logging level,
etc.

If you need to further customize things, `App.get_log` and
`App.reset_logging` are the methods you'll want to look at.

### Bugfixes

* Added more `str` methods to all the classes missing them


## 1.0.0-alpha2

### Features

* Added runnable examples

* `examples/tutorial_todolist` - The full code of the tutorial
* `examples/json_api` - A simple JSON-based API
* `examples/us_db_templates` - Demonstrates how to incorporate database &
template libraries into `itty3`. In this case, `peewee` & `Jinja2`
* `examples/unconventional` - Demonstrates some unconventional usages
of `itty3`.

* Added `App.render_json` as a convenience/shortcut for returning JSON
responses

### Documentation

* Added deployment docs
* Added docs about test coverage
* Added docs on how to extend `itty3`

### Bugfixes

* Added support for Read The Docs
* Added support for GitHub templates
* Fixed route regular expressions to be more consistent with the captured
types
* Removed an incorrect package classifiers (oops!)


## 1.0.0-alpha

The initial version of `itty3`.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ if __name__ == "__main__":

## Why?

`itty3` is a micro-framework for serving web traffic. Prior to its inital
release, `itty3` weighed in at just 500 lines of code.
`itty3` is a micro-framework for serving web traffic. At its `1.0.0`
release, `itty3` weighed in at less than ~1k lines of code.

Granted, it builds on the shoulders of giants, using big chunks of the Python
standard library. But it has no other external dependencies!
standard library. But it has **no** other external dependencies!

Reasons for `itty3`:

Expand Down
141 changes: 132 additions & 9 deletions itty3.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@
import functools
import io
import json
import logging
import re
import urllib.parse
import wsgiref.headers
import wsgiref.util


__author__ = "Daniel Lindsley"
__version__ = (1, 0, 0, "beta", 1)
__version__ = (
1,
0,
0,
)
__license__ = "New BSD"


Expand All @@ -39,6 +44,15 @@ def get_version(full=False):
return short


# Default logging config for itty3.
# We attach a NullHandler by default, so that we don't produce logs.
# The user can choose to add new handlers to capture logs, at the
# level/location they wish.
log = logging.getLogger(__name__)
null_handler = logging.NullHandler()
log.addHandler(null_handler)


# Constants
GET = "GET"
POST = "POST"
Expand Down Expand Up @@ -156,6 +170,12 @@ def __init__(self, data=None):
if self._data is None:
self._data = {}

def __str__(self):
return "<QueryDict: {} keys>".format(len(self._data))

def __repr__(self):
return str(self)

def __iter__(self):
return iter(self._data)

Expand Down Expand Up @@ -260,6 +280,8 @@ class HttpRequest(object):
scheme (str, Optional): The HTTP scheme ("http|https")
host (str, Optional): The hostname of the request
port (int, Optional): The port of the request
content_length (int, Optional): The length of the body of the request
request_protocol (str, Optional): The protocol of the request
"""

def __init__(
Expand All @@ -271,13 +293,17 @@ def __init__(
scheme="http",
host="",
port=80,
content_length=0,
request_protocol="HTTP/1.0",
):
self.raw_uri = uri
self.method = method.upper()
self.body = body
self.scheme = scheme
self.host = host
self.port = int(port)
self.content_length = int(content_length)
self.request_protocol = request_protocol

# For caching.
self._GET, self._POST, self._PUT = None, None, None
Expand All @@ -304,6 +330,17 @@ def __init__(
if len(domain_bits) > 1 and domain_bits[1]:
self.port = int(domain_bits[1])

def __str__(self):
return "<HttpRequest: {} {}>".format(self.method, self.raw_uri)

def __repr__(self):
return str(self)

def get_status_line(self):
return "{} {} {}".format(
self.method, self.path, self.request_protocol
)

def split_uri(self, full_uri):
"""
Breaks a URI down into components.
Expand Down Expand Up @@ -358,7 +395,6 @@ def from_wsgi(cls, environ):
# 'SCRIPT_NAME',
# 'SERVER_NAME',
# 'SERVER_PORT',
# 'SERVER_PROTOCOL'
]

for key, value in environ.items():
Expand All @@ -378,6 +414,8 @@ def from_wsgi(cls, environ):
# like gunicorn do not. Give it our best effort.
if not getattr(wsgi_input, "closed", False):
body = wsgi_input.read(int(content_length))
else:
content_length = 0

return cls(
uri=wsgiref.util.request_uri(environ),
Expand All @@ -386,6 +424,8 @@ def from_wsgi(cls, environ):
body=body,
scheme=wsgiref.util.guess_scheme(environ),
port=environ.get("SERVER_PORT", "80"),
content_length=content_length,
request_protocol=environ.get("SERVER_PROTOCOL", "HTTP/1.0"),
)

def content_type(self):
Expand Down Expand Up @@ -520,6 +560,12 @@ def __init__(

self.set_header("Content-Type", self.content_type)

def __str__(self):
return "<HttpResponse: {}>".format(self.status_code)

def __repr__(self):
return str(self)

def set_header(self, name, value):
"""
Sets a header on the response.
Expand Down Expand Up @@ -765,6 +811,19 @@ class App(object):
def __init__(self, debug=False):
self._routes = []
self.debug = debug
self.log = self.get_log()

def get_log(self):
"""
Returns a `logging.Logger` instance.
By default, we return the `itty3` module-level logger. Users are
free to override this to meet their needs.
Returns:
logging.Logger: The module-level logger
"""
return log

def __call__(self, environ, start_response):
# Allows providing the `App` instance as the WSGI handler to
Expand All @@ -782,6 +841,7 @@ def add_route(self, method, path, func):
"""
route = Route(method, path, func)
self._routes.append(route)
self.log.debug("Added {} - {}".format(route, func.__name__))

def find_route(self, method, path):
"""
Expand Down Expand Up @@ -814,7 +874,8 @@ def remove_route(self, method, path):
"""
try:
offset = self.find_route(method, path)
self._routes.pop(offset)
old_route = self._routes.pop(offset)
self.log.debug("Removed {}".format(old_route))
except RouteNotFound:
pass

Expand Down Expand Up @@ -1047,6 +1108,7 @@ def create_request(self, environ):
Returns:
HttpRequest: A built request object
"""
self.log.debug("Received environ {}".format(environ))
return HttpRequest.from_wsgi(environ)

def process_request(self, environ, start_response):
Expand Down Expand Up @@ -1075,6 +1137,11 @@ def process_request(self, environ, start_response):
iterable: The body iterable for the WSGI server
"""
request = self.create_request(environ)
self.log.debug(
"Started processing request for {} {}...".format(
request.method, request.path
)
)
resp = None

try:
Expand All @@ -1085,10 +1152,26 @@ def process_request(self, environ, start_response):
# We have a route that can handle the method & path!
# Call the view function!
try:
self.log.debug(
"Route {} will handle {} {}...".format(
route, request.method, request.raw_uri
)
)
kwargs = route.extract_kwargs(request.path)
self.log.debug(
"Calling {} with arguments {}".format(
route.func.__name__, kwargs
)
)
resp = route.func(request, **kwargs)
break
except Exception:
self.log.exception(
"View {} raised an exception!".format(
route.func.__name__
)
)

if self.debug:
raise

Expand All @@ -1098,14 +1181,53 @@ def process_request(self, environ, start_response):
if not resp:
raise RouteNotFound("No view found to handle method/path")
except RouteNotFound:
self.log.debug("No route matched. Returning a 404...")
resp = self.error_404(request)

if not resp:
self.log.debug("No response returned by view. Returning a 500...")
resp = self.error_500(request)

self.log.info(
'"{}" {}'.format(request.get_status_line(), resp.status_code)
)
resp.start_response = start_response
return resp.write()

def reset_logging(self, level=logging.INFO):
"""
A method for controlling how `App.run` does logging.
Disables `wsgiref`'s default "logging" to `stderr` & replaces it
with `itty3`-specific logging.
Args:
level (int, Optional): The `logging.LEVEL` you'd like to have
output. Default is `logging.INFO`.
Returns:
wsgiref.WSGIRequestHandler: The handler class to be used.
Defaults to a custom `NoStdErrHandler` class.
"""
from wsgiref.simple_server import WSGIRequestHandler

# Disable the vanilla wsgiref logging & enable itty3's logging.
# We don't do this by default at the top of the module, because it
# should be the user's choice how logging happens.
class NoStdErrHandler(WSGIRequestHandler):
def log_message(self, *args, **kwargs):
pass

self.log.removeHandler(null_handler)
default_format = logging.Formatter(
"%(asctime)s %(name)s %(levelname)s %(message)s"
)
stdout_handler = logging.StreamHandler()
stdout_handler.setFormatter(default_format)
self.log.addHandler(stdout_handler)
self.log.setLevel(level)
return NoStdErrHandler

def run(self, addr="127.0.0.1", port=8000, debug=None):
"""
An included development/debugging server for running the `App`
Expand All @@ -1125,17 +1247,18 @@ def run(self, addr="127.0.0.1", port=8000, debug=None):
import sys
from wsgiref.simple_server import make_server

handler = self.reset_logging()

if self.debug is not None:
self.debug = bool(debug)

httpd = make_server(addr, port, self.process_request)

print(
"itty3 {}: Now serving requests at http://{}:{}...".format(
get_version(full=True), addr, port
)
httpd = make_server(
addr, port, self.process_request, handler_class=handler
)

server_msg = "itty3 {}: Now serving requests at http://{}:{}..."
self.log.info(server_msg.format(get_version(full=True), addr, port))

try:
httpd.serve_forever()
except KeyboardInterrupt:
Expand Down

0 comments on commit f1da0cc

Please sign in to comment.