diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9a3994..f6123d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ Version 0.1 ----------- + - 0.1.6 (not released) + - Static files - 0.1.5 - Cookies - Blueprint listeners and ordering diff --git a/README.md b/README.md index cc62e6bf..a02dc703 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ app.run(host="0.0.0.0", port=8000) * [Exceptions](docs/exceptions.md) * [Blueprints](docs/blueprints.md) * [Cookies](docs/cookies.md) + * [Static Files](docs/static_files.md) * [Deploying](docs/deploying.md) * [Contributing](docs/contributing.md) * [License](LICENSE) diff --git a/docs/blueprints.md b/docs/blueprints.md index 1a516356..adc40dfa 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -42,7 +42,7 @@ from sanic import Sanic from my_blueprint import bp app = Sanic(__name__) -app.register_blueprint(bp) +app.blueprint(bp) app.run(host='0.0.0.0', port=8000, debug=True) ``` @@ -79,6 +79,12 @@ Exceptions can also be applied exclusively to blueprints globally. @bp.exception(NotFound) def ignore_404s(request, exception): return text("Yep, I totally found the page: {}".format(request.url)) + +## Static files +Static files can also be served globally, under the blueprint prefix. + +```python +bp.static('/folder/to/serve', '/web/path') ``` ## Start and Stop diff --git a/docs/static_files.md b/docs/static_files.md new file mode 100644 index 00000000..284d747d --- /dev/null +++ b/docs/static_files.md @@ -0,0 +1,18 @@ +# Static Files + +Both directories and files can be served by registering with static + +## Example + +```python +app = Sanic(__name__) + +# Serves files from the static folder to the URL /static +app.static('./static', '/static') + +# Serves the file /home/ubuntu/test.png when the URL /the_best.png +# is requested +app.static('/home/ubuntu/test.png', '/the_best.png') + +app.run(host="0.0.0.0", port=8000) +``` diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 37cfa1c3..d619b574 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -33,6 +33,15 @@ class BlueprintSetup: """ self.app.exception(*args, **kwargs)(handler) + def add_static(self, file_or_directory, uri, *args, **kwargs): + """ + Registers static files to sanic + """ + if self.url_prefix: + uri = self.url_prefix + uri + + self.app.static(file_or_directory, uri, *args, **kwargs) + def add_middleware(self, middleware, *args, **kwargs): """ Registers middleware to sanic @@ -112,3 +121,9 @@ class Blueprint: self.record(lambda s: s.add_exception(handler, *args, **kwargs)) return handler return decorator + + def static(self, file_or_directory, uri, *args, **kwargs): + """ + """ + self.record( + lambda s: s.add_static(file_or_directory, uri, *args, **kwargs)) diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 3ed5ab25..e21aca63 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -21,6 +21,15 @@ class ServerError(SanicException): status_code = 500 +class FileNotFound(NotFound): + status_code = 404 + + def __init__(self, message, path, relative_url): + super().__init__(message) + self.path = path + self.relative_url = relative_url + + class Handler: handlers = None diff --git a/sanic/response.py b/sanic/response.py index 20e69eff..d0e64cea 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,6 +1,9 @@ +from aiofiles import open as open_async from datetime import datetime from http.cookies import SimpleCookie -import ujson +from mimetypes import guess_type +from os import path +from ujson import dumps as json_dumps COMMON_STATUS_CODES = { 200: b'OK', @@ -136,7 +139,7 @@ class HTTPResponse: def json(body, status=200, headers=None): - return HTTPResponse(ujson.dumps(body), headers=headers, status=status, + return HTTPResponse(json_dumps(body), headers=headers, status=status, content_type="application/json") @@ -148,3 +151,17 @@ def text(body, status=200, headers=None): def html(body, status=200, headers=None): return HTTPResponse(body, status=status, headers=headers, content_type="text/html; charset=utf-8") + + +async def file(location, mime_type=None, headers=None): + filename = path.split(location)[-1] + + async with open_async(location, mode='rb') as _file: + out_stream = await _file.read() + + mime_type = mime_type or guess_type(filename)[0] or 'text/plain' + + return HTTPResponse(status=200, + headers=headers, + content_type=mime_type, + body_bytes=out_stream) diff --git a/sanic/router.py b/sanic/router.py index 951b49bc..8392dcd8 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -82,6 +82,9 @@ class Router: # Mark the whole route as unhashable if it has the hash key in it if re.search('(^|[^^]){1}/', pattern): properties['unhashable'] = True + # Mark the route as unhashable if it matches the hash key + elif re.search(pattern, '/'): + properties['unhashable'] = True return '({})'.format(pattern) diff --git a/sanic/sanic.py b/sanic/sanic.py index f8189d77..a26b48f1 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -12,6 +12,7 @@ from .log import log, logging from .response import HTTPResponse from .router import Router from .server import serve +from .static import register as static_register from .exceptions import ServerError @@ -28,6 +29,9 @@ class Sanic: self.loop = None self.debug = None + # Register alternative method names + self.go_fast = self.run + # -------------------------------------------------------------------- # # Registration # -------------------------------------------------------------------- # @@ -41,6 +45,11 @@ class Sanic: :return: decorated function """ + # Fix case where the user did not prefix the URL with a / + # and will probably get confused as to why it's not working + if not uri.startswith('/'): + uri = '/' + uri + def response(handler): self.router.add(uri=uri, methods=methods, handler=handler) return handler @@ -84,7 +93,17 @@ class Sanic: attach_to = args[0] return register_middleware - def register_blueprint(self, blueprint, **options): + # Static Files + def static(self, file_or_directory, uri, pattern='.+', + use_modified_since=True): + """ + Registers a root to serve files from. The input can either be a file + or a directory. See + """ + static_register(self, file_or_directory, uri, pattern, + use_modified_since) + + def blueprint(self, blueprint, **options): """ Registers a blueprint on the application. :param blueprint: Blueprint object @@ -101,6 +120,12 @@ class Sanic: self._blueprint_order.append(blueprint) blueprint.register(self, options) + def register_blueprint(self, *args, **kwargs): + # TODO: deprecate 1.0 + log.warning("Use of register_blueprint will be deprecated in " + "version 1.0. Please use the blueprint method instead") + return self.blueprint(*args, **kwargs) + # -------------------------------------------------------------------- # # Request Handling # -------------------------------------------------------------------- # diff --git a/sanic/static.py b/sanic/static.py index f351bc79..7c3a529f 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -1,48 +1,59 @@ -import re -import os -from zlib import adler32 -import mimetypes +from aiofiles.os import stat +from os import path +from re import sub +from time import strftime, gmtime -from sanic.response import HTTPResponse +from .exceptions import FileNotFound, InvalidUsage +from .response import file, HTTPResponse -def setup(app, dirname, url_prefix): - @app.middleware - async def static_middleware(request): - url = request.url - if url.startswith(url_prefix): - filename = url[len(url_prefix):] - if filename: - filename = secure_filename(filename) - filename = os.path.join(dirname, filename) - if os.path.isfile(filename): - return sendfile(filename) +def register(app, file_or_directory, uri, pattern, use_modified_since): + # TODO: Though sanic is not a file server, I feel like we should atleast + # make a good effort here. Modified-since is nice, but we could + # also look into etags, expires, and caching + """ + Registers a static directory handler with Sanic by adding a route to the + router and registering a handler. + :param app: Sanic + :param file_or_directory: File or directory path to serve from + :param uri: URL to serve from + :param pattern: regular expression used to match files in the URL + :param use_modified_since: If true, send file modified time, and return + not modified if the browser's matches the server's + """ + # If we're not trying to match a file directly, + # serve from the folder + if not path.isfile(file_or_directory): + uri += '' -_split = re.compile(r'[\0%s]' % re.escape(''.join( - [os.path.sep, os.path.altsep or '']))) + async def _handler(request, file_uri=None): + # Using this to determine if the URL is trying to break out of the path + # served. os.path.realpath seems to be very slow + if file_uri and '../' in file_uri: + raise InvalidUsage("Invalid URL") + # Merge served directory and requested file if provided + # Strip all / that in the beginning of the URL to help prevent python + # from herping a derp and treating the uri as an absolute path + file_path = path.join(file_or_directory, sub('^[/]*', '', file_uri)) \ + if file_uri else file_or_directory + try: + headers = {} + # Check if the client has been sent this file before + # and it has not been modified since + if use_modified_since: + stats = await stat(file_path) + modified_since = strftime('%a, %d %b %Y %H:%M:%S GMT', + gmtime(stats.st_mtime)) + if request.headers.get('If-Modified-Since') == modified_since: + return HTTPResponse(status=304) + headers['Last-Modified'] = modified_since -def secure_filename(path): - return _split.sub('', path) + return await file(file_path, headers=headers) + except: + raise FileNotFound('File not found', + path=file_or_directory, + relative_url=file_uri) - -def sendfile(location, mimetype=None, add_etags=True): - headers = {} - filename = os.path.split(location)[-1] - - with open(location, 'rb') as ins_file: - out_stream = ins_file.read() - - if add_etags: - headers['ETag'] = '{}-{}-{}'.format( - int(os.path.getmtime(location)), - hex(os.path.getsize(location)), - adler32(location.encode('utf-8')) & 0xffffffff) - - mimetype = mimetype or mimetypes.guess_type(filename)[0] or 'text/plain' - - return HTTPResponse(status=200, - headers=headers, - content_type=mimetype, - body_bytes=out_stream) + app.route(uri, methods=['GET'])(_handler) diff --git a/sanic/utils.py b/sanic/utils.py index 88aa8eae..04a7803a 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -11,6 +11,7 @@ async def local_request(method, uri, cookies=None, *args, **kwargs): async with aiohttp.ClientSession(cookies=cookies) as session: async with getattr(session, method)(url, *args, **kwargs) as response: response.text = await response.text() + response.body = await response.read() return response diff --git a/setup.py b/setup.py index 77210534..2e4e67e7 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ setup( 'uvloop>=0.5.3', 'httptools>=0.0.9', 'ujson>=1.35', + 'aiofiles>=0.3.0', ], classifiers=[ 'Development Status :: 2 - Pre-Alpha', diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 1b88795d..39303ff4 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,3 +1,5 @@ +import inspect + from sanic import Sanic from sanic.blueprints import Blueprint from sanic.response import json, text @@ -17,7 +19,7 @@ def test_bp(): def handler(request): return text('Hello') - app.register_blueprint(bp) + app.blueprint(bp) request, response = sanic_endpoint_test(app) assert response.text == 'Hello' @@ -30,7 +32,7 @@ def test_bp_with_url_prefix(): def handler(request): return text('Hello') - app.register_blueprint(bp) + app.blueprint(bp) request, response = sanic_endpoint_test(app, uri='/test1/') assert response.text == 'Hello' @@ -49,8 +51,8 @@ def test_several_bp_with_url_prefix(): def handler2(request): return text('Hello2') - app.register_blueprint(bp) - app.register_blueprint(bp2) + app.blueprint(bp) + app.blueprint(bp2) request, response = sanic_endpoint_test(app, uri='/test1/') assert response.text == 'Hello' @@ -70,7 +72,7 @@ def test_bp_middleware(): async def handler(request): return text('FAIL') - app.register_blueprint(blueprint) + app.blueprint(blueprint) request, response = sanic_endpoint_test(app) @@ -97,7 +99,7 @@ def test_bp_exception_handler(): def handler_exception(request, exception): return text("OK") - app.register_blueprint(blueprint) + app.blueprint(blueprint) request, response = sanic_endpoint_test(app, uri='/1') assert response.status == 400 @@ -140,8 +142,24 @@ def test_bp_listeners(): def handler_6(sanic, loop): order.append(6) - app.register_blueprint(blueprint) + app.blueprint(blueprint) request, response = sanic_endpoint_test(app, uri='/') - assert order == [1,2,3,4,5,6] \ No newline at end of file + assert order == [1,2,3,4,5,6] + +def test_bp_static(): + current_file = inspect.getfile(inspect.currentframe()) + with open(current_file, 'rb') as file: + current_file_contents = file.read() + + app = Sanic('test_static') + blueprint = Blueprint('test_static') + + blueprint.static(current_file, '/testing.file') + + app.blueprint(blueprint) + + request, response = sanic_endpoint_test(app, uri='/testing.file') + assert response.status == 200 + assert response.body == current_file_contents \ No newline at end of file diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 00000000..314a0927 --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,30 @@ +import inspect +import os + +from sanic import Sanic +from sanic.utils import sanic_endpoint_test + +def test_static_file(): + current_file = inspect.getfile(inspect.currentframe()) + with open(current_file, 'rb') as file: + current_file_contents = file.read() + + app = Sanic('test_static') + app.static(current_file, '/testing.file') + + request, response = sanic_endpoint_test(app, uri='/testing.file') + assert response.status == 200 + assert response.body == current_file_contents + +def test_static_directory(): + current_file = inspect.getfile(inspect.currentframe()) + current_directory = os.path.dirname(os.path.abspath(current_file)) + with open(current_file, 'rb') as file: + current_file_contents = file.read() + + app = Sanic('test_static') + app.static(current_directory, '/dir') + + request, response = sanic_endpoint_test(app, uri='/dir/test_static.py') + assert response.status == 200 + assert response.body == current_file_contents \ No newline at end of file