diff --git a/.appveyor.yml b/.appveyor.yml index 1818bd9a0f6a8b4b676239d16ae8464ae75a8de4..f2c86e4d61fe42847975a2e476ab0701a7e961ea 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,71 +1,36 @@ +branches: + except: + - /^gh-pages(-.*)?$/ + environment: + NOCODECHECKS: "true" matrix: - - PYTHON: "C:\\Python27" - PYTHON_VERSION: "2.7.x" # currently 2.7.9 - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python27-x64" - PYTHON_VERSION: "2.7.x" # currently 2.7.9 - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python33" - PYTHON_VERSION: "3.3.x" # currently 3.3.5 - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python33-x64" - PYTHON_VERSION: "3.3.x" # currently 3.3.5 - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python34" - PYTHON_VERSION: "3.4.x" # currently 3.4.3 - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python34-x64" - PYTHON_VERSION: "3.4.x" # currently 3.4.3 - PYTHON_ARCH: "64" - - # Python versions not pre-installed - - - PYTHON: "C:\\Python35" - PYTHON_VERSION: "3.5.3" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python35-x64" - PYTHON_VERSION: "3.5.3" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python36" - PYTHON_VERSION: "3.6.1" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python36-x64" - PYTHON_VERSION: "3.6.1" - PYTHON_ARCH: "64" + - PYTHON: "C:\\Python35" + - PYTHON: "C:\\Python35-x64" + - PYTHON: "C:\\Python36" + - PYTHON: "C:\\Python36-x64" + - PYTHON: "C:\\Python37" + - PYTHON: "C:\\Python37-x64" + - PYTHON: "C:\\Python38" + - PYTHON: "C:\\Python38-x64" build: off -init: - - "ECHO %PYTHON% %PYTHON_VERSION% %PYTHON_ARCH%" - install: - # If there is a newer build queued for the same PR, cancel this one. - # The AppVeyor 'rollout builds' option is supposed to serve the same - # purpose but it is problematic because it tends to cancel builds pushed - # directly to master instead of just PR builds (or the converse). - # credits: JuliaLang developers. - - ps: if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod ` - https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | ` - Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { ` - throw "There are newer queued builds for this pull request, failing early." } - - ECHO "Filesystem root:" - - ps: "ls \"C:/\"" - - ECHO "Installed SDKs:" - - ps: "ls \"C:/Program Files/Microsoft SDKs/Windows\"" - - ps: if (-not(Test-Path($env:PYTHON))) { & appveyor\install.ps1 } - - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - - "python --version" - - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" - - "pip install --disable-pip-version-check --user --upgrade pip setuptools" +- ps: | + echo "Available Python versions:" + ls "C:/" | Select-String -Pattern "Python*" + echo "" + echo "Installed SDKs:" + ls "C:/Program Files/Microsoft SDKs/Windows" + if (-not(Test-Path($env:PYTHON))) { + & appveyor\install.ps1 + } +- | + SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH% + python --version + python -c "import struct; print(struct.calcsize('P') * 8)" + pip install --disable-pip-version-check --user --upgrade pip setuptools wheel test_script: - - "python setup.py test" +- python setup.py test diff --git a/.eslintrc.json b/.eslintrc.json index 668c8d9e248b48067af6d3fea867f5f602261bb6..d379fb1d2353f360a5270fdefe719b97a40b999a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,7 +5,7 @@ }, "extends": "eslint:recommended", "parserOptions": { - "sourceType": "module" + "sourceType": "script" }, "rules": { "indent": [ diff --git a/.gitignore b/.gitignore index 93ff4b4787d8b47857062cf6e306afd7a280a9f6..40d9a684d6b0e83bf50f785cc5b38e688700ac23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,25 @@ -.git/* -.c9/* -.idea/* +.git +.c9 +.idea .coverage -htmlcov/* -dist/* -build/* -doc/.build/* -env/* -env2/* -env3/* -wenv2/* -wenv3/* -node_modules/* -.eggs/* -browsepy.build/* -browsepy.egg-info/* -**/__pycache__/* -**.egg/* -**.eggs/* -**.egg-info/* +.vscode + + +htmlcov +dist +build +env* +wenv* +package-lock.json + +**.build +**.egg-info +**.egg +**.eggs +**.egg-info **.pyc -**/node_modules/* + +**/node_modules +**/*.py[co] +**/__pycache__ +**/.mypy_cache diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..931d1ae3ab49491bbbdcb61bacdfd55e395a004e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,200 @@ + +# config + +stages: +- test +- report +- external +- publish + +.base: + except: + refs: + - /^gh-pages(-.*)?$/ + +.python: + extends: .base + image: python:3.8-alpine + cache: + paths: + - .cache/pip + variables: + PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip" + MODULE: browsepy + +.test: + extends: .python + stage: test + variables: &test-variables + PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip" + COVERAGE_FILE: "${CI_PROJECT_DIR}/.coverage.${CI_JOB_NAME}" + MODULE: browsepy + artifacts: + when: on_success + expire_in: 1h + paths: + - .coverage.* + before_script: + - | + apk add --no-cache build-base libffi-dev python3-dev + pip install coverage flake8 wheel + script: + - coverage run -p setup.py flake8 test + +.report: + extends: .python + stage: report + +.publish: + extends: .python + stage: publish + dependencies: [] + before_script: + - | + apk add --no-cache build-base libressl-dev libffi-dev python3-dev + pip install twine + +# stage: tests + +python35: + extends: .test + image: python:3.5-alpine + variables: + <<: *test-variables + NOCODECHECKS: "true" + +python36: + extends: .test + image: python:3.6-alpine + variables: + <<: *test-variables + NOCODECHECKS: "true" + +python37: + extends: .test + image: python:3.7-alpine + variables: + <<: *test-variables + NOCODECHECKS: "true" + +python38: + extends: .test + image: python:3.8-alpine + +eslint: + extends: .base + stage: test + image: node:alpine + cache: + paths: + - node_modules + only: + changes: + - "**.json" + - "**.js" + - .eslintignore + - .eslintrc.json + before_script: + - npm install eslint + script: + - node_modules/.bin/eslint ${MODULE} + +# stage: report + +coverage: + extends: .report + artifacts: + when: always + paths: + - htmlcov + coverage: '/^TOTAL\s+(?:\d+\s+)*(\d+\%)$/' + before_script: + - pip install coverage + script: + - | + coverage combine + coverage html --fail-under=0 + coverage report + +doc: + extends: .report + dependencies: [] + artifacts: + paths: + - doc/.build/html + before_script: + - | + apk add --no-cache make + pip install -r requirements/doc.txt + script: + - | + if [ "${CI_COMMIT_REF_NAME}" != "master" ]; then + python setup.py alpha_version + fi + make -C doc html + +# stage: publish + +publish-gh-pages: + extends: .base + stage: publish + image: + name: alpine/git + entrypoint: + - /bin/sh + - -c + dependencies: + - doc + only: + refs: + - master + - next + before_script: + - | + mkdir -p ~/.ssh + echo "${SSH_PRIVATE_KEY}" | tr -d '\r' > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H 'gitlab.com' >> ~/.ssh/known_hosts + git config --global user.name "${GITLAB_USER_LOGIN}" + git config --global user.email "${GITLAB_USER_EMAIL}" + after_script: + - rm ~/.ssh/id_rsa + script: + - | + if [ "${CI_COMMIT_REF_NAME}" == "master" ]; then + TARGET_BRANCH="gh-pages" + else + TARGET_BRANCH="gh-pages-${CI_COMMIT_REF_NAME}" + fi + git init .gh_pages + cd .gh_pages + git remote add origin "git@gitlab.com:$CI_PROJECT_PATH.git" + git fetch + git checkout "${TARGET_BRANCH}" || git checkout --orphan "${TARGET_BRANCH}" + cp ../.gitignore ../.appveyor.yml . + cp -rf ${CI_PROJECT_DIR}/doc/.build/html/** . + git add . + git commit -am "update ${TARGET_BRANCH}" + git push -u origin "${TARGET_BRANCH}" + +publish-next: + extends: .publish + dependencies: [] + only: + refs: + - next + script: + - | + python setup.py alpha_version bdist_wheel sdist + twine upload --repository-url=https://test.pypi.org/legacy/ dist/* + +publish-master: + extends: .publish + dependencies: [] + only: + refs: + - master + script: + - | + python setup.py bdist_wheel sdist + twine upload --repository-url=https://upload.pypi.org/legacy/ dist/* diff --git a/.python-version b/.python-version index 87ce492908ab2362771f27134bab4a075348a8f3..f2807196747ffcee4ae8a36604b9cff7ebeed9ca 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.5.2 +3.8.1 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2d7e50e214a690ce5a90d052f70c4eabc5c5a281..0000000000000000000000000000000000000000 --- a/.travis.yml +++ /dev/null @@ -1,55 +0,0 @@ -language: python - -addon: - apt: - nodejs - -matrix: - include: - - python: "2.7" - - python: "pypy" - # pypy3 disabled until fixed - #- python: "pypy3" - - python: "3.3" - - python: "3.4" - - python: "3.5" - - python: "3.6" - env: - - eslint=yes - - sphinx=yes - -install: - - pip install --upgrade pip - - pip install travis-sphinx flake8 pep8 codecov coveralls . - - | - if [ "$eslint" = "yes" ]; then - nvm install stable - nvm use stable - npm install eslint - export PATH="$PWD/node_modules/.bin:$PATH" - fi - -script: - - make travis-script - - | - if [ "$eslint" = "yes" ]; then - make eslint - fi - - | - if [ "$sphinx" = "yes" ]; then - make travis-script-sphinx - fi - -after_success: - - make travis-success - - | - if [ "$sphinx" = "yes" ]; then - make travis-success-sphinx - fi - -notifications: - email: false - -cache: - directories: - - $HOME/.cache/pip diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000000000000000000000000000000000000..eb11059cad321e4ed7bc81bad0c58e7676f6fef3 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,49 @@ +## [0.6.0] - Upcoming +### Added +- Plugin discovery. +- New plugin file-actions providing copy/cut/paste and directory creation. +- Smart cookie-based sessions. +- Directory browsing cache headers. +- Favicon statics and metadata. + +### Changes +- Faster directory tarball streaming with more options. +- Code simplification. +- Base directory is downloadable now. +- Directory entries stats are retrieved first and foremost. + +### Removed +- Python 2.7, 3.3 and 3.4 support have been dropped. + +## [0.5.6] - 2017-09-09 +### Added +- Handle filesystem path size limits. + +### Changes +- Player plugin honoring directory ordering. +- Windows fixes + +## [0.5.3] - 2017-05-12 +### Added +- New "exclude" option (--exclude and --exclude-from), accepting UNIX globs. +- Windows support. + +### Changed +- Fixes #12 (Windows compatibility) + +## [0.5.0] - 2016-11-24 +### Added +- Protocol for plugin command line argument handling. +- Player plugin playlist support (m3u, pls) and directory). +- Customizable browser file sorting. +- Multiple-file uploads. + +### Changed +- New plugin and widget API, old one have been deprecated. +- Use of optimized scandir (faster directory listing). +- Browsepy HTML responses are now minified. +- Browsepy command script. + +## [0.3.8] - 2015-11-05 +### Added +- First useable release. diff --git a/MANIFEST.in b/MANIFEST.in index b32f958030889488ecfe4c51930b321d01e2e468..78796c2a2189f92df8202d73eece493ecf937424 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,12 @@ include README.rst +include LICENSE + graft browsepy/templates graft browsepy/static graft browsepy/plugin/player/templates graft browsepy/plugin/player/static +graft browsepy/plugin/file_actions/templates +graft browsepy/plugin/file_actions/static + +global-exclude *.py[co] +global-exclude __pycache__ diff --git a/Makefile b/Makefile deleted file mode 100644 index 2b90dcf2776a7bcbdd4c294d1a57d1dee9686756..0000000000000000000000000000000000000000 --- a/Makefile +++ /dev/null @@ -1,68 +0,0 @@ -.PHONY: doc clean pep8 coverage travis - -test: pep8 flake8 eslint - python -c 'import yaml;yaml.load(open(".travis.yml").read())' -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-env: - mkdir -p build - python3 -m venv build/env3 - build/env3/bin/pip install pip --upgrade - build/env3/bin/pip install wheel - -build: clean build-env - build/env3/bin/python setup.py bdist_wheel - build/env3/bin/python setup.py sdist - -upload: clean build-env - build/env3/bin/python setup.py bdist_wheel upload - build/env3/bin/python setup.py sdist upload - -doc: - $(MAKE) -C doc html 2>&1 | grep -v \ - 'WARNING: more than one target found for cross-reference' - -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 {} + - -eslint: - eslint \ - --ignore-path .gitignore \ - --ignore-pattern *.min.js \ - ${CURDIR}/browsepy - -flake8: - flake8 browsepy/ - -coverage: - coverage run --source=browsepy setup.py test - -showcoverage: coverage - coverage html - xdg-open file://${CURDIR}/htmlcov/index.html >> /dev/null - -travis-script: pep8 flake8 coverage - -travis-script-sphinx: - travis-sphinx --nowarn --source=doc build - -travis-success: - codecov - coveralls - -travis-success-sphinx: - travis-sphinx deploy diff --git a/README.rst b/README.rst index b077dac1902a964080b9154caacb3f804780248d..a4768b7b6603bcce7e9ce9f40b41555e9302664b 100644 --- a/README.rst +++ b/README.rst @@ -25,9 +25,9 @@ browsepy :target: https://pypi.python.org/pypi/browsepy/ :alt: Version: 0.5.6 -.. image:: https://img.shields.io/badge/python-2.7%2B%2C%203.3%2B-FFC100.svg?style=flat-square +.. image:: https://img.shields.io/badge/python-2.7%2B%2C%203.5%2B-FFC100.svg?style=flat-square :target: https://pypi.python.org/pypi/browsepy/ - :alt: Python 2.7+, 3.3+ + :alt: Python 2.7+, 3.5+ The simple web file browser. @@ -38,7 +38,21 @@ 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` located at `docs` directory. +`Makefile` located under `docs` directory. + +License +------- + +MIT License. See `LICENSE`_. + +.. _LICENSE: https://raw.githubusercontent.com/ergoithz/browsepy/master/LICENSE + +Changelog +--------- + +See `CHANGELOG`_. + +.. _CHANGELOG: https://raw.githubusercontent.com/ergoithz/browsepy/master/CHANGELOG Screenshots ----------- @@ -54,36 +68,18 @@ Features * **Downloadable directories**, streaming directory tarballs on the fly. * **Optional remove** for files under given path. * **Optional upload** for directories under given path. -* **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 dependency 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. +* **Plugins** + * **player**, web audio player. + * **file-actions**, cut, copy paste and directory creation. Install ------- -It's on `pypi` so... +*Note*: with some legacy Python versions shipping outdated libraries, both +`pip` and `setuptools` libraries should be upgraded with +`pip install --upgrade pip setuptools`. + +It's on `pypi`_ so... .. _pypi: https://pypi.python.org/pypi/browsepy/ @@ -92,7 +88,7 @@ It's on `pypi` so... pip install browsepy -You can get the development version from our `github repository`. +You can get the same version from our `github repository`_. .. _github repository: https://github.com/ergoithz/browsepy @@ -122,6 +118,12 @@ Showing help including player plugin arguments browsepy --plugin=player --help +Showing help all detected plugin arguments + +.. code-block:: bash + + browsepy --help-all + This examples assume python's `bin` directory is in `PATH`, otherwise try replacing `browsepy` with `python -m browsepy`. @@ -129,29 +131,37 @@ Command-line arguments ---------------------- 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. +plugins (loaded with `plugin` argument) could add extra arguments to this list +(you can see all them running `browsepy --help-all` instead). :: - usage: browsepy [-h] [--directory PATH] [--initial PATH] [--removable PATH] - [--upload PATH] [--exclude PATTERN] [--exclude-from PATH] - [--plugin MODULE] + usage: browsepy [-h] [--help-all] [--directory PATH] [--initial PATH] + [--removable PATH] [--upload PATH] [--exclude PATTERN] + [--exclude-from PATH] [--version] [--plugin MODULE] [host] [port] + description: starts a browsepy web file browser + positional arguments: - host address to listen (default: 127.0.0.1) - port port to listen (default: 8080) + 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 serving directory (default: current path) - --initial PATH default directory (default: same as --directory) - --removable PATH base directory allowing remove (default: none) - --upload PATH base directory allowing upload (default: none) - --exclude PATTERN exclude paths by pattern (multiple) - --exclude-from PATH exclude paths by pattern file (multiple) - --plugin MODULE load plugin module (multiple) - + -h, --help show this help message and exit + --help-all show help for all available plugins and exit + --directory PATH serving directory (default: /my/current/path) + --initial PATH default directory (default: same as --directory) + --removable PATH base directory allowing remove (default: None) + --upload PATH base directory allowing upload (default: None) + --exclude PATTERN exclude paths by pattern (multiple) + --exclude-from PATH exclude paths by pattern file (multiple) + --version show program's version number and exit + --plugin MODULE load plugin module (multiple) + + available plugins: + file-actions, browsepy.plugin.file_actions + player, browsepy.plugin.player Using as library ---------------- @@ -167,34 +177,16 @@ url, and mounting **browsepy.app** on the appropriate parent .. _WSGI: https://www.python.org/dev/peps/pep-0333/ -Browsepy app config (available at :attr:`browsepy.app.config`) uses the -following configuration options. - -* **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. -* **exclude_fnc** function will be used to exclude files from listing and directory tarballs. Can be either None or function receiving an absolute path and returning a boolean. - -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 programmatically is calling plugin manager's -`load_plugin` method. +Browsepy app config is available at `browsepy.app.config`. + +**Note**: After editing `PLUGIN_MODULES` value, plugin manager (available at +module plugin_manager and app.extensions['plugin_manager']) should be reloaded +using `plugin_manager.reload` method. + +Alternatively, plugins can be loaded programmatically by calling +`plugin_manager.load_plugin` method. + +More information at http://ergoithz.github.io/browsepy/integrations.html Extend via plugin API --------------------- @@ -202,8 +194,11 @@ 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. +Starting from version 0.6.0, browsepy a new plugin `file-actions` is included +providing copy/cut/paste and directory creation operations. + 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. +convenience abstraction for frequently used elements like external stylesheets, +javascript, links, buttons and file upload. More information at http://ergoithz.github.io/browsepy/plugins.html diff --git a/browsepy/__init__.py b/browsepy/__init__.py index 52ccc07463e1c6f4fe074e16559bd50fca77b3ed..0b65493249aab107c1d595a6f8a34e4b90d26839 100644 --- a/browsepy/__init__.py +++ b/browsepy/__init__.py @@ -1,104 +1,176 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- +"""Browsepy simple file server.""" -import logging +__version__ = '0.6.0' + +import typing import os import os.path -import json -import base64 - -from flask import Response, request, render_template, redirect, \ - url_for, send_from_directory, stream_with_context, \ - make_response -from werkzeug.exceptions import NotFound - -from .appconfig import Flask -from .manager import PluginManager -from .file import Node, secure_filename -from .exceptions import OutsideRemovableBase, OutsideDirectoryBase, \ - InvalidFilenameError, InvalidPathError -from . import compat -from . import __meta__ as meta - -__app__ = meta.app # noqa -__version__ = meta.version # noqa -__license__ = meta.license # noqa -__author__ = meta.author # noqa -__basedir__ = os.path.abspath(os.path.dirname(compat.fsdecode(__file__))) - -logger = logging.getLogger(__name__) - -app = Flask( - __name__, - static_url_path='/static', - static_folder=os.path.join(__basedir__, "static"), - template_folder=os.path.join(__basedir__, "templates") +import time + +import jinja2 +import cookieman + +from flask import ( + request, render_template, redirect, + url_for, send_from_directory, + current_app, session, abort, + ) + +import browsepy.mimetype as mimetype + +from browsepy.appconfig import CreateApp +from browsepy.manager import PluginManager +from browsepy.file import Node, secure_filename +from browsepy.stream import tarfile_extension, stream_template +from browsepy.http import etag +from browsepy.exceptions import ( + OutsideRemovableBase, OutsideDirectoryBase, + InvalidFilenameError, InvalidPathError ) -app.config.update( - directory_base=compat.getcwd(), - directory_start=None, - directory_remove=None, - directory_upload=None, - directory_tar_buffsize=262144, - directory_downloadable=True, - use_binary_multiples=True, - plugin_modules=[], - plugin_namespaces=( - 'browsepy.plugin', - 'browsepy_', - '', - ), - exclude_fnc=None, + + +create_app = CreateApp( + __name__, + static_folder='static', + template_folder='templates', ) -app.jinja_env.add_extension('browsepy.transform.htmlcompress.HTMLCompress') -if 'BROWSEPY_SETTINGS' in os.environ: - app.config.from_envvar('BROWSEPY_SETTINGS') -plugin_manager = PluginManager(app) +@create_app.register +def init_config(): + """Configure application.""" + current_app.config.update( + SECRET_KEY=os.urandom(4096), + APPLICATION_NAME='browsepy', + APPLICATION_TIME=0, + DIRECTORY_BASE=os.getcwd(), + DIRECTORY_START=None, + DIRECTORY_REMOVE=None, + DIRECTORY_UPLOAD=None, + DIRECTORY_TAR_BUFFSIZE=262144, + DIRECTORY_TAR_COMPRESSION='gzip', + DIRECTORY_TAR_COMPRESSLEVEL=1, + DIRECTORY_DOWNLOADABLE=True, + USE_BINARY_MULTIPLES=True, + PLUGIN_MODULES=[], + PLUGIN_NAMESPACES=( + 'browsepy.plugin', + 'browsepy_', + '', + ), + EXCLUDE_FNC=None, + ) + current_app.jinja_loader = jinja2.PackageLoader( + __package__, + current_app.template_folder, + ) + current_app.jinja_env.add_extension( + 'browsepy.transform.template.MinifyTemplateExtension', + ) + if 'BROWSEPY_SETTINGS' in os.environ: + current_app.config.from_envvar('BROWSEPY_SETTINGS') + + +@create_app.register +def init_plugins(): + """Configure plugin manager.""" + current_app.session_interface = cookieman.CookieMan() + plugin_manager = PluginManager() + plugin_manager.init_app(current_app) + + @current_app.session_interface.register('browse:sort') + def shrink_browse_sort(data, last): + """Session `browse:short` size reduction logic.""" + if data['browse:sort'] and not last: + data['browse:sort'].pop() + else: + del data['browse:sort'] + return data -def iter_cookie_browse_sorting(cookies): - ''' - Get sorting-cookie from cookies dictionary. +@create_app.before_first_request +def prepare_config(): + """Prepare runtime app config.""" + config = current_app.config + if not config['APPLICATION_TIME']: + config['APPLICATION_TIME'] = time.time() - :yields: tuple of path and sorting property - :ytype: 2-tuple of strings - ''' - try: - data = 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) + +@create_app.context_processor +def template_globals(): + """Get template globals.""" + return { + 'manager': current_app.extensions['plugin_manager'], + 'len': len, + } + + +@create_app.url_defaults +def default_directory_download_extension(endpoint, values): + """Set default extension for download_directory endpoint.""" + if endpoint == 'download_directory': + compression = current_app.config['DIRECTORY_TAR_COMPRESSION'] + values.setdefault('ext', tarfile_extension(compression)) + + +@create_app.errorhandler(InvalidPathError) +def bad_request_error(e): + """Handle invalid requests errors, rendering HTTP 400 page.""" + file = None + if hasattr(e, 'path'): + file = Node(e.path) + if not isinstance(e, InvalidFilenameError): + file = file.parent + return render_template('400.html', file=file, error=e), 400 + + +@create_app.errorhandler(OutsideRemovableBase) +@create_app.errorhandler(OutsideDirectoryBase) +@create_app.errorhandler(404) +def page_not_found_error(e): + """Handle resource not found errors, rendering 404 error page.""" + return render_template('404.html'), 404 + + +@create_app.errorhandler(Exception) +@create_app.errorhandler(500) +def internal_server_error(e): # pragma: no cover + """Handle server errors, rendering 404 error page.""" + current_app.logger.exception(e) + return getattr(e, 'message', 'Internal server error'), 500 def get_cookie_browse_sorting(path, default): - ''' + # type: (str, str) -> str + """ Get sorting-cookie data for path of current request. - :returns: sorting property - :rtype: string - ''' + :param path: path for sorting attribute + :param default: default sorting attribute + :return: sorting property + """ if request: - for cpath, cprop in iter_cookie_browse_sorting(request.cookies): + for cpath, cprop in session.get('browse:sort', ()): if path == cpath: return cprop return default def browse_sortkey_reverse(prop): - ''' - Get sorting function for directory listing based on given attribute - name, with some caveats: - * Directories will be first. - * If *name* is given, link widget lowercase text will be used istead. - * If *size* is given, bytesize will be used. + # type: (str) -> typing.Tuple[typing.Callable[[Node], typing.Any], bool] + """ + Get directory content sort function based on given attribute name. :param prop: file attribute name - :returns: tuple with sorting gunction and reverse bool - :rtype: tuple of a dict and a bool - ''' + :return: tuple with sorting function and reverse bool + + The sort function takes some extra considerations: + + 1. Directories will be always first. + 2. If *name* is given, link widget lowercase text will be used instead. + 3. If *size* is given, bytesize will be used. + + """ if prop.startswith('-'): prop = prop[1:] reverse = True @@ -130,194 +202,141 @@ def browse_sortkey_reverse(prop): ) -def stream_template(template_name, **context): - ''' - Some templates can be huge, this function returns an streaming response, - sending the content in chunks and preventing from timeout. - - :param template_name: template - :param **context: parameters for templates. - :yields: HTML strings - ''' - app.update_template_context(context) - template = app.jinja_env.get_template(template_name) - stream = template.generate(context) - return Response(stream_with_context(stream)) - - -@app.context_processor -def template_globals(): - return { - 'manager': app.extensions['plugin_manager'], - 'len': len, - } - - -@app.route('/sort/', defaults={"path": ""}) -@app.route('/sort//') +@create_app.route('/sort/', defaults={'path': ''}) +@create_app.route('/sort//') def sort(property, path): - try: - directory = Node.from_urlpath(path) - except OutsideDirectoryBase: - return NotFound() - - if not directory.is_directory or directory.is_excluded: - return NotFound() - - data = [ - (cpath, cprop) - for cpath, cprop in iter_cookie_browse_sorting(request.cookies) - 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 + """Handle sort request, add sorting rule to session.""" + directory = Node.from_urlpath(path) + if directory.is_directory and not directory.is_excluded: + session['browse:sort'] = \ + [(path, property)] + session.get('browse:sort', []) + return redirect(url_for('.browse', path=directory.urlpath)) + abort(404) -@app.route("/browse", defaults={"path": ""}) -@app.route('/browse/') +@create_app.route('/browse', defaults={'path': ''}) +@create_app.route('/browse/') def browse(path): + """Handle browse request, serve directory listing.""" sort_property = get_cookie_browse_sorting(path, 'text') sort_fnc, sort_reverse = browse_sortkey_reverse(sort_property) - - try: - directory = Node.from_urlpath(path) - if directory.is_directory and not directory.is_excluded: - return stream_template( - 'browse.html', - file=directory, + directory = Node.from_urlpath(path) + if directory.is_directory and not directory.is_excluded: + last_modified = max( + directory.content_mtime, + current_app.config['APPLICATION_TIME'], + ) + response = stream_template( + 'browse.html', + file=directory, + sort_property=sort_property, + sort_fnc=sort_fnc, + sort_reverse=sort_reverse, + ) + response.last_modified = last_modified + response.set_etag( + etag( + last_modified=last_modified, sort_property=sort_property, - sort_fnc=sort_fnc, - sort_reverse=sort_reverse - ) - except OutsideDirectoryBase: - pass - return NotFound() + ), + ) + response.make_conditional(request) + return response + abort(404) -@app.route('/open/', endpoint="open") +@create_app.route('/open/', endpoint='open') def open_file(path): - try: - file = Node.from_urlpath(path) - if file.is_file and not file.is_excluded: - return send_from_directory(file.parent.path, file.name) - except OutsideDirectoryBase: - pass - return NotFound() + """Handle open request, serve file.""" + file = Node.from_urlpath(path) + if file.is_file and not file.is_excluded: + return send_from_directory(file.parent.path, file.name) + abort(404) -@app.route("/download/file/") +@create_app.route('/download/file/') def download_file(path): - try: - file = Node.from_urlpath(path) - if file.is_file and not file.is_excluded: - return file.download() - except OutsideDirectoryBase: - pass - return NotFound() - - -@app.route("/download/directory/.tgz") -def download_directory(path): - try: - directory = Node.from_urlpath(path) - if directory.is_directory and not directory.is_excluded: - return directory.download() - except OutsideDirectoryBase: - pass - return NotFound() - - -@app.route("/remove/", methods=("GET", "POST")) + """Handle download request, serve file as attachment.""" + file = Node.from_urlpath(path) + if file.is_file and not file.is_excluded: + return file.download() + abort(404) + + +@create_app.route('/download/directory.', defaults={'path': ''}) +@create_app.route('/download/directory/?.') +def download_directory(path, ext): + """Handle download directory request, serve tarfile as attachment.""" + compression = current_app.config['DIRECTORY_TAR_COMPRESSION'] + if ext != tarfile_extension(compression): + abort(404) + directory = Node.from_urlpath(path) + if directory.is_directory and not directory.is_excluded: + return directory.download() + abort(404) + + +@create_app.route('/remove/', methods=('GET', 'POST')) def remove(path): - try: - file = Node.from_urlpath(path) - except OutsideDirectoryBase: - return NotFound() - - if not file.can_remove or file.is_excluded or not file.parent: - return NotFound() - - if request.method == 'GET': - return render_template('remove.html', file=file) - - file.remove() - return redirect(url_for(".browse", path=file.parent.urlpath)) - - -@app.route("/upload", defaults={'path': ''}, methods=("POST",)) -@app.route("/upload/", methods=("POST",)) + """Handle remove request, serve confirmation dialog.""" + file = Node.from_urlpath(path) + if file.can_remove and not file.is_excluded: + if request.method == 'GET': + return render_template('remove.html', file=file) + file.remove() + return redirect(url_for(".browse", path=file.parent.urlpath)) + abort(404) + + +@create_app.route('/upload', defaults={'path': ''}, methods=('POST',)) +@create_app.route('/upload/', methods=('POST',)) def upload(path): - try: - directory = Node.from_urlpath(path) - except OutsideDirectoryBase: - return NotFound() - + """Handle upload request.""" + directory = Node.from_urlpath(path) if ( - not directory.is_directory or - not directory.can_upload or - directory.is_excluded + directory.is_directory and + directory.can_upload and + not directory.is_excluded ): - return NotFound() - - 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) - else: + files = ( + (secure_filename(file.filename), file) + for values in request.files.listvalues() + for file in values + ) + for filename, file in files: + if not filename: raise InvalidFilenameError( path=directory.path, - filename=f.filename + filename=file.filename, ) - return redirect(url_for(".browse", path=directory.urlpath)) - - -@app.route("/") -def index(): - path = app.config["directory_start"] or app.config["directory_base"] - try: - urlpath = Node(path).urlpath - except OutsideDirectoryBase: - return NotFound() - return browse(urlpath) - - -@app.after_request -def page_not_found(response): - if response.status_code == 404: - return make_response((render_template('404.html'), 404)) + filename = directory.choose_filename(filename) + filepath = os.path.join(directory.path, filename) + file.save(filepath) + return redirect(url_for('.browse', path=directory.urlpath)) + abort(404) + + +@create_app.route('/') +def metadata(filename): + """Handle metadata request, serve browse metadata file.""" + last_modified = current_app.config['APPLICATION_TIME'] + response = stream_template(filename) + response.content_type = mimetype.by_python(filename) + response.last_modified = current_app.config['APPLICATION_TIME'] + response.set_etag(etag(last_modified=last_modified)) + response.make_conditional(request) return response -@app.errorhandler(InvalidPathError) -def bad_request_error(e): - file = None - if hasattr(e, 'path'): - if isinstance(e, InvalidFilenameError): - file = Node(e.path) - else: - file = Node(e.path).parent - return render_template('400.html', file=file, error=e), 400 - - -@app.errorhandler(OutsideRemovableBase) -@app.errorhandler(404) -def page_not_found_error(e): - return render_template('404.html'), 404 +@create_app.route('/') +def index(): + """Handle index request, serve either start or base directory listing.""" + path = ( + current_app.config['DIRECTORY_START'] or + current_app.config['DIRECTORY_BASE'] + ) + return browse(Node(path).urlpath) -@app.errorhandler(500) -def internal_server_error(e): # pragma: no cover - logger.exception(e) - return getattr(e, 'message', 'Internal server error'), 500 +app = create_app() +plugin_manager = app.extensions['plugin_manager'] diff --git a/browsepy/__main__.py b/browsepy/__main__.py index 9bdb7a2fc34317594a8349cceb4e3e6b10dbcb51..278e99435e699ae5d63c6f25cb3b8b2b8a505d5a 100644 --- a/browsepy/__main__.py +++ b/browsepy/__main__.py @@ -1,42 +1,23 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +"""Browsepy command entrypoint module.""" import re import sys import os import os.path import argparse -import warnings import flask -from . import app -from . import __meta__ as meta -from .compat import PY_LEGACY, getdebug, get_terminal_size +from . import app, __version__ +from .compat import getdebug, SafeArgumentParser, HelpFormatter from .transform.glob import translate -class HelpFormatter(argparse.RawTextHelpFormatter): - def __init__(self, prog, indent_increment=2, max_help_position=24, - width=None): - if width is None: - try: - width = get_terminal_size().columns - 2 - except ValueError: # https://bugs.python.org/issue24966 - pass - super(HelpFormatter, self).__init__( - prog, indent_increment, max_help_position, width) +class CommaSeparatedAction(argparse.Action): + """Argparse action for multi-comma separated options.""" - -class PluginAction(argparse.Action): def __call__(self, parser, namespace, value, option_string=None): - warned = '%s_warning' % self.dest - if ',' in value and not getattr(namespace, warned, False): - setattr(namespace, warned, True) - warnings.warn( - 'Comma-separated --plugin value is deprecated, ' - 'use multiple --plugin options instead.' - ) + """Parse option value and update namespace.""" values = value.split(',') prev = getattr(namespace, self.dest, None) if isinstance(prev, list): @@ -44,27 +25,30 @@ class PluginAction(argparse.Action): setattr(namespace, self.dest, values) -class ArgParse(argparse.ArgumentParser): - default_directory = app.config['directory_base'] +class ArgParse(SafeArgumentParser): + """Browsepy argument parser class.""" + + default_directory = app.config['DIRECTORY_BASE'] default_initial = ( None - if app.config['directory_start'] == app.config['directory_base'] else - app.config['directory_start'] + if app.config['DIRECTORY_START'] == app.config['DIRECTORY_BASE'] else + app.config['DIRECTORY_START'] ) - default_removable = app.config['directory_remove'] - default_upload = app.config['directory_upload'] + default_removable = app.config['DIRECTORY_REMOVE'] + default_upload = app.config['DIRECTORY_UPLOAD'] + name = app.config['APPLICATION_NAME'] default_host = os.getenv('BROWSEPY_HOST', '127.0.0.1') default_port = os.getenv('BROWSEPY_PORT', '8080') - plugin_action_class = PluginAction defaults = { - 'prog': meta.app, + 'prog': name, 'formatter_class': HelpFormatter, - 'description': 'description: starts a %s web file browser' % meta.app + 'description': 'description: starts a %s web file browser' % name } def __init__(self, sep=os.sep): + """Initialize.""" super(ArgParse, self).__init__(**self.defaults) self.add_argument( 'host', nargs='?', @@ -74,6 +58,12 @@ class ArgParse(argparse.ArgumentParser): 'port', nargs='?', type=int, default=self.default_port, help='port to listen (default: %(default)s)') + self.add_argument( + '--help', action='store_true', + help='show help and exit (honors --plugin)') + self.add_argument( + '--help-all', action='store_true', + help='show help for all available plugins and exit') self.add_argument( '--directory', metavar='PATH', type=self._directory, default=self.default_directory, @@ -101,9 +91,12 @@ class ArgParse(argparse.ArgumentParser): action='append', default=[], help='exclude paths by pattern file (multiple)') + self.add_argument( + '--version', action='version', + version=__version__) self.add_argument( '--plugin', metavar='MODULE', - action=self.plugin_action_class, + action=CommaSeparatedAction, default=[], help='load plugin module (multiple)') self.add_argument( @@ -111,9 +104,6 @@ class ArgParse(argparse.ArgumentParser): help=argparse.SUPPRESS) def _path(self, arg): - if PY_LEGACY and hasattr(sys.stdin, 'encoding'): - encoding = sys.stdin.encoding or sys.getdefaultencoding() - arg = arg.decode(encoding) return os.path.abspath(arg) def _file(self, arg): @@ -130,6 +120,7 @@ class ArgParse(argparse.ArgumentParser): def create_exclude_fnc(patterns, base, sep=os.sep): + """Create browsepy exclude function from list of glob patterns.""" if patterns: regex = '|'.join(translate(pattern, sep, base) for pattern in patterns) return re.compile(regex).search @@ -137,6 +128,7 @@ def create_exclude_fnc(patterns, base, sep=os.sep): def collect_exclude_patterns(paths): + """Extract list of glob patterns from given list of glob files.""" patterns = [] for path in paths: with open(path, 'r') as f: @@ -148,11 +140,13 @@ def collect_exclude_patterns(paths): def list_union(*lists): + """Get unique union list from given iterables.""" lst = [i for l in lists for i in l] return sorted(frozenset(lst), key=lst.index) def filter_union(*functions): + """Get union of given filter callables.""" filtered = [fnc for fnc in functions if fnc] if filtered: if len(filtered) == 1: @@ -162,22 +156,23 @@ def filter_union(*functions): def main(argv=sys.argv[1:], app=app, parser=ArgParse, run_fnc=flask.Flask.run): + """Run browsepy.""" plugin_manager = app.extensions['plugin_manager'] args = plugin_manager.load_arguments(argv, parser()) patterns = args.exclude + collect_exclude_patterns(args.exclude_from) if args.debug: os.environ['DEBUG'] = 'true' app.config.update( - directory_base=args.directory, - directory_start=args.initial or args.directory, - directory_remove=args.removable, - directory_upload=args.upload, - plugin_modules=list_union( - app.config['plugin_modules'], + DIRECTORY_BASE=args.directory, + DIRECTORY_START=args.initial or args.directory, + DIRECTORY_REMOVE=args.removable, + DIRECTORY_UPLOAD=args.upload, + PLUGIN_MODULES=list_union( + app.config['PLUGIN_MODULES'], args.plugin, ), - exclude_fnc=filter_union( - app.config['exclude_fnc'], + EXCLUDE_FNC=filter_union( + app.config['EXCLUDE_FNC'], create_exclude_fnc(patterns, args.directory), ), ) diff --git a/browsepy/__meta__.py b/browsepy/__meta__.py deleted file mode 100644 index 0ca7f532de31f3b030183997462ac99c3cf38922..0000000000000000000000000000000000000000 --- a/browsepy/__meta__.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- - -app = 'browsepy' -description = 'Simple web file browser' -version = '0.5.6' -license = 'MIT' -author_name = 'Felipe A. Hernandez' -author_mail = 'ergoithz@gmail.com' -author = '%s <%s>' % (author_name, author_mail) -url = 'https://github.com/ergoithz/browsepy' -tarball = '%s/archive/%s.tar.gz' % (url, version) diff --git a/browsepy/appconfig.py b/browsepy/appconfig.py index 384108efae140913d1370f0483fdac7de440a728..14f65bdf83a9588e1a4e937692eb5b12bf821003 100644 --- a/browsepy/appconfig.py +++ b/browsepy/appconfig.py @@ -1,68 +1,151 @@ +"""Flask app config utilities.""" + +import typing +import warnings + import flask import flask.config -from .compat import basestring + +T = typing.TypeVar('T') class Config(flask.config.Config): - ''' - Flask-compatible case-insensitive Config classt. + """ + Flask-compatible case-insensitive Config class. See :type:`flask.config.Config` for more info. - ''' + """ + def __init__(self, root, defaults=None): + """Initialize.""" + self._warned = set() if defaults: defaults = self.gendict(defaults) super(Config, self).__init__(root, defaults) - @classmethod - def genkey(cls, k): - ''' - Key translation function. + def genkey(self, key): # type: (T) -> T + """ + Get translated key. :param k: key - :type k: str :returns: uppercase key - ;rtype: str - ''' - return k.upper() if isinstance(k, basestring) else k - - @classmethod - def gendict(cls, *args, **kwargs): - ''' - Pre-translated key dictionary constructor. + """ + if isinstance(key, str): + uppercase = key.upper() + if key not in self._warned and key != uppercase: + self._warned.add(key) + warnings.warn( + 'Config accessed with lowercase key ' + '%r, lowercase config is deprecated.' % key, + DeprecationWarning, + 3 + ) + return uppercase + return key + + def gendict(self, *args, **kwargs): # type: (...) -> dict + """ + Generate dictionary with pre-translated keys. See :type:`dict` for more info. :returns: dictionary with uppercase keys - :rtype: dict - ''' - gk = cls.genkey + """ + gk = self.genkey return dict((gk(k), v) for k, v in dict(*args, **kwargs).items()) def __getitem__(self, k): + """Return self[k].""" return super(Config, self).__getitem__(self.genkey(k)) def __setitem__(self, k, v): + """Assign value to key, same as self[k]=value.""" super(Config, self).__setitem__(self.genkey(k), v) def __delitem__(self, k): + """Remove item, same as del self[k].""" super(Config, self).__delitem__(self.genkey(k)) def get(self, k, default=None): + """Get option from config, return default if not found.""" return super(Config, self).get(self.genkey(k), default) def pop(self, k, *args): + """Remove and get option from config, accepts an optional default.""" return super(Config, self).pop(self.genkey(k), *args) def update(self, *args, **kwargs): + """Update current object with given keys and values.""" super(Config, self).update(self.gendict(*args, **kwargs)) class Flask(flask.Flask): - ''' + """ Flask class using case-insensitive :type:`Config` class. See :type:`flask.Flask` for more info. - ''' + """ + config_class = Config + + +class AppDecoratorProxy(object): + """Flask app decorator proxy.""" + + def __init__(self, name, nested=False): + """Initialize.""" + self.name = name + self.nested = nested + + def __get__(self, obj, type=None): + """Get deferred registering decorator.""" + def decorator(*args, **kwargs): + def wrapper(fnc): + def callback(): + meth = getattr(flask.current_app, self.name) + meth(*args, **kwargs)(fnc) if self.nested else meth(fnc) + obj.register(callback) + return fnc + return wrapper + return decorator if self.nested else decorator() + + +class CreateApp(object): + """Flask create_app pattern factory.""" + + flask_class = Flask + defaults = { + 'static_folder': None, + 'template_folder': None, + } + + def __init__(self, import_name, **options): + """ + Initialize. + + Arguments are passed to :class:`Flask` constructor. + """ + self.import_name = import_name + self.options = self.defaults.copy() + self.options.update(options) + self.registry = [] + + def __call__(self): # type: () -> Flask + """Create Flask app instance.""" + app = self.flask_class(self.import_name, **self.options) + with app.app_context(): + for fnc in self.registry: + fnc() + return app + + def register(self, fnc): + """Register function to be called when app is initialized.""" + self.registry.append(fnc) + return fnc + + before_first_request = AppDecoratorProxy('before_first_request') + context_processor = AppDecoratorProxy('context_processor') + url_defaults = AppDecoratorProxy('url_defaults') + errorhandler = AppDecoratorProxy('errorhandler', nested=True) + route = AppDecoratorProxy('route', nested=True) diff --git a/browsepy/compat.py b/browsepy/compat.py index 913588b4fc9572c6a4951d0964022b0c9ead7a7c..88abe956241f455463bf32b0e8563e891fa36534 100644 --- a/browsepy/compat.py +++ b/browsepy/compat.py @@ -1,119 +1,207 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- +"""Module providing both runtime and platform compatibility workarounds.""" +import typing import os import os.path import sys -import itertools - -import warnings +import errno +import time import functools - +import contextlib +import warnings import posixpath import ntpath - -FS_ENCODING = sys.getfilesystemencoding() -PY_LEGACY = sys.version_info < (3, ) -TRUE_VALUES = frozenset(('true', 'yes', '1', 'enable', 'enabled', True, 1)) +import argparse +import shutil try: - from os import scandir, walk + import importlib.resources as res # python 3.7+ except ImportError: - from scandir import scandir, walk # noqa + import importlib_resources as res # noqa try: - from shutil import get_terminal_size + from functools import cached_property # python 3.8+ except ImportError: - from backports.shutil_get_terminal_size import get_terminal_size # noqa + from werkzeug.utils import cached_property # noqa + + +TFunction = typing.Callable[..., typing.Any] +TFunction2 = typing.Callable[..., typing.Any] +OSEnvironType = typing.Mapping[str, str] +FS_ENCODING = sys.getfilesystemencoding() +TRUE_VALUES = frozenset( + # Truthy values + ('true', 'yes', '1', 'enable', 'enabled', True, 1) + ) +RETRYABLE_OSERROR_PROPERTIES = { + 'errno': frozenset( + # Error codes which could imply a busy filesystem + getattr(errno, prop) + for prop in ( + 'ENOENT', + 'EIO', + 'ENXIO', + 'EAGAIN', + 'EBUSY', + 'ENOTDIR', + 'EISDIR', + 'ENOTEMPTY', + 'EALREADY', + 'EINPROGRESS', + 'EREMOTEIO', + ) + if hasattr(errno, prop) + ), + 'winerror': frozenset( + # Handle WindowsError instances without errno + (5, 145) + ), + } + + +class SafeArgumentParser(argparse.ArgumentParser): + """ArgumentParser based class which safer default behavior.""" + + allow_abbrev_support = sys.version_info >= (3, 5, 0) + + def _get_option_tuples(self, option_string): + return [] + + def __init__(self, **kwargs): + """Initialize object.""" + if self.allow_abbrev_support: + kwargs.setdefault('allow_abbrev', False) + kwargs.setdefault('add_help', False) + super(SafeArgumentParser, self).__init__(**kwargs) + + +class HelpFormatter(argparse.RawTextHelpFormatter): + """HelpFormatter for argument parsers honoring terminal width.""" + + def __init__(self, prog, indent_increment=2, max_help_position=24, + width=None): + """Initialize.""" + if width is None: + try: + width = shutil.get_terminal_size().columns - 2 + except ValueError: # https://bugs.python.org/issue24966 + pass + super(HelpFormatter, self).__init__( + prog, indent_increment, max_help_position, width) + + +@contextlib.contextmanager +def scandir(path): + # type: (str) -> typing.Generator[typing.Iterator[os.DirEntry], None, None] + """ + Get iterable of :class:`os.DirEntry` as context manager. + + This is just backwards-compatible :func:`scandir.scandir` context + manager wrapper, as since `3.6` calling `close` method became + mandatory, but it's not available on previous versions. + + :param path: path to iterate + :type path: str + """ + files = os.scandir(path) + try: + yield files + finally: + if callable(getattr(files, 'close', None)): + files.close() + + +def _unsafe_rmtree(path): + # type: (str) -> None + """ + Remove directory tree, without error handling. + + :param path: directory path + :type path: str + """ + for base, dirs, files in os.walk(path, topdown=False): + for filename in files: + os.remove(os.path.join(base, filename)) + + with scandir(base) as remaining: + retry = any(remaining) + + if retry: + time.sleep(0.1) # wait for sluggish filesystems + _unsafe_rmtree(base) + else: + os.rmdir(base) + + +def rmtree(path): + # type: (str) -> None + """ + Remove directory tree, with platform-specific fixes. + + Implemented from scratch as :func:`shutil.rmtree` is broken on some + platforms and python version combinations. + + :param path: path to remove + """ + error = EnvironmentError + for retry in range(10): + try: + return _unsafe_rmtree(path) + except EnvironmentError as e: + if all(getattr(e, p, None) not in v + for p, v in RETRYABLE_OSERROR_PROPERTIES.items()): + raise + error = e + time.sleep(0.1) + raise error def isexec(path): - ''' + # type: (str) -> bool + """ 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 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 = fsdecode(cwd_fnc(), 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. + # type: (OSEnvironType, typing.Iterable[str]) -> bool + """ + Get if app is running in debug mode. + + This is detected looking at environment variables. :param environ: environment dict-like object - :type environ: collections.abc.Mapping + :param true_values: iterable of truthy values :returns: True if debug contains a true-like string, False otherwise - :rtype: bool - ''' + """ return environ.get('DEBUG', '').lower() in true_values +@typing.overload +def deprecated(func_or_text, environ=os.environ): + # type: (str, OSEnvironType) -> typing.Callable[[TFunction], TFunction] + """Get deprecation decorator with given message.""" + + +@typing.overload +def deprecated(func_or_text, environ=os.environ): + # type: (TFunction, OSEnvironType) -> TFunction + """Decorate with default deprecation message.""" + + 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. + """ + Decorate function and mark it as deprecated. + + Calling a deprected function will result in a warning message. + + :param func_or_text: message or callable to decorate + :param environ: optional environment mapping + :returns: nested decorator or new decorated function (depending on params) Usage: @@ -127,13 +215,7 @@ def deprecated(func_or_text, environ=os.environ): ... 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__) @@ -146,33 +228,37 @@ def deprecated(func_or_text, environ=os.environ): with warnings.catch_warnings(): if getdebug(environ): warnings.simplefilter('always', DeprecationWarning) - warnings.warn(message, category=DeprecationWarning, - stacklevel=3) + warnings.warn(message, DeprecationWarning, 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 + # type: (TFunction) -> typing.Callable[[TFunction2], TFunction2] + """ + Get decorating function which copies given object __doc__. :param other: anything with a __doc__ attribute :type other: any :returns: decorator function :rtype: callable - ''' + + Usage: + + .. code-block:: python + + def fnc1(): + '''docstring''' + pass + + @usedoc(fnc1) + def fnc2(): + pass + + print(fnc2.__doc__) # 'docstring' + + """ def inner(fnc): fnc.__doc__ = fnc.__doc__ or getattr(other, '__doc__') return fnc @@ -180,18 +266,16 @@ def usedoc(other): def pathsplit(value, sep=os.pathsep): - ''' - Get enviroment PATH elements as list. + # type: (str, str) -> typing.Generator[str, None, None] + """ + Iterate environment PATH elements. This function only cares about spliting across OSes. :param value: path string, as given by os.environ['PATH'] - :type value: str :param sep: PATH separator, defaults to os.pathsep - :type sep: str :yields: every path - :ytype: str - ''' + """ for part in value.split(sep): if part[:1] == part[-1:] == '"' or part[:1] == part[-1:] == '\'': part = part[1:-1] @@ -199,21 +283,18 @@ def pathsplit(value, sep=os.pathsep): def pathparse(value, sep=os.pathsep, os_sep=os.sep): - ''' - Get enviroment PATH directories as list. + # type: (str, str, str) -> typing.Generator[str, None, None] + """ + Iterate environment PATH directories. This function cares about spliting, escapes and normalization of paths across OSes. :param value: path string, as given by os.environ['PATH'] - :type value: str :param sep: PATH separator, defaults to os.pathsep - :type sep: str :param os_sep: OS filesystem path separator, defaults to os.sep - :type os_sep: str - :yields: every path - :ytype: str - ''' + :yields: every path in PATH + """ escapes = [] normpath = ntpath.normpath if os_sep == '\\' else posixpath.normpath if '\\' not in (os_sep, sep): @@ -230,7 +311,7 @@ def pathparse(value, sep=os.pathsep, os_sep=os.sep): part = part[:-1] for original, escape, unescape in escapes: part = part.replace(escape, unescape) - yield normpath(fsdecode(part)) + yield normpath(part) def pathconf(path, @@ -238,15 +319,14 @@ def pathconf(path, isdir_fnc=os.path.isdir, pathconf_fnc=getattr(os, 'pathconf', None), pathconf_names=getattr(os, 'pathconf_names', ())): - ''' + """ Get all pathconf variables for given path. :param path: absolute fs path :type path: str :returns: dictionary containing pathconf keys and their values (both str) :rtype: dict - ''' - + """ if pathconf_fnc and pathconf_names: return {key: pathconf_fnc(path, key) for key in pathconf_names} if os_name == 'nt': @@ -263,29 +343,24 @@ ENV_PATH = tuple(pathparse(os.getenv('PATH', ''))) ENV_PATHEXT = tuple(pathsplit(os.getenv('PATHEXT', ''))) -def which(name, - env_path=ENV_PATH, - env_path_ext=ENV_PATHEXT, - is_executable_fnc=isexec, - path_join_fnc=os.path.join, - os_name=os.name): - ''' +def which(name, # type: str + env_path=ENV_PATH, # type: typing.Iterable[str] + env_path_ext=ENV_PATHEXT, # type: typing.Iterable[str] + is_executable_fnc=isexec, # type: typing.Callable[[str], bool] + path_join_fnc=os.path.join, # type: typing.Callable[[...], str] + os_name=os.name, # type: str + ): # type: (...) -> typing.Optional[str] + """ 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 :param os_name: os name, defaults to os.name - :type os_name: str :return: absolute path - :rtype: str or None - ''' + """ for path in env_path: for suffix in env_path_ext: exe_file = path_join_fnc(path, name) + suffix @@ -295,36 +370,23 @@ def which(name, def re_escape(pattern, chars=frozenset("()[]{}?*+|^$\\.-#")): - ''' - Escape all special regex characters in pattern. + # type: (str, typing.Iterable[str]) -> str + """ + Escape pattern to include it safely into another regex. + + This function escapes all special regex characters while translating + non-ascii characters into unicode escape sequences. + Logic taken from regex module. :param pattern: regex pattern to escape - :type patterm: str :returns: escaped pattern - :rtype: str - ''' - escape = '\\{}'.format + """ + chr_escape = '\\{}'.format + uni_escape = '\\u{:04d}'.format return ''.join( - escape(c) if c in chars or c.isspace() else - '\\000' if c == '\x00' else c + chr_escape(c) if c in chars or c.isspace() else + c if '\x19' < c < '\x80' else + uni_escape(ord(c)) for c in pattern ) - - -if PY_LEGACY: - FileNotFoundError = OSError # noqa - range = xrange # noqa - filter = itertools.ifilter - basestring = basestring # noqa - unicode = unicode # noqa - chr = unichr # noqa - bytes = str # noqa -else: - FileNotFoundError = FileNotFoundError - range = range - filter = filter - basestring = str - unicode = str - chr = chr - bytes = bytes diff --git a/browsepy/exceptions.py b/browsepy/exceptions.py index 8a47c030c1683127c244ca2529bd848a779ad4c0..2ad5242ff27c5696c928efcc16c50073f166a4a4 100644 --- a/browsepy/exceptions.py +++ b/browsepy/exceptions.py @@ -1,71 +1,109 @@ +"""Common browsepy exceptions.""" + class OutsideDirectoryBase(Exception): - ''' + """ + Access denied because path outside DIRECTORY_BASE. + Exception raised when trying to access to a file outside path defined on - `directory_base` config property. - ''' - pass + `DIRECTORY_BASE` config property. + """ class OutsideRemovableBase(Exception): - ''' + """ + Access denied because path outside DIRECTORY_REMOVE. + Exception raised when trying to access to a file outside path defined on - `directory_remove` config property. - ''' - pass + `DIRECTORY_REMOVE` config property. + """ class InvalidPathError(ValueError): - ''' - Exception raised when a path is not valid. + """Invalid path error, raised when a path is invalid.""" - :property path: value whose length raised this Exception - ''' code = 'invalid-path' template = 'Path {0.path!r} is not valid.' def __init__(self, message=None, path=None): + """ + Initialize. + + :param message: custom error message + :param path: path causing this exception + """ self.path = path message = self.template.format(self) if message is None else message super(InvalidPathError, self).__init__(message) class InvalidFilenameError(InvalidPathError): - ''' - Exception raised when a filename is not valid. + """Invalid filename error, raised when a provided filename is invalid.""" - :property filename: value whose length raised this Exception - ''' code = 'invalid-filename' template = 'Filename {0.filename!r} is not valid.' def __init__(self, message=None, path=None, filename=None): + """ + Initialize. + + :param message: custom error message + :param path: target path + :param filename: filemane causing this exception + """ self.filename = filename super(InvalidFilenameError, self).__init__(message, path=path) class PathTooLongError(InvalidPathError): - ''' - Exception raised when maximum filesystem path length is reached. + """Path too long for filesystem error.""" - :property limit: value length limit - ''' code = 'invalid-path-length' template = 'Path {0.path!r} is too long, max length is {0.limit}' def __init__(self, message=None, path=None, limit=0): + """ + Initialize. + + :param message: custom error message + :param path: path causing this exception + :param limit: known path length limit + """ self.limit = limit super(PathTooLongError, self).__init__(message, path=path) class FilenameTooLongError(InvalidFilenameError): - ''' - Exception raised when maximum filesystem filename length is reached. - ''' + """Filename too long for filesystem error.""" + code = 'invalid-filename-length' template = 'Filename {0.filename!r} is too long, max length is {0.limit}' def __init__(self, message=None, path=None, filename=None, limit=0): + """ + Initialize. + + :param message: custom error message + :param path: target path + :param filename: filename causing this exception + :param limit: known filename length limit + """ self.limit = limit super(FilenameTooLongError, self).__init__( message, path=path, filename=filename) + + +class PluginNotFoundError(ImportError): + """Plugin not found error.""" + + +class WidgetException(Exception): + """Base widget exception.""" + + +class WidgetParameterException(WidgetException): + """Invalid widget parameter exception.""" + + +class InvalidArgumentError(ValueError): + """Invalid manager register_widget argument exception.""" diff --git a/browsepy/file.py b/browsepy/file.py index 33c50dd5bf05385cba228c64329411365a0d1641..4258faa6e18e31f94d516f8e67ead01abe24e78c 100644 --- a/browsepy/file.py +++ b/browsepy/file.py @@ -1,37 +1,42 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- +"""File node classes and functions.""" +import typing import os import os.path import re -import shutil import codecs import string import random import datetime -import logging -from flask import current_app, send_from_directory -from werkzeug.utils import cached_property +import flask from . import compat -from .compat import range -from .stream import TarFileStream -from .exceptions import OutsideDirectoryBase, OutsideRemovableBase, \ - PathTooLongError, FilenameTooLongError +from . import utils +from . import stream + +from .compat import cached_property +from .http import Headers + +from .exceptions import ( + OutsideDirectoryBase, OutsideRemovableBase, + PathTooLongError, FilenameTooLongError + ) -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) - ) -binary_units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB") -standard_units = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") +codecs.register_error( + underscore_replace, + lambda error: (u'_', getattr(error, 'start', 0) + 1), + ) +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' nt_restricted_chars = '/\0\\<>:"|?*' + ''.join(map(chr, range(1, 32))) +current_restricted_chars = ( + nt_restricted_chars if os.name == 'nt' else restricted_chars + ) restricted_names = ('.', '..', '::', '/', '\\') nt_device_names = ( ('CON', 'PRN', 'AUX', 'NUL') + @@ -42,7 +47,7 @@ fs_safe_characters = string.ascii_uppercase + string.digits class Node(object): - ''' + """ Abstract filesystem node class. This represents an unspecified entity with a filesystem's path suitable for @@ -52,13 +57,15 @@ class Node(object): to specify :meth:`from_urlpath` classmethod behavior: * :attr:`generic`, if true, an instance of directory_class or file_class - will be created instead of an instance of this class tself. + will be created instead of an instance of this class itself. * :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 + directory_class = None # type: typing.Type[Node] + file_class = None # type: typing.Type[Node] + manager_class = None re_charset = re.compile('; charset=(?P[^;]+)') can_download = False @@ -66,34 +73,58 @@ class Node(object): @cached_property def is_excluded(self): - ''' - Get if current node shouldn't be shown, using :attt:`app` config's - exclude_fnc. + """ + Get if current node should be avoided based on registered rules. :returns: True if excluded, False otherwise - ''' - exclude = self.app and self.app.config['exclude_fnc'] - return exclude and exclude(self.path) + """ + return self.plugin_manager.check_excluded( + self.path, + follow_symlinks=self.is_symlink, + ) + + @cached_property + def is_symlink(self): + """ + Get if current node is a symlink. + + :returns: True if symlink, False otherwise + """ + return os.path.islink(self.path) + + @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 plugin_manager(self): - ''' - Get current app's plugin manager. + """ + Get current app plugin manager. :returns: plugin manager instance - ''' - return self.app.extensions['plugin_manager'] + :type: browsepy.manager.PluginManagerBase + """ + return ( + self.app.extensions.get('plugin_manager') or + self.manager_class(self.app) + ) @cached_property def widgets(self): - ''' - List widgets with filter return True for this node (or without filter). + """ + List widgets 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( @@ -102,19 +133,19 @@ class Node(object): 'button', file=self, css='remove', - endpoint='remove' + endpoint='remove', ) ) return widgets + self.plugin_manager.get_widgets(file=self) @cached_property def link(self): - ''' - Get last widget with place "entry-link". + """ + 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': @@ -123,58 +154,66 @@ class Node(object): @cached_property def can_remove(self): - ''' - Get if current node can be removed based on app config's - directory_remove. + """ + Get if current node can be removed. + + This depends on app `DIRECTORY_REMOVE` config property. :returns: True if current node can be removed, False otherwise. :rtype: bool - ''' - dirbase = self.app.config["directory_remove"] + """ + dirbase = self.app.config.get('DIRECTORY_REMOVE') return bool(dirbase and check_under_base(self.path, dirbase)) @cached_property def stats(self): - ''' - Get current stats object as returned by os.stat function. + """ + Get current stats object as returned by `os.stat` function. :returns: stats object :rtype: posix.stat_result or nt.stat_result - ''' - return os.stat(self.path) + """ + try: + return os.stat(self.path) + except FileNotFoundError: + if self.is_symlink: + return os.lstat(self.path) + raise @cached_property def pathconf(self): - ''' + """ Get filesystem config for current path. + See :func:`compat.pathconf`. :returns: fs config :rtype: dict - ''' + """ return compat.pathconf(self.path) @cached_property def parent(self): - ''' - Get parent node if available based on app config's directory_base. + """ + Get parent node if available based on app config's `DIRECTORY_BASE`. :returns: parent object if available :rtype: Node instance or None - ''' - if check_path(self.path, self.app.config['directory_base']): + """ + directory_base = self.app.config.get('DIRECTORY_BASE', self.path) + if check_path(self.path, directory_base): return None 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. + """ + 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: @@ -184,12 +223,12 @@ class Node(object): @property def modified(self): - ''' + """ Get human-readable last modification date-time. :returns: iso9008-like date-time string (without timezone) :rtype: str - ''' + """ try: dt = datetime.datetime.fromtimestamp(self.stats.st_mtime) return dt.strftime('%Y.%m.%d %H:%M:%S') @@ -198,39 +237,44 @@ class Node(object): @property def urlpath(self): - ''' - Get the url substring corresponding to this node for those endpoints - accepting a 'path' parameter, suitable for :meth:`from_urlpath`. + """ + Get public url for this node, suitable for path endpoints. + + Endpoints accepting this value as 'path' parameter are also + expected to use :meth:`Node.from_urlpath`. :returns: relative-url-like for node's path :rtype: str - ''' - return abspath_to_urlpath(self.path, self.app.config['directory_base']) + """ + return abspath_to_urlpath( + self.path, + self.app.config.get('DIRECTORY_BASE', self.path), + ) @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). + """ + Get mimetype mime part (mimetype without encoding). :returns: mimetype :rtype: str - ''' + """ return self.mimetype.split(";", 1)[0] @property def category(self): - ''' - Get mimetype category (first portion of mimetype before the slash). + """ + Get mimetype category part (mimetype part before the slash). :returns: mimetype category :rtype: str @@ -246,34 +290,48 @@ class Node(object): * multipart * text * video - ''' + + """ return self.type.split('/', 1)[0] def __init__(self, path=None, app=None, **defaults): - ''' + """ + Initialize. + :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 + :param app: app instance (optional inside application context) + :type app: flask.Flask + :param **defaults: initial property values + """ + self.path = path if path else None + self.app = utils.solve_local(app or flask.current_app) self.__dict__.update(defaults) # only for attr and cached_property + def __repr__(self): + """ + Get str representation of instance. + + :returns: instance representation + :rtype: str + """ + return '<{0.__class__.__name__}({0.path!r})>'.format(self) + def remove(self): - ''' - Does nothing except raising if can_remove property returns False. + """ + Do nothing but 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 + """ + Create appropriate node from given url path. + + Alternative constructor accepting a path as taken from URL using the given app or the current app config to get the real path. If class has attribute `generic` set to True, `directory_class` or @@ -283,9 +341,9 @@ class Node(object): :param app: optional, flask application :return: file object pointing to path :rtype: File - ''' - app = app or current_app - base = app.config['directory_base'] + """ + app = utils.solve_local(app or flask.current_app) + base = app.config.get('DIRECTORY_BASE', path) path = urlpath_to_abspath(path, base) if not cls.generic: kls = cls @@ -297,34 +355,47 @@ class Node(object): @classmethod def register_file_class(cls, kls): - ''' - Convenience method for setting current class file_class property. + """ + Set given type as file_class constructor for current class. :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. + """ + Set given type as directory_class constructor for current class. :param kls: class to set :type kls: type :returns: given class (enabling using this as decorator) :rtype: type - ''' + """ cls.directory_class = kls return kls + @classmethod + def register_manager_class(cls, kls): + """ + Set given type as manager_class constructor for current class. + + :param kls: class to set + :type kls: type + :returns: given class (enabling using this as decorator) + :rtype: type + """ + cls.manager_class = kls + return kls + @Node.register_file_class class File(Node): - ''' + """ Filesystem file class. Some notes: @@ -337,7 +408,8 @@ class File(Node): 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 @@ -345,69 +417,77 @@ class File(Node): @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. + - 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' + endpoint='open', ) ] if self.can_download: - widgets.append( + widgets.extend(( self.plugin_manager.create_widget( 'entry-actions', 'button', file=self, css='download', - endpoint='download_file' - ) - ) + endpoint='download_file', + ), + self.plugin_manager.create_widget( + 'header', + 'button', + file=self, + text='Download file', + 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 - ''' + """ try: size, unit = fmt_size( self.stats.st_size, - self.app.config['use_binary_multiples'] if self.app else False + self.app.config.get('USE_BINARY_MULTIPLES', False) ) except OSError: return None @@ -417,12 +497,12 @@ class File(Node): @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 {} @@ -430,27 +510,28 @@ class File(Node): return "default" 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) + return flask.send_from_directory(directory, name, as_attachment=True) @Node.register_directory_class class Directory(Node): - ''' + """ Filesystem directory class. Some notes: @@ -462,7 +543,10 @@ class Directory(Node): * :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. - ''' + """ + + stream_class = stream.TarFileStream + _listdir_cache = None mimetype = 'inode/directory' is_file = False @@ -472,35 +556,52 @@ class Directory(Node): @property def name(self): - ''' + """ Get the basename portion of directory's path. :returns: filename :rtype: str - ''' + """ return super(Directory, self).name or self.path @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. + - 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' + endpoint='browse', ) ] + if self.can_download: + widgets.extend(( + self.plugin_manager.create_widget( + 'entry-actions', + 'button', + file=self, + css='download', + endpoint='download_directory', + ), + self.plugin_manager.create_widget( + 'header', + 'button', + file=self, + text='Download all', + endpoint='download_directory', + ), + )) if self.can_upload: widgets.extend(( self.plugin_manager.create_widget( @@ -508,143 +609,154 @@ class Directory(Node): 'script', file=self, endpoint='static', - filename='browse.directory.head.js' - ), + filename='browse.directory.head.js', + ), self.plugin_manager.create_widget( 'scripts', 'script', file=self, endpoint='static', - filename='browse.directory.body.js' - ), + filename='browse.directory.body.js', + ), self.plugin_manager.create_widget( 'header', 'upload', file=self, text='Upload', - endpoint='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 - @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 is_root(self): - ''' - Get if directory is filesystem's root + """ + Get if directory is filesystem root. :returns: True if FS root, False otherwise :rtype: bool - ''' + """ return check_path(os.path.dirname(self.path), self.path) @cached_property def can_download(self): - ''' - Get if path is downloadable (if app's `directory_downloadable` config - property is True). + """ + Get if node path is downloadable. + + This depends on app `DIRECTORY_DOWNLOADABLE` config property. :returns: True if downloadable, False otherwise :rtype: bool - ''' - return self.app.config['directory_downloadable'] + """ + return self.app.config.get('DIRECTORY_DOWNLOADABLE', False) @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). + """ + Get if a file can be uploaded to node path. + + This depends on app `DIRECTORY_UPLOAD` config property. :returns: True if a file can be upload to directory, False otherwise :rtype: bool - ''' - dirbase = self.app.config["directory_upload"] + """ + dirbase = self.app.config.get('DIRECTORY_UPLOAD') return dirbase and check_base(self.path, dirbase) @cached_property def can_remove(self): - ''' - Get if current node can be removed based on app config's - directory_remove. + """ + Get if current node can be removed. + + This depends on app `DIRECTORY_REMOVE` config property. :returns: True if current node can be removed, False otherwise. :rtype: bool - ''' + """ return self.parent and super(Directory, self).can_remove @cached_property def is_empty(self): - ''' - Get if directory is empty (based on :meth:`_listdir`). + """ + Get if directory is empty. :returns: True if this directory has no entries, False otherwise. :rtype: bool - ''' - if self._listdir_cache is not None: - return not bool(self._listdir_cache) - for entry in self._listdir(): - return False - return True + """ + if self._listdir_cache is None: + for entry in self._listdir(): + return False + return True + return not self._listdir_cache + + @cached_property + def content_mtime(self): + """ + Get computed modification file based on its content. + + :returns: modification id + :rtype: str + """ + mtimes = [node.stats.st_mtime for node in self.listdir()] + mtimes.append(self.stats.st_mtime) + return max(mtimes) def remove(self): - ''' + """ Remove directory tree. :raises OutsideRemovableBase: when not under removable base directory - ''' + """ super(Directory, self).remove() - shutil.rmtree(self.path) + compat.rmtree(self.path) def download(self): - ''' - Get a Flask Response object streaming a tarball of this directory. + """ + Generate Flask Response object streaming a tarball of this directory. :returns: Response object :rtype: flask.Response - ''' + """ + stream = self.stream_class( + self.path, + self.app.config.get('DIRECTORY_TAR_BUFFSIZE', 10240), + flask.copy_current_request_context( + self.plugin_manager.check_excluded, + ), + self.app.config.get('DIRECTORY_TAR_COMPRESS', 'gzip'), + self.app.config.get('DIRECTORY_TAR_COMPRESSLEVEL', 1), + ) return self.app.response_class( - TarFileStream( - self.path, - self.app.config['directory_tar_buffsize'], - self.app.config['exclude_fnc'], + flask.stream_with_context(stream), + direct_passthrough=True, + headers=Headers( + content_type=( + stream.mimetype, + {'encoding': stream.encoding} if stream.encoding else {}, + ), + content_disposition=( + 'attachment', + {'filename': stream.name} if stream.name else {}, + ), ), - mimetype="application/octet-stream" ) def contains(self, filename): - ''' + """ Check if directory contains an entry with given filename. + This method purposely hits the filesystem to support platform quirks. + :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. + """ + Get a filename for considered safe for this directory. :param filename: base filename :type filename: str @@ -655,7 +767,7 @@ class Directory(Node): :raises FilenameTooLong: when filesystem filename size limit is reached :raises PathTooLong: when OS or filesystem path size limit is reached - ''' + """ new_filename = filename for attempt in range(2, attempts + 1): if not self.contains(new_filename): @@ -678,48 +790,65 @@ class Directory(Node): return new_filename def _listdir(self, precomputed_stats=(os.name == 'nt')): - ''' - Iter unsorted entries on this directory. + # type: (bool) -> typing.Generator[Node, None, None] + """ + Iterate unsorted entries on this directory. + + Symlinks are skipped when pointing outside to base directory. :yields: Directory or File instance for each entry in directory - :ytype: Node - ''' - for entry in scandir(self.path, self.app): - kwargs = { - 'path': entry.path, - 'app': self.app, - 'parent': self, - 'is_excluded': False - } - try: - if precomputed_stats and not entry.is_symlink(): - kwargs['stats'] = entry.stat() - if entry.is_dir(follow_symlinks=True): - yield self.directory_class(**kwargs) - else: - yield self.file_class(**kwargs) - except OSError as e: - logger.exception(e) + """ + directory_class = self.directory_class + file_class = self.file_class + exclude_fnc = self.plugin_manager.check_excluded + with compat.scandir(self.path) as files: + for entry in files: + is_symlink = entry.is_symlink() + if exclude_fnc(entry.path, follow_symlinks=is_symlink): + continue + kwargs = { + 'path': entry.path, + 'app': self.app, + 'parent': self, + 'is_excluded': False, + 'is_symlink': is_symlink, + } + try: + if precomputed_stats: + kwargs['stats'] = entry.stat( + follow_symlinks=is_symlink, + ) + yield ( + directory_class(**kwargs) + if entry.is_dir(follow_symlinks=is_symlink) else + file_class(**kwargs) + ) + except FileNotFoundError: + pass + except OSError as e: + self.app.exception(e) def listdir(self, sortkey=None, reverse=False): - ''' + """ Get sorted list (by given sortkey and reverse params) of File objects. + Symlinks are skipped when pointing outside to base directory. + :return: sorted list of File instances :rtype: list of File instances - ''' - if self._listdir_cache is None: - self._listdir_cache = tuple(self._listdir()) - if sortkey: - return sorted(self._listdir_cache, key=sortkey, reverse=reverse) - data = list(self._listdir_cache) - if reverse: - data.reverse() - return data + """ + cache = self._listdir_cache + if cache is None: + cache = self._listdir_cache = ( + list(self._listdir(precomputed_stats=True)) + ) + if sortkey is None: + return cache[::-1 if reverse else 1] + return sorted(cache, key=sortkey, reverse=reverse) def fmt_size(size, binary=True): - ''' + """ Get size and unit. :param size: size in bytes @@ -728,7 +857,7 @@ def fmt_size(size, binary=True): :type binary: bool :return: size and unit :rtype: tuple of int and unit as str - ''' + """ if binary: fmt_sizes = binary_units fmt_divider = 1024. @@ -743,7 +872,7 @@ def fmt_size(size, binary=True): def relativize_path(path, base, os_sep=os.sep): - ''' + """ Make absolute path relative to an absolute base. :param path: absolute path @@ -755,7 +884,7 @@ def relativize_path(path, base, os_sep=os.sep): :return: relative path :rtype: str or unicode :raises OutsideDirectoryBase: if path is not below base - ''' + """ if not check_base(path, base, os_sep): raise OutsideDirectoryBase("%r is not under %r" % (path, base)) prefix_len = len(base) @@ -765,7 +894,7 @@ def relativize_path(path, base, os_sep=os.sep): def abspath_to_urlpath(path, base, os_sep=os.sep): - ''' + """ Make filesystem absolute path uri relative using given absolute base path. :param path: absolute path @@ -774,12 +903,12 @@ def abspath_to_urlpath(path, base, os_sep=os.sep): :return: relative uri :rtype: str or unicode :raises OutsideDirectoryBase: if resulting path is not below base - ''' + """ return relativize_path(path, base, os_sep).replace(os_sep, '/') def urlpath_to_abspath(path, base, os_sep=os.sep): - ''' + """ Make uri relative path fs absolute using a given absolute base path. :param path: relative path @@ -788,7 +917,7 @@ def urlpath_to_abspath(path, base, os_sep=os.sep): :return: absolute path :rtype: str or unicode :raises OutsideDirectoryBase: if resulting path is not below base - ''' + """ prefix = base if base.endswith(os_sep) else base + os_sep realpath = os.path.abspath(prefix + path.replace('/', os_sep)) if check_path(base, realpath) or check_under_base(realpath, base): @@ -797,29 +926,27 @@ def urlpath_to_abspath(path, base, os_sep=os.sep): def generic_filename(path): - ''' - Extract filename of given path os-indepently, taking care of known path - separators. + """ + Extract filename from given path based on all known path separators. :param path: path :return: filename :rtype: str or unicode (depending on given path) - ''' - + """ for sep in common_path_separators: if sep in path: _, path = path.rsplit(sep, 1) return path -def clean_restricted_chars(path, restricted_chars=restricted_chars): - ''' +def clean_restricted_chars(path, restricted_chars=current_restricted_chars): + """ Get path without restricted characters. :param path: path :return: path without restricted characters :rtype: str or unicode (depending on given path) - ''' + """ for character in restricted_chars: path = path.replace(character, '_') return path @@ -828,7 +955,7 @@ def clean_restricted_chars(path, restricted_chars=restricted_chars): def check_forbidden_filename(filename, destiny_os=os.name, restricted_names=restricted_names): - ''' + """ Get if given filename is forbidden for current OS or filesystem. :param filename: @@ -836,7 +963,7 @@ def check_forbidden_filename(filename, :param fs_encoding: destination filesystem filename encoding :return: wether is forbidden on given OS (or filesystem) or not :rtype: bool - ''' + """ return ( filename in restricted_names or destiny_os == 'nt' and @@ -845,7 +972,7 @@ def check_forbidden_filename(filename, def check_path(path, base, os_sep=os.sep): - ''' + """ Check if both given paths are equal. :param path: absolute path @@ -856,13 +983,13 @@ def check_path(path, base, os_sep=os.sep): :type base: str :return: wether two path are equal or not :rtype: bool - ''' + """ base = base[:-len(os_sep)] if base.endswith(os_sep) else base return os.path.normcase(path) == os.path.normcase(base) def check_base(path, base, os_sep=os.sep): - ''' + """ Check if given absolute path is under or given base. :param path: absolute path @@ -872,7 +999,7 @@ def check_base(path, base, os_sep=os.sep): :param os_sep: path separator, defaults to os.sep :return: wether path is under given base or not :rtype: bool - ''' + """ return ( check_path(path, base, os_sep) or check_under_base(path, base, os_sep) @@ -880,7 +1007,7 @@ def check_base(path, base, os_sep=os.sep): def check_under_base(path, base, os_sep=os.sep): - ''' + """ Check if given absolute path is under given base. :param path: absolute path @@ -890,13 +1017,13 @@ def check_under_base(path, base, os_sep=os.sep): :param os_sep: path separator, defaults to os.sep :return: wether file is under given base or not :rtype: bool - ''' + """ prefix = base if base.endswith(os_sep) else base + os_sep return os.path.normcase(path).startswith(os.path.normcase(prefix)) 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. @@ -909,7 +1036,7 @@ def secure_filename(path, destiny_os=os.name, fs_encoding=compat.FS_ENCODING): :type fs_encoding: str :return: filename or empty string :rtype: str - ''' + """ path = generic_filename(path) path = clean_restricted_chars( path, @@ -926,21 +1053,17 @@ def secure_filename(path, destiny_os=os.name, fs_encoding=compat.FS_ENCODING): if isinstance(path, bytes): path = path.decode('latin-1', errors=underscore_replace) - # Decode and recover from filesystem encoding in order to strip unwanted - # characters out - kwargs = { - '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 + # encode/decode with filesystem encoding to remove incompatible characters + return ( + path + .encode(fs_encoding, errors=underscore_replace) + .decode(fs_encoding, errors=underscore_replace) + ) def alternative_filename(filename, attempt=None): - ''' - Generates an alternative version of given filename. + """ + Generate an alternative version of given filename. If an number attempt parameter is given, will be used on the alternative name, a random value will be used otherwise. @@ -949,7 +1072,7 @@ def alternative_filename(filename, attempt=None): :param attempt: optional attempt number, defaults to null :return: new filename :rtype: str or unicode - ''' + """ filename_parts = filename.rsplit(u'.', 2) name = filename_parts[0] ext = ''.join(u'.%s' % ext for ext in filename_parts[1:]) @@ -959,24 +1082,3 @@ def alternative_filename(filename, attempt=None): else: extra = u' (%d)' % attempt return u'%s%s%s' % (name, extra, ext) - - -def scandir(path, app=None): - ''' - Config-aware scandir. Currently, only aware of ``exclude_fnc``. - - :param path: absolute path - :type path: str - :param app: flask application - :type app: flask.Flask or None - :returns: filtered scandir entries - :rtype: iterator - ''' - exclude = app and app.config.get('exclude_fnc') - if exclude: - return ( - item - for item in compat.scandir(path) - if not exclude(item.path) - ) - return compat.scandir(path) diff --git a/browsepy/http.py b/browsepy/http.py new file mode 100644 index 0000000000000000000000000000000000000000..918c61a62a4957e5b8a39b476a568be5ba08cfde --- /dev/null +++ b/browsepy/http.py @@ -0,0 +1,63 @@ +"""HTTP utility module.""" + +import typing +import re + +import msgpack + +from werkzeug.http import dump_header, dump_options_header, generate_etag +from werkzeug.datastructures import Headers as BaseHeaders + + +class Headers(BaseHeaders): + """ + Covenience :class:`werkzeug.datastructures.Headers` wrapper. + + This datastructure allows specifying initial values, as keyword + arguments while values can be either :type:`str` (no options) + or tuple of :type:`str` and :type:`dict`. + """ + + snake_replace = staticmethod(re.compile(r'(^|-)[a-z]').sub) + + @classmethod + def genpair(cls, + key, # type: str + value, # type: typing.Union[str, typing.Mapping] + ): # type: (...) -> typing.Tuple[str, str] + """ + Fix header name and options to be passed to werkzeug. + + :param key: value key + :param value: value or value/options pair + :returns: tuple with key and value + """ + rkey = cls.snake_replace( + lambda x: x.group(0).upper(), + key.replace('_', '-') + ) + rvalue = ( + dump_header([value]) + if isinstance(value, str) else + dump_options_header(*value) + ) + return rkey, rvalue + + def __init__(self, **kwargs): + # type: (**typing.Union[str, typing.Mapping]) -> None + """ + Initialize. + + :param **kwargs: header and values as keyword arguments + """ + items = [ + self.genpair(key, value) + for key, value in kwargs.items() + ] + return super(Headers, self).__init__(items) + + +def etag(*args, **kwargs): + # type: (*typing.Any, **typing.Any) -> str + """Generate etag identifier from given parameters.""" + return generate_etag(msgpack.dumps((args, kwargs))) diff --git a/browsepy/manager.py b/browsepy/manager.py index 5ad868a91b413eab678010610c879413ea69010e..06bf1e4ab6e1131d5c122d1477828367f871f5ad 100644 --- a/browsepy/manager.py +++ b/browsepy/manager.py @@ -1,149 +1,219 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- +"""Browsepy plugin manager classes.""" -import re -import sys +import typing +import types +import os.path +import pkgutil import argparse +import functools import warnings -import collections +import importlib -from flask import current_app -from werkzeug.utils import cached_property +from cookieman import CookieMan from . import mimetype from . import compat -from .compat import deprecated, usedoc +from . import utils +from . import file +from .compat import cached_property +from .utils import defaultsnamedtuple +from .exceptions import PluginNotFoundError, InvalidArgumentError, \ + WidgetParameterException -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 with loading and Flask extension logic.""" + _pyfile_extensions = ('.py', '.pyc', '.pyd', '.pyo') -class PluginManagerBase(object): - ''' - Base plugin manager for plugin module loading and Flask extension logic. - ''' + get_module = staticmethod(importlib.import_module) + plugin_module_methods = () # type: typing.Tuple[str, ...] @property def namespaces(self): - ''' - List of plugin namespaces taken from app config. - ''' - return self.app.config['plugin_namespaces'] if self.app else [] + # type: () -> typing.Iterable[str] + """ + List plugin namespaces taken from app config. + + :returns: list of plugin namespaces + :rtype: typing.List[str] + """ + return self.app.config.get('PLUGIN_NAMESPACES', []) if self.app else [] def __init__(self, app=None): + # type app: typing.Optional[flask.Flask] + """ + Initialize. + + :param app: flask application + """ if app is None: self.clear() else: self.init_app(app) def init_app(self, app): - ''' + # type app: flask.Flask + """ Initialize this Flask extension for given app. - ''' - self.app = app + + :param app: flask application + """ + self.app = utils.solve_local(app) if not hasattr(app, 'extensions'): app.extensions = {} app.extensions['plugin_manager'] = self self.reload() def reload(self): - ''' + """ Clear plugin manager state and reload plugins. This method will make use of :meth:`clear` and :meth:`load_plugin`, so all internal state will be cleared, and all plugins defined in - :data:`self.app.config['plugin_modules']` will be loaded. - ''' + :data:`self.app.config['PLUGIN_MODULES']` will be loaded. + """ self.clear() - for plugin in self.app.config.get('plugin_modules', ()): + for plugin in self.app.config.get('PLUGIN_MODULES', ()): self.load_plugin(plugin) def clear(self): - ''' - Clear plugin manager state. - ''' + """Clear plugin manager state.""" pass + def _iter_modules(self, prefix): + """Iterate thru all root modules containing given prefix.""" + for finder, name, ispkg in pkgutil.iter_modules(): + if name.startswith(prefix): + yield name + + def _content_import_name(self, module, item, prefix): + # type: (str, str, str) -> typing.Optional[str] + """Get importable module contnt import name.""" + res = compat.res + name = '%s.%s' % (module, item) + if name.startswith(prefix): + for ext in self._pyfile_extensions: + if name.endswith(ext): + return name[:-len(ext)] + if not res.is_resource(module, item): + return name + return None + + def _iter_submodules(self, prefix): + # type: (str) -> typing.Generator[str, None, None] + """Iterate thru all modules with an absolute prefix.""" + res = compat.res + parent = prefix.rsplit('.', 1)[0] + for base in (prefix, parent): + try: + for item in res.contents(base): + content = self._content_import_name(base, item, prefix) + if content: + yield content + except ImportError: + pass + + def _iter_namespace_modules(self): + # type: () -> typing.Generator[typing.Tuple[str, str], None, None] + """Iterate module names under namespaces.""" + nameset = set() # type: typing.Set[str] + for prefix in filter(None, self.namespaces): + name_iter_fnc = ( + self._iter_submodules + if '.' in prefix else + self._iter_modules + ) + for name in name_iter_fnc(prefix): + if name not in nameset: + nameset.add(name) + yield prefix, name + + def _iter_plugin_modules(self): + """ + Iterate plugin modules. + + This generator yields both full qualified name and short plugin + names. + """ + shortset = set() # type: typing.Set[str] + for namespace, name in self._iter_namespace_modules(): + plugin = self._get_plugin_module(name) + if plugin: + short = name[len(namespace):].lstrip('.').replace('_', '-') + yield ( + name, + None if short in shortset or '.' in short else short + ) + shortset.add(short) + + def _get_plugin_module(self, name): + """Import plugin module from absolute name.""" + try: + module = self.get_module(name) + for name in self.plugin_module_methods: + if callable(getattr(module, name, None)): + return module + except ImportError: + pass + return None + + @cached_property + def available_plugins(self): + # type: () -> typing.List[types.ModuleType] + """Iterate through all loadable plugins on typical paths.""" + return list(self._iter_plugin_modules()) + def import_plugin(self, plugin): - ''' + # type: (str) -> types.ModuleType + """ 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 - ''' + """ + plugin = plugin.replace('-', '_') names = [ - '%s%s%s' % (namespace, '' if namespace[-1] == '_' else '.', plugin) - if namespace else - plugin + '%s%s%s' % ( + namespace, + '.' if namespace and namespace[-1] not in '._' else '', + plugin, + ) for namespace in self.namespaces ] - - for name in names: - if name in sys.modules: - return sys.modules[name] - + names = sorted(frozenset(names), key=names.index) for name in names: - try: - __import__(name) - return sys.modules[name] - except (ImportError, KeyError): - pass - + module = self._get_plugin_module(name) + if module: + return module raise PluginNotFoundError( 'No plugin module %r found, tried %r' % (plugin, names), plugin, names) def load_plugin(self, plugin): - ''' + # type: (str) -> types.ModuleType + """ 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 RegistrablePluginManager(PluginManagerBase): - ''' - Base plugin manager for plugin registration via :func:`register_plugin` - functions at plugin module level. - ''' + """ + Plugin registration manager. + + Plugin registration requires a :func:`register_plugin` function at + the plugin module level. + """ + + plugin_module_methods = ('register_plugin',) + def load_plugin(self, plugin): - ''' + """ Import plugin (see :meth:`import_plugin`) and load related data. If available, plugin's module-level :func:`register_plugin` function @@ -152,49 +222,121 @@ class RegistrablePluginManager(PluginManagerBase): :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 BlueprintPluginManager(RegistrablePluginManager): - ''' - Manager for blueprint registration via :meth:`register_plugin` calls. +class BlueprintPluginManager(PluginManagerBase): + """ + Plugin blueprint registration manager. + + Blueprint registration done via :meth:`register_blueprint` + calls inside plugin :func:`register_plugin`. Note: blueprints are not removed on `clear` nor reloaded on `reload` - as flask does not allow it. - ''' + as flask does not allow it, consider creating a new clean + :class:`flask.Flask` browsepy app by using :func:`browsepy.create_app`. + + """ + def __init__(self, app=None): + """Initialize.""" 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 + This method is intended to be used on plugin's module-level :func:`register_plugin` 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. +class ExcludePluginManager(PluginManagerBase): + """ + Plugin node exclusion registration manager. + + Exclude-function registration done via :meth:`register_exclude_fnc` + calls inside plugin :func:`register_plugin`. + + """ + + def __init__(self, app=None): + """Initialize.""" + self._exclude_functions = set() + super(ExcludePluginManager, self).__init__(app=app) + + def register_exclude_function(self, exclude_fnc): + """ + Register given exclude-function on current app. + + This method is intended to be used on plugin's module-level + :func:`register_plugin` functions. + + :param blueprint: blueprint object with plugin endpoints + :type blueprint: flask.Blueprint + """ + self._exclude_functions.add(exclude_fnc) + + def check_excluded(self, path, follow_symlinks=True): + """ + Check if given path is excluded. + + Followed symlinks are checked against directory base for safety. - This class provides a dictionary of widget types at its + :param path: absolute path to check against config and plugins + :type path: str + :param follow_symlinks: wether or not follow_symlinks + :type follow_symlinks: bool + :return: wether if path should be excluded or not + :rtype: bool + """ + exclude_fnc = self.app.config.get('EXCLUDE_FNC') + if exclude_fnc and exclude_fnc(path): + return True + for fnc in self._exclude_functions: + if fnc(path): + return True + if follow_symlinks: + realpath = os.path.realpath(path) + dirbase = self.app.config.get('DIRECTORY_BASE') + return realpath != path and ( + dirbase and not file.check_base(path, dirbase) or + self.check_excluded(realpath, follow_symlinks=False) + ) + return False + + def clear(self): + """ + Clear plugin manager state. + + Registered exclude functions will be disposed. + """ + self._exclude_functions.clear() + super(ExcludePluginManager, self).clear() + + +class WidgetPluginManager(PluginManagerBase): + """ + Plugin widget registration manager. + + This class provides a dictionary of supported widget types available as :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', @@ -206,7 +348,7 @@ class WidgetPluginManager(RegistrablePluginManager): 'type': 'link', 'text': lambda f: f.name, 'icon': lambda f: f.category - }), + }), 'button': defaultsnamedtuple( 'Button', ('place', 'type', 'css', 'text', 'endpoint', 'href'), @@ -227,34 +369,34 @@ class WidgetPluginManager(RegistrablePluginManager): '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. + :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. @@ -263,14 +405,14 @@ class WidgetPluginManager(RegistrablePluginManager): :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 @@ -280,27 +422,28 @@ class WidgetPluginManager(RegistrablePluginManager): :type place: str :yields: widget instances :ytype: object - ''' + """ for filter, dynamic, cwidget in self._widgets: try: - if file and filter and not filter(file): + if ( + (file and filter and not filter(file)) or + (place and place != cwidget.place) + ): continue except BaseException as e: - # Exception is handled as this method execution is deffered, - # making hard to debug for plugin developers. + # Exception is catch as this execution is deferred, + # making debugging harder 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: @@ -318,7 +461,7 @@ class WidgetPluginManager(RegistrablePluginManager): :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: @@ -340,9 +483,8 @@ class WidgetPluginManager(RegistrablePluginManager): 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. + """ + Register a widget, optionally creating it with :meth:`create_widget`. This method provides this dual behavior in order to simplify widget creation-registration on an functional single step without sacrifycing @@ -361,7 +503,7 @@ class WidgetPluginManager(RegistrablePluginManager): 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' @@ -373,35 +515,29 @@ class WidgetPluginManager(RegistrablePluginManager): class MimetypePluginManager(RegistrablePluginManager): - ''' - Plugin manager for mimetype-function registration. - ''' - _default_mimetype_functions = ( - mimetype.by_python, - mimetype.by_file, - mimetype.by_default, - ) + """Plugin mimetype function registration manager.""" + + _default_mimetype_functions = mimetype.alternatives 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(MimetypePluginManager, self).clear() def get_mimetype(self, path): - ''' - Get mimetype of given path calling all registered mime functions (and - default ones). + """ + Get mimetype of given path based on registered mimetype functions. :param path: filesystem path of file :type path: str :returns: mimetype :rtype: str - ''' + """ for fnc in self._mimetype_functions: mime = fnc(path) if mime: @@ -409,7 +545,7 @@ class MimetypePluginManager(RegistrablePluginManager): return mimetype.by_default(path) def register_mimetype_function(self, fnc): - ''' + """ Register mimetype function. Given function must accept a filesystem path as string and return @@ -417,13 +553,41 @@ class MimetypePluginManager(RegistrablePluginManager): :param fnc: callable accepting a path string :type fnc: callable - ''' + """ self._mimetype_functions.insert(0, fnc) +class SessionPluginManager(PluginManagerBase): + """Plugin session shrink function registration manager.""" + + def register_session(self, key_or_keys, shrink_fnc=None): + """ + Register shrink function for specific session key or keys. + + Can be used as decorator. + + Usage: + >>> @manager.register_session('my_session_key') + ... def my_shrink_fnc(data): + ... del data['my_session_key'] + ... return data + + :param key_or_keys: key or iterable of keys would be affected + :type key_or_keys: Union[str, Iterable[str]] + :param shrink_fnc: shrinking function (optional for decorator) + :type shrink_fnc: cookieman.abc.ShrinkFunction + :returns: either original given shrink_fnc or decorator + :rtype: cookieman.abc.ShrinkFunction + """ + interface = self.app.session_interface + if isinstance(interface, CookieMan): + return interface.register(key_or_keys, shrink_fnc) + return shrink_fnc + + class ArgumentPluginManager(PluginManagerBase): - ''' - Plugin manager for command-line argument registration. + """ + Plugin command-line argument registration manager. This function is used by browsepy's :mod:`__main__` module in order to attach extra arguments at argument-parsing time. @@ -431,30 +595,86 @@ class ArgumentPluginManager(PluginManagerBase): 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() + plugin_module_methods = ('register_arguments',) + + @cached_property + def _default_argument_parser(self): + parser = compat.SafeArgumentParser() + parser.add_argument('--plugin', action='append', default=[]) + parser.add_argument('--help', action='store_true') + parser.add_argument('--help-all', action='store_true') + return parser + def extract_plugin_arguments(self, plugin): - ''' - Given a plugin name, extracts its registered_arguments as an - iterable of (args, kwargs) tuples. + """ + Extract registered argument pairs from given plugin name. + + Arguments are returned as an iterable of (args, kwargs) tuples. :param plugin: plugin name :type plugin: str :returns: iterable if (args, kwargs) tuples. :rtype: iterable - ''' + """ module = self.import_plugin(plugin) - if hasattr(module, 'register_arguments'): + register_arguments = getattr(module, 'register_arguments', None) + if callable(register_arguments): manager = ArgumentPluginManager() - module.register_arguments(manager) + register_arguments(manager) return manager._argparse_argkwargs return () - def load_arguments(self, argv, base=None): - ''' - Process given argument list based on registered arguments and given + def _plugin_argument_parser(self, base=None): + plugins = self.available_plugins + parent = base or self._default_argument_parser + prop = functools.partial(getattr, parent) + epilog = prop('epilog') or '' + if plugins: + epilog += '\n\navailable plugins:\n%s' % '\n'.join( + ' %s, %s' % (short, name) if short else ' %s' % name + for name, short in plugins + ) + return compat.SafeArgumentParser( + parents=[parent], + prog=prop('prog', self.app.config['APPLICATION_NAME']), + description=prop('description'), + formatter_class=prop('formatter_class', compat.HelpFormatter), + epilog=epilog.strip(), + ) + + def _plugin_arguments(self, parser, options): + plugins = [ + plugin + for plugins in options.plugin + for plugin in plugins.split(',') + ] + + if options.help_all: + plugins.extend( + short if short else name + for name, short in self.available_plugins + if not (name in plugins or short in plugins) + ) + + for plugin in sorted(set(plugins), key=plugins.index): + arguments = self.extract_plugin_arguments(plugin) + if arguments: + yield plugin, arguments + + def load_arguments( + self, + argv, # type: typing.Iterable[str] + base=None, # type: typing.Optional[argparse.ArgumentParser] + ): # type: (...) -> argparse.Namespace + """ + Process command line argument iterable. + + Argument processing is based on registered arguments and given optional base :class:`argparse.ArgumentParser` instance. This method saves processed arguments on itself, and this state won't @@ -464,47 +684,38 @@ class ArgumentPluginManager(PluginManagerBase): 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', action='append', default=[]) - parent = base or plugin_parser - parser = argparse.ArgumentParser( - parents=(parent,), - add_help=False, - **getattr(parent, 'defaults', {}) - ) - plugins = [ - plugin - for plugins in plugin_parser.parse_known_args(argv)[0].plugin - for plugin in plugins.split(',') - ] - for plugin in sorted(set(plugins), key=plugins.index): - arguments = self.extract_plugin_arguments(plugin) - if arguments: - group = parser.add_argument_group('%s arguments' % plugin) - for argargs, argkwargs in arguments: - group.add_argument(*argargs, **argkwargs) + """ + parser = self._plugin_argument_parser(base) + options, _ = parser.parse_known_args(argv) + + for plugin, arguments in self._plugin_arguments(parser, options): + group = parser.add_argument_group('%s arguments' % plugin) + for argargs, argkwargs in arguments: + group.add_argument(*argargs, **argkwargs) + + if options.help or options.help_all: + parser.print_help() + parser.exit() + 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 @@ -513,11 +724,11 @@ class ArgumentPluginManager(PluginManagerBase): 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 @@ -528,166 +739,27 @@ class ArgumentPluginManager(PluginManagerBase): :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): - return [ - self.action_class(endpoint, deprecated.for_file(file)) - for deprecated, filter, endpoint in self._action_widgets - if endpoint and filter(file) - ] - - @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) - - @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) - - -class PluginManager(MimetypeActionPluginManager, - BlueprintPluginManager, WidgetPluginManager, - MimetypePluginManager, ArgumentPluginManager): - ''' - Main plugin manager +@file.Node.register_manager_class +class PluginManager(BlueprintPluginManager, + ExcludePluginManager, + WidgetPluginManager, + MimetypePluginManager, + SessionPluginManager, + ArgumentPluginManager): + """ + Main plugin manager. 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. + * Plugin app-level file exclusion via exclude-function registration + via :meth:`register_exclude_fnc`. * Widget registration via :meth:`register_widget` method. * Mimetype function registration via :meth:`register_mimetype_function` method. @@ -700,9 +772,21 @@ class PluginManager(MimetypeActionPluginManager, both :meth:`create_widget` and :meth:`register_widget` methods' `type` parameter, or instantiated directly and passed to :meth:`register_widget` via `widget` parameter. - ''' + """ + + plugin_module_methods = sum(( + parent.plugin_module_methods + for parent in ( + BlueprintPluginManager, + ExcludePluginManager, + WidgetPluginManager, + MimetypePluginManager, + SessionPluginManager, + ArgumentPluginManager) + ), ()) + def clear(self): - ''' + """ Clear plugin manager state. Registered widgets will be disposed after calling this method. @@ -712,5 +796,5 @@ class PluginManager(MimetypeActionPluginManager, 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 3fe83e0c21f85e3deea07df86d48fc6f1b2aaf68..064a5f47cac95278bb369face3ad6a5d6f1c3553 100644 --- a/browsepy/mimetype.py +++ b/browsepy/mimetype.py @@ -1,47 +1,54 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- +""" +File mimetype detection functions. + +This module exposes an :var:`alternatives` tuple containing an ordered +list of detection strategies, sorted by priority. +""" import re import subprocess import mimetypes -from .compat import FileNotFoundError, which # noqa +from .compat import which generic_mimetypes = frozenset(('application/octet-stream', None)) -re_mime_validate = re.compile('\w+/\w+(; \w+=[^;]+)*') +re_mime_validate = re.compile(r'\w+/\w+(; \w+=[^;]+)*') def by_python(path): + """Get mimetype by file extension using python mimetype database.""" mime, encoding = mimetypes.guess_type(path) if mime in generic_mimetypes: return None return "%s%s%s" % ( - mime or "application/octet-stream", "; " - if encoding else - "", encoding or "" + mime or "application/octet-stream", + "; " if encoding else "", + encoding or "" ) -if which('file'): - def by_file(path): - try: - output = subprocess.check_output( - ("file", "-ib", path), - universal_newlines=True - ).strip() - if ( - re_mime_validate.match(output) and - output not in generic_mimetypes - ): - # 'file' command can return status zero with invalid output - return output - except (subprocess.CalledProcessError, FileNotFoundError): - pass - return None -else: - def by_file(path): - return None +def by_file(path): + """Get mimetype by calling file POSIX utility.""" + try: + output = subprocess.check_output( + ("file", "-ib", path), + universal_newlines=True + ).strip() + if re_mime_validate.match(output) and output not in generic_mimetypes: + # 'file' command can return status zero with invalid output + return output + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return None def by_default(path): + """Get default generic mimetype.""" return "application/octet-stream" + + +alternatives = ( + (by_python, by_file, by_default) + if which('file') else + (by_python, by_default) + ) diff --git a/browsepy/plugin/__init__.py b/browsepy/plugin/__init__.py index 8cb7f459eae7db66d213f4197be0fa6f4daa3a14..348dc20508c8c0614533a76c9c4d664b9a4b975f 100644 --- a/browsepy/plugin/__init__.py +++ b/browsepy/plugin/__init__.py @@ -1,2 +1 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- +"""Browsepy builtin plugin submodule.""" diff --git a/browsepy/plugin/file_actions/__init__.py b/browsepy/plugin/file_actions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f5ef21fb6a1c1408e0790cab7348d0217b5cba60 --- /dev/null +++ b/browsepy/plugin/file_actions/__init__.py @@ -0,0 +1,273 @@ +"""Plugin module with filesystem functionality.""" + +from flask import Blueprint, render_template, request, redirect, url_for, \ + session, current_app, g +from werkzeug.exceptions import NotFound + +from browsepy import get_cookie_browse_sorting, browse_sortkey_reverse +from browsepy.file import Node, abspath_to_urlpath, current_restricted_chars, \ + common_path_separators +from browsepy.compat import re_escape +from browsepy.exceptions import OutsideDirectoryBase +from browsepy.stream import stream_template + +from .exceptions import FileActionsException, \ + InvalidClipboardItemsError, \ + InvalidClipboardModeError, \ + InvalidClipboardSizeError + +from . import utils + + +actions = Blueprint( + 'file_actions', + __name__, + url_prefix='/file-actions', + template_folder='templates', + static_folder='static', +) + +re_basename = '^[^ {0}]([^{0}]*[^ {0}])?$'.format( + re_escape(current_restricted_chars + common_path_separators) + ) + + +@actions.route('/create/directory', methods=('GET', 'POST'), + defaults={'path': ''}) +@actions.route('/create/directory/', methods=('GET', 'POST')) +def create_directory(path): + """Handle request to create directory.""" + try: + directory = Node.from_urlpath(path) + except OutsideDirectoryBase: + return NotFound() + + if not directory.is_directory or not directory.can_upload: + return NotFound() + + if request.method == 'POST': + path = utils.mkdir(directory.path, request.form['name']) + base = current_app.config['DIRECTORY_BASE'] + return redirect( + url_for('browse', path=abspath_to_urlpath(path, base)) + ) + + return render_template( + 'create_directory.file_actions.html', + file=directory, + re_basename=re_basename, + ) + + +@actions.route('/selection', methods=('GET', 'POST'), defaults={'path': ''}) +@actions.route('/selection/', methods=('GET', 'POST')) +def selection(path): + """Handle file selection clipboard request.""" + sort_property = get_cookie_browse_sorting(path, 'text') + sort_fnc, sort_reverse = browse_sortkey_reverse(sort_property) + + try: + directory = Node.from_urlpath(path) + except OutsideDirectoryBase: + return NotFound() + + if directory.is_excluded or not directory.is_directory: + return NotFound() + + if request.method == 'POST': + mode = ( + 'cut' if request.form.get('action-cut') else + 'copy' if request.form.get('action-copy') else + None + ) + + clipboard = request.form.getlist('path') + + if mode is None: + raise InvalidClipboardModeError( + path=directory.path, + clipboard=clipboard, + ) + + session['clipboard:mode'] = mode + session['clipboard:items'] = clipboard + return redirect(url_for('browse', path=directory.urlpath)) + + return stream_template( + 'selection.file_actions.html', + file=directory, + mode=session.get('clipboard:mode'), + clipboard=session.get('clipboard:items', ()), + cut_support=any(node.can_remove for node in directory.listdir()), + sort_property=sort_property, + sort_fnc=sort_fnc, + sort_reverse=sort_reverse, + ) + + +@actions.route('/clipboard/paste', defaults={'path': ''}) +@actions.route('/clipboard/paste/') +def clipboard_paste(path): + """Handle clipboard paste-here request.""" + try: + directory = Node.from_urlpath(path) + except OutsideDirectoryBase: + return NotFound() + + if ( + not directory.is_directory or + not directory.can_upload or + directory.is_excluded + ): + return NotFound() + + mode = session.get('clipboard:mode') + clipboard = session.get('clipboard:items', ()) + + g.file_actions_paste = True # disable exclude function + success, issues = utils.paste(directory, mode, clipboard) + + if issues: + raise InvalidClipboardItemsError( + path=directory.path, + mode=mode, + clipboard=clipboard, + issues=issues + ) + + if mode == 'cut': + session.pop('clipboard:mode', None) + session.pop('clipboard:items', None) + + return redirect(url_for('browse', path=directory.urlpath)) + + +@actions.route('/clipboard/clear', defaults={'path': ''}) +@actions.route('/clipboard/clear/') +def clipboard_clear(path): + """Handle clear clipboard request.""" + session.pop('clipboard:mode', None) + session.pop('clipboard:items', None) + return redirect(url_for('browse', path=path)) + + +@actions.errorhandler(FileActionsException) +def file_actions_error(e): + """Serve informative error page on plugin errors.""" + file = Node(e.path) if hasattr(e, 'path') else None + issues = getattr(e, 'issues', ()) + + clipboard = session.get('clipboard:items') + if clipboard and issues: + for issue in issues: + if isinstance(issue.error, FileNotFoundError): + path = issue.item.urlpath + if path in clipboard: + clipboard.remove(path) + session.modified = True + + return ( + render_template( + '400.file_actions.html', + error=e, + file=file, + mode=session.get('clipboard:mode'), + clipboard=session.get('clipboard:items', ()), + issues=issues, + ), + 400 + ) + + +def shrink_session(data, last): + """Session shrinking logic (only obeys final attempt).""" + if last: + raise InvalidClipboardSizeError( + mode=data.pop('clipboard:mode', None), + clipboard=data.pop('clipboard:items', None), + ) + return data + + +def detect_upload(directory): + """Detect if directory node can be used as clipboard target.""" + return directory.is_directory and directory.can_upload + + +def detect_clipboard(directory): + """Detect if clipboard is available on given directory node.""" + return directory.is_directory and session.get('clipboard:mode') + + +def detect_selection(directory): + """Detect if file selection is available on given directory node.""" + return ( + directory.is_directory and + current_app.config.get('DIRECTORY_UPLOAD') + ) + + +def excluded_clipboard(path): + """ + Check if given path should be ignored when pasting clipboard. + + :param path: path to check + :type path: str + :return: wether path should be excluded or not + :rtype: str + """ + if ( + not getattr(g, 'file_actions_paste', False) and + session.get('clipboard:mode') == 'cut' + ): + base = current_app.config['DIRECTORY_BASE'] + clipboard = session.get('clipboard:items', ()) + return abspath_to_urlpath(path, base) in clipboard + return False + + +def register_plugin(manager): + """ + Register blueprints and actions using given plugin manager. + + :param manager: plugin manager + :type manager: browsepy.manager.PluginManager + """ + manager.register_exclude_function(excluded_clipboard) + manager.register_blueprint(actions) + manager.register_widget( + place='styles', + type='stylesheet', + endpoint='file_actions.static', + filename='browse.css', + filter=detect_clipboard, + ) + manager.register_widget( + place='header', + type='button', + endpoint='file_actions.create_directory', + text='Create directory', + filter=detect_upload, + ) + manager.register_widget( + place='header', + type='button', + endpoint='file_actions.selection', + filter=detect_selection, + text='Selection...', + ) + manager.register_widget( + place='header', + type='html', + html=lambda file: render_template( + 'widget.clipboard.file_actions.html', + file=file, + mode=session.get('clipboard:mode'), + clipboard=session.get('clipboard:items', ()), + ), + filter=detect_clipboard, + ) + manager.register_session( + ('clipboard:items', 'clipboard:mode'), + shrink_session, + ) diff --git a/browsepy/plugin/file_actions/exceptions.py b/browsepy/plugin/file_actions/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..ac53f7cb48784d109631be2416cc3454e0b50775 --- /dev/null +++ b/browsepy/plugin/file_actions/exceptions.py @@ -0,0 +1,198 @@ +"""Exception classes for file action errors.""" + +import os +import errno + + +class FileActionsException(Exception): + """ + Base class for file-actions exceptions. + + :property path: item path which raised this Exception + """ + + code = None + template = 'Unhandled error.' + + def __init__(self, message=None, path=None): + """Initialize.""" + self.path = path + message = self.template.format(self) if message is None else message + super(FileActionsException, self).__init__(message) + + +class InvalidDirnameError(FileActionsException): + """ + Exception raised when a new directory name is invalid. + + :property path: item path which raised this Exception + :property name: name which raised this Exception + """ + + code = 'directory-invalid-name' + template = 'Clipboard item {0.name!r} is not valid.' + + def __init__(self, message=None, path=None, name=None): + """Initialize.""" + self.name = name + super(InvalidDirnameError, self).__init__(message, path) + + +class DirectoryCreationError(FileActionsException): + """ + Exception raised when a new directory creation fails. + + :property path: item path which raised this Exception + :property name: name which raised this Exception + """ + + code = 'directory-mkdir-error' + template = 'Clipboard item {0.name!r} is not valid.' + + def __init__(self, message=None, path=None, name=None): + """Initialize.""" + self.name = name + super(DirectoryCreationError, self).__init__(message, path) + + @property + def message(self): + """Get error message.""" + return self.args[0] + + @classmethod + def from_exception(cls, exception, *args, **kwargs): + """Generate exception from another.""" + message = None + if isinstance(exception, OSError): + message = '%s (%s)' % ( + os.strerror(exception.errno), + errno.errorcode[exception.errno] + ) + return cls(message, *args, **kwargs) + + +class ClipboardException(FileActionsException): + """ + Base class for clipboard exceptions. + + :property path: item path which raised this Exception + :property mode: mode which raised this Exception + :property clipboard: :class Clipboard: instance + """ + + code = 'clipboard-invalid' + template = 'Clipboard is invalid.' + + def __init__(self, message=None, path=None, mode=None, clipboard=None): + """Initialize.""" + self.mode = mode + self.clipboard = clipboard + super(ClipboardException, self).__init__(message, path) + + +class ItemIssue(tuple): + """Item/error issue pair.""" + + @property + def item(self): + """Get item.""" + return self[0] + + @property + def error(self): + """Get error.""" + return self[1] + + @property + def message(self): + """Get file error message.""" + if isinstance(self.error, OSError): + return '%s (%s)' % ( + os.strerror(self.error.errno), + errno.errorcode[self.error.errno] + ) + + # ensure full path is never returned + text = str(self.error) + text = text.replace(self.item.path, self.item.name) + return text + + +class InvalidClipboardItemsError(ClipboardException): + """ + Exception raised when a clipboard item is not valid. + + :property path: item path which raised this Exception + :property mode: mode which raised this Exception + :property clipboard: :class Clipboard: instance + :property issues: iterable of issues + """ + + pair_class = ItemIssue + code = 'clipboard-invalid-items' + template = 'Clipboard has invalid items.' + + def __init__(self, message=None, path=None, mode=None, clipboard=None, + issues=()): + """Initialize.""" + self.issues = list(map(self.pair_class, issues)) + supa = super(InvalidClipboardItemsError, self) + supa.__init__(message, path, mode, clipboard) + + def append(self, item, error): + """Add item/error pair to issue list.""" + self.issues.append(self.pair_class((item, error))) + + +class InvalidClipboardModeError(ClipboardException): + """ + Exception raised when a clipboard mode is not valid. + + :property path: item path which raised this Exception + :property mode: mode which raised this Exception + :property clipboard: :class Clipboard: instance + """ + + code = 'clipboard-invalid-mode' + template = 'Clipboard mode {0.mode!r} is not valid.' + + def __init__(self, message=None, path=None, mode=None, clipboard=None): + """Initialize.""" + supa = super(InvalidClipboardModeError, self) + supa.__init__(message, path, mode, clipboard) + + +class InvalidEmptyClipboardError(ClipboardException): + """ + Exception raised when an invalid action is requested on an empty clipboard. + + :property path: item path which raised this Exception + :property mode: mode which raised this Exception + :property clipboard: :class Clipboard: instance + """ + + code = 'clipboard-invalid-empty' + template = 'Clipboard action {0.mode!r} cannot be performed without items.' + + def __init__(self, message=None, path=None, mode=None, clipboard=None): + """Initialize.""" + supa = super(InvalidEmptyClipboardError, self) + supa.__init__(message, path, mode, clipboard) + + +class InvalidClipboardSizeError(ClipboardException): + """ + Exception raised when session manager evicts clipboard data. + + :property path: item path which raised this Exception + :property mode: mode which raised this Exception + :property clipboard: :class Clipboard: instance + """ + + code = 'clipboard-invalid-size' + template = 'Clipboard evicted due session size limit.' + + def __init__(self, message=None, path=None, mode=None, clipboard=None): + """Initialize.""" + supa = super(InvalidClipboardSizeError, self) + supa.__init__(message, path, mode, clipboard) diff --git a/browsepy/plugin/file_actions/static/browse.css b/browsepy/plugin/file_actions/static/browse.css new file mode 100644 index 0000000000000000000000000000000000000000..803c3e0854679353ee326dcc418d9d860cfbebf9 --- /dev/null +++ b/browsepy/plugin/file_actions/static/browse.css @@ -0,0 +1,36 @@ +.clipboard { + display: inline-block; + vertical-align: middle; + text-align: right; + margin: 0 0.5em 0.5em 0; + line-height: 1.5em; + border-radius: 0.25em; + box-shadow: 1px 1px 0 black, -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black; + color: white; + background: #000; +} +.clipboard div { + display: inline-block; + white-space: nowrap; +} +.clipboard a.button { + margin: 0 0 0 1px; + box-shadow: none; + border-radius: 0; + padding: 0.25em 1em; +} +.clipboard a.button:first-of-type { + border-top-left-radius: 0.25em; + border-bottom-left-radius: 0.25em; +} +.clipboard a.button:last-child { + border-top-right-radius: 0.25em; + border-bottom-right-radius: 0.25em; +} +.clipboard h3 { + font-size: 1em; + font-weight: normal; + margin: 0 1px 0 0; + padding: 0 0.5em; + display: inline; +} \ No newline at end of file diff --git a/browsepy/plugin/file_actions/static/script.js b/browsepy/plugin/file_actions/static/script.js new file mode 100644 index 0000000000000000000000000000000000000000..ffc6bf15b56b8b521ef207b1a4a527181dc37d10 --- /dev/null +++ b/browsepy/plugin/file_actions/static/script.js @@ -0,0 +1,61 @@ +(function() { + function every(arr, fnc) { + for (var i = 0, l = arr.length; i < l; i++) { + if (!fnc(arr[i], i, arr)) { + return false; + } + } + return true; + } + function event(element, event, callback) { + var args = Array.prototype.slice.call(arguments, 3); + element.addEventListener(event, function () { + var args2 = Array.prototype.slice.call(arguments); + callback.apply(this, args.concat(args2)); + }); + } + function checkAll(checkbox) { + checkbox.form + .querySelectorAll('td input[type=checkbox]') + .forEach(function (target) { target.checked = checkbox.checked; }); + } + function checkRow(checkbox, tr, event) { + if (!event || !event.target || event.target.type !== 'checkbox') { + var + targets = tr.querySelectorAll('td input[type=checkbox]'), + checked = every(targets, function (target) { return target.checked; }); + targets.forEach(function (target) { + target.checked = !checked; + check(checkbox, event); + }); + } + } + function check(checkbox) { + checkbox.checked = every( + checkbox.form.querySelectorAll('td input[type=checkbox]'), + function (checkbox) { return checkbox.checked; }); + } + if (document.querySelectorAll && document.addEventListener) { + document + .querySelectorAll('form th.select-all-container') + .forEach(function (container) { + var checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + container.appendChild(checkbox); + event(checkbox, 'change', checkAll, checkbox); + checkbox.form + .querySelectorAll('td input[type=checkbox]') + .forEach(function (input) { + event(input, 'change', check, checkbox); + }); + checkbox.form + .querySelectorAll('tbody tr') + .forEach(function (tr) { + tr.className += ' clickable'; + event(tr, 'click', checkRow, checkbox, tr); + event(tr, 'selectstart', function(e) {return e.preventDefault();}); + }); + check(checkbox); + }); + } +}()); diff --git a/browsepy/plugin/file_actions/static/style.css b/browsepy/plugin/file_actions/static/style.css new file mode 100644 index 0000000000000000000000000000000000000000..626029462049c59b5849dda5e23601cd149eda04 --- /dev/null +++ b/browsepy/plugin/file_actions/static/style.css @@ -0,0 +1,61 @@ +input[type=checkbox] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + outline: 0; + padding: 1px; +} +input[type=checkbox], +input[type=checkbox]:after { + font-size: 1em; + display: inline-block; + line-height: 1.5em; + width: 1.5em; + height: 1.5em; + margin: 0; + vertical-align: middle; + cursor: pointer; + text-align: center; +} +input[type=checkbox]:after { + content: ""; + 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; + margin: -1px; + + 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; + + color: white; + background: #333; + 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; +} +input[type=checkbox]:hover:after { + background: black; +} +input[type=checkbox]:checked:after { + content: "\ea10"; + background: black; + border: 1px solid black; +} +input[type=text] { + padding: 0.25em 1px; + width: 22.5em; +} +tr.clickable:hover { + cursor: pointer; +} +tr.clickable:hover > td{ + background-color: #ddd; +} +.issue-name { + font-weight: bold; + font-family: monospace; +} \ No newline at end of file diff --git a/browsepy/plugin/file_actions/templates/400.file_actions.html b/browsepy/plugin/file_actions/templates/400.file_actions.html new file mode 100644 index 0000000000000000000000000000000000000000..b8caa30de6ab4f903cf343939c9e25f6d381597c --- /dev/null +++ b/browsepy/plugin/file_actions/templates/400.file_actions.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} + +{% set buttons %} + {% if file %} + Accept + {% else %} + Accept + {% endif %} +{% endset %} + +{% set messages %} + {% for issue in issues %} +

{{ issue.item.name }}: {{ issue.message }}

+ {% endfor %} +{% endset %} + +{% if error and error.code %} + {% if error.code.startswith('directory-') %} + {% set workaround %} +

Please try again with other directory name or contact server administrator.

+ {% endset %} + {% if error.code == 'directory-invalid-name' %} + {% set description %} +

Directory name is not valid

+

Given directory name is not valid: incompatible name encoding or reserved name on filesystem.

+ {{ workaround }} + {%- endset %} + {% elif error.code == 'directory-mkdir-error' %} + {% set description %} +

Directory creation failed

+

{{ error.message }}.

+ {{ workaround }} + {%- endset %} + {% endif %} + {% elif error.code.startswith('clipboard-') %} + {% set workaround %} +

Please try again, if this problem persists contact server administrator.

+ {% endset %} + {% if error.code == 'clipboard-invalid-items' %} + {% set description -%} + {% if issues.length == 1 -%} +

A clipboard item is not valid

+ {%- else -%} +

Some clipboard items are not valid

+ {%- endif %} + {{ messages }} + {{ workaround }} + {%- endset %} + {% elif error.code == 'clipboard-invalid-size' %} + {% set description -%} +

Session size limit exceeded, clipboard lost.

+

Clipboard became too big to be stored in your browser cookies.

+

Please try again with fewer items and avoid other operations in the process.

+ {%- endset %} + {% else %} + {% set description %} +

Clipboard state is corrupted

+

Saved clipboard state is no longer valid.

+ {{ workaround }} + {% endset %} + {% endif %} + {% endif %} +{% else %} + {% set description %} +

Unexpected error

+

Operation failed due an unexpected error.

+

Please contact server administrator.

+ {% endset %} +{% endif %} + +{% block title %}400 Bad Request{% endblock %} + +{% block header %} +

Bad Request

+{% endblock %} + +{% block content %} + {{ description }} + {{ buttons }} +{% endblock %} diff --git a/browsepy/plugin/file_actions/templates/create_directory.file_actions.html b/browsepy/plugin/file_actions/templates/create_directory.file_actions.html new file mode 100644 index 0000000000000000000000000000000000000000..2ba84e4ed7ea56f6529b745361ee8aebd1f3acc7 --- /dev/null +++ b/browsepy/plugin/file_actions/templates/create_directory.file_actions.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} + {{- super() -}} + {%- if file and file.urlpath %} - {{ file.urlpath }}{% endif -%} + {{- ' - create directory' -}} +{% endblock %} + +{% block styles %} + {{ super() }} + +{% endblock %} + +{% block scripts %} + +{% endblock %} + +{% block content %} +

{{ breadcrumbs(file, circular=True) }}

+

Create directory

+
+ +
+ Cancel + +
+
+{% endblock %} diff --git a/browsepy/plugin/file_actions/templates/selection.file_actions.html b/browsepy/plugin/file_actions/templates/selection.file_actions.html new file mode 100644 index 0000000000000000000000000000000000000000..30bcc6711fc60eea23b4b5a4695de9b2f27b515a --- /dev/null +++ b/browsepy/plugin/file_actions/templates/selection.file_actions.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block title %} + {{- super() -}} + {%- if file and file.urlpath %} - {{ file.urlpath }}{% endif -%} + {{- ' - selection' -}} +{% endblock %} + +{% block styles %} + {{ super() }} + +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} + +{% block header %} +

{{ breadcrumbs(file, circular=True) }}

+

Selection

+{% endblock %} + +{% block content %} +
+
+ Cancel + {% if cut_support %} + + {% endif %} + +
+ {% if file.is_empty %} +

No files in directory

+ {% else %} + + + + + + + + + + + + {% for f in file.listdir(sortkey=sort_fnc, reverse=sort_reverse) %} + {% if f.link and f.link.text %} + + + + + + + + {% endif %} + {% endfor %} + +
NameMimetypeModifiedSize
+ + {{ f.link.text }}{{ f.type or '' }}{{ f.modified or '' }}{{ f.size or '' }}
+ {% endif %} +
+{% endblock %} diff --git a/browsepy/plugin/file_actions/templates/widget.clipboard.file_actions.html b/browsepy/plugin/file_actions/templates/widget.clipboard.file_actions.html new file mode 100644 index 0000000000000000000000000000000000000000..beef2815ae64ed8b93a7002b30cc3de29a119f2b --- /dev/null +++ b/browsepy/plugin/file_actions/templates/widget.clipboard.file_actions.html @@ -0,0 +1,15 @@ +
+

Clipboard ({{ clipboard|length }})

+
+ {% if file.can_upload %} + Paste + {% endif %} + Clear +
+
\ No newline at end of file diff --git a/browsepy/plugin/file_actions/tests.py b/browsepy/plugin/file_actions/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..80e543f2566b773d95ea02a3b2d1d9650fda0dbf --- /dev/null +++ b/browsepy/plugin/file_actions/tests.py @@ -0,0 +1,473 @@ + +import unittest +import tempfile +import os +import os.path +import functools + +import bs4 +import flask + +import browsepy.compat as compat +import browsepy.utils as utils +import browsepy.plugin.file_actions as file_actions +import browsepy.plugin.file_actions.exceptions as file_actions_exceptions +import browsepy.file as browsepy_file +import browsepy.exceptions as browsepy_exceptions +import browsepy + +from browsepy.compat import cached_property + + +class Page(object): + def __init__(self, source): + self.tree = bs4.BeautifulSoup(source, 'html.parser') + + @cached_property + def widgets(self): + header = self.tree.find('header') + return [ + (r.name, r[attr]) + for (source, attr) in ( + (header.find_all('a'), 'href'), + (header.find_all('link', rel='stylesheet'), 'href'), + (header.find_all('script'), 'src'), + (header.find_all('form'), 'action'), + ) + for r in source + ] + + @cached_property + def urlpath(self): + return self.tree.h1.find('ol', class_='path').get_text().strip() + + @cached_property + def entries(self): + table = self.tree.find('table', class_='browser') + return {} if not table else { + r('td')[1].get_text(): r.input['value'] if r.input else r.a['href'] + for r in table.find('tbody').find_all('tr') + } + + @cached_property + def selected(self): + table = self.tree.find('table', class_='browser') + return {} if not table else { + r('td')[1].get_text(): i['value'] + for r in table.find('tbody').find_all('tr') + for i in r.find_all('input', selected=True) + } + + +class TestRegistration(unittest.TestCase): + actions_module = file_actions + browsepy_module = browsepy + + def setUp(self): + self.base = 'c:\\base' if os.name == 'nt' else '/base' + self.app = browsepy.create_app() + self.app.config.update( + DIRECTORY_BASE=self.base, + EXCLUDE_FNC=None, + ) + self.manager = self.app.extensions['plugin_manager'] + + def tearDown(self): + utils.clear_flask_context() + + def test_register_plugin(self): + self.manager.load_plugin('file-actions') + self.assertIn( + self.actions_module.actions, + self.app.blueprints.values() + ) + + def test_reload(self): + self.app.config.update( + PLUGIN_MODULES=[], + PLUGIN_NAMESPACES=[] + ) + self.assertNotIn( + self.actions_module.actions, + self.app.blueprints.values() + ) + self.app.config.update( + PLUGIN_MODULES=['file-actions'], + PLUGIN_NAMESPACES=['browsepy.plugin'] + ) + self.manager.reload() + self.assertIn( + self.actions_module.actions, + self.app.blueprints.values() + ) + + +class TestIntegration(unittest.TestCase): + actions_module = file_actions + browsepy_module = browsepy + + def setUp(self): + self.base = tempfile.mkdtemp() + self.app = browsepy.create_app() + self.app.config.update( + SECRET_KEY='secret', + DIRECTORY_BASE=self.base, + DIRECTORY_START=self.base, + DIRECTORY_UPLOAD=None, + EXCLUDE_FNC=None, + PLUGIN_MODULES=['file-actions'], + PLUGIN_NAMESPACES=[ + 'browsepy.plugin' + ] + ) + self.manager = self.app.extensions['plugin_manager'] + self.manager.reload() + + def tearDown(self): + compat.rmtree(self.base) + utils.clear_flask_context() + + def test_detection(self): + url = functools.partial(flask.url_for, path='') + + with self.app.test_client() as client: + self.app.config['DIRECTORY_UPLOAD'] = self.base + response = client.get('/') + self.assertEqual(response.status_code, 200) + + page = Page(response.data) + with self.app.app_context(): + self.assertIn( + ('a', url('file_actions.selection')), + page.widgets + ) + + self.assertNotIn( + ('a', url('file_actions.clipboard_paste')), + page.widgets + ) + + with client.session_transaction() as session: + session['clipboard:mode'] = 'copy' + session['clipboard:items'] = ['whatever'] + + self.app.config['DIRECTORY_UPLOAD'] = 'whatever' + response = client.get('/') + self.assertEqual(response.status_code, 200) + + page = Page(response.data) + with self.app.app_context(): + self.assertIn( + ('a', url('file_actions.selection')), + page.widgets + ) + self.assertNotIn( + ('a', url('file_actions.clipboard_paste')), + page.widgets + ) + self.assertIn( + ('a', url('file_actions.clipboard_clear')), + page.widgets + ) + + self.app.config['DIRECTORY_UPLOAD'] = self.base + response = client.get('/') + self.assertEqual(response.status_code, 200) + + page = Page(response.data) + with self.app.app_context(): + self.assertIn( + ('a', url('file_actions.selection')), + page.widgets + ) + self.assertIn( + ('a', url('file_actions.clipboard_paste')), + page.widgets + ) + self.assertIn( + ('a', url('file_actions.clipboard_clear')), + page.widgets + ) + + def test_exclude(self): + open(os.path.join(self.base, 'potato'), 'w').close() + with self.app.test_client() as client: + response = client.get('/') + self.assertEqual(response.status_code, 200) + + page = Page(response.data) + self.assertIn('potato', page.entries) + + with client.session_transaction() as session: + session['clipboard:mode'] = 'copy' + session['clipboard:items'] = ['potato'] + response = client.get('/') + self.assertEqual(response.status_code, 200) + page = Page(response.data) + self.assertIn('potato', page.entries) + + with client.session_transaction() as session: + session['clipboard:mode'] = 'cut' + response = client.get('/') + self.assertEqual(response.status_code, 200) + page = Page(response.data) + self.assertNotIn('potato', page.entries) + + +class TestAction(unittest.TestCase): + module = file_actions + + def setUp(self): + self.base = tempfile.mkdtemp() + self.basename = os.path.basename(self.base) + self.app = browsepy.create_app() + self.app.extensions['plugin_manager'].load_plugin('file_actions') + self.app.config.update( + SECRET_KEY='secret', + DIRECTORY_BASE=self.base, + DIRECTORY_UPLOAD=self.base, + DIRECTORY_REMOVE=self.base, + ) + + @self.app.errorhandler(browsepy_exceptions.InvalidPathError) + def handler(e): + return '', 400 + + def tearDown(self): + compat.rmtree(self.base) + utils.clear_flask_context() + + def mkdir(self, *path): + os.mkdir(os.path.join(self.base, *path)) + + def touch(self, *path): + open(os.path.join(self.base, *path), 'w').close() + + def assertExist(self, *path): + abspath = os.path.join(self.base, *path) + self.assertTrue( + os.path.exists(abspath), + 'File %s does not exist.' % abspath + ) + + def assertNotExist(self, *path): + abspath = os.path.join(self.base, *path) + self.assertFalse( + os.path.exists(abspath), + 'File %s does exist.' % abspath + ) + + def test_create_directory(self): + with self.app.test_client() as client: + response = client.get('/file-actions/create/directory') + self.assertEqual(response.status_code, 200) + page = Page(response.data) + self.assertEqual(page.urlpath, self.basename) + + response = client.post( + '/file-actions/create/directory', + data={ + 'name': 'asdf' + }) + self.assertEqual(response.status_code, 302) + self.assertExist('asdf') + + response = client.post( + '/file-actions/create/directory', + data={ + 'name': 'asdf' + }) + self.assertEqual(response.status_code, 400) # already exists + + response = client.post( + '/file-actions/create/directory/..', + data={ + 'name': 'asdf', + }) + self.assertEqual(response.status_code, 404) + + response = client.post( + '/file-actions/create/directory/nowhere', + data={ + 'name': 'asdf', + }) + self.assertEqual(response.status_code, 404) + + response = client.post( + '/file-actions/create/directory', + data={ + 'name': '..', + }) + self.assertEqual(response.status_code, 400) + + response = client.post( + '/file-actions/create/directory', + data={ + 'name': '\0', + }) + self.assertEqual(response.status_code, 400) + + def test_selection(self): + self.touch('a') + self.touch('b') + with self.app.test_client() as client: + response = client.get('/file-actions/selection') + self.assertEqual(response.status_code, 200) + page = Page(response.data) + self.assertEqual(page.urlpath, self.basename) + self.assertIn('a', page.entries) + self.assertIn('b', page.entries) + + response = client.get('/file-actions/selection/..') + self.assertEqual(response.status_code, 404) + + response = client.get('/file-actions/selection/nowhere') + self.assertEqual(response.status_code, 404) + + response = client.post('/file-actions/selection', data={}) + self.assertEqual(response.status_code, 400) + + def test_paste(self): + files = ['a', 'b', 'c'] + self.touch('a') + self.touch('b') + self.mkdir('c') + self.mkdir('target') + + with self.app.test_client() as client: + response = client.post( + '/file-actions/selection', + data={ + 'path': files, + 'action-copy': 'whatever', + }) + self.assertEqual(response.status_code, 302) + + response = client.get('/file-actions/clipboard/paste/target') + self.assertEqual(response.status_code, 302) + + for p in files: + self.assertExist(p) + + for p in files: + self.assertExist('target', p) + + with self.app.test_client() as client: + response = client.post( + '/file-actions/selection', + data={ + 'path': files, + 'action-cut': 'something', + }) + self.assertEqual(response.status_code, 302) + + response = client.get('/file-actions/clipboard/paste/target') + self.assertEqual(response.status_code, 302) + + for p in files: + self.assertNotExist(p) + + for p in files: + self.assertExist('target', p) + + with self.app.test_client() as client: + response = client.get('/file-actions/clipboard/paste/..') + self.assertEqual(response.status_code, 404) + response = client.get('/file-actions/clipboard/paste/nowhere') + self.assertEqual(response.status_code, 404) + + with self.app.test_client() as client: + with client.session_transaction() as session: + session['clipboard:items'] = ['whatever'] + session['clipboard:mode'] = 'wrong-mode' + response = client.get('/file-actions/clipboard/paste') + self.assertEqual(response.status_code, 400) + + with client.session_transaction() as session: + session['clipboard:items'] = ['whatever'] + session['clipboard:mode'] = 'cut' + response = client.get('/file-actions/clipboard/paste') + self.assertEqual(response.status_code, 302) # same location + + with client.session_transaction() as session: + session['clipboard:items'] = ['whatever'] + session['clipboard:mode'] = 'cut' + response = client.get('/file-actions/clipboard/paste/target') + self.assertEqual(response.status_code, 400) + + with client.session_transaction() as session: + session['clipboard:items'] = ['whatever'] + session['clipboard:mode'] = 'copy' + response = client.get('/file-actions/clipboard/paste') + self.assertEqual(response.status_code, 400) + + with client.session_transaction() as session: + session['clipboard:items'] = ['whatever'] + session['clipboard:mode'] = 'copy' + response = client.get('/file-actions/clipboard/paste/target') + self.assertEqual(response.status_code, 400) + + self.app.config['EXCLUDE_FNC'] = lambda n: n.endswith('whatever') + + with client.session_transaction() as session: + session['clipboard:items'] = ['whatever'] + session['clipboard:mode'] = 'cut' + response = client.get('/file-actions/clipboard/paste') + self.assertEqual(response.status_code, 400) + + with client.session_transaction() as session: + session['clipboard:items'] = ['whatever'] + session['clipboard:mode'] = 'copy' + response = client.get('/file-actions/clipboard/paste') + self.assertEqual(response.status_code, 400) + + def test_clear(self): + files = ['a', 'b'] + for p in files: + self.touch(p) + + with self.app.test_client() as client: + response = client.post( + '/file-actions/selection', + data={ + 'path': files, + 'action-copy': 'whatever', + }) + self.assertEqual(response.status_code, 302) + + response = client.get('/file-actions/clipboard/clear') + self.assertEqual(response.status_code, 302) + + response = client.get('/file-actions/selection') + self.assertEqual(response.status_code, 200) + page = Page(response.data) + self.assertFalse(page.selected) + + +class TestException(unittest.TestCase): + module = file_actions_exceptions + node_class = browsepy_file.Node + + def setUp(self): + self.app = browsepy.create_app() + + def tearDown(self): + utils.clear_flask_context() + + def test_invalid_clipboard_items_error(self): + pair = ( + self.node_class('/base/asdf', app=self.app), + Exception('Uncaught exception /base/asdf'), + ) + e = self.module.InvalidClipboardItemsError( + path='/', + mode='cut', + clipboard=('asdf,'), + issues=[pair] + ) + e.append( + self.node_class('/base/other', app=self.app), + OSError(2, 'Not found with random message'), + ) + self.assertIn('Uncaught exception asdf', e.issues[0].message) + self.assertIn('No such file or directory', e.issues[1].message) diff --git a/browsepy/plugin/file_actions/utils.py b/browsepy/plugin/file_actions/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..babd92b35617ed79c451bc6b0c67e6a2cefbef37 --- /dev/null +++ b/browsepy/plugin/file_actions/utils.py @@ -0,0 +1,89 @@ +"""Filesystem utility functions.""" + +import os +import os.path +import shutil +import functools + +from browsepy.file import Node, secure_filename + +from .exceptions import InvalidClipboardModeError, \ + InvalidEmptyClipboardError,\ + InvalidDirnameError, \ + DirectoryCreationError + + +def copy(target, node, join_fnc=os.path.join): + """Copy node into target path.""" + if node.is_excluded: + raise OSError(2, os.strerror(2)) + + dest = join_fnc( + target.path, + target.choose_filename(node.name) + ) + + if node.is_directory: + shutil.copytree(node.path, dest) + else: + shutil.copy2(node.path, dest) + + return dest + + +def move(target, node, join_fnc=os.path.join): + """Move node into target path.""" + if node.is_excluded or not node.can_remove: + code = 2 if node.is_excluded else 1 + raise OSError(code, os.strerror(code)) + if node.parent.path != target.path: + dest = join_fnc( + target.path, + target.choose_filename(node.name) + ) + shutil.move(node.path, dest) + return dest + return node.path + + +def paste(target, mode, clipboard): + """Get pasting function for given directory and keyboard.""" + if mode == 'cut': + paste_fnc = functools.partial(move, target) + elif mode == 'copy': + paste_fnc = functools.partial(copy, target) + else: + raise InvalidClipboardModeError( + path=target.path, + mode=mode, + clipboard=clipboard, + ) + + if not clipboard: + raise InvalidEmptyClipboardError( + path=target.path, + mode=mode, + clipboard=clipboard, + ) + + success = [] + issues = [] + for node in map(Node.from_urlpath, clipboard): + try: + success.append(paste_fnc(node)) + except BaseException as e: + issues.append((node, e)) + return success, issues + + +def mkdir(path, name): + """Create directory under path with given nam.""" + if secure_filename(name) != name or not name: + raise InvalidDirnameError(path=path, name=name) + + try: + path = os.path.join(path, name) + os.mkdir(path) + return path + except BaseException as e: + raise DirectoryCreationError.from_exception(e, path=path, name=name) diff --git a/browsepy/plugin/player/__init__.py b/browsepy/plugin/player/__init__.py index 357b8bca3da26320f887bff4864a0362285475bc..8186cb1c578e4dd1ae3d450f640aef3132a9edfa 100644 --- a/browsepy/plugin/player/__init__.py +++ b/browsepy/plugin/player/__init__.py @@ -1,87 +1,63 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- +""" +Player plugin. -import os.path +This plugin is both functional and simple enough to server as example for: +- Blueprint registration. +- Widget with filtering function. +- Custom file nodes. +- Sorting cookie retrieval and usage. -from flask import Blueprint, render_template -from werkzeug.exceptions import NotFound +""" +from flask import Blueprint, abort +from browsepy import get_cookie_browse_sorting, browse_sortkey_reverse +from browsepy.stream import stream_template -from browsepy import stream_template, get_cookie_browse_sorting, \ - browse_sortkey_reverse -from browsepy.file import OutsideDirectoryBase +from .playable import PlayableNode, PlayableDirectory, PlayableFile -from .playable import PlayableFile, PlayableDirectory, \ - PlayListFile, detect_playable_mimetype - - -__basedir__ = os.path.dirname(os.path.abspath(__file__)) player = Blueprint( 'player', __name__, url_prefix='/play', - template_folder=os.path.join(__basedir__, 'templates'), - static_folder=os.path.join(__basedir__, 'static'), + template_folder='templates', + static_folder='static', ) -@player.route('/audio/') -def audio(path): - 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): - 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): +@player.route('/', defaults={'path': ''}) +@player.route('/') +def play(path): + """ + Handle player requests. + + :param path: path to directory + :type path: str + :returns: flask.Response + """ sort_property = get_cookie_browse_sorting(path, 'text') sort_fnc, sort_reverse = browse_sortkey_reverse(sort_property) - try: - file = PlayableDirectory.from_urlpath(path) - if file.is_directory: - return stream_template( - 'audio.player.html', - file=file, - sort_property=sort_property, - sort_fnc=sort_fnc, - sort_reverse=sort_reverse, - playlist=True - ) - except OutsideDirectoryBase: - pass - return NotFound() + node = PlayableNode.from_urlpath(path) + if node.is_playable and not node.is_excluded: + return stream_template( + 'audio.player.html', + file=node, + sort_property=sort_property, + sort_fnc=sort_fnc, + sort_reverse=sort_reverse, + playlist=node.playable_list, + ) + abort(404) 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 - ''' - + """ # Arguments are forwarded to argparse:ArgumentParser.add_argument, # https://docs.python.org/3.7/library/argparse.html#the-add-argument-method manager.register_argument( @@ -91,14 +67,14 @@ def register_arguments(manager): 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) + manager.register_mimetype_function(PlayableFile.detect_mimetype) # add style tag manager.register_widget( @@ -106,38 +82,24 @@ def register_plugin(manager): 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 - ) + endpoint='player.play', + filter=PlayableFile.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 - ) + endpoint='player.play', + filter=PlayableFile.detect, + ) # check argument (see `register_arguments`) before registering if manager.get_argument('player_directory_play'): @@ -145,7 +107,7 @@ def register_plugin(manager): manager.register_widget( place='header', type='button', - endpoint='player.directory', + endpoint='player.play', text='Play directory', - filter=PlayableDirectory.detect + filter=PlayableDirectory.detect, ) diff --git a/browsepy/plugin/player/playable.py b/browsepy/plugin/player/playable.py index ea967ea9a924c0f7bbddc1ec8a69271aafd9670b..3a64bb2a7a1d4a8f5dc7b0600cfdff739e8740d1 100644 --- a/browsepy/plugin/player/playable.py +++ b/browsepy/plugin/player/playable.py @@ -1,248 +1,125 @@ +"""Playable file classes.""" -import sys -import codecs -import os.path -import warnings - -from werkzeug.utils import cached_property - -from browsepy.compat import range, PY_LEGACY # noqa -from browsepy.file import Node, File, Directory, \ - underscore_replace, check_under_base - - -if PY_LEGACY: - import ConfigParser as configparser -else: - import configparser - -ConfigParserBase = ( - configparser.SafeConfigParser - if hasattr(configparser, 'SafeConfigParser') else - configparser.ConfigParser - ) - - -class PLSFileParser(object): - ''' - ConfigParser wrapper accepting fallback on get for convenience. - - 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', - } +from browsepy.compat import cached_property +from browsepy.file import Node, File, Directory - @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 +from .playlist import iter_pls_entries, iter_m3u_entries -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()} +class PlayableNode(Node): + """Base class for playable nodes.""" - def __init__(self, **kwargs): - self.duration = kwargs.pop('duration', None) - self.title = kwargs.pop('title', None) - super(PlayableFile, self).__init__(**kwargs) + playable_list = False + duration = None - @property + @cached_property def title(self): - return self._title or self.name + """Get playable filename.""" + return self.name - @title.setter - def title(self, title): - self._title = title + @cached_property + def is_playable(self): + # type: () -> bool + """ + Get if node is playable. - @property - def media_format(self): - return self.media_map[self.type] + :returns: True if node is playable, False otherwise + """ + return self.detect(self) + @classmethod + def detect(cls, node, fast=False): + """Check if class supports node.""" + kls = cls.directory_class if node.is_directory else cls.file_class + return kls.detect(node) -class PlayListFile(PlayableBase): - playable_class = PlayableFile - 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 - path = os.path.normpath(path) - if not os.path.isabs(path): - return os.path.join(self.parent.path, path) - drive = os.path.splitdrive(self.path)[0] - if drive and not os.path.splitdrive(path)[0]: - path = drive + path - if check_under_base(path, self.app.config['directory_base']): - return path - return None +@PlayableNode.register_file_class +class PlayableFile(PlayableNode, File): + """Generic node for filenames with extension.""" - def _entries(self): - return - yield # noqa + playable_extensions = { + 'mp3': 'audio/mpeg', + 'ogg': 'audio/ogg', + 'oga': 'audio/ogg', + 'm4a': 'audio/mp4', + 'wav': 'audio/wav', + 'm3u': 'audio/x-mpegurl', + 'm3u8': 'audio/x-mpegurl', + 'pls': 'audio/x-scpls', + } + playable_list_parsers = { + 'pls': iter_pls_entries, + 'm3u': iter_m3u_entries, + 'm3u8': iter_m3u_entries, + } - def entries(self, sortkey=None, reverse=None): - for file in self._entries(): - if PlayableFile.detect(file): - yield file - - -class PLSFile(PlayListFile): - ini_parser_class = PLSFileParser - maxsize = getattr(sys, 'maxint', 0) or getattr(sys, 'maxsize', 0) or 2**32 - mimetype = 'audio/x-scpls' - 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 - ), - ) - - -class M3UFile(PlayListFile): - mimetype = 'audio/x-mpegurl' - extensions = PlayableBase.extensions_from_mimetypes([mimetype]) - - def _iter_lines(self): - prefix = '#EXTM3U\n' - encoding = 'utf-8' if self.path.endswith('.m3u8') else 'ascii' - with codecs.open( - self.path, 'r', - encoding=encoding, - errors=underscore_replace - ) as f: - if f.read(len(prefix)) != prefix: - f.seek(0) - for line in f: - line = line.rstrip() - 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 = '' + @cached_property + def playable_list(self): + """Get whether file is a playlist.""" + return self.extension in self.playable_list_parsers + + @cached_property + def mimetype(self): + """Get mimetype.""" + return self.detect_mimetype(self.path) @cached_property - def parent(self): - return Directory(self.path) + def extension(self): + """Get filename extension.""" + return self.detect_extension(self.path) + + def entries(self): + """Iterate playlist files.""" + parser = self.playable_list_parsers.get(self.extension) + if parser: + for options in parser(self): + node = self.file_class(**options, app=self.app) + if not node.is_excluded and node.detect(node, fast=True): + yield node @classmethod - def detect(cls, node): - if node.is_directory: - for file in node._listdir(): - if PlayableFile.detect(file): - return cls.mimetype + def detect(cls, node, fast=False): + """Get whether file is playable.""" + return ( + (fast or node.is_file) and + cls.detect_extension(node.path) in cls.playable_extensions + ) + + @classmethod + def detect_extension(cls, path): + """Detect extension from given path.""" + for extension in cls.playable_extensions: + if path.endswith('.%s' % extension): + return extension return None + @classmethod + def detect_mimetype(cls, path): + """Detect mimetype by its extension.""" + return cls.playable_extensions.get( + cls.detect_extension(path), + 'application/octet-stream' + ) + + +@PlayableNode.register_directory_class +class PlayableDirectory(PlayableNode, Directory): + """Playable directory node.""" + + playable_list = True + def entries(self, sortkey=None, reverse=None): - listdir_fnc = super(PlayableDirectory, self).listdir - for file in listdir_fnc(sortkey=sortkey, reverse=reverse): - 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 + """Iterate playable directory playable files.""" + for node in self.listdir(sortkey=sortkey, reverse=reverse): + if not node.is_directory and node.detect(node, fast=True): + yield node + + @classmethod + def detect(cls, node, fast=False): + """Detect if given node contains playable files.""" + return ( + node.is_directory and + any(PlayableFile.detect(n, fast=True) for n in node._listdir()) + ) diff --git a/browsepy/plugin/player/playlist.py b/browsepy/plugin/player/playlist.py new file mode 100644 index 0000000000000000000000000000000000000000..5472a3bca78c13380533f02ae2ab880e8adf63aa --- /dev/null +++ b/browsepy/plugin/player/playlist.py @@ -0,0 +1,106 @@ +"""Utility functions for playlist files.""" + +import sys +import codecs +import os.path +import warnings +import configparser + +from browsepy.file import underscore_replace, check_under_base + + +configparser_class = getattr( + configparser, + 'SafeConfigParser', + configparser.ConfigParser + ) +configparser_option_exceptions = ( + configparser.NoSectionError, + configparser.NoOptionError, + ValueError, + ) + + +def normalize_playable_path(path, node): + """ + Fix path of playable file from a playlist. + + :param path: absolute or relative path or uri + :type path: str + :param node: node where path was found + :type node: browsepy.file.Node + :returns: absolute path or uri + :rtype: str or None + """ + if '://' in path: + return path + path = os.path.normpath(path) + if not os.path.isabs(path): + return os.path.join(node.parent.path, path) + drive = os.path.splitdrive(path)[0] + if drive and not os.path.splitdrive(path)[0]: + path = drive + path + if check_under_base(path, node.app.config['DIRECTORY_BASE']): + return path + return None + + +def iter_pls_fields(parser, index): + """Iterate pls entry fields from parser and entry index.""" + for prop, get_value, entry in ( + ('path', parser.get, 'File%d' % index), + ('duration', parser.getint, 'Length%d' % index), + ('title', parser.get, 'Title%d' % index), + ): + try: + yield prop, get_value('playlist', entry) + except configparser_option_exceptions: + pass + + +def iter_pls_entries(node): + """Iterate entries on a PLS playlist file node.""" + with warnings.catch_warnings(): + # We already know about SafeConfigParser deprecation! + warnings.simplefilter('ignore', category=DeprecationWarning) + parser = configparser_class() + parser.read(node.path) + try: + maxsize = parser.getint('playlist', 'NumberOfEntries') + 1 + except configparser_option_exceptions: + maxsize = sys.maxsize + failures = 0 + for i in range(1, maxsize): + if failures > 5: + break + data = dict(iter_pls_fields(parser, i)) + if not data.get('path'): + failures += 1 + continue + data['path'] = normalize_playable_path(data['path'], node) + if data['path']: + failures = 0 + yield data + + +def iter_m3u_entries(node): + """Iterate entries on a M3U playlist file node.""" + with codecs.open( + node.path, + 'r', + encoding='utf-8' if node.path.endswith('.m3u8') else 'ascii', + errors=underscore_replace + ) as f: + data = {} + for line in filter(None, map(str.rstrip, f)): + if line.startswith('#EXTINF:'): + duration, title = line.split(',', 1) + data.update( + duration=None if duration == '-1' else int(duration), + title=title, + ) + elif not line.startswith('#'): + path = normalize_playable_path(line, node) + if path: + yield dict(path=path, **data) + data.clear() diff --git a/browsepy/plugin/player/static/css/base.css b/browsepy/plugin/player/static/css/base.css index f1199d48757bd2c32b8c928cd1ab57491c28c477..4b92457870a935f5fdca9a7c496e8ce817c46373 100644 --- a/browsepy/plugin/player/static/css/base.css +++ b/browsepy/plugin/player/static/css/base.css @@ -6,6 +6,10 @@ width: auto; } +.jp-audio button { + box-sizing: border-box; +} + .jp-current-time, .jp-duration { width: 4.5em; } diff --git a/browsepy/plugin/player/templates/audio.player.html b/browsepy/plugin/player/templates/audio.player.html index 63bfc8cc476ac42c4fe543d52cdd3462037d6f18..47921538a236c194ddd215f85333c7db9ec62eff 100644 --- a/browsepy/plugin/player/templates/audio.player.html +++ b/browsepy/plugin/player/templates/audio.player.html @@ -1,4 +1,10 @@ -{% extends "browse.html" %} +{% extends "base.html" %} + +{% block title %} + {{- super() -}} + {%- if file and file.urlpath %} - {{ file.urlpath }}{% endif -%} + {{- ' - play' -}} +{% endblock %} {% block styles %} {{ super() }} @@ -6,6 +12,11 @@ {% endblock %} +{% block header %} +

{{ breadcrumbs(file, circular=True) }}

+

Play

+{% endblock %} + {% block content %} @@ -73,7 +84,7 @@ {% endif %} diff --git a/browsepy/plugin/player/tests.py b/browsepy/plugin/player/tests.py index 605f08f3bc011eeef25d392a9d4699860655b46d..fefc53aeac912c9220f1507e61886d46971e6539 100644 --- a/browsepy/plugin/player/tests.py +++ b/browsepy/plugin/player/tests.py @@ -1,21 +1,19 @@ +# -*- coding: UTF-8 -*- import os import os.path import unittest -import shutil import tempfile import flask -from werkzeug.exceptions import NotFound - import browsepy import browsepy.compat as compat +import browsepy.utils as utils 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 +import browsepy.plugin.player.playlist as player_playlist class ManagerMock(object): @@ -55,58 +53,66 @@ class TestPlayerBase(unittest.TestCase): return self.assertListEqual( list(map(os.path.normcase, a)), list(map(os.path.normcase, b)) - ) + ) def setUp(self): - self.base = 'c:\\base' if os.name == 'nt' else '/base' - self.app = flask.Flask(self.__class__.__name__) - self.app.config['directory_base'] = self.base - self.manager = ManagerMock() + self.base = tempfile.mkdtemp() + self.app = browsepy.create_app() + self.app.config.update( + PLUGIN_NAMESPACES=['browsepy.plugin'], + DIRECTORY_BASE=self.base, + SERVER_NAME='localhost', + ) + self.manager = self.app.extensions['plugin_manager'] + + def tearDown(self): + compat.rmtree(self.base) + utils.clear_flask_context() class TestPlayer(TestPlayerBase): def test_register_plugin(self): - self.module.register_plugin(self.manager) - self.assertListEqual(self.manager.arguments, []) + manager = ManagerMock() + self.module.register_plugin(manager) + self.assertListEqual(manager.arguments, []) - self.assertIn(self.module.player, self.manager.blueprints) + self.assertIn(self.module.player, manager.blueprints) self.assertIn( - self.module.playable.detect_playable_mimetype, - self.manager.mimetype_functions + self.module.playable.PlayableFile.detect_mimetype, + manager.mimetype_functions ) widgets = [ action['filename'] - for action in self.manager.widgets + for action in manager.widgets if action['type'] == 'stylesheet' ] self.assertIn('css/browse.css', widgets) - actions = [action['endpoint'] for action in self.manager.widgets] + actions = [action['endpoint'] for action in manager.widgets] self.assertIn('player.static', actions) - self.assertIn('player.audio', actions) - self.assertIn('player.playlist', actions) - self.assertNotIn('player.directory', actions) + self.assertIn('player.play', actions) def test_register_plugin_with_arguments(self): - self.manager.argument_values['player_directory_play'] = True - self.module.register_plugin(self.manager) + manager = ManagerMock() + manager.argument_values['player_directory_play'] = True + self.module.register_plugin(manager) - actions = [action['endpoint'] for action in self.manager.widgets] - self.assertIn('player.directory', actions) + actions = [action['endpoint'] for action in manager.widgets] + self.assertIn('player.play', actions) def test_register_arguments(self): - self.module.register_arguments(self.manager) - self.assertEqual(len(self.manager.arguments), 1) + manager = ManagerMock() + self.module.register_arguments(manager) + self.assertEqual(len(manager.arguments), 1) - arguments = [arg[0][0] for arg in self.manager.arguments] + arguments = [arg[0][0] for arg in manager.arguments] self.assertIn('--player-directory-play', arguments) class TestIntegrationBase(TestPlayerBase): player_module = player browsepy_module = browsepy - manager_module = browsepy_manager class TestIntegration(TestIntegrationBase): @@ -114,259 +120,201 @@ class TestIntegration(TestIntegrationBase): 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',) - manager = self.manager_module.PluginManager(self.app) - manager.load_plugin('player') + self.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')) + self.manager.load_arguments(self.non_directory_args) + self.assertFalse(self.manager.get_argument('player_directory_play')) + self.manager.load_arguments(self.directory_args) + self.assertTrue(self.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() + self.app.config.update(PLUGIN_MODULES=['player']) - manager = self.manager_module.PluginManager(self.app) - manager.load_arguments(self.directory_args) - manager.reload() + self.manager.load_arguments(self.non_directory_args) + self.manager.reload() + + self.manager.load_arguments(self.directory_args) + self.manager.reload() class TestPlayable(TestIntegrationBase): module = player_playable - def setUp(self): - super(TestPlayable, self).setUp() - self.manager = self.manager_module.MimetypePluginManager( - self.app - ) - self.manager.register_mimetype_function( - self.player_module.playable.detect_playable_mimetype - ) - def test_normalize_playable_path(self): - playable = self.module.PlayListFile( + playable = self.module.PlayableFile( path=p(self.base, 'a.m3u'), app=self.app ) + normalize = player_playlist.normalize_playable_path self.assertEqual( - playable.normalize_playable_path('http://asdf/asdf.mp3'), + normalize('http://asdf/asdf.mp3', playable), 'http://asdf/asdf.mp3' ) self.assertEqual( - playable.normalize_playable_path('ftp://asdf/asdf.mp3'), + normalize('ftp://asdf/asdf.mp3', playable), 'ftp://asdf/asdf.mp3' ) self.assertPathEqual( - playable.normalize_playable_path('asdf.mp3'), + normalize('asdf.mp3', playable), self.base + '/asdf.mp3' ) self.assertPathEqual( - playable.normalize_playable_path(self.base + '/other/../asdf.mp3'), + normalize(self.base + '/other/../asdf.mp3', playable), self.base + '/asdf.mp3' ) self.assertEqual( - playable.normalize_playable_path('/other/asdf.mp3'), + normalize('/other/asdf.mp3', playable), None ) - def test_playablefile(self): + def test_playable_file(self): exts = { - 'mp3': 'mp3', - 'wav': 'wav', - 'ogg': 'ogg' - } - for ext, media_format in exts.items(): + 'mp3': 'mp3', + 'wav': 'wav', + 'ogg': 'ogg' + } + for ext, extension in exts.items(): pf = self.module.PlayableFile(path='asdf.%s' % ext, app=self.app) - self.assertEqual(pf.media_format, media_format) + self.assertEqual(pf.extension, extension) self.assertEqual(pf.title, 'asdf.%s' % ext) - def test_playabledirectory(self): - tmpdir = tempfile.mkdtemp() - try: - file = p(tmpdir, 'playable.mp3') - open(file, 'w').close() - node = browsepy_file.Directory(tmpdir) - self.assertTrue(self.module.PlayableDirectory.detect(node)) - - directory = self.module.PlayableDirectory(tmpdir, app=self.app) - - self.assertEqual(directory.parent.path, directory.path) + def test_playable_directory(self): + file = p(self.base, 'playable.mp3') + open(file, 'w').close() + node = browsepy_file.Directory(self.base, app=self.app) + self.assertTrue(self.module.PlayableDirectory.detect(node)) - entries = directory.entries() - self.assertEqual(next(entries).path, file) - self.assertRaises(StopIteration, next, entries) + directory = self.module.PlayableDirectory(self.base, app=self.app) - os.remove(file) - self.assertFalse(self.module.PlayableDirectory.detect(node)) + entries = directory.entries() + self.assertEqual(next(entries).path, file) + self.assertRaises(StopIteration, next, entries) - finally: - shutil.rmtree(tmpdir) + os.remove(file) + self.assertFalse(self.module.PlayableDirectory.detect(node)) - def test_playlistfile(self): - pf = self.module.PlayListFile.from_urlpath( + def test_playlist(self): + pf = self.module.PlayableNode.from_urlpath( path='filename.m3u', app=self.app) - self.assertTrue(isinstance(pf, self.module.M3UFile)) - pf = self.module.PlayListFile.from_urlpath( + self.assertTrue(isinstance(pf, self.module.PlayableFile)) + pf = self.module.PlayableNode.from_urlpath( path='filename.m3u8', app=self.app) - self.assertTrue(isinstance(pf, self.module.M3UFile)) - pf = self.module.PlayListFile.from_urlpath( + self.assertTrue(isinstance(pf, self.module.PlayableFile)) + pf = self.module.PlayableNode.from_urlpath( path='filename.pls', app=self.app) - self.assertTrue(isinstance(pf, self.module.PLSFile)) - - def test_m3ufile(self): - data = '/base/valid.mp3\n/outside.ogg\n/base/invalid.bin\nrelative.ogg' - tmpdir = tempfile.mkdtemp() - try: - file = p(tmpdir, 'playable.m3u') - with open(file, 'w') as f: - f.write(data) - playlist = self.module.M3UFile(path=file, app=self.app) - self.assertPathListEqual( - [a.path for a in playlist.entries()], - [p(self.base, 'valid.mp3'), p(tmpdir, 'relative.ogg')] - ) - finally: - shutil.rmtree(tmpdir) - - def test_plsfile(self): + self.assertTrue(isinstance(pf, self.module.PlayableFile)) + + def test_m3u_file(self): + data = ( + '{0}/valid.mp3\n' + '/outside.ogg\n' + '{0}/invalid.bin\n' + 'relative.ogg' + ).format(self.base) + file = p(self.base, 'playable.m3u') + with open(file, 'w') as f: + f.write(data) + playlist = self.module.PlayableFile(path=file, app=self.app) + self.assertPathListEqual( + [a.path for a in playlist.entries()], + [p(self.base, 'valid.mp3'), p(self.base, 'relative.ogg')] + ) + + def test_pls_file(self): data = ( '[playlist]\n' - 'File1=/base/valid.mp3\n' + 'File1={0}/valid.mp3\n' 'File2=/outside.ogg\n' - 'File3=/base/invalid.bin\n' + 'File3={0}/invalid.bin\n' 'File4=relative.ogg' + ).format(self.base) + file = p(self.base, 'playable.pls') + with open(file, 'w') as f: + f.write(data) + playlist = self.module.PlayableFile(path=file, app=self.app) + self.assertPathListEqual( + [a.path for a in playlist.entries()], + [p(self.base, 'valid.mp3'), p(self.base, 'relative.ogg')] ) - tmpdir = tempfile.mkdtemp() - try: - file = p(tmpdir, 'playable.pls') - with open(file, 'w') as f: - f.write(data) - playlist = self.module.PLSFile(path=file, app=self.app) - self.assertPathListEqual( - [a.path for a in playlist.entries()], - [p(self.base, 'valid.mp3'), p(tmpdir, 'relative.ogg')] - ) - finally: - shutil.rmtree(tmpdir) - - def test_plsfile_with_holes(self): + + def test_pls_file_with_holes(self): data = ( '[playlist]\n' - 'File1=/base/valid.mp3\n' - 'File3=/base/invalid.bin\n' + 'File1={0}/valid.mp3\n' + 'File3={0}/invalid.bin\n' 'File4=relative.ogg\n' 'NumberOfEntries=4' + ).format(self.base) + file = p(self.base, 'playable.pls') + with open(file, 'w') as f: + f.write(data) + playlist = self.module.PlayableFile(path=file, app=self.app) + self.assertPathListEqual( + [a.path for a in playlist.entries()], + [p(self.base, 'valid.mp3'), p(self.base, 'relative.ogg')] ) - tmpdir = tempfile.mkdtemp() - try: - file = p(tmpdir, 'playable.pls') - with open(file, 'w') as f: - f.write(data) - playlist = self.module.PLSFile(path=file, app=self.app) - self.assertPathListEqual( - [a.path for a in playlist.entries()], - [p(self.base, 'valid.mp3'), p(tmpdir, 'relative.ogg')] - ) - 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.update( - directory_base=tempfile.mkdtemp(), - SERVER_NAME='test' - ) self.app.register_blueprint(self.module.player) - def tearDown(self): - shutil.rmtree(self.app.config['directory_base']) - test_utils.clear_flask_context() - 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) - return response + return flask.url_for(endpoint, **kwargs) def file(self, path, data=''): - apath = p(self.app.config['directory_base'], path) + apath = p(self.app.config['DIRECTORY_BASE'], path) with open(apath, 'w') as f: f.write(data) return apath def directory(self, path): - apath = p(self.app.config['directory_base'], path) + apath = p(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) + url = self.url_for('player.play', path=name) + with self.app.test_client() as client: + result = client.get(url) + self.assertEqual(result.status_code, 404) + + self.file(name) + result = client.get(url) + 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) + url = self.url_for('player.play', path=name) + with self.app.test_client() as client: + result = client.get(url) + self.assertEqual(result.status_code, 404) + + self.file(name) + result = client.get(url) + 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) - - def test_endpoints(self): - with self.app.app_context(): - self.assertIsInstance( - self.module.audio(path='..'), - NotFound - ) + url = self.url_for('player.play', path=name) + with self.app.test_client() as client: + result = client.get(url) + self.assertEqual(result.status_code, 404) - self.assertIsInstance( - self.module.playlist(path='..'), - NotFound - ) + self.directory(name) + result = client.get(url) + self.assertEqual(result.status_code, 404) - self.assertIsInstance( - self.module.directory(path='..'), - NotFound - ) + self.file('directory/test.mp3') + result = client.get(url) + self.assertEqual(result.status_code, 200) + self.assertIn(b'test.mp3', result.data) def p(*args): - args = [ - arg if isinstance(arg, compat.unicode) else arg.decode('utf-8') - for arg in args - ] return os.path.join(*args) diff --git a/browsepy/static/base.css b/browsepy/static/base.css index 016797b7ce2040a70f8ec8ad9f090fbfacac2238..16ef3eb6fc7f6d4f1f6ca1a2a00064444cfe5bb4 100644 --- a/browsepy/static/base.css +++ b/browsepy/static/base.css @@ -33,10 +33,17 @@ body { min-height: 100%; } +*, :after, :before { + box-sizing: content-box; +} + a, label, input[type=submit], -input[type=file] { +input[type=file], +input[type=button], +input[type=radio], +input[type=checkbox] { cursor: pointer; } @@ -53,72 +60,44 @@ 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; +form.autosubmit label { + border-radius: 0.25em; display: inline-block; - margin: 0 0 1em; - padding: 0; -} - -form.upload:after { - content: ''; - clear: both; + vertical-align: middle; } -form.upload h2 { - display: inline-block; - padding: 1em 0; - margin: 0; +form.autosubmit label span { + display: none; } -form.upload label { +html.autosubmit-support form.autosubmit label span { 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.autosubmit input{ + margin: 0; } -form.remove input[type=submit] { - min-width: 10em; +form.autosubmit label input[type=file] { + height: 2em; + line-height: 2em; + width: auto; + padding: 0; + margin: 0 1px 0 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } -html.autosubmit-support form.autosubmit{ - border: 0; - background: transparent; +form.autosubmit input[type=submit] { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + vertical-align: middle; + margin: 0; } html.autosubmit-support form.autosubmit input{ position: fixed; - top: -500px; -} - -html.autosubmit-support form.autosubmit h2{ - font-size: 1em; - font-weight: normal; - padding: 0; + top: -10em; } table.browser { @@ -126,6 +105,7 @@ table.browser { table-layout: fixed; border-collapse: collapse; overflow-x: auto; + margin: 1.5em 0; } table.browser tr:nth-child(2n) { @@ -134,15 +114,15 @@ table.browser tr:nth-child(2n) { table.browser th, table.browser td { + padding: 0; margin: 0; text-align: center; vertical-align: middle; white-space: nowrap; - padding: 5px; + padding: 0.5em; } table.browser th { - padding-bottom: 10px; border-bottom: 1px solid black; } @@ -155,22 +135,49 @@ table.browser td:nth-child(2) { width: 100%; } +table.browser td:nth-child(2) a { + display: block; + line-height: 1.5em; + margin: -0.5em; + padding: 0.5em; +} + table.browser td:nth-child(3) { text-align: left; } -h1 { +h1, h2, h3, h4 { line-height: 1.15em; - font-size: 3em; white-space: normal; word-wrap: normal; margin: 0; - padding: 0 0 0.3em; + padding: 0; display: block; - color: black; + color: inherit; +} + +h1 { + padding: 0 0 0.3em; + font-size: 3em; 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; } +h2, h3, h4 { + margin: 0 0 0.75em; +} + +h2 { + font-size: 1.75em; +} + +h3 { + font-size: 1.5em; +} + +h4 { + font-size: 1.25em; +} + ol.path, ol.path li { display: inline; @@ -192,46 +199,6 @@ ol.path li *.root:after { content: ''; } -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; -} - -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%; -} - .inode.icon:after, .dir.icon:after{ content: "\e90a"; @@ -263,13 +230,19 @@ ul.footer > li { a.sorting { display: block; - padding: 0 1.4em 0 0; +} + +table.browser th a.sorting { + margin: -0.5em; + padding: 0.5em 0.5em 0.75em; } a.sorting:after, a.sorting:before { float: right; - margin: 0.2em -1.3em -0.2em; + line-height: 1.25em; + vertical-align: middle; + margin-left: -1em; } a.sorting:hover:after, @@ -303,46 +276,110 @@ a.sorting.numeric.desc.active:after { } a.button, -html.autosubmit-support form.autosubmit label { - color: white; - background: #333; +input.button, +input.entry, +form.autosubmit{ + margin-right: 0.5em; + font-size: 1em; display: inline-block; vertical-align: middle; line-height: 1.5em; - text-align: center; border-radius: 0.25em; +} + +a.button, +input.button, +input.entry, +html.autosubmit-support form.autosubmit label { border: 1px solid gray; box-shadow: 1px 1px 0 black, -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black; +} + +header a.button, +header input.button, +header form.autosubmit, +footer a.button, +footer input.button, +footer form.autosubmit { + margin: 0 0.5em 0.5em 1px; +} + +a.button:last-child, +input.button:last-child, +input.entry:last-child, +form.autosubmit:last-child { + margin-right: 0; +} + +a.button, +input.button, +html.autosubmit-support form.autosubmit label { + color: white; + background: #333; + text-align: center; 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{ +input.button:active, +html.autosubmit-support form.autosubmit label:active { border: 1px solid black; } a.button:hover, +input.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.long, +input.button.long { + min-width: 10em; +} + +a.button.destructive, +input.button.destructive { + background: #f31; + border-color: #f88; +} + +a.button.destructive:hover, input.button.destructive:hover { + background: #d10; +} + +a.button.destructive:active, input.button.destructive:active { + background: #d10; + border-color: #d10; +} + +a.button.suggested, input.button.suggested { + background: #29f; + border-color: #8cF; +} + +a.button.suggested:hover, input.button.suggested:hover { + background: #16e; +} + +a.button.suggested:active, input.button.suggested:active { + background: #16e; + border-color: #16e; } -a.button { +a.button, input.button { width: 1.5em; height: 1.5em; } -a.button.text{ +a.button.text, input.button.text{ width: auto; height: auto; } a.button.text, +input.button.text, +input.entry, html.autosubmit-support form.autosubmit label{ padding: 0.25em 0.5em; } @@ -355,31 +392,24 @@ a.button.remove:after { content: "\e902"; } -strong[data-prefix] { - display: inline-block; - cursor: help; +input.entry { + position: relative; + min-width: 10em; + overflow: visible; + outline: 0; } -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; +input.entry:hover, +input.entry:focus { + border-color: #8cF; } -strong[data-prefix]:hover:after { - display: none; +input.entry:invalid { + border-color: #f31; } -strong[data-prefix]:hover:before { - content: attr(data-prefix); +.group { + display: block; + padding: 0; + margin: 1em 0; } diff --git a/browsepy/static/browse.directory.body.js b/browsepy/static/browse.directory.body.js index 15e68004ebbbc54562f0785e9b06f4a60cc8376b..74ad9c5f90cd9847d9f530fef7c8d54f3064a3c2 100644 --- a/browsepy/static/browse.directory.body.js +++ b/browsepy/static/browse.directory.body.js @@ -4,12 +4,15 @@ forms = document.querySelectorAll('html.autosubmit-support form.autosubmit'), i = forms.length; while (i--) { - var files = forms[i].querySelectorAll('input[type=file]'); - files[0].addEventListener('change', (function(form) { + var + input = forms[i].querySelectorAll('input[type=file]')[0], + label = forms[i].querySelectorAll('label')[0]; + input.addEventListener('change', (function(form) { return function() { form.submit(); }; }(forms[i]))); + label.tabIndex = 0; } } }()); diff --git a/browsepy/static/icon/android-icon-144x144.png b/browsepy/static/icon/android-icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..fb24422d9ac18370f3a878c3f20845ae27737c7d Binary files /dev/null and b/browsepy/static/icon/android-icon-144x144.png differ diff --git a/browsepy/static/icon/android-icon-192x192.png b/browsepy/static/icon/android-icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..0b2f194b2aab1555a1a7cf7b84f019bbd472f18f Binary files /dev/null and b/browsepy/static/icon/android-icon-192x192.png differ diff --git a/browsepy/static/icon/android-icon-36x36.png b/browsepy/static/icon/android-icon-36x36.png new file mode 100644 index 0000000000000000000000000000000000000000..97aeced9efa68f0c8bd22dab4d69e75bafa46fca Binary files /dev/null and b/browsepy/static/icon/android-icon-36x36.png differ diff --git a/browsepy/static/icon/android-icon-48x48.png b/browsepy/static/icon/android-icon-48x48.png new file mode 100644 index 0000000000000000000000000000000000000000..374448741ac3ddf81768c2d3365ce46793428f5b Binary files /dev/null and b/browsepy/static/icon/android-icon-48x48.png differ diff --git a/browsepy/static/icon/android-icon-72x72.png b/browsepy/static/icon/android-icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..b72780ec6fdc46f78468d6799bb6076dbe9cf2f7 Binary files /dev/null and b/browsepy/static/icon/android-icon-72x72.png differ diff --git a/browsepy/static/icon/android-icon-96x96.png b/browsepy/static/icon/android-icon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..1bed499c3bce1f97511cdf11a5c52ce1155b11c4 Binary files /dev/null and b/browsepy/static/icon/android-icon-96x96.png differ diff --git a/browsepy/static/icon/apple-icon-114x114.png b/browsepy/static/icon/apple-icon-114x114.png new file mode 100644 index 0000000000000000000000000000000000000000..3186726c070df3ceb50fc4e2d13897af44ea31f0 Binary files /dev/null and b/browsepy/static/icon/apple-icon-114x114.png differ diff --git a/browsepy/static/icon/apple-icon-120x120.png b/browsepy/static/icon/apple-icon-120x120.png new file mode 100644 index 0000000000000000000000000000000000000000..2e34a6aff9c76e617878d4494eecd93d6fcdefc0 Binary files /dev/null and b/browsepy/static/icon/apple-icon-120x120.png differ diff --git a/browsepy/static/icon/apple-icon-144x144.png b/browsepy/static/icon/apple-icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..fb24422d9ac18370f3a878c3f20845ae27737c7d Binary files /dev/null and b/browsepy/static/icon/apple-icon-144x144.png differ diff --git a/browsepy/static/icon/apple-icon-152x152.png b/browsepy/static/icon/apple-icon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..9b3a10d5e94ed7dd9ba3ed8d0091308f573623ba Binary files /dev/null and b/browsepy/static/icon/apple-icon-152x152.png differ diff --git a/browsepy/static/icon/apple-icon-180x180.png b/browsepy/static/icon/apple-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..93d88f178666d693c6039f6756be1fa778277e7b Binary files /dev/null and b/browsepy/static/icon/apple-icon-180x180.png differ diff --git a/browsepy/static/icon/apple-icon-57x57.png b/browsepy/static/icon/apple-icon-57x57.png new file mode 100644 index 0000000000000000000000000000000000000000..4e53b728232f3d116966ab7c682d92ac03a878c5 Binary files /dev/null and b/browsepy/static/icon/apple-icon-57x57.png differ diff --git a/browsepy/static/icon/apple-icon-60x60.png b/browsepy/static/icon/apple-icon-60x60.png new file mode 100644 index 0000000000000000000000000000000000000000..039d0a1253a6a51d6cf0491d694d90bacc02e9af Binary files /dev/null and b/browsepy/static/icon/apple-icon-60x60.png differ diff --git a/browsepy/static/icon/apple-icon-72x72.png b/browsepy/static/icon/apple-icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..b72780ec6fdc46f78468d6799bb6076dbe9cf2f7 Binary files /dev/null and b/browsepy/static/icon/apple-icon-72x72.png differ diff --git a/browsepy/static/icon/apple-icon-76x76.png b/browsepy/static/icon/apple-icon-76x76.png new file mode 100644 index 0000000000000000000000000000000000000000..62ffd5c740509e66963fc437117d9254ef18982a Binary files /dev/null and b/browsepy/static/icon/apple-icon-76x76.png differ diff --git a/browsepy/static/icon/apple-icon-precomposed.png b/browsepy/static/icon/apple-icon-precomposed.png new file mode 100644 index 0000000000000000000000000000000000000000..b0adf90d9b504d8dbf2b9c5e0895f61e6a140f5e Binary files /dev/null and b/browsepy/static/icon/apple-icon-precomposed.png differ diff --git a/browsepy/static/icon/apple-icon.png b/browsepy/static/icon/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b0adf90d9b504d8dbf2b9c5e0895f61e6a140f5e Binary files /dev/null and b/browsepy/static/icon/apple-icon.png differ diff --git a/browsepy/static/icon/favicon-16x16.png b/browsepy/static/icon/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..742da968b1855e29f1eabad972ef0db1ecce1395 Binary files /dev/null and b/browsepy/static/icon/favicon-16x16.png differ diff --git a/browsepy/static/icon/favicon-32x32.png b/browsepy/static/icon/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..e82936ed5841b91c4e7c4b690eed48429b03cca2 Binary files /dev/null and b/browsepy/static/icon/favicon-32x32.png differ diff --git a/browsepy/static/icon/favicon-96x96.png b/browsepy/static/icon/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..1bed499c3bce1f97511cdf11a5c52ce1155b11c4 Binary files /dev/null and b/browsepy/static/icon/favicon-96x96.png differ diff --git a/browsepy/static/icon/favicon.ico b/browsepy/static/icon/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b2722282ba237bc020e87019e57ffea5850dbf23 Binary files /dev/null and b/browsepy/static/icon/favicon.ico differ diff --git a/browsepy/static/icon/ms-icon-144x144.png b/browsepy/static/icon/ms-icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..fb24422d9ac18370f3a878c3f20845ae27737c7d Binary files /dev/null and b/browsepy/static/icon/ms-icon-144x144.png differ diff --git a/browsepy/static/icon/ms-icon-150x150.png b/browsepy/static/icon/ms-icon-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..5012e62bb66ba380002e9ed2a7592bde7f84ee8c Binary files /dev/null and b/browsepy/static/icon/ms-icon-150x150.png differ diff --git a/browsepy/static/icon/ms-icon-310x310.png b/browsepy/static/icon/ms-icon-310x310.png new file mode 100644 index 0000000000000000000000000000000000000000..99db385b4c1f86ea75b65739c54bba4ccba73927 Binary files /dev/null and b/browsepy/static/icon/ms-icon-310x310.png differ diff --git a/browsepy/static/icon/ms-icon-70x70.png b/browsepy/static/icon/ms-icon-70x70.png new file mode 100644 index 0000000000000000000000000000000000000000..58f2868b7a16eb9470d9d67bde3933c43cdfafff Binary files /dev/null and b/browsepy/static/icon/ms-icon-70x70.png differ diff --git a/browsepy/stream.py b/browsepy/stream.py index 5fb0189ac8b17051a7f093baeb6cbbd65d2dd0a3..f2a63cdc135edacfb7050e399840e495d109e590 100644 --- a/browsepy/stream.py +++ b/browsepy/stream.py @@ -1,148 +1,284 @@ +"""Streaming functionality with generators and response constructors.""" import os import os.path import tarfile -import functools import threading +import queue +import collections.abc +import flask -class TarFileStream(object): - ''' - Tarfile which compresses while reading for streaming. - Buffsize can be provided, it must be 512 multiple (the tar block size) for - compression. +class ByteQueue(queue.Queue): + """ + Synchronized byte queue, with an additional finish method. - 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 + On a finished, :method:`put` will raise queue.Full exceptions and + :method:`get` will return empty bytes without blockng. + """ + + def _init(self, maxsize): + self.queue = [] + self.bytes = 0 + self.finished = False + self.closed = False + + def _qsize(self): + return -1 if self.finished else self.bytes + + def _put(self, item): + if self.finished: + raise queue.Full + self.queue.append(item) + self.bytes += len(item) + + def _get(self): + size = self.maxsize + data = b''.join(self.queue) + data, tail = data[:size], data[size:] + self.queue[:] = (tail,) + self.bytes = len(tail) + return data + + def qsize(self): + """Return the number of bytes in the queue.""" + with self.mutex: + return self.bytes + + def finish(self): + """ + Put queue into finished mode. + + On finished mode, :method:`get` becomes non-blocking once empty + by returning empty and :method:`put` raises :class:`queue.Full` + exceptions unconditionally. + """ + self.finished = True + + with self.not_full: + self.not_empty.notify() + + +class WriteAbort(Exception): + """Exception to stop tarfile compression process.""" + + pass + + +class TarFileStream(collections.abc.Iterator): + """ + Iterator class which yields tarfile chunks for streaming. + + This class implements :class:`collections.abc.Iterator` interface + with :method:`close`, so it can be appropriately handled by wsgi servers + (`PEP 333`_). + + Buffsize can be provided, it should be 512 multiple (the tar block size) + for and will be used as tarfile block size. + + This class uses :module:`threading` for offloading, so if your exclude + function would not have access to any thread-local specific data. + + If your exclude function requires accessing to :data:`flask.app`, + :data:`flask.g`, :data:`flask.request` or any other flask contexts, + ensure is wrapped with :func:`flask.copy_current_request_context` + """ + + queue_class = ByteQueue + abort_exception = WriteAbort thread_class = threading.Thread - tarfile_class = tarfile.open + tarfile_open = tarfile.open + tarfile_format = tarfile.PAX_FORMAT - def __init__(self, path, buffsize=10240, exclude=None): - ''' - 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. + mimetype = 'application/x-tar' + compresion_modes = { + None: ('', 'tar'), + 'gzip': ('gz', 'tgz'), + 'bzip2': ('bz2', 'tar.bz2'), + 'xz': ('xz', 'tar.xz'), + } + + @property + def name(self): + """Get filename generated from given path and compression method.""" + return '%s.%s' % (os.path.basename(self.path), self._extension) + + @property + def encoding(self): + """Mimetype parameters (such as encoding).""" + return self._compress + + @property + def closed(self): + """Get if input stream have been closed with no further writes.""" + return self._closed + + def __init__(self, path, buffsize=10240, exclude=None, compress='gzip', + compresslevel=1): + """ + Initialize thread and class (thread is not started until iteration). + + Note that compress parameter will be ignored if buffsize is below 16. :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 + :param buffsize: byte size of tarfile blocks, defaults to 10KiB :type buffsize: int - :param exclude: path filter function, defaults to None + :param exclude: absolute path filtering function, defaults to None :type exclude: callable - ''' + :param compress: compression method ('gz', 'bz2', 'xz' or None) + :type compress: str or None + :param compresslevel: compression level [1-9] defaults to 1 (fastest) + :type compresslevel: int + """ self.path = path - self.name = os.path.basename(path) + ".tgz" self.exclude = exclude - self._finished = 0 - self._want = 0 - self._data = bytes() - self._add = self.event_class() - self._result = self.event_class() - self._tarfile = self.tarfile_class( # stream write - fileobj=self, - mode="w|gz", - bufsize=buffsize - ) - self._th = self.thread_class(target=self.fill) - 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. - ''' - if self.exclude: - exclude = self.exclude - ap = functools.partial(os.path.join, self.path) - self._tarfile.add( - self.path, "", - filter=lambda info: None if exclude(ap(info.name)) else info + self._started = False + self._closed = False + self._buffsize = buffsize + + self._compress = compress if compress and buffsize > 15 else None + self._mode, self._extension = self.compresion_modes[self._compress] + + self._queue = self.queue_class(buffsize) + self._thread = self.thread_class(target=self._fill) + self._thread_exception = None + + def _fill(self): + """ + Compress files in path, pushing compressed data into internal queue. + + Used as compression thread target, started on first iteration. + """ + path = self.path + path_join = os.path.join + exclude = self.exclude + + def infofilter(info): + """ + Filter TarInfo objects from TarFile. + + :param info: + :type info: tarfile.TarInfo + :return: infofile or None if file must be excluded + :rtype: tarfile.TarInfo or None + """ + return ( + None + if exclude( + path_join(path, info.name), + follow_symlinks=info.issym(), + ) else + info ) - else: - self._tarfile.add(self.path, "") - self._tarfile.close() # force stream flush - self._finished += 1 - if not self._result.is_set(): - self._result.set() + + try: + with self.tarfile_open( + fileobj=self, + mode='w|{}'.format(self._mode), + bufsize=self._buffsize, + format=self.tarfile_format, + encoding='utf-8', + ) as tarfile: + tarfile.add( + path, + arcname='', # as archive root + recursive=True, + filter=infofilter if exclude else None, + ) + except self.abort_exception: + pass + except Exception as e: + self._thread_exception = e + finally: + self._queue.finish() + + def __next__(self): + """ + Get chunk from internal queue. + + Starts compression thread on first call. + + :param want: number bytes to read, defaults to 0 (all available) + :type want: int + :returns: tarfile data as bytes + :rtype: bytes + """ + if self._closed: + raise StopIteration + + if not self._started: + self._started = True + self._thread.start() + + data = self._queue.get() + if not data: + raise StopIteration + + return data def write(self, data): - ''' - Write method used by internal tarfile instance to output data. - This method blocks tarfile execution once internal buffer is full. + """ + Add chunk of data into data queue. - As this method is blocking, it is used inside the same thread of - :meth:`fill`. + This method is used inside the compression thread, blocking when + the internal queue is already full, propagating backpressure to + writer. - :param data: bytes to write to internal buffer + :param data: bytes to write to pipe :type data: bytes :returns: number of bytes written :rtype: int - ''' - self._add.wait() - self._data += data - if len(self._data) > self._want: - self._add.clear() - self._result.set() + :raises WriteAbort: if already closed or closed while blocking + """ + if self._closed: + raise self.abort_exception() + + try: + self._queue.put(data) + except queue.Full: + raise self.abort_exception() + return len(data) - def read(self, want=0): - ''' - Read method, gets data from internal buffer while releasing - :meth:`write` locks when needed. + def close(self): + """Close tarfile pipe and stops further processing.""" + if not self._closed: + self._closed = True + self._queue.finish() + if self._started and self._thread.is_alive(): + self._thread.join() + if self._thread_exception: + raise self._thread_exception - 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. +def tarfile_extension(compress, tarfile_class=TarFileStream): + """ + Get file extension for given compression mode (as in contructor). + + :param compress: compression mode + :returns: file extension + """ + _, extension = tarfile_class.compresion_modes[compress] + return extension - :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 - return "" - return EOFError("EOF reached") - - # Thread communication - self._want = want - self._add.set() - self._result.wait() - self._result.clear() - - if want: - data = self._data[:want] - self._data = self._data[want:] - else: - data = self._data - self._data = bytes() - return data - def __iter__(self): - ''' - Iterate through tarfile result chunks. +def stream_template(template_name, **context): + """ + Get streaming response rendering a jinja template. - Similarly to :meth:`read`, this methos must ran on a different thread - than :meth:`write` calls. + Some templates can be huge, this function returns an streaming response, + sending the content in chunks and preventing from timeout. - :yields: data chunks as taken from :meth:`read`. - :ytype: bytes - ''' - data = self.read() - while data: - yield data - data = self.read() + :param template_name: template + :param **context: parameters for templates. + :yields: HTML strings + :rtype: Iterator of str + """ + app = context.get('current_app', flask.current_app) + app.update_template_context(context) + template = app.jinja_env.get_template(template_name) + stream = template.generate(context) + return app.response_class(flask.stream_with_context(stream)) diff --git a/browsepy/templates/400.html b/browsepy/templates/400.html index 714fc69bb1b029ba60f46960d5782cfa7dd8eed4..52119a12ee3669233a64ed6bb0c716a9f4b9d357 100644 --- a/browsepy/templates/400.html +++ b/browsepy/templates/400.html @@ -4,6 +4,15 @@

The server cannot or will not process the request due to an apparent client error (e.g., malformed request syntax, size too large, invalid request message framing, or deceptive request routing).

Please try other parameters or contact server administrator.

{% endset %} + +{% set buttons %} + {% if file %} + Accept + {% else %} + Accept + {% endif %} +{% endset %} + {% if error and error.code %} {% if error.code == 'invalid-filename-length' or error.code == 'invalid-path-length'%} {% set workaround %} @@ -38,12 +47,12 @@ {% endif %} {% block title %}400 Bad Request{% endblock %} -{% block content %} + +{% block header %}

Bad Request

+{% endblock %} + +{% block content %} {{ description }} - {% if file %} -
- -
- {% endif %} + {{ buttons }} {% endblock %} diff --git a/browsepy/templates/404.html b/browsepy/templates/404.html index 4734727fc345d60fc4d01419b6fcb712fc49bade..3165b63a735258766ca3af3e01a9d10b5f991be1 100644 --- a/browsepy/templates/404.html +++ b/browsepy/templates/404.html @@ -1,8 +1,13 @@ {% extends "base.html" %} -{% block title %}Upload failed{% endblock %} -{% block content %} +{% block title %}404 Not Found{% endblock %} + +{% block header %}

Not Found

+{% endblock %} + +{% block content %} -

+

The resource you're looking for is not available.

+ Accept {% endblock %} diff --git a/browsepy/templates/base.html b/browsepy/templates/base.html index 5aefd81650e670ab68152189a18097fb65b97c2e..620315b902b30f3f5a581d75c1ba81eacc2301fc 100644 --- a/browsepy/templates/base.html +++ b/browsepy/templates/base.html @@ -1,11 +1,56 @@ +{% macro breadcrumb(file) -%} + {{ file.name }} +{%- endmacro %} + +{% macro breadcrumbs(file, circular=False) -%} +
    + {% for parent in file.ancestors[::-1] %} +
  1. {{ breadcrumb(parent) }}
  2. + {% endfor %} + {% if circular and file.is_directory %} +
  3. {{ breadcrumb(file) }}
  4. + {% elif file.name %} +
  5. {{ file.name }}
  6. + {% endif %} +
+{%- endmacro %} + - {% block title %}{{ config.get("title", "BrowsePy") }}{% endblock %} + {% block title %}{{ config.get('APPLICATION_NAME', 'browsepy') }}{% endblock %} {% block styles %} - + + {% block icon %} + {% for i in (57, 60, 72, 76, 114, 120, 144, 152, 180) %} + + {% endfor %} + {% for i in (192, 96, 32, 16) %} + + {% endfor %} + + + + + {% endblock %} {% endblock %} {% block head %}{% endblock %} diff --git a/browsepy/templates/browse.html b/browsepy/templates/browse.html index d94e7bb193906a8bbe0355b1897cd1b5ac70341f..0713611204109af3a9824145f97708c0b9423999 100644 --- a/browsepy/templates/browse.html +++ b/browsepy/templates/browse.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% macro draw_widget(f, widget) -%} - {%- if widget.type == 'button' -%} + {%- if widget.type == 'button' or widget.type == 'link'-%} {{ widget.text or '' }} - {%- elif widget.type == 'link' -%} - {{ widget.text or '' }} {%- elif widget.type == 'script' -%} ', - 'style': '', - } - - -class HTMLCompress(jinja2.ext.Extension): - context_class = HTMLCompressContext - token_class = jinja2.lexer.Token - block_tokens = { - 'variable_begin': 'variable_end', - 'block_begin': 'block_end' - } - - def filter_stream(self, stream): - transform = self.context_class() - lineno = 0 - skip_until_token = None - for token in stream: - if skip_until_token: - yield token - if token.type == skip_until_token: - skip_until_token = None - continue - - if token.type != 'data': - for data in transform.finish(): - yield self.token_class(lineno, 'data', data) - yield token - skip_until_token = self.block_tokens.get(token.type) - continue - - if not transform.pending: - lineno = token.lineno - - for data in transform.feed(token.value): - yield self.token_class(lineno, 'data', data) - lineno = token.lineno - - for data in transform.finish(): - yield self.token_class(lineno, 'data', data) diff --git a/browsepy/transform/template.py b/browsepy/transform/template.py new file mode 100755 index 0000000000000000000000000000000000000000..128b29e87c00d09d1248d73ae7778ccd3f671151 --- /dev/null +++ b/browsepy/transform/template.py @@ -0,0 +1,215 @@ +"""Module providing HTML compression extension jinja2.""" + +import re +import functools + +import jinja2 +import jinja2.ext +import jinja2.lexer + +from . import StateMachine + + +class OriginalContext: + """Non-minifying context stub class.""" + + def feed(self, token): + """Add token to context an yield tokens.""" + yield token + + def finish(self): + """Finish context and yield tokens.""" + return + yield + + +class MinifyContext(StateMachine): + """Base minifying jinja2 template token finite state machine.""" + + token_class = jinja2.lexer.Token + block_tokens = { + 'variable_begin': 'variable_end', + 'block_begin': 'block_end' + } + skip_until_token = None + lineno = 0 + + def feed(self, token): + """Process a single token, yielding processed ones.""" + if self.skip_until_token: + yield token + if token.type == self.skip_until_token: + self.skip_until_token = None + elif token.type != 'data': + for ftoken in self.finish(): + yield ftoken + yield token + self.skip_until_token = self.block_tokens.get(token.type) + else: + if not self.pending: + self.lineno = token.lineno + + for data in super().feed(token.value): + yield self.token_class(self.lineno, 'data', data) + self.lineno = token.lineno + + def finish(self): + """Set state machine as finished, yielding remaining tokens.""" + for data in super().finish(): + yield self.token_class(self.lineno, 'data', data) + + +class SGMLMinifyContext(MinifyContext): + """Minifying context for jinja2 SGML templates.""" + + re_whitespace = re.compile('[ \\t\\r\\n]+') + block_tags = {} # block content will be treated as literal text + jumps = { # state machine jumps + 'text': { + '<': 'tag', + '': 'text'}, + 'cdata': {']]>': 'text'} + } + skip_until_text = None # inside text until this is met + current = 'text' + + @property + def nearest(self): + """Get next data chunk to be processed.""" + if self.skip_until_text and self.current == 'text': + mark = self.skip_until_text + index = self.pending.find(mark, len(self.start)) + if index == -1: + return len(self.pending), '', None + return index, mark, self.current + return super().nearest + + def transform_tag(self, data, mark, next): + """Minify SML tag node.""" + tagstart = self.start == '<' + data = self.re_whitespace.sub(' ', data[1:] if tagstart else data) + if tagstart: + data = data.lstrip() if next is None else data.strip() + tagname = data.split(' ', 1)[0] + self.skip_until_text = self.block_tags.get(tagname) + return '<' + data + elif next is None: + return data.rstrip() + return self.start if data.strip() == self.start else data + + def transform_text(self, data, mark, next): + """Minify SGML text node.""" + if not self.skip_until_text: + return self.start if data.strip() == self.start else data + elif next is not None: + self.skip_until_text = None + return data + + +class HTMLMinifyContext(SGMLMinifyContext): + """Minifying context for jinja2 HTML templates.""" + + block_tags = { + 'textarea': '', + 'pre': '', + 'script': '', + 'style': '', + } + + +class JSMinifyContext(MinifyContext): + """Minifying context for jinja2 JavaScript templates.""" + + jumps = { + 'code': { + '\'': 'single_string', + '"': 'double_string', + '//': 'line_comment', + '/*': 'multiline_comment', + }, + 'single_string': { + '\'': 'code', + '\\': 'single_string_escape', + }, + 'single_string_escape': { + c: 'single_string' for c in ('\\', '\'', '') + }, + 'double_string': { + '"': 'code', + '\\': 'double_string_escape', + }, + 'double_string_escape': { + c: 'double_string' for c in ('\\', '"', '') + }, + 'line_comment': { + '\n': 'ignore', + }, + 'multiline_comment': { + '*/': 'ignore', + }, + 'ignore': { + '': 'code', + } + } + current = 'code' + pristine = True + strip_tokens = staticmethod( + functools.partial( + re.compile(r'\s+[^\w\d\s]\s*|[^\w\d\s]\s+|\s{2,}').sub, + lambda x: x.group(0).strip() or ' ' + ) + ) + + def transform_code(self, data, mark, next): + """Minify JS-like code.""" + if self.pristine: + data = data.lstrip() + self.pristine = False + return self.strip_tokens(data) + + def transform_ignore(self, data, mark, next): + """Ignore text.""" + self.pristine = True + return '' + + transform_line_comment = transform_ignore + transform_multiline_comment = transform_ignore + + +class MinifyTemplateExtension(jinja2.ext.Extension): + """Jinja2 template minifying extension.""" + + default_context_class = OriginalContext + context_classes = { + '.xml': SGMLMinifyContext, + '.xhtml': HTMLMinifyContext, + '.html': HTMLMinifyContext, + '.htm': HTMLMinifyContext, + '.json': JSMinifyContext, + '.js': JSMinifyContext, + } + + def get_context(self, filename=None): + """Get minifying context based on given filename.""" + if filename: + for extension, context_class in self.context_classes.items(): + if filename.endswith(extension): + return context_class() + return self.default_context_class() + + def filter_stream(self, stream): + """Yield minified tokens from :class:`~jinja2.lexer.TokenStream`.""" + transform = self.get_context(stream.name) + for token in stream: + yield from transform.feed(token) + yield from transform.finish() diff --git a/browsepy/utils.py b/browsepy/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e7e1720b885b2247f232840288eb2b711bcaaca4 --- /dev/null +++ b/browsepy/utils.py @@ -0,0 +1,68 @@ +"""Small utility functions for common tasks.""" + +import collections + +import flask + + +def solve_local(context_local): + """ + Resolve given context local to its actual value. + + If given object isn't a context local, nothing happens, return the + same object. + """ + if callable(getattr(context_local, '_get_current_object', None)): + return context_local._get_current_object() + return context_local + + +def clear_localstack(stack): + """ + Clear given werkzeug LocalStack instance. + + :param ctx: local stack instance + :type ctx: werkzeug.local.LocalStack + """ + while stack.pop(): + pass + + +def clear_flask_context(): + """ + Clear flask current_app and request globals. + + When using :meth:`flask.Flask.test_client`, even as context manager, + the flask's globals :attr:`flask.current_app` and :attr:`flask.request` + are left dirty, so testing code relying on them will probably fail. + + This function clean said globals, and should be called after testing + with :meth:`flask.Flask.test_client`. + """ + clear_localstack(flask._app_ctx_stack) + clear_localstack(flask._request_ctx_stack) + + +def defaultsnamedtuple(name, fields, defaults=None): + """ + Generate namedtuple with default values. + + This somewhat tries to mimic py3.7 namedtuple with keyword-based + defaults, in a backwards-compatible way. + + :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(fields) + nt.__module__ = __name__ + if defaults: + nt.__new__.__defaults__ = tuple( + nt(**defaults) + if isinstance(defaults, collections.Mapping) else + nt(*defaults) + ) + return nt diff --git a/browsepy/widget.py b/browsepy/widget.py deleted file mode 100644 index afbda38812026fd4a62a20ead13d39783afd0b5e..0000000000000000000000000000000000000000 --- a/browsepy/widget.py +++ /dev/null @@ -1,87 +0,0 @@ -''' -WARNING: deprecated module. - -API defined in this module has been deprecated in version 0.5 will likely be -removed at 0.6. -''' -import warnings - -from markupsafe import Markup -from flask import url_for - -from .compat import deprecated - - -warnings.warn('Deprecated module widget', category=DeprecationWarning) - - -class WidgetBase(object): - _type = 'base' - place = None - - @deprecated('Deprecated widget API') - def __new__(cls, *args, **kwargs): - return super(WidgetBase, cls).__new__(cls) - - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - def for_file(self, file): - return self - - @classmethod - def from_file(cls, file): - if not hasattr(cls, '__empty__'): - cls.__empty__ = cls() - return cls.__empty__.for_file(file) - - -class LinkWidget(WidgetBase): - _type = 'link' - place = 'link' - - def __init__(self, text=None, css=None, icon=None): - self.text = text - self.css = css - self.icon = icon - super(LinkWidget, self).__init__() - - def for_file(self, file): - if None in (self.text, self.icon): - return self.__class__( - file.name if self.text is None else self.text, - self.css, - self.icon if self.icon is not None else - 'dir-icon' if file.is_directory else - 'file-icon', - ) - return self - - -class ButtonWidget(WidgetBase): - _type = 'button' - place = 'button' - - def __init__(self, html='', text='', css=''): - self.content = Markup(html) if html else text - self.css = css - super(ButtonWidget, self).__init__() - - -class StyleWidget(WidgetBase): - _type = 'stylesheet' - place = 'style' - - @property - def href(self): - return url_for(*self.args, **self.kwargs) - - -class JavascriptWidget(WidgetBase): - _type = 'script' - place = 'javascript' - - @property - def src(self): - return url_for(*self.args, **self.kwargs) diff --git a/doc/builtin_plugins.rst b/doc/builtin_plugins.rst index f5f2224c12d7e3307d21b30598a8ae210a620527..9ccdd8d532675148bde13aa5bd38be3f7a80007f 100644 --- a/doc/builtin_plugins.rst +++ b/doc/builtin_plugins.rst @@ -29,16 +29,16 @@ Sources are available at browsepy's `plugin.player`_ submodule. Contributing Builtin Plugins ---------------------------- -Browsepy's team is open to contributions of any kind, even about adding -built-in plugins, as long as they comply with the following requirements: +Browsepy's team is open to contributions of any kind, including built-in +plugins, as long as they comply with the following requirements: * Plugins must be sufficiently covered by tests to avoid lowering browsepy's - overall test coverage. -* Plugins must not add external requirements to browsepy, optional - requirements are allowed if plugin can work without them, even with + global test coverage. +* Plugins must not add external requirements to browsepy, but optional + requirements will be allowed if plugin can work without them, even with limited functionality. * Plugins should avoid adding specific logic on browsepy itself, but extending - browsepy's itself (specially via plugin interface) in a generic and useful + browsepy itself (specially on the plugin interface) in a generic and useful way is definitely welcome. Said that, feel free to fork, code great stuff and fill pull requests at diff --git a/doc/compat.rst b/doc/compat.rst index 421d8d7197c73c5296aefc98cca35f390bc7c9e0..66ec01c768fdacbf9ad519b9080221e3de980451 100644 --- a/doc/compat.rst +++ b/doc/compat.rst @@ -10,19 +10,14 @@ Compat Module :members: :inherited-members: :undoc-members: - :exclude-members: which, getdebug, deprecated, fsencode, fsdecode, getcwd, - FS_ENCODING, PY_LEGACY, ENV_PATH, TRUE_VALUES + :exclude-members: which, getdebug, deprecated, + FS_ENCODING, ENV_PATH, ENV_PATHEXT, TRUE_VALUES .. attribute:: FS_ENCODING :annotation: = sys.getfilesystemencoding() Detected filesystem encoding: ie. `utf-8`. -.. attribute:: PY_LEGACY - :annotation: = sys.version_info < (3, ) - - True on Python 2, False on newer. - .. attribute:: ENV_PATH :annotation: = ('/usr/local/bin', '/usr/bin', ... ) @@ -37,41 +32,6 @@ Compat Module Values which should be equivalent to True, used by :func:`getdebug` -.. attribute:: FileNotFoundError - :annotation: = OSError if PY_LEGACY else FileNotFoundError - - Convenience python exception type reference. - -.. attribute:: range - :annotation: = xrange if PY_LEGACY else range - - Convenience python builtin function reference. - -.. attribute:: filter - :annotation: = itertools.ifilter if PY_LEGACY else filter - - Convenience python builtin function reference. - -.. attribute:: basestring - :annotation: = basestring if PY_LEGACY else str - - Convenience python type reference. - -.. attribute:: unicode - :annotation: = unicode if PY_LEGACY else str - - Convenience python type reference. - -.. attribute:: scandir - :annotation: = scandir.scandir or os.walk - - New scandir, either from scandir module or Python3.6+ os module. - -.. attribute:: walk - :annotation: = scandir.walk or os.walk - - New walk, either from scandir module or Python3.6+ os module. - .. autofunction:: pathconf(path) .. autofunction:: isexec(path) @@ -84,12 +44,6 @@ Compat Module .. autofunction:: usedoc(other) -.. autofunction:: fsdecode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None) - -.. autofunction:: fsencode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None) - -.. autofunction:: getcwd(fs_encoding=FS_ENCODING, cwd_fnc=os.getcwd) - .. autofunction:: re_escape(pattern, chars="()[]{}?*+|^$\\.-#") .. autofunction:: pathsplit(value, sep=os.pathsep) diff --git a/doc/conf.py b/doc/conf.py index 6835895d2951a9fcc28a0efaecb605c6b229686a..ffb45b5808f577e3f8747c7d54ff6a1e16e5faf4 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -18,10 +18,15 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import os +import io +import re import sys -import time + sys.path.insert(0, os.path.abspath('..')) # noqa -import browsepy.__meta__ as meta + +with io.open('../browsepy/__init__.py', 'rt', encoding='utf8') as f: + app_version = re.search(r'__version__ = \'(.*?)\'', f.read()).group(1) + assert isinstance(app_version, str) # -- General configuration ------------------------------------------------ @@ -39,7 +44,7 @@ extensions = [ 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', - 'sphinx.ext.githubpages', + 'sphinx_autodoc_typehints', ] # Add any paths that contain templates here, relative to this directory. @@ -59,18 +64,18 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = meta.app -copyright = '%d, %s' % (time.gmtime()[0], meta.author_name) -author = meta.author_name +project = 'browsepy' +copyright = '2019, Felipe A. Hernandez' +author = 'Felipe A. Hernandez' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '{0}.{0}'.format(*meta.version.split('.')) +version = '{0}.{0}'.format(*app_version.split('.')) # The full version, including alpha/beta/rc tags. -release = meta.version +release = app_version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/doc/index.rst b/doc/index.rst index e5435e8ce5df86eeb60aae34902b6e438f60a61b..2bf57db024f297f6bc2242db10d48c12532756d5 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -11,8 +11,8 @@ Welcome to browsepy's documentation. It's recommended to start reading both detailed tutorials about integrating :mod:`browsepy` as module or plugin development are also available. -Browsepy has few dependencies: `Flask`_ and `Scandir`_. `Flask`_ is an awesome -web microframework while `Scandir`_ is a directory listing library which `was +Browsepy has few dependencies: `Flask`_ and `scandir`_. `Flask`_ is an awesome +web microframework while `scandir`_ is a directory listing library which `was included `_ in Python 3.5's standard library. @@ -26,11 +26,11 @@ If you want to dive into their documentation, check out the following links: `_ .. _Flask: http://jinja.pocoo.org/ -.. _Scandir: http://werkzeug.pocoo.org/ +.. _scandir: http://werkzeug.pocoo.org/ User's Guide ============ -Instructions for users, implementers and developers. +Instructions for users, implementors and developers. .. toctree:: :maxdepth: 2 @@ -53,7 +53,7 @@ Specific information about functions, class or methods. stream compat exceptions - tests_utils + utils Indices and tables ================== diff --git a/doc/integrations.rst b/doc/integrations.rst index 6352b774dff8ab18e86fd0df94f57230fbe7663e..8abd594352ee428133fafc41bc6edeb2816728ad 100644 --- a/doc/integrations.rst +++ b/doc/integrations.rst @@ -10,25 +10,25 @@ is supported. Also, browsepy's public API could be easily reused. Browsepy app config (available at :attr:`browsepy.app.config`) exposes the following configuration options. -* **directory_base**: anything under this directory will be served, +* **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, +* **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, +* **DIRECTORY_UPLOAD**: file upload will be available under this path, defaults to **None**. -* **directory_tar_buffsize**, directory tar streaming buffer size, +* **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, +* **DIRECTORY_DOWNLOADABLE** whether enable directory download or not, defaults to **True**. -* **use_binary_multiples** whether use binary units (bi-bytes, like KiB) +* **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_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. +* **PLUGIN_NAMESPACES** prefixes for module names listed at PLUGIN_MODULES + where relative PLUGIN_MODULES are searched. -Please note: After editing `plugin_modules` value, plugin manager (available +Please note: After editing `PLUGIN_MODULES` value, plugin manager (available at module :data:`browsepy.plugin_manager` and :data:`browsepy.app.extensions['plugin_manager']`) should be reloaded using the :meth:`browsepy.plugin_manager.reload` instance method of :meth:`browsepy.manager.PluginManager.reload` for browsepy's plugin @@ -39,6 +39,99 @@ The other way of loading a plugin programmatically is calling :meth:`browsepy.manager.PluginManager.load_plugin` for browsepy's plugin manager. +.. _integrations-wsgi: + +Waitress and any WSGI application +--------------------------------- + +Startup script running browsepy inside +`waitress `_ +along with a root wsgi application. + +.. code-block:: python + + #!/env/bin/python + + import os + import os.path + import sys + + import flask + import werkzeug.wsgi + import waitress + + import browsepy + + + class cfg(): + base_path = os.path.abspath(os.path.dirname(__file__)) + static_path = os.path.join(base_path, 'static') + media_path = os.path.expandvars('$HOME/media') + + stderr_path = os.path.join(base_path, 'stderr.log') + stdout_path = os.path.join(base_path, 'stdout.log') + pid_path = os.path.join(base_path, 'pidfile.pid') + + + def setup_browsepy(): + browsepy.app.config.update( + APPLICATION_ROOT='/browse', + DIRECTORY_BASE=cfg.media_path, + DIRECTORY_START=cfg.media_path, + DIRECTORY_REMOVE=cfg.media_path, + DIRECTORY_UPLOAD=cfg.media_path, + PLUGIN_MODULES=['player'], + ) + browsepy.plugin_manager.load_arguments([ + '--plugin=player', + '--player-directory-play' + ]) + browsepy.plugin_manager.reload() + return browsepy.app + + + def setup_app(): + app = flask.Flask( + __name__, + static_folder=cfg.static_path, + static_url_path='', + ) + + @app.route('/') + def index(): + return flask.send_from_directory(cfg.static_path, 'index.html') + + return app + + + def setup_dispatcher(): + return werkzeug.wsgi.DispatcherMiddleware( + setup_app(), + { + '/browse': setup_browsepy(), + # add other wsgi apps here + } + ) + + + def main(): + sys.stderr = open(cfg.stderr_path, 'w') + sys.stdout = open(cfg.stdout_path, 'w') + with open(cfg.pid_path, 'w') as f: + f.write('%d' % os.getpid()) + + try: + print('Starting server') + waitress.serve(setup_dispatcher(), listen='127.0.0.1:8080') + finally: + sys.stderr.close() + sys.stdout.close() + + + if __name__ == '__main__': + main() + + .. _integrations-cherrymusic: Cherrypy and Cherrymusic @@ -50,7 +143,6 @@ server provided by `cherrymusic `_. .. code-block:: python #!/env/bin/python - # -*- coding: UTF-8 -*- import os import sys @@ -73,7 +165,6 @@ server provided by `cherrymusic `_. base_path = abspath(dirname(__file__)) static_path = joinpath(base_path, 'static') media_path = expandvars('$HOME/media') - download_path = joinpath(media_path, 'downloads') root_config = { '/': { 'tools.staticdir.on': True, @@ -85,12 +176,12 @@ server provided by `cherrymusic `_. 'server.rootpath': '/player', } browsepy.config.update( - APPLICATION_ROOT = '/browse', - directory_base = media_path, - directory_start = media_path, - directory_remove = media_path, - directory_upload = media_path, - plugin_modules = ['player'], + APPLICATION_ROOT='/browse', + DIRECTORY_BASE=media_path, + DIRECTORY_START=media_path, + DIRECTORY_REMOVE=media_path, + DIRECTORY_UPLOAD=media_path, + PLUGIN_MODULES=['player'], ) plugin_manager.reload() diff --git a/doc/plugins.rst b/doc/plugins.rst index ffa34deb9e07ad8016dd752a73036a3ffd5f2cb8..7541d4015357e9b00b81026ff384d00d45706333 100644 --- a/doc/plugins.rst +++ b/doc/plugins.rst @@ -74,17 +74,17 @@ manager itself (type :class:`PluginManager`) as first parameter. Plugin manager exposes several methods to register widgets and mimetype detection functions. -A *sregister_plugin*s function looks like this (taken from player plugin): +A *sregister_plugin* function looks like this (taken from player plugin): .. code-block:: python 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) @@ -148,14 +148,14 @@ A simple `register_arguments` example (from player plugin): .. code-block:: python 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 - ''' + """ # Arguments are forwarded to argparse:ArgumentParser.add_argument, # https://docs.python.org/3.7/library/argparse.html#the-add-argument-method @@ -191,7 +191,7 @@ Here is the "widget_types" for reference. .. code-block:: python class WidgetPluginManager(RegistrablePluginManager): - ''' ... ''' + # ... widget_types = { 'base': defaultsnamedtuple( 'Widget', @@ -219,6 +219,7 @@ Here is the "widget_types" for reference. 'Html', ('place', 'type', 'html')), } + # ... Function :func:`browsepy.file.defaultsnamedtuple` is a :func:`collections.namedtuple` which uses a third argument dictionary @@ -244,6 +245,50 @@ former case it is recommended to use :meth:`browsepy.file.Node.from_urlpath` static method to create the appropriate file/directory object (see :mod:`browsepy.file`). +.. _plugins-exclude: + +File exclusions +--------------- + +Plugins can define their own file listing exclusion functions, so they can +effectively control what it hidden when browsing, and excluded from +directory tarballs. + +This is useful as plugins can now define hidden-file behaviors for user +operations or disk-backed configuration files. + + +.. code-block:: python + + import os.path + + + def excluded_files(path): + """ + Exclusion function for files in clipboard. + + :param path: path to check + :type path: str + :return: wether path should be excluded or not + :rtype: str + """ + return os.path.basename(path) == '.my-hidden-file': + + + def register_plugin(manager): + """ + Register blueprints and actions using given plugin manager. + + :param manager: plugin manager + :type manager: browsepy.manager.PluginManager + """ + manager.register_exclude_function(excluded_files) + + +Be careful when saving global state from an exclusion function using +:module:`flask` contexts, as the request context is garanteed to be available +to the function but not to be processed at this point. + .. _plugins-considerations: Considerations diff --git a/doc/quickstart.rst b/doc/quickstart.rst index df20c969ec2ed7446e0fff7b7505b67ec6576ae8..7aed4efee683f5f6f4f1efc53582a66e8bf8f374 100644 --- a/doc/quickstart.rst +++ b/doc/quickstart.rst @@ -56,10 +56,9 @@ this list. :: - usage: browsepy [-h] [--directory PATH] [--initial PATH] - [--removable PATH] [--upload PATH] - [--exclude PATTERN] [--exclude-from PATH] - [--plugin MODULE] + usage: browsepy [-h] [--help-all] [--directory PATH] [--initial PATH] + [--removable PATH] [--upload PATH] [--exclude PATTERN] + [--exclude-from PATH] [--version] [--plugin MODULE] [host] [port] description: starts a browsepy web file browser @@ -70,14 +69,20 @@ this list. optional arguments: -h, --help show this help message and exit - --directory PATH serving directory (default: current path) + --help-all show help for all available plugins and exit + --directory PATH serving directory (default: /my/current/path) --initial PATH default directory (default: same as --directory) - --removable PATH base directory allowing remove (default: none) - --upload PATH base directory allowing upload (default: none) + --removable PATH base directory allowing remove (default: None) + --upload PATH base directory allowing upload (default: None) --exclude PATTERN exclude paths by pattern (multiple) --exclude-from PATH exclude paths by pattern file (multiple) + --version show program's version number and exit --plugin MODULE load plugin module (multiple) + available plugins: + file-actions, browsepy.plugin.file_actions + player, browsepy.plugin.player + Showing help including player plugin arguments: .. code-block:: bash @@ -89,10 +94,10 @@ Please note the extra parameters below `player arguments`. :: - usage: browsepy [-h] [--directory PATH] [--initial PATH] - [--removable PATH] [--upload PATH] - [--exclude PATTERN] [--exclude-from PATH] - [--plugin MODULE] [--player-directory-play] + usage: browsepy [-h] [--help-all] [--directory PATH] [--initial PATH] + [--removable PATH] [--upload PATH] [--exclude PATTERN] + [--exclude-from PATH] [--version] [--plugin MODULE] + [--player-directory-play] [host] [port] description: starts a browsepy web file browser @@ -103,14 +108,20 @@ Please note the extra parameters below `player arguments`. optional arguments: -h, --help show this help message and exit - --directory PATH serving directory (default: current path) + --help-all show help for all available plugins and exit + --directory PATH serving directory (default: /my/current/path) --initial PATH default directory (default: same as --directory) - --removable PATH base directory allowing remove (default: none) - --upload PATH base directory allowing upload (default: none) + --removable PATH base directory allowing remove (default: None) + --upload PATH base directory allowing upload (default: None) --exclude PATTERN exclude paths by pattern (multiple) --exclude-from PATH exclude paths by pattern file (multiple) + --version show program's version number and exit --plugin MODULE load plugin module (multiple) player arguments: --player-directory-play enable directories as playlist + + available plugins: + file-actions, browsepy.plugin.file_actions + player, browsepy.plugin.player diff --git a/doc/stream.rst b/doc/stream.rst index 29c38e0425e44833f2f416fe98007e60c3bab254..13c40e0768591a5b056ff28af38a1d8d8469858c 100644 --- a/doc/stream.rst +++ b/doc/stream.rst @@ -5,13 +5,16 @@ Stream Module .. currentmodule:: browsepy.stream -This module provides a class for streaming directory tarballs. This is used -by :meth:`browsepy.file.Directory.download` method. +This module provides a utilities for streaming responses. .. _tarfilestream-node: TarFileStream ----- +------------- + +Iterator class for streaming directory tarballs. + +This is currently used by :meth:`browsepy.file.Directory.download` method. .. currentmodule:: browsepy.stream @@ -19,3 +22,8 @@ TarFileStream :members: :inherited-members: :undoc-members: + +Utility functions +----------------- + +.. autofunction:: stream_template diff --git a/doc/tests_utils.rst b/doc/tests_utils.rst index 8816eb724e09cdbbb2416bac942af662fc9dd760..40d89e580b868d9c58b00007776b300d1e9be4ac 100644 --- a/doc/tests_utils.rst +++ b/doc/tests_utils.rst @@ -5,7 +5,7 @@ Test Utility Module Convenience functions for unit testing. -.. automodule:: browsepy.tests.utils +.. automodule:: browsepy.utils :show-inheritance: :members: :inherited-members: diff --git a/requirements.txt b/requirements.txt index bbb34a05544651167bdad77ebc21027516627d86..fc0c4c1217d309d306c2fa6aa48799ceaf0c75bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,26 @@ -flask -unicategories +# +# Default requirements file +# ========================= +# +# This file includes all development dependencies, including compatibility +# modules. +# +# If you do not want compatibility dependencies, install +# `requirements/development.txt` instead. +# +# Requirement file inheritance tree +# --------------------------------- +# +# requirements.txt +# └ requirements/development.txt +# └ requirements/base.txt +# +# See also +# -------- +# +# - requirements/base.txt +# - requirements/development.txt +# - requirements/doc.txt +# -# for python < 3.6 -scandir - -# for python < 3.3 -backports.shutil_get_terminal_size - -# for building -setuptools -wheel - -# for Makefile tasks (testing, coverage, docs) -pep8 -flake8 -coverage -pyaml -sphinx +-r requirements/development.txt diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000000000000000000000000000000000000..c31d9ae7e64748a291ceed580f7e86feeca73e17 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,21 @@ +# +# Base requirements +# ================= +# +# This module lists the strict minimal subset of dependencies required to +# run this application. +# +# See also +# -------- +# +# - requirements.txt +# - requirements/development.txt +# - requirements/doc.txt +# + +flask +cookieman +unicategories + +# compat +importlib_resources ; python_version<'3.7' diff --git a/requirements/development.txt b/requirements/development.txt new file mode 100644 index 0000000000000000000000000000000000000000..cb29aee259300dad3f22a3882db8f7b1348133b9 --- /dev/null +++ b/requirements/development.txt @@ -0,0 +1,40 @@ +# +# Development requirements file +# ============================= +# +# This module includes only development dependencies for Makefile tasks such as +# code analysis and building (see Makefile). +# +# Documentation requirements are on its own file `requirements/doc.txt`. +# +# Requirement file inheritance tree +# --------------------------------- +# +# requirements/development.txt +# └ requirements/base.txt +# +# See also +# -------- +# +# - requirements.txt +# - requirements/doc.txt +# + +-r base.txt + +# lint and test +flake8 +yapf +coverage +jedi +pycodestyle +pydocstyle +mypy +radon +unittest-resources[testing] +pyyaml +beautifulsoup4 + +# dist +wheel +twine diff --git a/requirements/doc.txt b/requirements/doc.txt new file mode 100644 index 0000000000000000000000000000000000000000..52012c74949a5201f85ed1bec97913ca448e94d1 --- /dev/null +++ b/requirements/doc.txt @@ -0,0 +1,27 @@ +# +# Documentation requirements file +# =============================== +# +# This module includes only dependencies required to build the documentation. +# +# These requirements are kept separated from both development and integration +# requirement files as they tend to be picky about python versions. +# +# Requirement file inheritance tree +# --------------------------------- +# +# requirements/doc.txt +# └ requirements/base.txt +# +# See also +# -------- +# +# - requirements.txt +# - requirements/development.txt +# - requirements/doc.txt +# + +-r base.txt + +sphinx +sphinx_autodoc_typehints[type_comments] diff --git a/setup.cfg b/setup.cfg index 0de37e5e5c1d6d5e1779cf99a379e3a3fb4280bb..724b42b790b0464695268b14dc142d6561e5e387 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,38 @@ [metadata] description-file = README.rst -[wheel] +[bdist_wheel] universal = 1 [egg_info] -tag_build = \ No newline at end of file +tag_build = + +[pycodestyle] +ignore = E123,E126,E121,W504 + +[flake8] +ignore = E123,E126,E121,W504 +max-complexity = 7 +select = F,C +show-source = True + +[yapf] +align_closing_bracket_with_visual_indent = true + +[mypy] +allow_redefinition = true + +[coverage:run] +branch = True +source = browsepy +omit = + */tests/* + */tests.py + +[coverage:report] +show_missing = True +fail_under = 95 +exclude_lines = + pragma: no cover + pass + noqa diff --git a/setup.py b/setup.py index 868e28c2fbae4b79be39de3fefadd10acff5988e..2c764d2fa37c62de1c5aa50f37db3714fc048f06 100644 --- a/setup.py +++ b/setup.py @@ -1,56 +1,31 @@ -# -*- coding: utf-8 -*- """ -browsepy -======== +Browsepy package setup script. -Simple web file browser with directory gzipped tarball download, file upload, -removal and plugins. +Usage +----- -More details on project's README and -`github page `_. +..code-block:: python + python setup.py --help-commands -Development Version -------------------- - -The browsepy development version can be installed by cloning the git -repository from `github`_:: - - git clone git@github.com:ergoithz/browsepy.git - -.. _github: https://github.com/ergoithz/browsepy - -License -------- -MIT (see LICENSE file). """ - +import io +import re import os -import os.path import sys -import shutil +import time +import distutils -try: - from setuptools import setup -except ImportError: - from distutils.core import setup +from setuptools import setup, find_packages -sys_path = sys.path[:] -sys.path[:] = (os.path.abspath('browsepy'),) -__import__('__meta__') -meta = sys.modules['__meta__'] -sys.path[:] = sys_path -with open('README.rst') as f: - meta_doc = f.read() +version_pattern = re.compile(r'__version__ = \'(.*?)\'') -extra_requires = [] -bdist = 'bdist' in sys.argv or any(a.startswith('bdist_') for a in sys.argv) -if bdist or not hasattr(os, 'scandir'): - extra_requires.append('scandir') +with io.open('README.rst', 'rt', encoding='utf8') as f: + readme = f.read() -if bdist or not hasattr(shutil, 'get_terminal_size'): - extra_requires.append('backports.shutil_get_terminal_size') +with io.open('browsepy/__init__.py', 'rt', encoding='utf8') as f: + version = version_pattern.search(f.read()).group(1) for debugger in ('ipdb', 'pudb', 'pdb'): opt = '--debug=%s' % debugger @@ -58,51 +33,100 @@ for debugger in ('ipdb', 'pudb', 'pdb'): os.environ['UNITTEST_DEBUG'] = debugger sys.argv.remove(opt) + +class AlphaVersionCommand(distutils.cmd.Command): + """Command which update package version with alpha timestamp.""" + + description = 'update package version with alpha timestamp' + user_options = [('alpha=', None, 'alpha version (defaults to timestamp)')] + + def initialize_options(self): + """Set alpha version.""" + self.alpha = '{:.0f}'.format(time.time()) + + def finalize_options(self): + """Check alpha version.""" + assert self.alpha, 'alpha cannot be empty' + + def replace_version(self, path, version): + """Replace version dunder variable on given path with value.""" + with io.open(path, 'r+', encoding='utf8') as f: + data = version_pattern.sub( + '__version__ = {!r}'.format(version), + f.read(), + ) + f.seek(0) + f.write(data) + f.truncate() + + def run(self): + """Run command.""" + alpha = '{}a{}'.format(version.split('a', 1)[0], self.alpha) + path = '/'.join( + self.distribution.metadata.name.split('.') + ['__init__.py'] + ) + self.execute( + self.replace_version, + (path, version), + 'updating {!r} __version__ with {!r}'.format(path, alpha), + ) + self.distribution.metadata.version = alpha + + setup( - name=meta.app, - version=meta.version, - url=meta.url, - download_url=meta.tarball, - license=meta.license, - author=meta.author_name, - author_email=meta.author_mail, - description=meta.description, - long_description=meta_doc, + name='browsepy', + version=version, + url='https://github.com/ergoithz/browsepy', + license='MIT', + author='Felipe A. Hernandez', + author_email='ergoithz@gmail.com', + description='Simple web file browser', + long_description=readme, + long_description_content_type='text/x-rst', classifiers=[ 'Development Status :: 4 - Beta', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', - 'Programming Language :: Python', 'Programming Language :: Python :: 3', ], keywords=['web', 'file', 'browser'], - packages=[ - 'browsepy', - 'browsepy.tests', - 'browsepy.tests.deprecated', - 'browsepy.tests.deprecated.plugin', - 'browsepy.transform', - 'browsepy.plugin', - 'browsepy.plugin.player', - ], + packages=find_packages(), + cmdclass={ + 'alpha_version': AlphaVersionCommand, + }, entry_points={ 'console_scripts': ( 'browsepy=browsepy.__main__:main' ) }, package_data={ # ignored by sdist (see MANIFEST.in), used by bdist_wheel - 'browsepy': [ - 'templates/*', - 'static/fonts/*', - 'static/*.*', # do not capture directories + package: [ + '{}/{}*'.format(directory, '**/' * level) + for directory in ('static', 'templates') + for level in range(3) + ] + for package in find_packages() + }, + python_requires='>=3.5', + setup_requires=[ + 'setuptools>36.2', + ], + install_requires=[ + 'flask', + 'cookieman', + 'unicategories', + 'importlib-resources ; python_version<"3.7"', + ], + tests_require=[ + 'beautifulsoup4', + 'unittest-resources', + 'pycodestyle', + 'pydocstyle', + 'mypy', + 'radon', + 'unittest-resources[testing]', ], - 'browsepy.plugin.player': [ - 'templates/*', - 'static/*/*', - ]}, - install_requires=['flask', 'unicategories'] + extra_requires, - test_suite='browsepy.tests', - test_runner='browsepy.tests.runner:DebuggerTextTestRunner', + test_suite='browsepy', zip_safe=False, - platforms='any' -) + platforms='any', + )