├── .appveyor.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .python-version ├── .scrutinizer.yml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── browsepy ├── __init__.py ├── __main__.py ├── __meta__.py ├── appconfig.py ├── compat.py ├── exceptions.py ├── file.py ├── manager.py ├── mimetype.py ├── plugin │ ├── __init__.py │ └── player │ │ ├── __init__.py │ │ ├── playable.py │ │ ├── static │ │ ├── css │ │ │ ├── base.css │ │ │ ├── browse.css │ │ │ ├── jplayer.blue.monday.css │ │ │ └── jplayer.blue.monday.min.css │ │ ├── image │ │ │ ├── jplayer.blue.monday.jpg │ │ │ ├── jplayer.blue.monday.seeking.gif │ │ │ └── jplayer.blue.monday.video.play.png │ │ └── js │ │ │ ├── base.js │ │ │ ├── jplayer.playlist.min.js │ │ │ ├── jquery.jplayer.min.js │ │ │ ├── jquery.jplayer.swf │ │ │ └── jquery.min.js │ │ ├── templates │ │ └── audio.player.html │ │ └── tests.py ├── static │ ├── base.css │ ├── browse.directory.body.js │ ├── browse.directory.head.js │ ├── fonts │ │ ├── demo.html │ │ ├── icomoon.eot │ │ ├── icomoon.svg │ │ ├── icomoon.ttf │ │ └── icomoon.woff │ └── giorgio.jpg ├── stream.py ├── templates │ ├── 400.html │ ├── 404.html │ ├── base.html │ ├── browse.html │ └── remove.html ├── tests │ ├── __init__.py │ ├── deprecated │ │ ├── __init__.py │ │ ├── plugin │ │ │ ├── __init__.py │ │ │ └── player.py │ │ └── test_plugins.py │ ├── runner.py │ ├── test_app.py │ ├── test_compat.py │ ├── test_extensions.py │ ├── test_file.py │ ├── test_main.py │ ├── test_module.py │ ├── test_plugins.py │ ├── test_transform.py │ └── utils.py ├── transform │ ├── __init__.py │ ├── glob.py │ └── htmlcompress.py └── widget.py ├── doc ├── .static │ └── logo.css ├── .templates │ ├── layout.html │ └── sidebar.html ├── Makefile ├── builtin_plugins.rst ├── compat.rst ├── conf.py ├── exceptions.rst ├── exclude.rst ├── file.rst ├── index.rst ├── integrations.rst ├── manager.rst ├── plugins.rst ├── quickstart.rst ├── screenshot.0.3.1-0.png ├── stream.rst └── tests_utils.rst ├── requirements.txt ├── setup.cfg └── setup.py /.appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - PYTHON: "C:\\Python27" 4 | PYTHON_VERSION: "2.7.x" # currently 2.7.9 5 | PYTHON_ARCH: "32" 6 | 7 | - PYTHON: "C:\\Python27-x64" 8 | PYTHON_VERSION: "2.7.x" # currently 2.7.9 9 | PYTHON_ARCH: "64" 10 | 11 | - PYTHON: "C:\\Python33" 12 | PYTHON_VERSION: "3.3.x" # currently 3.3.5 13 | PYTHON_ARCH: "32" 14 | 15 | - PYTHON: "C:\\Python33-x64" 16 | PYTHON_VERSION: "3.3.x" # currently 3.3.5 17 | PYTHON_ARCH: "64" 18 | 19 | - PYTHON: "C:\\Python34" 20 | PYTHON_VERSION: "3.4.x" # currently 3.4.3 21 | PYTHON_ARCH: "32" 22 | 23 | - PYTHON: "C:\\Python34-x64" 24 | PYTHON_VERSION: "3.4.x" # currently 3.4.3 25 | PYTHON_ARCH: "64" 26 | 27 | # Python versions not pre-installed 28 | 29 | - PYTHON: "C:\\Python35" 30 | PYTHON_VERSION: "3.5.3" 31 | PYTHON_ARCH: "32" 32 | 33 | - PYTHON: "C:\\Python35-x64" 34 | PYTHON_VERSION: "3.5.3" 35 | PYTHON_ARCH: "64" 36 | 37 | - PYTHON: "C:\\Python36" 38 | PYTHON_VERSION: "3.6.1" 39 | PYTHON_ARCH: "32" 40 | 41 | - PYTHON: "C:\\Python36-x64" 42 | PYTHON_VERSION: "3.6.1" 43 | PYTHON_ARCH: "64" 44 | 45 | build: off 46 | 47 | init: 48 | - "ECHO %PYTHON% %PYTHON_VERSION% %PYTHON_ARCH%" 49 | 50 | install: 51 | # If there is a newer build queued for the same PR, cancel this one. 52 | # The AppVeyor 'rollout builds' option is supposed to serve the same 53 | # purpose but it is problematic because it tends to cancel builds pushed 54 | # directly to master instead of just PR builds (or the converse). 55 | # credits: JuliaLang developers. 56 | - ps: if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod ` 57 | https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | ` 58 | Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { ` 59 | throw "There are newer queued builds for this pull request, failing early." } 60 | - ECHO "Filesystem root:" 61 | - ps: "ls \"C:/\"" 62 | - ECHO "Installed SDKs:" 63 | - ps: "ls \"C:/Program Files/Microsoft SDKs/Windows\"" 64 | - ps: if (-not(Test-Path($env:PYTHON))) { & appveyor\install.ps1 } 65 | - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" 66 | - "python --version" 67 | - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" 68 | - "pip install --disable-pip-version-check --user --upgrade pip setuptools" 69 | 70 | test_script: 71 | - "python setup.py test" 72 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | max_line_length = 79 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [Makefile] 14 | indent_style = tab 15 | 16 | [*.{yml,json,css,js,html,rst}] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.{py,js}] 21 | quote_type = single 22 | 23 | [*.md] 24 | trim_trailing_whitespace = false 25 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.min.js 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "jquery": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "rules": { 11 | "indent": [ 12 | "error", 13 | 2, { 14 | "SwitchCase": 1 15 | } 16 | ], 17 | "linebreak-style": [ 18 | "error", 19 | "unix" 20 | ], 21 | "quotes": [ 22 | "error", 23 | "single" 24 | ], 25 | "semi": [ 26 | "error", 27 | "always" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git/* 2 | .c9/* 3 | .idea/* 4 | .coverage 5 | htmlcov/* 6 | dist/* 7 | build/* 8 | doc/.build/* 9 | env/* 10 | env2/* 11 | env3/* 12 | wenv2/* 13 | wenv3/* 14 | node_modules/* 15 | .eggs/* 16 | browsepy.build/* 17 | browsepy.egg-info/* 18 | **/__pycache__/* 19 | **.egg/* 20 | **.eggs/* 21 | **.egg-info/* 22 | **.pyc 23 | **/node_modules/* 24 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.5.2 2 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | checks: 2 | python: 3 | code_rating: true 4 | duplicate_code: true 5 | filter: 6 | excluded_paths: 7 | - "*/tests.py" 8 | - "*/tests/*" 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | addon: 4 | apt: 5 | nodejs 6 | 7 | matrix: 8 | include: 9 | - python: "2.7" 10 | - python: "pypy" 11 | # pypy3 disabled until fixed 12 | #- python: "pypy3" 13 | - python: "3.3" 14 | - python: "3.4" 15 | - python: "3.5" 16 | - python: "3.6" 17 | env: 18 | - eslint=yes 19 | - sphinx=yes 20 | 21 | install: 22 | - pip install --upgrade pip 23 | - pip install travis-sphinx flake8 pep8 codecov coveralls . 24 | - | 25 | if [ "$eslint" = "yes" ]; then 26 | nvm install stable 27 | nvm use stable 28 | npm install eslint 29 | export PATH="$PWD/node_modules/.bin:$PATH" 30 | fi 31 | 32 | script: 33 | - make travis-script 34 | - | 35 | if [ "$eslint" = "yes" ]; then 36 | make eslint 37 | fi 38 | - | 39 | if [ "$sphinx" = "yes" ]; then 40 | make travis-script-sphinx 41 | fi 42 | 43 | after_success: 44 | - make travis-success 45 | - | 46 | if [ "$sphinx" = "yes" ]; then 47 | make travis-success-sphinx 48 | fi 49 | 50 | notifications: 51 | email: false 52 | 53 | cache: 54 | directories: 55 | - $HOME/.cache/pip 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2016 Felipe A. Hernandez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | graft browsepy/templates 3 | graft browsepy/static 4 | graft browsepy/plugin/player/templates 5 | graft browsepy/plugin/player/static 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: doc clean pep8 coverage travis 2 | 3 | test: pep8 flake8 eslint 4 | python -c 'import yaml;yaml.load(open(".travis.yml").read())' 5 | ifdef debug 6 | python setup.py test --debug=$(debug) 7 | else 8 | python setup.py test 9 | endif 10 | 11 | clean: 12 | rm -rf build dist browsepy.egg-info htmlcov MANIFEST \ 13 | .eggs *.egg .coverage 14 | find browsepy -type f -name "*.py[co]" -delete 15 | find browsepy -type d -name "__pycache__" -delete 16 | $(MAKE) -C doc clean 17 | 18 | build-env: 19 | mkdir -p build 20 | python3 -m venv build/env3 21 | build/env3/bin/pip install pip --upgrade 22 | build/env3/bin/pip install wheel 23 | 24 | build: clean build-env 25 | build/env3/bin/python setup.py bdist_wheel 26 | build/env3/bin/python setup.py sdist 27 | 28 | upload: clean build-env 29 | build/env3/bin/python setup.py bdist_wheel upload 30 | build/env3/bin/python setup.py sdist upload 31 | 32 | doc: 33 | $(MAKE) -C doc html 2>&1 | grep -v \ 34 | 'WARNING: more than one target found for cross-reference' 35 | 36 | showdoc: doc 37 | xdg-open file://${CURDIR}/doc/.build/html/index.html >> /dev/null 38 | 39 | pep8: 40 | find browsepy -type f -name "*.py" -exec pep8 --ignore=E123,E126,E121 {} + 41 | 42 | eslint: 43 | eslint \ 44 | --ignore-path .gitignore \ 45 | --ignore-pattern *.min.js \ 46 | ${CURDIR}/browsepy 47 | 48 | flake8: 49 | flake8 browsepy/ 50 | 51 | coverage: 52 | coverage run --source=browsepy setup.py test 53 | 54 | showcoverage: coverage 55 | coverage html 56 | xdg-open file://${CURDIR}/htmlcov/index.html >> /dev/null 57 | 58 | travis-script: pep8 flake8 coverage 59 | 60 | travis-script-sphinx: 61 | travis-sphinx --nowarn --source=doc build 62 | 63 | travis-success: 64 | codecov 65 | coveralls 66 | 67 | travis-success-sphinx: 68 | travis-sphinx deploy 69 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | browsepy 2 | ======== 3 | 4 | .. image:: http://img.shields.io/travis/ergoithz/browsepy/master.svg?style=flat-square 5 | :target: https://travis-ci.org/ergoithz/browsepy 6 | :alt: Travis-CI badge 7 | 8 | .. image:: https://img.shields.io/appveyor/ci/ergoithz/browsepy/master.svg?style=flat-square 9 | :target: https://ci.appveyor.com/project/ergoithz/browsepy/branch/master 10 | :alt: AppVeyor badge 11 | 12 | .. image:: http://img.shields.io/coveralls/ergoithz/browsepy/master.svg?style=flat-square 13 | :target: https://coveralls.io/r/ergoithz/browsepy?branch=master 14 | :alt: Coveralls badge 15 | 16 | .. image:: https://img.shields.io/codacy/grade/e27821fb6289410b8f58338c7e0bc686/master.svg?style=flat-square 17 | :target: https://www.codacy.com/app/ergoithz/browsepy/dashboard?bid=4246124 18 | :alt: Codacy badge 19 | 20 | .. image:: http://img.shields.io/pypi/l/browsepy.svg?style=flat-square 21 | :target: https://pypi.python.org/pypi/browsepy/ 22 | :alt: License: MIT 23 | 24 | .. image:: http://img.shields.io/pypi/v/browsepy.svg?style=flat-square 25 | :target: https://pypi.python.org/pypi/browsepy/ 26 | :alt: Version: 0.5.6 27 | 28 | .. image:: https://img.shields.io/badge/python-2.7%2B%2C%203.3%2B-FFC100.svg?style=flat-square 29 | :target: https://pypi.python.org/pypi/browsepy/ 30 | :alt: Python 2.7+, 3.3+ 31 | 32 | The simple web file browser. 33 | 34 | Documentation 35 | ------------- 36 | 37 | Head to http://ergoithz.github.io/browsepy/ for an online version of current 38 | *master* documentation, 39 | 40 | You can also build yourself from sphinx sources using the documentation 41 | `Makefile` located at `docs` directory. 42 | 43 | Screenshots 44 | ----------- 45 | 46 | .. image:: https://raw.githubusercontent.com/ergoithz/browsepy/master/doc/screenshot.0.3.1-0.png 47 | :target: https://raw.githubusercontent.com/ergoithz/browsepy/master/doc/screenshot.0.3.1-0.png 48 | :alt: Screenshot of directory with enabled remove 49 | 50 | Features 51 | -------- 52 | 53 | * **Simple**, like Python's SimpleHTTPServer or Apache's Directory Listing. 54 | * **Downloadable directories**, streaming directory tarballs on the fly. 55 | * **Optional remove** for files under given path. 56 | * **Optional upload** for directories under given path. 57 | * **Player** audio player plugin is provided (without transcoding). 58 | 59 | New in 0.5 60 | ---------- 61 | 62 | * File and plugin APIs have been fully reworked making them more complete and 63 | extensible, so they can be considered stable now. As a side-effect backward 64 | compatibility on some edge cases could be broken (please fill an issue if 65 | your code is affected). 66 | 67 | * Old widget API have been deprecated and warnings will be shown if used. 68 | * Widget registration in a single call (passing a widget instances is still 69 | available though), no more action-widget duality. 70 | * Callable-based widget filtering (no longer limited to mimetypes). 71 | * A raw HTML widget for maximum flexibility. 72 | 73 | * Plugins can register command-line arguments now. 74 | * Player plugin is now able to load `m3u` and `pls` playlists, and optionally 75 | play everything on a directory (adding a command-line argument). 76 | * Browsing now takes full advantage of `scandir` (already in Python 3.5 and an 77 | external dependency for older versions) providing faster directory listing. 78 | * Custom file ordering while browsing directories. 79 | * Easy multi-file uploads. 80 | * Jinja2 template output minification, saving those precious bytes. 81 | * Setup script now registers a proper `browsepy` command. 82 | 83 | Install 84 | ------- 85 | 86 | It's on `pypi` so... 87 | 88 | .. _pypi: https://pypi.python.org/pypi/browsepy/ 89 | 90 | .. code-block:: bash 91 | 92 | pip install browsepy 93 | 94 | 95 | You can get the development version from our `github repository`. 96 | 97 | .. _github repository: https://github.com/ergoithz/browsepy 98 | 99 | .. code-block:: bash 100 | 101 | pip install git+https://github.com/ergoithz/browsepy.git 102 | 103 | 104 | Usage 105 | ----- 106 | 107 | Serving $HOME/shared to all addresses 108 | 109 | .. code-block:: bash 110 | 111 | browsepy 0.0.0.0 8080 --directory $HOME/shared 112 | 113 | Showing help 114 | 115 | .. code-block:: bash 116 | 117 | browsepy --help 118 | 119 | Showing help including player plugin arguments 120 | 121 | .. code-block:: bash 122 | 123 | browsepy --plugin=player --help 124 | 125 | This examples assume python's `bin` directory is in `PATH`, otherwise try 126 | replacing `browsepy` with `python -m browsepy`. 127 | 128 | Command-line arguments 129 | ---------------------- 130 | 131 | This is what is printed when you run `browsepy --help`, keep in mind that 132 | plugins (loaded with `plugin` argument) could add extra arguments to this list. 133 | 134 | :: 135 | 136 | usage: browsepy [-h] [--directory PATH] [--initial PATH] [--removable PATH] 137 | [--upload PATH] [--exclude PATTERN] [--exclude-from PATH] 138 | [--plugin MODULE] 139 | [host] [port] 140 | 141 | positional arguments: 142 | host address to listen (default: 127.0.0.1) 143 | port port to listen (default: 8080) 144 | 145 | optional arguments: 146 | -h, --help show this help message and exit 147 | --directory PATH serving directory (default: current path) 148 | --initial PATH default directory (default: same as --directory) 149 | --removable PATH base directory allowing remove (default: none) 150 | --upload PATH base directory allowing upload (default: none) 151 | --exclude PATTERN exclude paths by pattern (multiple) 152 | --exclude-from PATH exclude paths by pattern file (multiple) 153 | --plugin MODULE load plugin module (multiple) 154 | 155 | 156 | Using as library 157 | ---------------- 158 | 159 | It's a python module, so you can import **browsepy**, mount **app**, and serve 160 | it (it's `WSGI`_ compliant) using 161 | your preferred server. 162 | 163 | Browsepy is a Flask application, so it can be served along with any `WSGI`_ app 164 | just setting **APPLICATION_ROOT** in **browsepy.app** config to browsepy prefix 165 | url, and mounting **browsepy.app** on the appropriate parent 166 | *url-resolver*/*router*. 167 | 168 | .. _WSGI: https://www.python.org/dev/peps/pep-0333/ 169 | 170 | Browsepy app config (available at :attr:`browsepy.app.config`) uses the 171 | following configuration options. 172 | 173 | * **directory_base**: anything under this directory will be served, 174 | defaults to current path. 175 | * **directory_start**: directory will be served when accessing root URL 176 | * **directory_remove**: file removing will be available under this path, 177 | defaults to **None**. 178 | * **directory_upload**: file upload will be available under this path, 179 | defaults to **None**. 180 | * **directory_tar_buffsize**, directory tar streaming buffer size, 181 | defaults to **262144** and must be multiple of 512. 182 | * **directory_downloadable** whether enable directory download or not, 183 | defaults to **True**. 184 | * **use_binary_multiples** whether use binary units (bi-bytes, like KiB) 185 | instead of common ones (bytes, like KB), defaults to **True**. 186 | * **plugin_modules** list of module names (absolute or relative to 187 | plugin_namespaces) will be loaded. 188 | * **plugin_namespaces** prefixes for module names listed at plugin_modules 189 | where relative plugin_modules are searched. 190 | * **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. 191 | 192 | After editing `plugin_modules` value, plugin manager (available at module 193 | plugin_manager and app.extensions['plugin_manager']) should be reloaded using 194 | the `reload` method. 195 | 196 | The other way of loading a plugin programmatically is calling plugin manager's 197 | `load_plugin` method. 198 | 199 | Extend via plugin API 200 | --------------------- 201 | 202 | Starting from version 0.4.0, browsepy is extensible via plugins. A functional 203 | 'player' plugin is provided as example, and some more are planned. 204 | 205 | Plugins can add HTML content to browsepy's browsing view, using some 206 | convenience abstraction for already used elements like external stylesheet and 207 | javascript tags, links, buttons and file upload. 208 | 209 | More information at http://ergoithz.github.io/browsepy/plugins.html 210 | -------------------------------------------------------------------------------- /browsepy/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | 4 | import logging 5 | import os 6 | import os.path 7 | import json 8 | import base64 9 | 10 | from flask import Response, request, render_template, redirect, \ 11 | url_for, send_from_directory, stream_with_context, \ 12 | make_response 13 | from werkzeug.exceptions import NotFound 14 | 15 | from .appconfig import Flask 16 | from .manager import PluginManager 17 | from .file import Node, secure_filename 18 | from .exceptions import OutsideRemovableBase, OutsideDirectoryBase, \ 19 | InvalidFilenameError, InvalidPathError 20 | from . import compat 21 | from . import __meta__ as meta 22 | 23 | __app__ = meta.app # noqa 24 | __version__ = meta.version # noqa 25 | __license__ = meta.license # noqa 26 | __author__ = meta.author # noqa 27 | __basedir__ = os.path.abspath(os.path.dirname(compat.fsdecode(__file__))) 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | app = Flask( 32 | __name__, 33 | static_url_path='/static', 34 | static_folder=os.path.join(__basedir__, "static"), 35 | template_folder=os.path.join(__basedir__, "templates") 36 | ) 37 | app.config.update( 38 | directory_base=compat.getcwd(), 39 | directory_start=None, 40 | directory_remove=None, 41 | directory_upload=None, 42 | directory_tar_buffsize=262144, 43 | directory_downloadable=True, 44 | use_binary_multiples=True, 45 | plugin_modules=[], 46 | plugin_namespaces=( 47 | 'browsepy.plugin', 48 | 'browsepy_', 49 | '', 50 | ), 51 | exclude_fnc=None, 52 | ) 53 | app.jinja_env.add_extension('browsepy.transform.htmlcompress.HTMLCompress') 54 | 55 | if 'BROWSEPY_SETTINGS' in os.environ: 56 | app.config.from_envvar('BROWSEPY_SETTINGS') 57 | 58 | plugin_manager = PluginManager(app) 59 | 60 | 61 | def iter_cookie_browse_sorting(cookies): 62 | ''' 63 | Get sorting-cookie from cookies dictionary. 64 | 65 | :yields: tuple of path and sorting property 66 | :ytype: 2-tuple of strings 67 | ''' 68 | try: 69 | data = cookies.get('browse-sorting', 'e30=').encode('ascii') 70 | for path, prop in json.loads(base64.b64decode(data).decode('utf-8')): 71 | yield path, prop 72 | except (ValueError, TypeError, KeyError) as e: 73 | logger.exception(e) 74 | 75 | 76 | def get_cookie_browse_sorting(path, default): 77 | ''' 78 | Get sorting-cookie data for path of current request. 79 | 80 | :returns: sorting property 81 | :rtype: string 82 | ''' 83 | if request: 84 | for cpath, cprop in iter_cookie_browse_sorting(request.cookies): 85 | if path == cpath: 86 | return cprop 87 | return default 88 | 89 | 90 | def browse_sortkey_reverse(prop): 91 | ''' 92 | Get sorting function for directory listing based on given attribute 93 | name, with some caveats: 94 | * Directories will be first. 95 | * If *name* is given, link widget lowercase text will be used istead. 96 | * If *size* is given, bytesize will be used. 97 | 98 | :param prop: file attribute name 99 | :returns: tuple with sorting gunction and reverse bool 100 | :rtype: tuple of a dict and a bool 101 | ''' 102 | if prop.startswith('-'): 103 | prop = prop[1:] 104 | reverse = True 105 | else: 106 | reverse = False 107 | 108 | if prop == 'text': 109 | return ( 110 | lambda x: ( 111 | x.is_directory == reverse, 112 | x.link.text.lower() if x.link and x.link.text else x.name 113 | ), 114 | reverse 115 | ) 116 | if prop == 'size': 117 | return ( 118 | lambda x: ( 119 | x.is_directory == reverse, 120 | x.stats.st_size 121 | ), 122 | reverse 123 | ) 124 | return ( 125 | lambda x: ( 126 | x.is_directory == reverse, 127 | getattr(x, prop, None) 128 | ), 129 | reverse 130 | ) 131 | 132 | 133 | def stream_template(template_name, **context): 134 | ''' 135 | Some templates can be huge, this function returns an streaming response, 136 | sending the content in chunks and preventing from timeout. 137 | 138 | :param template_name: template 139 | :param **context: parameters for templates. 140 | :yields: HTML strings 141 | ''' 142 | app.update_template_context(context) 143 | template = app.jinja_env.get_template(template_name) 144 | stream = template.generate(context) 145 | return Response(stream_with_context(stream)) 146 | 147 | 148 | @app.context_processor 149 | def template_globals(): 150 | return { 151 | 'manager': app.extensions['plugin_manager'], 152 | 'len': len, 153 | } 154 | 155 | 156 | @app.route('/sort/', defaults={"path": ""}) 157 | @app.route('/sort//') 158 | def sort(property, path): 159 | try: 160 | directory = Node.from_urlpath(path) 161 | except OutsideDirectoryBase: 162 | return NotFound() 163 | 164 | if not directory.is_directory or directory.is_excluded: 165 | return NotFound() 166 | 167 | data = [ 168 | (cpath, cprop) 169 | for cpath, cprop in iter_cookie_browse_sorting(request.cookies) 170 | if cpath != path 171 | ] 172 | data.append((path, property)) 173 | raw_data = base64.b64encode(json.dumps(data).encode('utf-8')) 174 | 175 | # prevent cookie becoming too large 176 | while len(raw_data) > 3975: # 4000 - len('browse-sorting=""; Path=/') 177 | data.pop(0) 178 | raw_data = base64.b64encode(json.dumps(data).encode('utf-8')) 179 | 180 | response = redirect(url_for(".browse", path=directory.urlpath)) 181 | response.set_cookie('browse-sorting', raw_data) 182 | return response 183 | 184 | 185 | @app.route("/browse", defaults={"path": ""}) 186 | @app.route('/browse/') 187 | def browse(path): 188 | sort_property = get_cookie_browse_sorting(path, 'text') 189 | sort_fnc, sort_reverse = browse_sortkey_reverse(sort_property) 190 | 191 | try: 192 | directory = Node.from_urlpath(path) 193 | if directory.is_directory and not directory.is_excluded: 194 | return stream_template( 195 | 'browse.html', 196 | file=directory, 197 | sort_property=sort_property, 198 | sort_fnc=sort_fnc, 199 | sort_reverse=sort_reverse 200 | ) 201 | except OutsideDirectoryBase: 202 | pass 203 | return NotFound() 204 | 205 | 206 | @app.route('/open/', endpoint="open") 207 | def open_file(path): 208 | try: 209 | file = Node.from_urlpath(path) 210 | if file.is_file and not file.is_excluded: 211 | return send_from_directory(file.parent.path, file.name) 212 | except OutsideDirectoryBase: 213 | pass 214 | return NotFound() 215 | 216 | 217 | @app.route("/download/file/") 218 | def download_file(path): 219 | try: 220 | file = Node.from_urlpath(path) 221 | if file.is_file and not file.is_excluded: 222 | return file.download() 223 | except OutsideDirectoryBase: 224 | pass 225 | return NotFound() 226 | 227 | 228 | @app.route("/download/directory/.tgz") 229 | def download_directory(path): 230 | try: 231 | directory = Node.from_urlpath(path) 232 | if directory.is_directory and not directory.is_excluded: 233 | return directory.download() 234 | except OutsideDirectoryBase: 235 | pass 236 | return NotFound() 237 | 238 | 239 | @app.route("/remove/", methods=("GET", "POST")) 240 | def remove(path): 241 | try: 242 | file = Node.from_urlpath(path) 243 | except OutsideDirectoryBase: 244 | return NotFound() 245 | 246 | if not file.can_remove or file.is_excluded or not file.parent: 247 | return NotFound() 248 | 249 | if request.method == 'GET': 250 | return render_template('remove.html', file=file) 251 | 252 | file.remove() 253 | return redirect(url_for(".browse", path=file.parent.urlpath)) 254 | 255 | 256 | @app.route("/upload", defaults={'path': ''}, methods=("POST",)) 257 | @app.route("/upload/", methods=("POST",)) 258 | def upload(path): 259 | try: 260 | directory = Node.from_urlpath(path) 261 | except OutsideDirectoryBase: 262 | return NotFound() 263 | 264 | if ( 265 | not directory.is_directory or 266 | not directory.can_upload or 267 | directory.is_excluded 268 | ): 269 | return NotFound() 270 | 271 | for v in request.files.listvalues(): 272 | for f in v: 273 | filename = secure_filename(f.filename) 274 | if filename: 275 | filename = directory.choose_filename(filename) 276 | filepath = os.path.join(directory.path, filename) 277 | f.save(filepath) 278 | else: 279 | raise InvalidFilenameError( 280 | path=directory.path, 281 | filename=f.filename 282 | ) 283 | return redirect(url_for(".browse", path=directory.urlpath)) 284 | 285 | 286 | @app.route("/") 287 | def index(): 288 | path = app.config["directory_start"] or app.config["directory_base"] 289 | try: 290 | urlpath = Node(path).urlpath 291 | except OutsideDirectoryBase: 292 | return NotFound() 293 | return browse(urlpath) 294 | 295 | 296 | @app.after_request 297 | def page_not_found(response): 298 | if response.status_code == 404: 299 | return make_response((render_template('404.html'), 404)) 300 | return response 301 | 302 | 303 | @app.errorhandler(InvalidPathError) 304 | def bad_request_error(e): 305 | file = None 306 | if hasattr(e, 'path'): 307 | if isinstance(e, InvalidFilenameError): 308 | file = Node(e.path) 309 | else: 310 | file = Node(e.path).parent 311 | return render_template('400.html', file=file, error=e), 400 312 | 313 | 314 | @app.errorhandler(OutsideRemovableBase) 315 | @app.errorhandler(404) 316 | def page_not_found_error(e): 317 | return render_template('404.html'), 404 318 | 319 | 320 | @app.errorhandler(500) 321 | def internal_server_error(e): # pragma: no cover 322 | logger.exception(e) 323 | return getattr(e, 'message', 'Internal server error'), 500 324 | -------------------------------------------------------------------------------- /browsepy/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | import sys 6 | import os 7 | import os.path 8 | import argparse 9 | import warnings 10 | 11 | import flask 12 | 13 | from . import app 14 | from . import __meta__ as meta 15 | from .compat import PY_LEGACY, getdebug, get_terminal_size 16 | from .transform.glob import translate 17 | 18 | 19 | class HelpFormatter(argparse.RawTextHelpFormatter): 20 | def __init__(self, prog, indent_increment=2, max_help_position=24, 21 | width=None): 22 | if width is None: 23 | try: 24 | width = get_terminal_size().columns - 2 25 | except ValueError: # https://bugs.python.org/issue24966 26 | pass 27 | super(HelpFormatter, self).__init__( 28 | prog, indent_increment, max_help_position, width) 29 | 30 | 31 | class PluginAction(argparse.Action): 32 | def __call__(self, parser, namespace, value, option_string=None): 33 | warned = '%s_warning' % self.dest 34 | if ',' in value and not getattr(namespace, warned, False): 35 | setattr(namespace, warned, True) 36 | warnings.warn( 37 | 'Comma-separated --plugin value is deprecated, ' 38 | 'use multiple --plugin options instead.' 39 | ) 40 | values = value.split(',') 41 | prev = getattr(namespace, self.dest, None) 42 | if isinstance(prev, list): 43 | values = prev + [p for p in values if p not in prev] 44 | setattr(namespace, self.dest, values) 45 | 46 | 47 | class ArgParse(argparse.ArgumentParser): 48 | default_directory = app.config['directory_base'] 49 | default_initial = ( 50 | None 51 | if app.config['directory_start'] == app.config['directory_base'] else 52 | app.config['directory_start'] 53 | ) 54 | default_removable = app.config['directory_remove'] 55 | default_upload = app.config['directory_upload'] 56 | 57 | default_host = os.getenv('BROWSEPY_HOST', '127.0.0.1') 58 | default_port = os.getenv('BROWSEPY_PORT', '8080') 59 | plugin_action_class = PluginAction 60 | 61 | defaults = { 62 | 'prog': meta.app, 63 | 'formatter_class': HelpFormatter, 64 | 'description': 'description: starts a %s web file browser' % meta.app 65 | } 66 | 67 | def __init__(self, sep=os.sep): 68 | super(ArgParse, self).__init__(**self.defaults) 69 | self.add_argument( 70 | 'host', nargs='?', 71 | default=self.default_host, 72 | help='address to listen (default: %(default)s)') 73 | self.add_argument( 74 | 'port', nargs='?', type=int, 75 | default=self.default_port, 76 | help='port to listen (default: %(default)s)') 77 | self.add_argument( 78 | '--directory', metavar='PATH', type=self._directory, 79 | default=self.default_directory, 80 | help='serving directory (default: %(default)s)') 81 | self.add_argument( 82 | '--initial', metavar='PATH', 83 | type=lambda x: self._directory(x) if x else None, 84 | default=self.default_initial, 85 | help='default directory (default: same as --directory)') 86 | self.add_argument( 87 | '--removable', metavar='PATH', type=self._directory, 88 | default=self.default_removable, 89 | help='base directory allowing remove (default: %(default)s)') 90 | self.add_argument( 91 | '--upload', metavar='PATH', type=self._directory, 92 | default=self.default_upload, 93 | help='base directory allowing upload (default: %(default)s)') 94 | self.add_argument( 95 | '--exclude', metavar='PATTERN', 96 | action='append', 97 | default=[], 98 | help='exclude paths by pattern (multiple)') 99 | self.add_argument( 100 | '--exclude-from', metavar='PATH', type=self._file, 101 | action='append', 102 | default=[], 103 | help='exclude paths by pattern file (multiple)') 104 | self.add_argument( 105 | '--plugin', metavar='MODULE', 106 | action=self.plugin_action_class, 107 | default=[], 108 | help='load plugin module (multiple)') 109 | self.add_argument( 110 | '--debug', action='store_true', 111 | help=argparse.SUPPRESS) 112 | 113 | def _path(self, arg): 114 | if PY_LEGACY and hasattr(sys.stdin, 'encoding'): 115 | encoding = sys.stdin.encoding or sys.getdefaultencoding() 116 | arg = arg.decode(encoding) 117 | return os.path.abspath(arg) 118 | 119 | def _file(self, arg): 120 | path = self._path(arg) 121 | if os.path.isfile(path): 122 | return path 123 | self.error('%s is not a valid file' % arg) 124 | 125 | def _directory(self, arg): 126 | path = self._path(arg) 127 | if os.path.isdir(path): 128 | return path 129 | self.error('%s is not a valid directory' % arg) 130 | 131 | 132 | def create_exclude_fnc(patterns, base, sep=os.sep): 133 | if patterns: 134 | regex = '|'.join(translate(pattern, sep, base) for pattern in patterns) 135 | return re.compile(regex).search 136 | return None 137 | 138 | 139 | def collect_exclude_patterns(paths): 140 | patterns = [] 141 | for path in paths: 142 | with open(path, 'r') as f: 143 | for line in f: 144 | line = line.split('#')[0].strip() 145 | if line: 146 | patterns.append(line) 147 | return patterns 148 | 149 | 150 | def list_union(*lists): 151 | lst = [i for l in lists for i in l] 152 | return sorted(frozenset(lst), key=lst.index) 153 | 154 | 155 | def filter_union(*functions): 156 | filtered = [fnc for fnc in functions if fnc] 157 | if filtered: 158 | if len(filtered) == 1: 159 | return filtered[0] 160 | return lambda data: any(fnc(data) for fnc in filtered) 161 | return None 162 | 163 | 164 | def main(argv=sys.argv[1:], app=app, parser=ArgParse, run_fnc=flask.Flask.run): 165 | plugin_manager = app.extensions['plugin_manager'] 166 | args = plugin_manager.load_arguments(argv, parser()) 167 | patterns = args.exclude + collect_exclude_patterns(args.exclude_from) 168 | if args.debug: 169 | os.environ['DEBUG'] = 'true' 170 | app.config.update( 171 | directory_base=args.directory, 172 | directory_start=args.initial or args.directory, 173 | directory_remove=args.removable, 174 | directory_upload=args.upload, 175 | plugin_modules=list_union( 176 | app.config['plugin_modules'], 177 | args.plugin, 178 | ), 179 | exclude_fnc=filter_union( 180 | app.config['exclude_fnc'], 181 | create_exclude_fnc(patterns, args.directory), 182 | ), 183 | ) 184 | plugin_manager.reload() 185 | run_fnc( 186 | app, 187 | host=args.host, 188 | port=args.port, 189 | debug=getdebug(), 190 | use_reloader=False, 191 | threaded=True 192 | ) 193 | 194 | 195 | if __name__ == '__main__': # pragma: no cover 196 | main() 197 | -------------------------------------------------------------------------------- /browsepy/__meta__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | 4 | app = 'browsepy' 5 | description = 'Simple web file browser' 6 | version = '0.5.6' 7 | license = 'MIT' 8 | author_name = 'Felipe A. Hernandez' 9 | author_mail = 'ergoithz@gmail.com' 10 | author = '%s <%s>' % (author_name, author_mail) 11 | url = 'https://github.com/ergoithz/browsepy' 12 | tarball = '%s/archive/%s.tar.gz' % (url, version) 13 | -------------------------------------------------------------------------------- /browsepy/appconfig.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import flask.config 3 | 4 | from .compat import basestring 5 | 6 | 7 | class Config(flask.config.Config): 8 | ''' 9 | Flask-compatible case-insensitive Config classt. 10 | 11 | See :type:`flask.config.Config` for more info. 12 | ''' 13 | def __init__(self, root, defaults=None): 14 | if defaults: 15 | defaults = self.gendict(defaults) 16 | super(Config, self).__init__(root, defaults) 17 | 18 | @classmethod 19 | def genkey(cls, k): 20 | ''' 21 | Key translation function. 22 | 23 | :param k: key 24 | :type k: str 25 | :returns: uppercase key 26 | ;rtype: str 27 | ''' 28 | return k.upper() if isinstance(k, basestring) else k 29 | 30 | @classmethod 31 | def gendict(cls, *args, **kwargs): 32 | ''' 33 | Pre-translated key dictionary constructor. 34 | 35 | See :type:`dict` for more info. 36 | 37 | :returns: dictionary with uppercase keys 38 | :rtype: dict 39 | ''' 40 | gk = cls.genkey 41 | return dict((gk(k), v) for k, v in dict(*args, **kwargs).items()) 42 | 43 | def __getitem__(self, k): 44 | return super(Config, self).__getitem__(self.genkey(k)) 45 | 46 | def __setitem__(self, k, v): 47 | super(Config, self).__setitem__(self.genkey(k), v) 48 | 49 | def __delitem__(self, k): 50 | super(Config, self).__delitem__(self.genkey(k)) 51 | 52 | def get(self, k, default=None): 53 | return super(Config, self).get(self.genkey(k), default) 54 | 55 | def pop(self, k, *args): 56 | return super(Config, self).pop(self.genkey(k), *args) 57 | 58 | def update(self, *args, **kwargs): 59 | super(Config, self).update(self.gendict(*args, **kwargs)) 60 | 61 | 62 | class Flask(flask.Flask): 63 | ''' 64 | Flask class using case-insensitive :type:`Config` class. 65 | 66 | See :type:`flask.Flask` for more info. 67 | ''' 68 | config_class = Config 69 | -------------------------------------------------------------------------------- /browsepy/compat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | 4 | import os 5 | import os.path 6 | import sys 7 | import itertools 8 | 9 | import warnings 10 | import functools 11 | 12 | import posixpath 13 | import ntpath 14 | 15 | FS_ENCODING = sys.getfilesystemencoding() 16 | PY_LEGACY = sys.version_info < (3, ) 17 | TRUE_VALUES = frozenset(('true', 'yes', '1', 'enable', 'enabled', True, 1)) 18 | 19 | try: 20 | from os import scandir, walk 21 | except ImportError: 22 | from scandir import scandir, walk # noqa 23 | 24 | try: 25 | from shutil import get_terminal_size 26 | except ImportError: 27 | from backports.shutil_get_terminal_size import get_terminal_size # noqa 28 | 29 | 30 | def isexec(path): 31 | ''' 32 | Check if given path points to an executable file. 33 | 34 | :param path: file path 35 | :type path: str 36 | :return: True if executable, False otherwise 37 | :rtype: bool 38 | ''' 39 | return os.path.isfile(path) and os.access(path, os.X_OK) 40 | 41 | 42 | def fsdecode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None): 43 | ''' 44 | Decode given path. 45 | 46 | :param path: path will be decoded if using bytes 47 | :type path: bytes or str 48 | :param os_name: operative system name, defaults to os.name 49 | :type os_name: str 50 | :param fs_encoding: current filesystem encoding, defaults to autodetected 51 | :type fs_encoding: str 52 | :return: decoded path 53 | :rtype: str 54 | ''' 55 | if not isinstance(path, bytes): 56 | return path 57 | if not errors: 58 | use_strict = PY_LEGACY or os_name == 'nt' 59 | errors = 'strict' if use_strict else 'surrogateescape' 60 | return path.decode(fs_encoding, errors=errors) 61 | 62 | 63 | def fsencode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None): 64 | ''' 65 | Encode given path. 66 | 67 | :param path: path will be encoded if not using bytes 68 | :type path: bytes or str 69 | :param os_name: operative system name, defaults to os.name 70 | :type os_name: str 71 | :param fs_encoding: current filesystem encoding, defaults to autodetected 72 | :type fs_encoding: str 73 | :return: encoded path 74 | :rtype: bytes 75 | ''' 76 | if isinstance(path, bytes): 77 | return path 78 | if not errors: 79 | use_strict = PY_LEGACY or os_name == 'nt' 80 | errors = 'strict' if use_strict else 'surrogateescape' 81 | return path.encode(fs_encoding, errors=errors) 82 | 83 | 84 | def getcwd(fs_encoding=FS_ENCODING, cwd_fnc=os.getcwd): 85 | ''' 86 | Get current work directory's absolute path. 87 | Like os.getcwd but garanteed to return an unicode-str object. 88 | 89 | :param fs_encoding: filesystem encoding, defaults to autodetected 90 | :type fs_encoding: str 91 | :param cwd_fnc: callable used to get the path, defaults to os.getcwd 92 | :type cwd_fnc: Callable 93 | :return: path 94 | :rtype: str 95 | ''' 96 | path = fsdecode(cwd_fnc(), fs_encoding=fs_encoding) 97 | return os.path.abspath(path) 98 | 99 | 100 | def getdebug(environ=os.environ, true_values=TRUE_VALUES): 101 | ''' 102 | Get if app is expected to be ran in debug mode looking at environment 103 | variables. 104 | 105 | :param environ: environment dict-like object 106 | :type environ: collections.abc.Mapping 107 | :returns: True if debug contains a true-like string, False otherwise 108 | :rtype: bool 109 | ''' 110 | return environ.get('DEBUG', '').lower() in true_values 111 | 112 | 113 | def deprecated(func_or_text, environ=os.environ): 114 | ''' 115 | Decorator used to mark functions as deprecated. It will result in a 116 | warning being emmitted hen the function is called. 117 | 118 | Usage: 119 | 120 | >>> @deprecated 121 | ... def fnc(): 122 | ... pass 123 | 124 | Usage (custom message): 125 | 126 | >>> @deprecated('This is deprecated') 127 | ... def fnc(): 128 | ... pass 129 | 130 | :param func_or_text: message or callable to decorate 131 | :type func_or_text: callable 132 | :param environ: optional environment mapping 133 | :type environ: collections.abc.Mapping 134 | :returns: nested decorator or new decorated function (depending on params) 135 | :rtype: callable 136 | ''' 137 | def inner(func): 138 | message = ( 139 | 'Deprecated function {}.'.format(func.__name__) 140 | if callable(func_or_text) else 141 | func_or_text 142 | ) 143 | 144 | @functools.wraps(func) 145 | def new_func(*args, **kwargs): 146 | with warnings.catch_warnings(): 147 | if getdebug(environ): 148 | warnings.simplefilter('always', DeprecationWarning) 149 | warnings.warn(message, category=DeprecationWarning, 150 | stacklevel=3) 151 | return func(*args, **kwargs) 152 | return new_func 153 | return inner(func_or_text) if callable(func_or_text) else inner 154 | 155 | 156 | def usedoc(other): 157 | ''' 158 | Decorator which copies __doc__ of given object into decorated one. 159 | 160 | Usage: 161 | 162 | >>> def fnc1(): 163 | ... """docstring""" 164 | ... pass 165 | >>> @usedoc(fnc1) 166 | ... def fnc2(): 167 | ... pass 168 | >>> fnc2.__doc__ 169 | 'docstring'collections.abc.D 170 | 171 | :param other: anything with a __doc__ attribute 172 | :type other: any 173 | :returns: decorator function 174 | :rtype: callable 175 | ''' 176 | def inner(fnc): 177 | fnc.__doc__ = fnc.__doc__ or getattr(other, '__doc__') 178 | return fnc 179 | return inner 180 | 181 | 182 | def pathsplit(value, sep=os.pathsep): 183 | ''' 184 | Get enviroment PATH elements as list. 185 | 186 | This function only cares about spliting across OSes. 187 | 188 | :param value: path string, as given by os.environ['PATH'] 189 | :type value: str 190 | :param sep: PATH separator, defaults to os.pathsep 191 | :type sep: str 192 | :yields: every path 193 | :ytype: str 194 | ''' 195 | for part in value.split(sep): 196 | if part[:1] == part[-1:] == '"' or part[:1] == part[-1:] == '\'': 197 | part = part[1:-1] 198 | yield part 199 | 200 | 201 | def pathparse(value, sep=os.pathsep, os_sep=os.sep): 202 | ''' 203 | Get enviroment PATH directories as list. 204 | 205 | This function cares about spliting, escapes and normalization of paths 206 | across OSes. 207 | 208 | :param value: path string, as given by os.environ['PATH'] 209 | :type value: str 210 | :param sep: PATH separator, defaults to os.pathsep 211 | :type sep: str 212 | :param os_sep: OS filesystem path separator, defaults to os.sep 213 | :type os_sep: str 214 | :yields: every path 215 | :ytype: str 216 | ''' 217 | escapes = [] 218 | normpath = ntpath.normpath if os_sep == '\\' else posixpath.normpath 219 | if '\\' not in (os_sep, sep): 220 | escapes.extend(( 221 | ('\\\\', '', '\\'), 222 | ('\\"', '', '"'), 223 | ('\\\'', '', '\''), 224 | ('\\%s' % sep, '', sep), 225 | )) 226 | for original, escape, unescape in escapes: 227 | value = value.replace(original, escape) 228 | for part in pathsplit(value, sep=sep): 229 | if part[-1:] == os_sep and part != os_sep: 230 | part = part[:-1] 231 | for original, escape, unescape in escapes: 232 | part = part.replace(escape, unescape) 233 | yield normpath(fsdecode(part)) 234 | 235 | 236 | def pathconf(path, 237 | os_name=os.name, 238 | isdir_fnc=os.path.isdir, 239 | pathconf_fnc=getattr(os, 'pathconf', None), 240 | pathconf_names=getattr(os, 'pathconf_names', ())): 241 | ''' 242 | Get all pathconf variables for given path. 243 | 244 | :param path: absolute fs path 245 | :type path: str 246 | :returns: dictionary containing pathconf keys and their values (both str) 247 | :rtype: dict 248 | ''' 249 | 250 | if pathconf_fnc and pathconf_names: 251 | return {key: pathconf_fnc(path, key) for key in pathconf_names} 252 | if os_name == 'nt': 253 | maxpath = 246 if isdir_fnc(path) else 259 # 260 minus 254 | else: 255 | maxpath = 255 # conservative sane default 256 | return { 257 | 'PC_PATH_MAX': maxpath, 258 | 'PC_NAME_MAX': maxpath - len(path), 259 | } 260 | 261 | 262 | ENV_PATH = tuple(pathparse(os.getenv('PATH', ''))) 263 | ENV_PATHEXT = tuple(pathsplit(os.getenv('PATHEXT', ''))) 264 | 265 | 266 | def which(name, 267 | env_path=ENV_PATH, 268 | env_path_ext=ENV_PATHEXT, 269 | is_executable_fnc=isexec, 270 | path_join_fnc=os.path.join, 271 | os_name=os.name): 272 | ''' 273 | Get command absolute path. 274 | 275 | :param name: name of executable command 276 | :type name: str 277 | :param env_path: OS environment executable paths, defaults to autodetected 278 | :type env_path: list of str 279 | :param is_executable_fnc: callable will be used to detect if path is 280 | executable, defaults to `isexec` 281 | :type is_executable_fnc: Callable 282 | :param path_join_fnc: callable will be used to join path components 283 | :type path_join_fnc: Callable 284 | :param os_name: os name, defaults to os.name 285 | :type os_name: str 286 | :return: absolute path 287 | :rtype: str or None 288 | ''' 289 | for path in env_path: 290 | for suffix in env_path_ext: 291 | exe_file = path_join_fnc(path, name) + suffix 292 | if is_executable_fnc(exe_file): 293 | return exe_file 294 | return None 295 | 296 | 297 | def re_escape(pattern, chars=frozenset("()[]{}?*+|^$\\.-#")): 298 | ''' 299 | Escape all special regex characters in pattern. 300 | Logic taken from regex module. 301 | 302 | :param pattern: regex pattern to escape 303 | :type patterm: str 304 | :returns: escaped pattern 305 | :rtype: str 306 | ''' 307 | escape = '\\{}'.format 308 | return ''.join( 309 | escape(c) if c in chars or c.isspace() else 310 | '\\000' if c == '\x00' else c 311 | for c in pattern 312 | ) 313 | 314 | 315 | if PY_LEGACY: 316 | FileNotFoundError = OSError # noqa 317 | range = xrange # noqa 318 | filter = itertools.ifilter 319 | basestring = basestring # noqa 320 | unicode = unicode # noqa 321 | chr = unichr # noqa 322 | bytes = str # noqa 323 | else: 324 | FileNotFoundError = FileNotFoundError 325 | range = range 326 | filter = filter 327 | basestring = str 328 | unicode = str 329 | chr = chr 330 | bytes = bytes 331 | -------------------------------------------------------------------------------- /browsepy/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class OutsideDirectoryBase(Exception): 3 | ''' 4 | Exception raised when trying to access to a file outside path defined on 5 | `directory_base` config property. 6 | ''' 7 | pass 8 | 9 | 10 | class OutsideRemovableBase(Exception): 11 | ''' 12 | Exception raised when trying to access to a file outside path defined on 13 | `directory_remove` config property. 14 | ''' 15 | pass 16 | 17 | 18 | class InvalidPathError(ValueError): 19 | ''' 20 | Exception raised when a path is not valid. 21 | 22 | :property path: value whose length raised this Exception 23 | ''' 24 | code = 'invalid-path' 25 | template = 'Path {0.path!r} is not valid.' 26 | 27 | def __init__(self, message=None, path=None): 28 | self.path = path 29 | message = self.template.format(self) if message is None else message 30 | super(InvalidPathError, self).__init__(message) 31 | 32 | 33 | class InvalidFilenameError(InvalidPathError): 34 | ''' 35 | Exception raised when a filename is not valid. 36 | 37 | :property filename: value whose length raised this Exception 38 | ''' 39 | code = 'invalid-filename' 40 | template = 'Filename {0.filename!r} is not valid.' 41 | 42 | def __init__(self, message=None, path=None, filename=None): 43 | self.filename = filename 44 | super(InvalidFilenameError, self).__init__(message, path=path) 45 | 46 | 47 | class PathTooLongError(InvalidPathError): 48 | ''' 49 | Exception raised when maximum filesystem path length is reached. 50 | 51 | :property limit: value length limit 52 | ''' 53 | code = 'invalid-path-length' 54 | template = 'Path {0.path!r} is too long, max length is {0.limit}' 55 | 56 | def __init__(self, message=None, path=None, limit=0): 57 | self.limit = limit 58 | super(PathTooLongError, self).__init__(message, path=path) 59 | 60 | 61 | class FilenameTooLongError(InvalidFilenameError): 62 | ''' 63 | Exception raised when maximum filesystem filename length is reached. 64 | ''' 65 | code = 'invalid-filename-length' 66 | template = 'Filename {0.filename!r} is too long, max length is {0.limit}' 67 | 68 | def __init__(self, message=None, path=None, filename=None, limit=0): 69 | self.limit = limit 70 | super(FilenameTooLongError, self).__init__( 71 | message, path=path, filename=filename) 72 | -------------------------------------------------------------------------------- /browsepy/mimetype.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | 4 | import re 5 | import subprocess 6 | import mimetypes 7 | 8 | from .compat import FileNotFoundError, which # noqa 9 | 10 | generic_mimetypes = frozenset(('application/octet-stream', None)) 11 | re_mime_validate = re.compile('\w+/\w+(; \w+=[^;]+)*') 12 | 13 | 14 | def by_python(path): 15 | mime, encoding = mimetypes.guess_type(path) 16 | if mime in generic_mimetypes: 17 | return None 18 | return "%s%s%s" % ( 19 | mime or "application/octet-stream", "; " 20 | if encoding else 21 | "", encoding or "" 22 | ) 23 | 24 | 25 | if which('file'): 26 | def by_file(path): 27 | try: 28 | output = subprocess.check_output( 29 | ("file", "-ib", path), 30 | universal_newlines=True 31 | ).strip() 32 | if ( 33 | re_mime_validate.match(output) and 34 | output not in generic_mimetypes 35 | ): 36 | # 'file' command can return status zero with invalid output 37 | return output 38 | except (subprocess.CalledProcessError, FileNotFoundError): 39 | pass 40 | return None 41 | else: 42 | def by_file(path): 43 | return None 44 | 45 | 46 | def by_default(path): 47 | return "application/octet-stream" 48 | -------------------------------------------------------------------------------- /browsepy/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | -------------------------------------------------------------------------------- /browsepy/plugin/player/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | 4 | import os.path 5 | 6 | from flask import Blueprint, render_template 7 | from werkzeug.exceptions import NotFound 8 | 9 | from browsepy import stream_template, get_cookie_browse_sorting, \ 10 | browse_sortkey_reverse 11 | from browsepy.file import OutsideDirectoryBase 12 | 13 | from .playable import PlayableFile, PlayableDirectory, \ 14 | PlayListFile, detect_playable_mimetype 15 | 16 | 17 | __basedir__ = os.path.dirname(os.path.abspath(__file__)) 18 | 19 | player = Blueprint( 20 | 'player', 21 | __name__, 22 | url_prefix='/play', 23 | template_folder=os.path.join(__basedir__, 'templates'), 24 | static_folder=os.path.join(__basedir__, 'static'), 25 | ) 26 | 27 | 28 | @player.route('/audio/') 29 | def audio(path): 30 | try: 31 | file = PlayableFile.from_urlpath(path) 32 | if file.is_file: 33 | return render_template('audio.player.html', file=file) 34 | except OutsideDirectoryBase: 35 | pass 36 | return NotFound() 37 | 38 | 39 | @player.route('/list/') 40 | def playlist(path): 41 | try: 42 | file = PlayListFile.from_urlpath(path) 43 | if file.is_file: 44 | return stream_template( 45 | 'audio.player.html', 46 | file=file, 47 | playlist=True 48 | ) 49 | except OutsideDirectoryBase: 50 | pass 51 | return NotFound() 52 | 53 | 54 | @player.route("/directory", defaults={"path": ""}) 55 | @player.route('/directory/') 56 | def directory(path): 57 | sort_property = get_cookie_browse_sorting(path, 'text') 58 | sort_fnc, sort_reverse = browse_sortkey_reverse(sort_property) 59 | try: 60 | file = PlayableDirectory.from_urlpath(path) 61 | if file.is_directory: 62 | return stream_template( 63 | 'audio.player.html', 64 | file=file, 65 | sort_property=sort_property, 66 | sort_fnc=sort_fnc, 67 | sort_reverse=sort_reverse, 68 | playlist=True 69 | ) 70 | except OutsideDirectoryBase: 71 | pass 72 | return NotFound() 73 | 74 | 75 | def register_arguments(manager): 76 | ''' 77 | Register arguments using given plugin manager. 78 | 79 | This method is called before `register_plugin`. 80 | 81 | :param manager: plugin manager 82 | :type manager: browsepy.manager.PluginManager 83 | ''' 84 | 85 | # Arguments are forwarded to argparse:ArgumentParser.add_argument, 86 | # https://docs.python.org/3.7/library/argparse.html#the-add-argument-method 87 | manager.register_argument( 88 | '--player-directory-play', action='store_true', 89 | help='enable directories as playlist' 90 | ) 91 | 92 | 93 | def register_plugin(manager): 94 | ''' 95 | Register blueprints and actions using given plugin manager. 96 | 97 | :param manager: plugin manager 98 | :type manager: browsepy.manager.PluginManager 99 | ''' 100 | manager.register_blueprint(player) 101 | manager.register_mimetype_function(detect_playable_mimetype) 102 | 103 | # add style tag 104 | manager.register_widget( 105 | place='styles', 106 | type='stylesheet', 107 | endpoint='player.static', 108 | filename='css/browse.css' 109 | ) 110 | 111 | # register link actions 112 | manager.register_widget( 113 | place='entry-link', 114 | type='link', 115 | endpoint='player.audio', 116 | filter=PlayableFile.detect 117 | ) 118 | manager.register_widget( 119 | place='entry-link', 120 | icon='playlist', 121 | type='link', 122 | endpoint='player.playlist', 123 | filter=PlayListFile.detect 124 | ) 125 | 126 | # register action buttons 127 | manager.register_widget( 128 | place='entry-actions', 129 | css='play', 130 | type='button', 131 | endpoint='player.audio', 132 | filter=PlayableFile.detect 133 | ) 134 | manager.register_widget( 135 | place='entry-actions', 136 | css='play', 137 | type='button', 138 | endpoint='player.playlist', 139 | filter=PlayListFile.detect 140 | ) 141 | 142 | # check argument (see `register_arguments`) before registering 143 | if manager.get_argument('player_directory_play'): 144 | # register header button 145 | manager.register_widget( 146 | place='header', 147 | type='button', 148 | endpoint='player.directory', 149 | text='Play directory', 150 | filter=PlayableDirectory.detect 151 | ) 152 | -------------------------------------------------------------------------------- /browsepy/plugin/player/playable.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import codecs 4 | import os.path 5 | import warnings 6 | 7 | from werkzeug.utils import cached_property 8 | 9 | from browsepy.compat import range, PY_LEGACY # noqa 10 | from browsepy.file import Node, File, Directory, \ 11 | underscore_replace, check_under_base 12 | 13 | 14 | if PY_LEGACY: 15 | import ConfigParser as configparser 16 | else: 17 | import configparser 18 | 19 | ConfigParserBase = ( 20 | configparser.SafeConfigParser 21 | if hasattr(configparser, 'SafeConfigParser') else 22 | configparser.ConfigParser 23 | ) 24 | 25 | 26 | class PLSFileParser(object): 27 | ''' 28 | ConfigParser wrapper accepting fallback on get for convenience. 29 | 30 | This wraps instead of inheriting due ConfigParse being classobj on python2. 31 | ''' 32 | NOT_SET = type('NotSetType', (object,), {}) 33 | parser_class = ( 34 | configparser.SafeConfigParser 35 | if hasattr(configparser, 'SafeConfigParser') else 36 | configparser.ConfigParser 37 | ) 38 | 39 | def __init__(self, path): 40 | with warnings.catch_warnings(): 41 | # We already know about SafeConfigParser deprecation! 42 | warnings.filterwarnings('ignore', category=DeprecationWarning) 43 | self._parser = self.parser_class() 44 | self._parser.read(path) 45 | 46 | def getint(self, section, key, fallback=NOT_SET): 47 | try: 48 | return self._parser.getint(section, key) 49 | except (configparser.NoOptionError, ValueError): 50 | if fallback is self.NOT_SET: 51 | raise 52 | return fallback 53 | 54 | def get(self, section, key, fallback=NOT_SET): 55 | try: 56 | return self._parser.get(section, key) 57 | except (configparser.NoOptionError, ValueError): 58 | if fallback is self.NOT_SET: 59 | raise 60 | return fallback 61 | 62 | 63 | class PlayableBase(File): 64 | extensions = { 65 | 'mp3': 'audio/mpeg', 66 | 'ogg': 'audio/ogg', 67 | 'wav': 'audio/wav', 68 | 'm3u': 'audio/x-mpegurl', 69 | 'm3u8': 'audio/x-mpegurl', 70 | 'pls': 'audio/x-scpls', 71 | } 72 | 73 | @classmethod 74 | def extensions_from_mimetypes(cls, mimetypes): 75 | mimetypes = frozenset(mimetypes) 76 | return { 77 | ext: mimetype 78 | for ext, mimetype in cls.extensions.items() 79 | if mimetype in mimetypes 80 | } 81 | 82 | @classmethod 83 | def detect(cls, node, os_sep=os.sep): 84 | basename = node.path.rsplit(os_sep)[-1] 85 | if '.' in basename: 86 | ext = basename.rsplit('.')[-1] 87 | return cls.extensions.get(ext, None) 88 | return None 89 | 90 | 91 | class PlayableFile(PlayableBase): 92 | mimetypes = ['audio/mpeg', 'audio/ogg', 'audio/wav'] 93 | extensions = PlayableBase.extensions_from_mimetypes(mimetypes) 94 | media_map = {mime: ext for ext, mime in extensions.items()} 95 | 96 | def __init__(self, **kwargs): 97 | self.duration = kwargs.pop('duration', None) 98 | self.title = kwargs.pop('title', None) 99 | super(PlayableFile, self).__init__(**kwargs) 100 | 101 | @property 102 | def title(self): 103 | return self._title or self.name 104 | 105 | @title.setter 106 | def title(self, title): 107 | self._title = title 108 | 109 | @property 110 | def media_format(self): 111 | return self.media_map[self.type] 112 | 113 | 114 | class PlayListFile(PlayableBase): 115 | playable_class = PlayableFile 116 | mimetypes = ['audio/x-mpegurl', 'audio/x-mpegurl', 'audio/x-scpls'] 117 | extensions = PlayableBase.extensions_from_mimetypes(mimetypes) 118 | 119 | @classmethod 120 | def from_urlpath(cls, path, app=None): 121 | original = Node.from_urlpath(path, app) 122 | if original.mimetype == PlayableDirectory.mimetype: 123 | return PlayableDirectory(original.path, original.app) 124 | elif original.mimetype == M3UFile.mimetype: 125 | return M3UFile(original.path, original.app) 126 | if original.mimetype == PLSFile.mimetype: 127 | return PLSFile(original.path, original.app) 128 | return original 129 | 130 | def normalize_playable_path(self, path): 131 | if '://' in path: 132 | return path 133 | path = os.path.normpath(path) 134 | if not os.path.isabs(path): 135 | return os.path.join(self.parent.path, path) 136 | drive = os.path.splitdrive(self.path)[0] 137 | if drive and not os.path.splitdrive(path)[0]: 138 | path = drive + path 139 | if check_under_base(path, self.app.config['directory_base']): 140 | return path 141 | return None 142 | 143 | def _entries(self): 144 | return 145 | yield # noqa 146 | 147 | def entries(self, sortkey=None, reverse=None): 148 | for file in self._entries(): 149 | if PlayableFile.detect(file): 150 | yield file 151 | 152 | 153 | class PLSFile(PlayListFile): 154 | ini_parser_class = PLSFileParser 155 | maxsize = getattr(sys, 'maxint', 0) or getattr(sys, 'maxsize', 0) or 2**32 156 | mimetype = 'audio/x-scpls' 157 | extensions = PlayableBase.extensions_from_mimetypes([mimetype]) 158 | 159 | def _entries(self): 160 | parser = self.ini_parser_class(self.path) 161 | maxsize = parser.getint('playlist', 'NumberOfEntries', None) 162 | for i in range(1, self.maxsize if maxsize is None else maxsize + 1): 163 | path = parser.get('playlist', 'File%d' % i, None) 164 | if not path: 165 | if maxsize: 166 | continue 167 | break 168 | path = self.normalize_playable_path(path) 169 | if not path: 170 | continue 171 | yield self.playable_class( 172 | path=path, 173 | app=self.app, 174 | duration=parser.getint( 175 | 'playlist', 'Length%d' % i, 176 | None 177 | ), 178 | title=parser.get( 179 | 'playlist', 180 | 'Title%d' % i, 181 | None 182 | ), 183 | ) 184 | 185 | 186 | class M3UFile(PlayListFile): 187 | mimetype = 'audio/x-mpegurl' 188 | extensions = PlayableBase.extensions_from_mimetypes([mimetype]) 189 | 190 | def _iter_lines(self): 191 | prefix = '#EXTM3U\n' 192 | encoding = 'utf-8' if self.path.endswith('.m3u8') else 'ascii' 193 | with codecs.open( 194 | self.path, 'r', 195 | encoding=encoding, 196 | errors=underscore_replace 197 | ) as f: 198 | if f.read(len(prefix)) != prefix: 199 | f.seek(0) 200 | for line in f: 201 | line = line.rstrip() 202 | if line: 203 | yield line 204 | 205 | def _entries(self): 206 | data = {} 207 | for line in self._iter_lines(): 208 | if line.startswith('#EXTINF:'): 209 | duration, title = line.split(',', 1) 210 | data['duration'] = None if duration == '-1' else int(duration) 211 | data['title'] = title 212 | if not line: 213 | continue 214 | path = self.normalize_playable_path(line) 215 | if path: 216 | yield self.playable_class(path=path, app=self.app, **data) 217 | data.clear() 218 | 219 | 220 | class PlayableDirectory(Directory): 221 | file_class = PlayableFile 222 | name = '' 223 | 224 | @cached_property 225 | def parent(self): 226 | return Directory(self.path) 227 | 228 | @classmethod 229 | def detect(cls, node): 230 | if node.is_directory: 231 | for file in node._listdir(): 232 | if PlayableFile.detect(file): 233 | return cls.mimetype 234 | return None 235 | 236 | def entries(self, sortkey=None, reverse=None): 237 | listdir_fnc = super(PlayableDirectory, self).listdir 238 | for file in listdir_fnc(sortkey=sortkey, reverse=reverse): 239 | if PlayableFile.detect(file): 240 | yield file 241 | 242 | 243 | def detect_playable_mimetype(path, os_sep=os.sep): 244 | basename = path.rsplit(os_sep)[-1] 245 | if '.' in basename: 246 | ext = basename.rsplit('.')[-1] 247 | return PlayableBase.extensions.get(ext, None) 248 | return None 249 | -------------------------------------------------------------------------------- /browsepy/plugin/player/static/css/base.css: -------------------------------------------------------------------------------- 1 | .jp-audio { 2 | width: auto; 3 | } 4 | 5 | .jp-audio .jp-controls { 6 | width: auto; 7 | } 8 | 9 | .jp-current-time, .jp-duration { 10 | width: 4.5em; 11 | } 12 | 13 | .jp-audio .jp-type-playlist .jp-toggles { 14 | left: auto; 15 | right: -90px; 16 | top: 0; 17 | } 18 | 19 | .jp-audio .jp-type-single .jp-progress, .jp-audio .jp-type-playlist .jp-progress { 20 | width: auto; 21 | right: 130px; 22 | } 23 | 24 | .jp-volume-controls { 25 | left: auto; 26 | right: 15px; 27 | width: 100px; 28 | } 29 | 30 | .jp-audio .jp-type-single .jp-time-holder, .jp-audio .jp-type-playlist .jp-time-holder { 31 | width: auto; 32 | right: 130px; 33 | } 34 | -------------------------------------------------------------------------------- /browsepy/plugin/player/static/css/browse.css: -------------------------------------------------------------------------------- 1 | a.button.play:after { 2 | content: "\e903"; 3 | } 4 | 5 | .playlist.icon:after{ 6 | content: "\e907"; 7 | } 8 | -------------------------------------------------------------------------------- /browsepy/plugin/player/static/css/jplayer.blue.monday.min.css: -------------------------------------------------------------------------------- 1 | /*! Blue Monday Skin for jPlayer 2.9.2 ~ (c) 2009-2014 Happyworm Ltd ~ MIT License */.jp-audio :focus,.jp-audio-stream :focus,.jp-video :focus{outline:0}.jp-audio button::-moz-focus-inner,.jp-audio-stream button::-moz-focus-inner,.jp-video button::-moz-focus-inner{border:0}.jp-audio,.jp-audio-stream,.jp-video{font-size:16px;font-family:Verdana,Arial,sans-serif;line-height:1.6;color:#666;border:1px solid #009be3;background-color:#eee}.jp-audio{width:420px}.jp-audio-stream{width:182px}.jp-video-270p{width:480px}.jp-video-360p{width:640px}.jp-video-full{width:480px;height:270px;position:static!important;position:relative}.jp-video-full div div{z-index:1000}.jp-video-full .jp-jplayer{top:0;left:0;position:fixed!important;position:relative;overflow:hidden}.jp-video-full .jp-gui{position:fixed!important;position:static;top:0;left:0;width:100%;height:100%;z-index:1001}.jp-video-full .jp-interface{position:absolute!important;position:relative;bottom:0;left:0}.jp-interface{position:relative;background-color:#eee;width:100%}.jp-audio .jp-interface,.jp-audio-stream .jp-interface{height:80px}.jp-video .jp-interface{border-top:1px solid #009be3}.jp-controls-holder{clear:both;width:440px;margin:0 auto;position:relative;overflow:hidden;top:-8px}.jp-interface .jp-controls{margin:0;padding:0;overflow:hidden}.jp-audio .jp-controls{width:380px;padding:20px 20px 0}.jp-audio-stream .jp-controls{position:absolute;top:20px;left:20px;width:142px}.jp-video .jp-type-single .jp-controls{width:78px;margin-left:200px}.jp-video .jp-type-playlist .jp-controls{width:134px;margin-left:172px}.jp-video .jp-controls{float:left}.jp-controls button{display:block;float:left;overflow:hidden;text-indent:-9999px;border:none;cursor:pointer}.jp-play{width:40px;height:40px;background:url(../image/jplayer.blue.monday.jpg) no-repeat}.jp-play:focus{background:url(../image/jplayer.blue.monday.jpg) -41px 0 no-repeat}.jp-state-playing .jp-play{background:url(../image/jplayer.blue.monday.jpg) 0 -42px no-repeat}.jp-state-playing .jp-play:focus{background:url(../image/jplayer.blue.monday.jpg) -41px -42px no-repeat}.jp-next,.jp-previous,.jp-stop{width:28px;height:28px;margin-top:6px}.jp-stop{background:url(../image/jplayer.blue.monday.jpg) 0 -83px no-repeat;margin-left:10px}.jp-stop:focus{background:url(../image/jplayer.blue.monday.jpg) -29px -83px no-repeat}.jp-previous{background:url(../image/jplayer.blue.monday.jpg) 0 -112px no-repeat}.jp-previous:focus{background:url(../image/jplayer.blue.monday.jpg) -29px -112px no-repeat}.jp-next{background:url(../image/jplayer.blue.monday.jpg) 0 -141px no-repeat}.jp-next:focus{background:url(../image/jplayer.blue.monday.jpg) -29px -141px no-repeat}.jp-progress{overflow:hidden;background-color:#ddd}.jp-audio .jp-progress{position:absolute;top:32px;height:15px}.jp-audio .jp-type-single .jp-progress{left:110px;width:186px}.jp-audio .jp-type-playlist .jp-progress{left:166px;width:130px}.jp-video .jp-progress{top:0;left:0;width:100%;height:10px}.jp-seek-bar{background:url(../image/jplayer.blue.monday.jpg) 0 -202px repeat-x;width:0;height:100%;cursor:pointer}.jp-play-bar{background:url(../image/jplayer.blue.monday.jpg) 0 -218px repeat-x;width:0;height:100%}.jp-seeking-bg{background:url(../image/jplayer.blue.monday.seeking.gif)}.jp-state-no-volume .jp-volume-controls{display:none}.jp-volume-controls{position:absolute;top:32px;left:308px;width:200px}.jp-audio-stream .jp-volume-controls{left:70px}.jp-video .jp-volume-controls{top:12px;left:50px}.jp-volume-controls button{display:block;position:absolute;overflow:hidden;text-indent:-9999px;border:none;cursor:pointer}.jp-mute,.jp-volume-max{width:18px;height:15px}.jp-volume-max{left:74px}.jp-mute{background:url(../image/jplayer.blue.monday.jpg) 0 -170px no-repeat}.jp-mute:focus{background:url(../image/jplayer.blue.monday.jpg) -19px -170px no-repeat}.jp-state-muted .jp-mute{background:url(../image/jplayer.blue.monday.jpg) -60px -170px no-repeat}.jp-state-muted .jp-mute:focus{background:url(../image/jplayer.blue.monday.jpg) -79px -170px no-repeat}.jp-volume-max{background:url(../image/jplayer.blue.monday.jpg) 0 -186px no-repeat}.jp-volume-max:focus{background:url(../image/jplayer.blue.monday.jpg) -19px -186px no-repeat}.jp-volume-bar{position:absolute;overflow:hidden;background:url(../image/jplayer.blue.monday.jpg) 0 -250px repeat-x;top:5px;left:22px;width:46px;height:5px;cursor:pointer}.jp-volume-bar-value{background:url(../image/jplayer.blue.monday.jpg) 0 -256px repeat-x;width:0;height:5px}.jp-audio .jp-time-holder{position:absolute;top:50px}.jp-audio .jp-type-single .jp-time-holder{left:110px;width:186px}.jp-audio .jp-type-playlist .jp-time-holder{left:166px;width:130px}.jp-current-time,.jp-duration{width:60px;font-size:.64em;font-style:oblique}.jp-current-time{float:left;display:inline;cursor:default}.jp-duration{float:right;display:inline;text-align:right;cursor:pointer}.jp-video .jp-current-time{margin-left:20px}.jp-video .jp-duration{margin-right:20px}.jp-details{font-weight:700;text-align:center;cursor:default}.jp-details,.jp-playlist{width:100%;background-color:#ccc;border-top:1px solid #009be3}.jp-type-playlist .jp-details,.jp-type-single .jp-details{border-top:none}.jp-details .jp-title{margin:0;padding:5px 20px;font-size:.72em;font-weight:700}.jp-playlist ul{list-style-type:none;margin:0;padding:0 20px;font-size:.72em}.jp-playlist li{padding:5px 0 4px 20px;border-bottom:1px solid #eee}.jp-playlist li div{display:inline}div.jp-type-playlist div.jp-playlist li:last-child{padding:5px 0 5px 20px;border-bottom:none}div.jp-type-playlist div.jp-playlist li.jp-playlist-current{list-style-type:square;list-style-position:inside;padding-left:7px}div.jp-type-playlist div.jp-playlist a{color:#333;text-decoration:none}div.jp-type-playlist div.jp-playlist a.jp-playlist-current,div.jp-type-playlist div.jp-playlist a:hover{color:#0d88c1}div.jp-type-playlist div.jp-playlist a.jp-playlist-item-remove{float:right;display:inline;text-align:right;margin-right:10px;font-weight:700;color:#666}div.jp-type-playlist div.jp-playlist a.jp-playlist-item-remove:hover{color:#0d88c1}div.jp-type-playlist div.jp-playlist span.jp-free-media{float:right;display:inline;text-align:right;margin-right:10px}div.jp-type-playlist div.jp-playlist span.jp-free-media a{color:#666}div.jp-type-playlist div.jp-playlist span.jp-free-media a:hover{color:#0d88c1}span.jp-artist{font-size:.8em;color:#666}.jp-video-play{width:100%;overflow:hidden;cursor:pointer;background-color:transparent}.jp-video-270p .jp-video-play{margin-top:-270px;height:270px}.jp-video-360p .jp-video-play{margin-top:-360px;height:360px}.jp-video-full .jp-video-play{height:100%}.jp-video-play-icon{position:relative;display:block;width:112px;height:100px;margin-left:-56px;margin-top:-50px;left:50%;top:50%;background:url(../image/jplayer.blue.monday.video.play.png) no-repeat;text-indent:-9999px;border:none;cursor:pointer}.jp-video-play-icon:focus{background:url(../image/jplayer.blue.monday.video.play.png) 0 -100px no-repeat}.jp-jplayer,.jp-jplayer audio{width:0;height:0}.jp-jplayer{background-color:#000}.jp-toggles{padding:0;margin:0 auto;overflow:hidden}.jp-audio .jp-type-single .jp-toggles{width:25px}.jp-audio .jp-type-playlist .jp-toggles{width:55px;margin:0;position:absolute;left:325px;top:50px}.jp-video .jp-toggles{position:absolute;right:16px;margin:10px 0 0;width:100px}.jp-toggles button{display:block;float:left;width:25px;height:18px;text-indent:-9999px;line-height:100%;border:none;cursor:pointer}.jp-full-screen{background:url(../image/jplayer.blue.monday.jpg) 0 -310px no-repeat;margin-left:20px}.jp-full-screen:focus{background:url(../image/jplayer.blue.monday.jpg) -30px -310px no-repeat}.jp-state-full-screen .jp-full-screen{background:url(../image/jplayer.blue.monday.jpg) -60px -310px no-repeat}.jp-state-full-screen .jp-full-screen:focus{background:url(../image/jplayer.blue.monday.jpg) -90px -310px no-repeat}.jp-repeat{background:url(../image/jplayer.blue.monday.jpg) 0 -290px no-repeat}.jp-repeat:focus{background:url(../image/jplayer.blue.monday.jpg) -30px -290px no-repeat}.jp-state-looped .jp-repeat{background:url(../image/jplayer.blue.monday.jpg) -60px -290px no-repeat}.jp-state-looped .jp-repeat:focus{background:url(../image/jplayer.blue.monday.jpg) -90px -290px no-repeat}.jp-shuffle{background:url(../image/jplayer.blue.monday.jpg) 0 -270px no-repeat;margin-left:5px}.jp-shuffle:focus{background:url(../image/jplayer.blue.monday.jpg) -30px -270px no-repeat}.jp-state-shuffled .jp-shuffle{background:url(../image/jplayer.blue.monday.jpg) -60px -270px no-repeat}.jp-state-shuffled .jp-shuffle:focus{background:url(../image/jplayer.blue.monday.jpg) -90px -270px no-repeat}.jp-no-solution{padding:5px;font-size:.8em;background-color:#eee;border:2px solid #009be3;color:#000;display:none}.jp-no-solution a{color:#000}.jp-no-solution span{font-size:1em;display:block;text-align:center;font-weight:700} -------------------------------------------------------------------------------- /browsepy/plugin/player/static/image/jplayer.blue.monday.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/plugin/player/static/image/jplayer.blue.monday.jpg -------------------------------------------------------------------------------- /browsepy/plugin/player/static/image/jplayer.blue.monday.seeking.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/plugin/player/static/image/jplayer.blue.monday.seeking.gif -------------------------------------------------------------------------------- /browsepy/plugin/player/static/image/jplayer.blue.monday.video.play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/plugin/player/static/image/jplayer.blue.monday.video.play.png -------------------------------------------------------------------------------- /browsepy/plugin/player/static/js/base.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var 3 | jPlayerPlaylist = window.jPlayerPlaylist, 4 | $player = $('.jp-jplayer'), 5 | playlists = (window._player = window._player || {playlist: []}).playlist, 6 | options = { 7 | swfPath: $player.attr('data-player-swf'), 8 | wmode: 'window', 9 | useStateClassSkin: true, 10 | autoBlur: false, 11 | smoothPlayBar: true, 12 | keyEnabled: true, 13 | remainingDuration: true, 14 | toggleDuration: true, 15 | cssSelectorAncestor: '.jp-audio', 16 | playlistOptions: { 17 | autoPlay: true 18 | } 19 | }; 20 | if ($player.is('[data-player-urls]')) { 21 | var 22 | list = [], 23 | formats = [], 24 | urls = $player.attr('data-player-urls').split('|'), 25 | sel = { 26 | jPlayer: $player, 27 | cssSelectorAncestor: '.jp-audio' 28 | }; 29 | for (var i = 0, stack = [], d, o; (o = urls[i++]);) { 30 | stack.push(o); 31 | if (stack.length == 3) { 32 | d = { 33 | title: stack[1] 34 | }; 35 | d[stack[0]] = stack[2]; 36 | list.push(d); 37 | formats.push(stack[0]); 38 | stack.splice(0, stack.length); 39 | } 40 | } 41 | options.supplied = formats.join(', '); 42 | playlists.push(new jPlayerPlaylist(sel, list, options)); 43 | } else { 44 | var 45 | media = {}, 46 | format = $player.attr('data-player-format'); 47 | media.title = $player.attr('data-player-title'); 48 | media[format] = $player.attr('data-player-url'); 49 | options.supplied = format; 50 | options.ready = function() { 51 | $(this).jPlayer('setMedia', media).jPlayer('play'); 52 | }; 53 | $player.jPlayer(options); 54 | } 55 | }()); 56 | -------------------------------------------------------------------------------- /browsepy/plugin/player/static/js/jplayer.playlist.min.js: -------------------------------------------------------------------------------- 1 | /*! jPlayerPlaylist for jPlayer 2.9.2 ~ (c) 2009-2014 Happyworm Ltd ~ MIT License */ 2 | !function(a,b){jPlayerPlaylist=function(b,c,d){var e=this;this.current=0,this.loop=!1,this.shuffled=!1,this.removing=!1,this.cssSelector=a.extend({},this._cssSelector,b),this.options=a.extend(!0,{keyBindings:{next:{key:221,fn:function(){e.next()}},previous:{key:219,fn:function(){e.previous()}},shuffle:{key:83,fn:function(){e.shuffle()}}},stateClass:{shuffled:"jp-state-shuffled"}},this._options,d),this.playlist=[],this.original=[],this._initPlaylist(c),this.cssSelector.details=this.cssSelector.cssSelectorAncestor+" .jp-details",this.cssSelector.playlist=this.cssSelector.cssSelectorAncestor+" .jp-playlist",this.cssSelector.next=this.cssSelector.cssSelectorAncestor+" .jp-next",this.cssSelector.previous=this.cssSelector.cssSelectorAncestor+" .jp-previous",this.cssSelector.shuffle=this.cssSelector.cssSelectorAncestor+" .jp-shuffle",this.cssSelector.shuffleOff=this.cssSelector.cssSelectorAncestor+" .jp-shuffle-off",this.options.cssSelectorAncestor=this.cssSelector.cssSelectorAncestor,this.options.repeat=function(a){e.loop=a.jPlayer.options.loop},a(this.cssSelector.jPlayer).bind(a.jPlayer.event.ready,function(){e._init()}),a(this.cssSelector.jPlayer).bind(a.jPlayer.event.ended,function(){e.next()}),a(this.cssSelector.jPlayer).bind(a.jPlayer.event.play,function(){a(this).jPlayer("pauseOthers")}),a(this.cssSelector.jPlayer).bind(a.jPlayer.event.resize,function(b){b.jPlayer.options.fullScreen?a(e.cssSelector.details).show():a(e.cssSelector.details).hide()}),a(this.cssSelector.previous).click(function(a){a.preventDefault(),e.previous(),e.blur(this)}),a(this.cssSelector.next).click(function(a){a.preventDefault(),e.next(),e.blur(this)}),a(this.cssSelector.shuffle).click(function(b){b.preventDefault(),e.shuffle(e.shuffled&&a(e.cssSelector.jPlayer).jPlayer("option","useStateClassSkin")?!1:!0),e.blur(this)}),a(this.cssSelector.shuffleOff).click(function(a){a.preventDefault(),e.shuffle(!1),e.blur(this)}).hide(),this.options.fullScreen||a(this.cssSelector.details).hide(),a(this.cssSelector.playlist+" ul").empty(),this._createItemHandlers(),a(this.cssSelector.jPlayer).jPlayer(this.options)},jPlayerPlaylist.prototype={_cssSelector:{jPlayer:"#jquery_jplayer_1",cssSelectorAncestor:"#jp_container_1"},_options:{playlistOptions:{autoPlay:!1,loopOnPrevious:!1,shuffleOnLoop:!0,enableRemoveControls:!1,displayTime:"slow",addTime:"fast",removeTime:"fast",shuffleTime:"slow",itemClass:"jp-playlist-item",freeGroupClass:"jp-free-media",freeItemClass:"jp-playlist-item-free",removeItemClass:"jp-playlist-item-remove"}},option:function(a,c){if(c===b)return this.options.playlistOptions[a];switch(this.options.playlistOptions[a]=c,a){case"enableRemoveControls":this._updateControls();break;case"itemClass":case"freeGroupClass":case"freeItemClass":case"removeItemClass":this._refresh(!0),this._createItemHandlers()}return this},_init:function(){var a=this;this._refresh(function(){a.options.playlistOptions.autoPlay?a.play(a.current):a.select(a.current)})},_initPlaylist:function(b){this.current=0,this.shuffled=!1,this.removing=!1,this.original=a.extend(!0,[],b),this._originalPlaylist()},_originalPlaylist:function(){var b=this;this.playlist=[],a.each(this.original,function(a){b.playlist[a]=b.original[a]})},_refresh:function(b){var c=this;if(b&&!a.isFunction(b))a(this.cssSelector.playlist+" ul").empty(),a.each(this.playlist,function(b){a(c.cssSelector.playlist+" ul").append(c._createListItem(c.playlist[b]))}),this._updateControls();else{var d=a(this.cssSelector.playlist+" ul").children().length?this.options.playlistOptions.displayTime:0;a(this.cssSelector.playlist+" ul").slideUp(d,function(){var d=a(this);a(this).empty(),a.each(c.playlist,function(a){d.append(c._createListItem(c.playlist[a]))}),c._updateControls(),a.isFunction(b)&&b(),c.playlist.length?a(this).slideDown(c.options.playlistOptions.displayTime):a(this).show()})}},_createListItem:function(b){var c=this,d="
  • ";if(d+="×",b.free){var e=!0;d+="(",a.each(b,function(b,f){a.jPlayer.prototype.format[b]&&(e?e=!1:d+=" | ",d+=""+b+"")}),d+=")"}return d+=""+b.title+(b.artist?" ":"")+"",d+="
  • "},_createItemHandlers:function(){var b=this;a(this.cssSelector.playlist).off("click","a."+this.options.playlistOptions.itemClass).on("click","a."+this.options.playlistOptions.itemClass,function(c){c.preventDefault();var d=a(this).parent().parent().index();b.current!==d?b.play(d):a(b.cssSelector.jPlayer).jPlayer("play"),b.blur(this)}),a(this.cssSelector.playlist).off("click","a."+this.options.playlistOptions.freeItemClass).on("click","a."+this.options.playlistOptions.freeItemClass,function(c){c.preventDefault(),a(this).parent().parent().find("."+b.options.playlistOptions.itemClass).click(),b.blur(this)}),a(this.cssSelector.playlist).off("click","a."+this.options.playlistOptions.removeItemClass).on("click","a."+this.options.playlistOptions.removeItemClass,function(c){c.preventDefault();var d=a(this).parent().parent().index();b.remove(d),b.blur(this)})},_updateControls:function(){this.options.playlistOptions.enableRemoveControls?a(this.cssSelector.playlist+" ."+this.options.playlistOptions.removeItemClass).show():a(this.cssSelector.playlist+" ."+this.options.playlistOptions.removeItemClass).hide(),this.shuffled?a(this.cssSelector.jPlayer).jPlayer("addStateClass","shuffled"):a(this.cssSelector.jPlayer).jPlayer("removeStateClass","shuffled"),a(this.cssSelector.shuffle).length&&a(this.cssSelector.shuffleOff).length&&(this.shuffled?(a(this.cssSelector.shuffleOff).show(),a(this.cssSelector.shuffle).hide()):(a(this.cssSelector.shuffleOff).hide(),a(this.cssSelector.shuffle).show()))},_highlight:function(c){this.playlist.length&&c!==b&&(a(this.cssSelector.playlist+" .jp-playlist-current").removeClass("jp-playlist-current"),a(this.cssSelector.playlist+" li:nth-child("+(c+1)+")").addClass("jp-playlist-current").find(".jp-playlist-item").addClass("jp-playlist-current"))},setPlaylist:function(a){this._initPlaylist(a),this._init()},add:function(b,c){a(this.cssSelector.playlist+" ul").append(this._createListItem(b)).find("li:last-child").hide().slideDown(this.options.playlistOptions.addTime),this._updateControls(),this.original.push(b),this.playlist.push(b),c?this.play(this.playlist.length-1):1===this.original.length&&this.select(0)},remove:function(c){var d=this;return c===b?(this._initPlaylist([]),this._refresh(function(){a(d.cssSelector.jPlayer).jPlayer("clearMedia")}),!0):this.removing?!1:(c=0>c?d.original.length+c:c,c>=0&&cb?this.original.length+b:b,b>=0&&bc?this.original.length+c:c,c>=0&&c1?this.shuffle(!0,!0):this.play(a):a>0&&this.play(a)},previous:function(){var a=this.current-1>=0?this.current-1:this.playlist.length-1;(this.loop&&this.options.playlistOptions.loopOnPrevious||a 6 | 7 | {% endblock %} 8 | 9 | {% block content %} 10 | 28 | 80 | {% endblock %} 81 | 82 | {% block scripts %} 83 | {{ super() }} 84 | 85 | 86 | {% if playlist %} 87 | 88 | {% endif %} 89 | 90 | {% endblock %} 91 | -------------------------------------------------------------------------------- /browsepy/static/base.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icomoon'; 3 | src: url('fonts/icomoon.eot?c0qaf0'); 4 | src: url('fonts/icomoon.eot?c0qaf0#iefix') format('embedded-opentype'), url('fonts/icomoon.ttf?c0qaf0') format('truetype'), url('fonts/icomoon.woff?c0qaf0') format('woff'), url('fonts/icomoon.svg?c0qaf0#icomoon') format('svg'); 5 | font-weight: normal; 6 | font-style: normal; 7 | } 8 | 9 | .button:after, 10 | .icon:after, 11 | .sorting:after, 12 | .sorting:before { 13 | font-family: 'icomoon'; 14 | speak: none; 15 | font-style: normal; 16 | font-weight: normal; 17 | font-variant: normal; 18 | text-transform: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | html { 24 | min-height: 100%; 25 | position: relative; 26 | } 27 | 28 | body { 29 | font-family: sans; 30 | font-size: 0.75em; 31 | padding: 3.5em 1em 3.5em; 32 | margin: 0; 33 | min-height: 100%; 34 | } 35 | 36 | a, 37 | label, 38 | input[type=submit], 39 | input[type=file] { 40 | cursor: pointer; 41 | } 42 | 43 | a { 44 | color: #666; 45 | text-decoration: none; 46 | } 47 | 48 | a:hover { 49 | color: black; 50 | } 51 | 52 | a:active { 53 | color: #666; 54 | } 55 | 56 | ul.main-menu { 57 | padding: 0; 58 | margin: 0; 59 | list-style: none; 60 | display: block; 61 | } 62 | 63 | ul.main-menu > li { 64 | font-size: 1.25em; 65 | } 66 | 67 | form.upload { 68 | border: 1px solid #333; 69 | display: inline-block; 70 | margin: 0 0 1em; 71 | padding: 0; 72 | } 73 | 74 | form.upload:after { 75 | content: ''; 76 | clear: both; 77 | } 78 | 79 | form.upload h2 { 80 | display: inline-block; 81 | padding: 1em 0; 82 | margin: 0; 83 | } 84 | 85 | form.upload label { 86 | display: inline-block; 87 | background: #333; 88 | color: white; 89 | padding: 0 1.5em; 90 | } 91 | 92 | form.upload label input { 93 | margin-right: 0; 94 | } 95 | 96 | form.upload input { 97 | margin: 0.5em 1em; 98 | } 99 | 100 | form.remove { 101 | display: inline-block; 102 | } 103 | 104 | form.remove input[type=submit] { 105 | min-width: 10em; 106 | } 107 | 108 | html.autosubmit-support form.autosubmit{ 109 | border: 0; 110 | background: transparent; 111 | } 112 | 113 | html.autosubmit-support form.autosubmit input{ 114 | position: fixed; 115 | top: -500px; 116 | } 117 | 118 | html.autosubmit-support form.autosubmit h2{ 119 | font-size: 1em; 120 | font-weight: normal; 121 | padding: 0; 122 | } 123 | 124 | table.browser { 125 | display: block; 126 | table-layout: fixed; 127 | border-collapse: collapse; 128 | overflow-x: auto; 129 | } 130 | 131 | table.browser tr:nth-child(2n) { 132 | background-color: #efefef; 133 | } 134 | 135 | table.browser th, 136 | table.browser td { 137 | margin: 0; 138 | text-align: center; 139 | vertical-align: middle; 140 | white-space: nowrap; 141 | padding: 5px; 142 | } 143 | 144 | table.browser th { 145 | padding-bottom: 10px; 146 | border-bottom: 1px solid black; 147 | } 148 | 149 | table.browser td:first-child { 150 | min-width: 1em; 151 | } 152 | 153 | table.browser td:nth-child(2) { 154 | text-align: left; 155 | width: 100%; 156 | } 157 | 158 | table.browser td:nth-child(3) { 159 | text-align: left; 160 | } 161 | 162 | h1 { 163 | line-height: 1.15em; 164 | font-size: 3em; 165 | white-space: normal; 166 | word-wrap: normal; 167 | margin: 0; 168 | padding: 0 0 0.3em; 169 | display: block; 170 | color: black; 171 | 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; 172 | } 173 | 174 | ol.path, 175 | ol.path li { 176 | display: inline; 177 | margin: 0; 178 | padding: 0; 179 | } 180 | 181 | ol.path li *:before { 182 | content: ' '; 183 | } 184 | 185 | ol.path li *:after{ 186 | content: ' /'; 187 | } 188 | 189 | ol.path li:first-child *:before, 190 | ol.path li:last-child *:after, 191 | ol.path li *.root:after { 192 | content: ''; 193 | } 194 | 195 | ul.navbar, 196 | ul.footer { 197 | position: absolute; 198 | left: 0; 199 | right: 0; 200 | width: auto; 201 | margin: 0; 202 | padding: 0 1em; 203 | display: block; 204 | line-height: 2.5em; 205 | background: #333; 206 | color: white; 207 | } 208 | 209 | ul.navbar a, 210 | ul.footer a { 211 | color: white; 212 | font-weight: bold; 213 | } 214 | 215 | ul.navbar { 216 | top: 0; 217 | } 218 | 219 | ul.footer { 220 | bottom: 0; 221 | } 222 | 223 | ul.navbar a:hover { 224 | color: gray; 225 | } 226 | 227 | ul.navbar > li, 228 | ul.footer > li { 229 | list-style: none; 230 | display: inline; 231 | margin-right: 10px; 232 | width: 100%; 233 | } 234 | 235 | .inode.icon:after, 236 | .dir.icon:after{ 237 | content: "\e90a"; 238 | } 239 | 240 | .text.icon:after{ 241 | content: "\e901"; 242 | } 243 | 244 | .audio.icon:after{ 245 | content: "\e906"; 246 | } 247 | 248 | .video.icon:after{ 249 | content: "\e908"; 250 | } 251 | 252 | .image.icon:after{ 253 | content: "\e905"; 254 | } 255 | 256 | .multipart.icon:after, 257 | .model.icon:after, 258 | .message.icon:after, 259 | .example.icon:after, 260 | .application.icon:after{ 261 | content: "\e900"; 262 | } 263 | 264 | a.sorting { 265 | display: block; 266 | padding: 0 1.4em 0 0; 267 | } 268 | 269 | a.sorting:after, 270 | a.sorting:before { 271 | float: right; 272 | margin: 0.2em -1.3em -0.2em; 273 | } 274 | 275 | a.sorting:hover:after, 276 | a.sorting.active:after { 277 | content: "\ea4c"; 278 | } 279 | 280 | a.sorting.desc:hover:after, 281 | a.sorting.desc.active:after { 282 | content: "\ea4d"; 283 | } 284 | 285 | a.sorting.text:hover:after, 286 | a.sorting.text.active:after { 287 | content: "\ea48"; 288 | } 289 | 290 | a.sorting.text.desc:hover:after, 291 | a.sorting.text.desc.active:after { 292 | content: "\ea49"; 293 | } 294 | 295 | a.sorting.numeric:hover:after, 296 | a.sorting.numeric.active:after { 297 | content: "\ea4a"; 298 | } 299 | 300 | a.sorting.numeric.desc:hover:after, 301 | a.sorting.numeric.desc.active:after { 302 | content: "\ea4b"; 303 | } 304 | 305 | a.button, 306 | html.autosubmit-support form.autosubmit label { 307 | color: white; 308 | background: #333; 309 | display: inline-block; 310 | vertical-align: middle; 311 | line-height: 1.5em; 312 | text-align: center; 313 | border-radius: 0.25em; 314 | border: 1px solid gray; 315 | box-shadow: 1px 1px 0 black, -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black; 316 | 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; 317 | } 318 | 319 | a.button:active, 320 | html.autosubmit-support form.autosubmit label:active{ 321 | border: 1px solid black; 322 | } 323 | 324 | a.button:hover, 325 | html.autosubmit-support form.autosubmit label:hover { 326 | color: white; 327 | background: black; 328 | } 329 | 330 | a.button, 331 | html.autosubmit-support form.autosubmit{ 332 | margin-left: 3px; 333 | } 334 | 335 | a.button { 336 | width: 1.5em; 337 | height: 1.5em; 338 | } 339 | 340 | a.button.text{ 341 | width: auto; 342 | height: auto; 343 | } 344 | 345 | a.button.text, 346 | html.autosubmit-support form.autosubmit label{ 347 | padding: 0.25em 0.5em; 348 | } 349 | 350 | a.button.download:after { 351 | content: "\e904"; 352 | } 353 | 354 | a.button.remove:after { 355 | content: "\e902"; 356 | } 357 | 358 | strong[data-prefix] { 359 | display: inline-block; 360 | cursor: help; 361 | } 362 | 363 | strong[data-prefix]:after { 364 | display: inline-block; 365 | position: relative; 366 | top: -0.5em; 367 | margin: 0 0.25em; 368 | width: 1.2em; 369 | height: 1.2em; 370 | font-size: 0.75em; 371 | text-align: center; 372 | line-height: 1.2em; 373 | content: 'i'; 374 | border-radius: 0.5em; 375 | color: white; 376 | background-color: darkgray; 377 | } 378 | 379 | strong[data-prefix]:hover:after { 380 | display: none; 381 | } 382 | 383 | strong[data-prefix]:hover:before { 384 | content: attr(data-prefix); 385 | } 386 | -------------------------------------------------------------------------------- /browsepy/static/browse.directory.body.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | if (document.querySelectorAll) { 3 | var 4 | forms = document.querySelectorAll('html.autosubmit-support form.autosubmit'), 5 | i = forms.length; 6 | while (i--) { 7 | var files = forms[i].querySelectorAll('input[type=file]'); 8 | files[0].addEventListener('change', (function(form) { 9 | return function() { 10 | form.submit(); 11 | }; 12 | }(forms[i]))); 13 | } 14 | } 15 | }()); 16 | -------------------------------------------------------------------------------- /browsepy/static/browse.directory.head.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | if(document.documentElement && document.querySelectorAll && document.addEventListener){ 3 | document.documentElement.className += ' autosubmit-support'; 4 | } 5 | }()); 6 | -------------------------------------------------------------------------------- /browsepy/static/fonts/icomoon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/static/fonts/icomoon.eot -------------------------------------------------------------------------------- /browsepy/static/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/static/fonts/icomoon.ttf -------------------------------------------------------------------------------- /browsepy/static/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/static/fonts/icomoon.woff -------------------------------------------------------------------------------- /browsepy/static/giorgio.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/static/giorgio.jpg -------------------------------------------------------------------------------- /browsepy/stream.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import os.path 4 | import tarfile 5 | import functools 6 | import threading 7 | 8 | 9 | class TarFileStream(object): 10 | ''' 11 | Tarfile which compresses while reading for streaming. 12 | 13 | Buffsize can be provided, it must be 512 multiple (the tar block size) for 14 | compression. 15 | 16 | Note on corroutines: this class uses threading by default, but 17 | corroutine-based applications can change this behavior overriding the 18 | :attr:`event_class` and :attr:`thread_class` values. 19 | ''' 20 | event_class = threading.Event 21 | thread_class = threading.Thread 22 | tarfile_class = tarfile.open 23 | 24 | def __init__(self, path, buffsize=10240, exclude=None): 25 | ''' 26 | Internal tarfile object will be created, and compression will start 27 | on a thread until buffer became full with writes becoming locked until 28 | a read occurs. 29 | 30 | :param path: local path of directory whose content will be compressed. 31 | :type path: str 32 | :param buffsize: size of internal buffer on bytes, defaults to 10KiB 33 | :type buffsize: int 34 | :param exclude: path filter function, defaults to None 35 | :type exclude: callable 36 | ''' 37 | self.path = path 38 | self.name = os.path.basename(path) + ".tgz" 39 | self.exclude = exclude 40 | 41 | self._finished = 0 42 | self._want = 0 43 | self._data = bytes() 44 | self._add = self.event_class() 45 | self._result = self.event_class() 46 | self._tarfile = self.tarfile_class( # stream write 47 | fileobj=self, 48 | mode="w|gz", 49 | bufsize=buffsize 50 | ) 51 | self._th = self.thread_class(target=self.fill) 52 | self._th.start() 53 | 54 | def fill(self): 55 | ''' 56 | Writes data on internal tarfile instance, which writes to current 57 | object, using :meth:`write`. 58 | 59 | As this method is blocking, it is used inside a thread. 60 | 61 | This method is called automatically, on a thread, on initialization, 62 | so there is little need to call it manually. 63 | ''' 64 | if self.exclude: 65 | exclude = self.exclude 66 | ap = functools.partial(os.path.join, self.path) 67 | self._tarfile.add( 68 | self.path, "", 69 | filter=lambda info: None if exclude(ap(info.name)) else info 70 | ) 71 | else: 72 | self._tarfile.add(self.path, "") 73 | self._tarfile.close() # force stream flush 74 | self._finished += 1 75 | if not self._result.is_set(): 76 | self._result.set() 77 | 78 | def write(self, data): 79 | ''' 80 | Write method used by internal tarfile instance to output data. 81 | This method blocks tarfile execution once internal buffer is full. 82 | 83 | As this method is blocking, it is used inside the same thread of 84 | :meth:`fill`. 85 | 86 | :param data: bytes to write to internal buffer 87 | :type data: bytes 88 | :returns: number of bytes written 89 | :rtype: int 90 | ''' 91 | self._add.wait() 92 | self._data += data 93 | if len(self._data) > self._want: 94 | self._add.clear() 95 | self._result.set() 96 | return len(data) 97 | 98 | def read(self, want=0): 99 | ''' 100 | Read method, gets data from internal buffer while releasing 101 | :meth:`write` locks when needed. 102 | 103 | The lock usage means it must ran on a different thread than 104 | :meth:`fill`, ie. the main thread, otherwise will deadlock. 105 | 106 | The combination of both write and this method running on different 107 | threads makes tarfile being streamed on-the-fly, with data chunks being 108 | processed and retrieved on demand. 109 | 110 | :param want: number bytes to read, defaults to 0 (all available) 111 | :type want: int 112 | :returns: tarfile data as bytes 113 | :rtype: bytes 114 | ''' 115 | if self._finished: 116 | if self._finished == 1: 117 | self._finished += 1 118 | return "" 119 | return EOFError("EOF reached") 120 | 121 | # Thread communication 122 | self._want = want 123 | self._add.set() 124 | self._result.wait() 125 | self._result.clear() 126 | 127 | if want: 128 | data = self._data[:want] 129 | self._data = self._data[want:] 130 | else: 131 | data = self._data 132 | self._data = bytes() 133 | return data 134 | 135 | def __iter__(self): 136 | ''' 137 | Iterate through tarfile result chunks. 138 | 139 | Similarly to :meth:`read`, this methos must ran on a different thread 140 | than :meth:`write` calls. 141 | 142 | :yields: data chunks as taken from :meth:`read`. 143 | :ytype: bytes 144 | ''' 145 | data = self.read() 146 | while data: 147 | yield data 148 | data = self.read() 149 | -------------------------------------------------------------------------------- /browsepy/templates/400.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% set description %} 4 |

    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).

    5 |

    Please try other parameters or contact server administrator.

    6 | {% endset %} 7 | {% if error and error.code %} 8 | {% if error.code == 'invalid-filename-length' or error.code == 'invalid-path-length'%} 9 | {% set workaround %} 10 |

    Please try again with a shorter filename, other location, or contact server administrator.

    11 | {% endset %} 12 | {% if error.code == 'invalid-filename-length' %} 13 | {% set description -%} 14 |

    Filename is too long.

    15 |

    Upload filename is too long for target directory.

    16 | {%- if error.limit -%} 17 |

    {% if error.filename %}Filename length is {{ error.filename | length }}. {% endif %}Maximum allowed filename size in target directory is {{ error.limit }}.

    18 | {%- endif -%} 19 | {{ workaround }} 20 | {%- endset %} 21 | {% else %} 22 | {% set description %} 23 |

    Path is too long.

    24 |

    Resulting path is too long for target filesystem.

    25 | {%- if error.limit -%} 26 |

    {% if error.path %}Path length is {{ error.path | length }}. {% endif %}Maximum allowed path size in target filesystem is {{ error.limit }}.

    27 | {%- endif -%} 28 | {{ workaround }} 29 | {% endset %} 30 | {% endif %} 31 | {% elif error.code.startswith('invalid-filename') %} 32 | {% set description %} 33 |

    Filename is not valid.

    34 |

    Upload filename is not valid: incompatible name encoding or reserved name on filesystem.

    35 |

    Please try again with other filename or contact server administrator.

    36 | {%- endset %} 37 | {% endif %} 38 | {% endif %} 39 | 40 | {% block title %}400 Bad Request{% endblock %} 41 | {% block content %} 42 |

    Bad Request

    43 | {{ description }} 44 | {% if file %} 45 |
    46 | 47 |
    48 | {% endif %} 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /browsepy/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Upload failed{% endblock %} 4 | {% block content %} 5 |

    Not Found

    6 | 7 |

    8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /browsepy/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}{{ config.get("title", "BrowsePy") }}{% endblock %} 7 | {% block styles %} 8 | 9 | {% endblock %} 10 | {% block head %}{% endblock %} 11 | 12 | 13 | {% block header %}{% endblock %} 14 | {% block content %}{% endblock %} 15 | {% block footer %}{% endblock %} 16 | {% block scripts %}{% endblock %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /browsepy/templates/browse.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% macro draw_widget(f, widget) -%} 4 | {%- if widget.type == 'button' -%} 5 | {{ widget.text or '' }} 12 | {%- elif widget.type == 'link' -%} 13 | {{ widget.text or '' }} 16 | {%- elif widget.type == 'script' -%} 17 | 20 | {%- elif widget.type == 'stylesheet' -%} 21 | 25 | {%- elif widget.type == 'upload' -%} 26 |
    30 | 34 | 35 |
    36 | {%- elif widget.type == 'html' -%} 37 | {{ widget.html|safe }} 38 | {%- endif -%} 39 | {%- endmacro %} 40 | 41 | {% macro draw_widgets(f, place) -%} 42 | {%- for widget in f.widgets -%} 43 | {%- if widget.place == place -%} 44 | {{ draw_widget(f, widget) }} 45 | {%- endif -%} 46 | {%- endfor -%} 47 | {%- endmacro %} 48 | 49 | {% macro th(text, property, type='text', colspan=1) -%} 50 | 1 %} colspan="{{ colspan }}"{% endif %}> 51 | {% set urlpath = file.urlpath or None %} 52 | {% set property_desc = '-{}'.format(property) %} 53 | {% set prop = property_desc if sort_property == property else property %} 54 | {% set active = ' active' if sort_property in (property, property_desc) else '' %} 55 | {% set desc = ' desc' if sort_property == property_desc else '' %} 56 | {{ text }} 59 | 60 | {%- endmacro %} 61 | 62 | {% block styles %} 63 | {{ super() }} 64 | {{ draw_widgets(file, 'styles') }} 65 | {% endblock %} 66 | 67 | {% block head %} 68 | {{ super() }} 69 | {{ draw_widgets(file, 'head') }} 70 | {% endblock %} 71 | 72 | {% block scripts %} 73 | {{ super() }} 74 | {{ draw_widgets(file, 'scripts') }} 75 | {% endblock %} 76 | 77 | {% block header %} 78 |

    79 |
      80 | {% for parent in file.ancestors[::-1] %} 81 |
    1. 82 | {{ parent.name }} 85 |
    2. 86 | {% endfor %} 87 | {% if file.name %} 88 |
    3. {{ file.name }}
    4. 89 | {% endif %} 90 |
    91 |

    92 | {% endblock %} 93 | 94 | {% block content %} 95 | {% block content_header %} 96 | {{ draw_widgets(file, 'header') }} 97 | {% endblock %} 98 | 99 | {% block content_table %} 100 | {% if file.is_empty %} 101 |

    No files in directory

    102 | {% else %} 103 | 104 | 105 | 106 | {{ th('Name', 'text', 'text', 3) }} 107 | {{ th('Mimetype', 'type') }} 108 | {{ th('Modified', 'modified', 'numeric') }} 109 | {{ th('Size', 'size', 'numeric') }} 110 | 111 | 112 | 113 | {% for f in file.listdir(sortkey=sort_fnc, reverse=sort_reverse) %} 114 | 115 | {% if f.link %} 116 | 117 | 118 | {% else %} 119 | 120 | 121 | {% endif %} 122 | 123 | 124 | 125 | 126 | 127 | {% endfor %} 128 | 129 |
    {{ draw_widget(f, f.link) }}{{ draw_widgets(f, 'entry-actions') }}{{ f.type or '' }}{{ f.modified or '' }}{{ f.size or '' }}
    130 | {% endif %} 131 | {% endblock %} 132 | 133 | {% block content_footer %} 134 | {{ draw_widgets(file, 'footer') }} 135 | {% endblock %} 136 | {% endblock %} 137 | -------------------------------------------------------------------------------- /browsepy/templates/remove.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

    Remove

    5 |

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

    8 |
    9 | 10 |
    11 |
    12 | 13 |
    14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /browsepy/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/tests/__init__.py -------------------------------------------------------------------------------- /browsepy/tests/deprecated/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/tests/deprecated/__init__.py -------------------------------------------------------------------------------- /browsepy/tests/deprecated/plugin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/browsepy/tests/deprecated/plugin/__init__.py -------------------------------------------------------------------------------- /browsepy/tests/deprecated/plugin/player.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | 4 | import os.path 5 | 6 | from flask import Blueprint, render_template 7 | from browsepy.file import File 8 | 9 | 10 | mimetypes = { 11 | 'mp3': 'audio/mpeg', 12 | 'ogg': 'audio/ogg', 13 | 'wav': 'audio/wav' 14 | } 15 | 16 | __basedir__ = os.path.dirname(os.path.abspath(__file__)) 17 | 18 | player = Blueprint( 19 | 'deprecated_player', __name__, 20 | url_prefix='/play', 21 | template_folder=os.path.join(__basedir__, 'templates'), 22 | static_folder=os.path.join(__basedir__, 'static'), 23 | ) 24 | 25 | 26 | class PlayableFile(File): 27 | parent_class = File 28 | media_map = { 29 | 'audio/mpeg': 'mp3', 30 | 'audio/ogg': 'ogg', 31 | 'audio/wav': 'wav', 32 | } 33 | 34 | def __init__(self, duration=None, title=None, **kwargs): 35 | self.duration = duration 36 | self.title = title 37 | super(PlayableFile, self).__init__(**kwargs) 38 | 39 | @property 40 | def title(self): 41 | return self._title or self.name 42 | 43 | @title.setter 44 | def title(self, title): 45 | self._title = title 46 | 47 | @property 48 | def media_format(self): 49 | return self.media_map[self.type] 50 | 51 | 52 | @player.route('/audio/') 53 | def audio(path): 54 | f = PlayableFile.from_urlpath(path) 55 | return render_template('audio.player.html', file=f) 56 | 57 | 58 | def detect_playable_mimetype(path, os_sep=os.sep): 59 | basename = path.rsplit(os_sep)[-1] 60 | if '.' in basename: 61 | ext = basename.rsplit('.')[-1] 62 | return mimetypes.get(ext, None) 63 | return None 64 | 65 | 66 | def register_plugin(manager): 67 | ''' 68 | Register blueprints and actions using given plugin manager. 69 | 70 | :param manager: plugin manager 71 | :type manager: browsepy.manager.PluginManager 72 | ''' 73 | manager.register_blueprint(player) 74 | manager.register_mimetype_function(detect_playable_mimetype) 75 | 76 | style = manager.style_class( 77 | 'deprecated_player.static', 78 | filename='css/browse.css' 79 | ) 80 | manager.register_widget(style) 81 | 82 | button_widget = manager.button_class(css='play') 83 | link_widget = manager.link_class() 84 | for widget in (link_widget, button_widget): 85 | manager.register_action( 86 | 'deprecated_player.audio', 87 | widget, 88 | mimetypes=( 89 | 'audio/mpeg', 90 | 'audio/ogg', 91 | 'audio/wav', 92 | )) 93 | -------------------------------------------------------------------------------- /browsepy/tests/runner.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import unittest 4 | 5 | 6 | class DebuggerTextTestResult(unittest._TextTestResult): # pragma: no cover 7 | def __init__(self, stream, descriptions, verbosity, debugger): 8 | self.debugger = debugger 9 | self.shouldStop = True 10 | supa = super(DebuggerTextTestResult, self) 11 | supa.__init__(stream, descriptions, verbosity) 12 | 13 | def addError(self, test, exc_info): 14 | self.debugger(exc_info) 15 | super(DebuggerTextTestResult, self).addError(test, exc_info) 16 | 17 | def addFailure(self, test, exc_info): 18 | self.debugger(exc_info) 19 | super(DebuggerTextTestResult, self).addFailure(test, exc_info) 20 | 21 | 22 | class DebuggerTextTestRunner(unittest.TextTestRunner): # pragma: no cover 23 | debugger = os.environ.get('UNITTEST_DEBUG', 'none') 24 | test_result_class = DebuggerTextTestResult 25 | 26 | def __init__(self, *args, **kwargs): 27 | kwargs.setdefault('verbosity', 2) 28 | super(DebuggerTextTestRunner, self).__init__(*args, **kwargs) 29 | 30 | @staticmethod 31 | def debug_none(exc_info): 32 | pass 33 | 34 | @staticmethod 35 | def debug_pdb(exc_info): 36 | import pdb 37 | pdb.post_mortem(exc_info[2]) 38 | 39 | @staticmethod 40 | def debug_ipdb(exc_info): 41 | import ipdb 42 | ipdb.post_mortem(exc_info[2]) 43 | 44 | @staticmethod 45 | def debug_pudb(exc_info): 46 | import pudb 47 | pudb.post_mortem(exc_info[2], exc_info[1], exc_info[0]) 48 | 49 | def _makeResult(self): 50 | return self.test_result_class( 51 | self.stream, self.descriptions, self.verbosity, 52 | getattr(self, 'debug_%s' % self.debugger, self.debug_none) 53 | ) 54 | -------------------------------------------------------------------------------- /browsepy/tests/test_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import unittest 4 | import tempfile 5 | 6 | import browsepy 7 | import browsepy.appconfig 8 | 9 | 10 | class TestApp(unittest.TestCase): 11 | module = browsepy 12 | app = browsepy.app 13 | 14 | def test_config(self): 15 | try: 16 | with tempfile.NamedTemporaryFile(delete=False) as f: 17 | f.write(b'DIRECTORY_DOWNLOADABLE = False\n') 18 | name = f.name 19 | os.environ['BROWSEPY_TEST_SETTINGS'] = name 20 | self.app.config['directory_downloadable'] = True 21 | self.app.config.from_envvar('BROWSEPY_TEST_SETTINGS') 22 | self.assertFalse(self.app.config['directory_downloadable']) 23 | finally: 24 | os.remove(name) 25 | 26 | 27 | class TestConfig(unittest.TestCase): 28 | pwd = os.path.dirname(os.path.abspath(__file__)) 29 | module = browsepy.appconfig 30 | 31 | def test_case_insensitivity(self): 32 | cfg = self.module.Config(self.pwd, defaults={'prop': 2}) 33 | self.assertEqual(cfg['prop'], cfg['PROP']) 34 | self.assertEqual(cfg['pRoP'], cfg.pop('prop')) 35 | cfg.update(prop=1) 36 | self.assertEqual(cfg['PROP'], 1) 37 | self.assertEqual(cfg.get('pRop'), 1) 38 | self.assertEqual(cfg.popitem(), ('PROP', 1)) 39 | self.assertRaises(KeyError, cfg.pop, 'prop') 40 | cfg.update(prop=1) 41 | del cfg['PrOp'] 42 | self.assertRaises(KeyError, cfg.__delitem__, 'prop') 43 | self.assertIsNone(cfg.pop('prop', None)) 44 | self.assertIsNone(cfg.get('prop')) 45 | -------------------------------------------------------------------------------- /browsepy/tests/test_compat.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import re 3 | 4 | from werkzeug.utils import cached_property 5 | 6 | import browsepy.compat 7 | 8 | 9 | class TestCompat(unittest.TestCase): 10 | module = browsepy.compat 11 | 12 | def _warn(self, message, category=None, stacklevel=None): 13 | if not hasattr(self, '_warnings'): 14 | self._warnings = [] 15 | self._warnings.append({ 16 | 'message': message, 17 | 'category': category, 18 | 'stacklevel': stacklevel 19 | }) 20 | 21 | @cached_property 22 | def assertWarnsRegex(self): 23 | supa = super(TestCompat, self) 24 | if hasattr(supa, 'assertWarnsRegex'): 25 | return supa.assertWarnsRegex 26 | return self.customAssertWarnsRegex 27 | 28 | def customAssertWarnsRegex(self, expected_warning, expected_regex, fnc, 29 | *args, **kwargs): 30 | 31 | import warnings 32 | old_warn = warnings.warn 33 | warnings.warn = self._warn 34 | try: 35 | fnc(*args, **kwargs) 36 | finally: 37 | warnings.warn = old_warn 38 | warnings = () 39 | if hasattr(self, '_warnings'): 40 | warnings = self._warnings 41 | del self._warnings 42 | regex = re.compile(expected_regex) 43 | self.assertTrue(any( 44 | warn['category'] == expected_warning and 45 | regex.match(warn['message']) 46 | for warn in warnings 47 | )) 48 | 49 | def test_which(self): 50 | self.assertTrue(self.module.which('python')) 51 | self.assertIsNone(self.module.which('lets-put-a-wrong-executable')) 52 | 53 | def test_fsdecode(self): 54 | path = b'/a/\xc3\xb1' 55 | self.assertEqual( 56 | self.module.fsdecode(path, os_name='posix', fs_encoding='utf-8'), 57 | path.decode('utf-8') 58 | ) 59 | path = b'/a/\xf1' 60 | self.assertEqual( 61 | self.module.fsdecode(path, os_name='nt', fs_encoding='latin-1'), 62 | path.decode('latin-1') 63 | ) 64 | path = b'/a/\xf1' 65 | self.assertRaises( 66 | UnicodeDecodeError, 67 | self.module.fsdecode, 68 | path, 69 | fs_encoding='utf-8', 70 | errors='strict' 71 | ) 72 | 73 | def test_fsencode(self): 74 | path = b'/a/\xc3\xb1' 75 | self.assertEqual( 76 | self.module.fsencode( 77 | path.decode('utf-8'), 78 | fs_encoding='utf-8' 79 | ), 80 | path 81 | ) 82 | path = b'/a/\xf1' 83 | self.assertEqual( 84 | self.module.fsencode( 85 | path.decode('latin-1'), 86 | fs_encoding='latin-1' 87 | ), 88 | path 89 | ) 90 | path = b'/a/\xf1' 91 | self.assertEqual( 92 | self.module.fsencode(path, fs_encoding='utf-8'), 93 | path 94 | ) 95 | 96 | def test_pathconf(self): 97 | kwargs = { 98 | 'os_name': 'posix', 99 | 'pathconf_fnc': lambda x, k: 500, 100 | 'pathconf_names': ('PC_PATH_MAX', 'PC_NAME_MAX') 101 | } 102 | pcfg = self.module.pathconf('/', **kwargs) 103 | self.assertEqual(pcfg['PC_PATH_MAX'], 500) 104 | self.assertEqual(pcfg['PC_NAME_MAX'], 500) 105 | kwargs.update( 106 | pathconf_fnc=None, 107 | ) 108 | pcfg = self.module.pathconf('/', **kwargs) 109 | self.assertEqual(pcfg['PC_PATH_MAX'], 255) 110 | self.assertEqual(pcfg['PC_NAME_MAX'], 254) 111 | kwargs.update( 112 | os_name='nt', 113 | isdir_fnc=lambda x: False 114 | ) 115 | pcfg = self.module.pathconf('c:\\a', **kwargs) 116 | self.assertEqual(pcfg['PC_PATH_MAX'], 259) 117 | self.assertEqual(pcfg['PC_NAME_MAX'], 255) 118 | kwargs.update( 119 | isdir_fnc=lambda x: True 120 | ) 121 | pcfg = self.module.pathconf('c:\\a', **kwargs) 122 | self.assertEqual(pcfg['PC_PATH_MAX'], 246) 123 | self.assertEqual(pcfg['PC_NAME_MAX'], 242) 124 | 125 | def test_getcwd(self): 126 | self.assertIsInstance(self.module.getcwd(), self.module.unicode) 127 | self.assertIsInstance( 128 | self.module.getcwd( 129 | fs_encoding='latin-1', 130 | cwd_fnc=lambda: b'\xf1' 131 | ), 132 | self.module.unicode 133 | ) 134 | self.assertIsInstance( 135 | self.module.getcwd( 136 | fs_encoding='utf-8', 137 | cwd_fnc=lambda: b'\xc3\xb1' 138 | ), 139 | self.module.unicode 140 | ) 141 | 142 | def test_path(self): 143 | parse = self.module.pathparse 144 | self.assertListEqual( 145 | list(parse('"/":/escaped\\:path:asdf/', sep=':', os_sep='/')), 146 | ['/', '/escaped:path', 'asdf'] 147 | ) 148 | 149 | def test_getdebug(self): 150 | enabled = ('TRUE', 'true', 'True', '1', 'yes', 'enabled') 151 | for case in enabled: 152 | self.assertTrue(self.module.getdebug({'DEBUG': case})) 153 | disabled = ('FALSE', 'false', 'False', '', '0', 'no', 'disabled') 154 | for case in disabled: 155 | self.assertFalse(self.module.getdebug({'DEBUG': case})) 156 | 157 | def test_deprecated(self): 158 | environ = {'DEBUG': 'true'} 159 | self.assertWarnsRegex( 160 | DeprecationWarning, 161 | 'DEPRECATED', 162 | self.module.deprecated('DEPRECATED', environ)(lambda: None) 163 | ) 164 | -------------------------------------------------------------------------------- /browsepy/tests/test_extensions.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | import jinja2 4 | 5 | import browsepy.transform.htmlcompress 6 | 7 | 8 | class TestHTMLCompress(unittest.TestCase): 9 | extension = browsepy.transform.htmlcompress.HTMLCompress 10 | 11 | def setUp(self): 12 | self.env = jinja2.Environment( 13 | autoescape=True, 14 | extensions=[self.extension] 15 | ) 16 | 17 | def render(self, html, **kwargs): 18 | return self.env.from_string(html).render(**kwargs) 19 | 20 | def test_compress(self): 21 | html = self.render(''' 22 | 23 | 24 | {{ title }} 25 | 26 | 29 | a b 30 | {% if a %}b{% endif %} 31 | 32 | 33 | ''', title=42, href='index.html', css='t', a=True) 34 | self.assertEqual( 35 | html, 36 | '42' 37 | 'a bb' 38 | '' 39 | ) 40 | 41 | def test_ignored_content(self): 42 | html = self.render( 43 | '\n asdf \n

    ' 44 | ) 45 | self.assertEqual( 46 | html, 47 | '

    ' 48 | ) 49 | 50 | def test_cdata(self): 51 | html = self.render( 52 | '
    \n]]>\n

    \n' 53 | ) 54 | self.assertEqual( 55 | html, 56 | '
    \n]]>

    ' 57 | ) 58 | 59 | def test_broken(self): 60 | html = self.render('', 67 | 'style': '', 68 | } 69 | 70 | 71 | class HTMLCompress(jinja2.ext.Extension): 72 | context_class = HTMLCompressContext 73 | token_class = jinja2.lexer.Token 74 | block_tokens = { 75 | 'variable_begin': 'variable_end', 76 | 'block_begin': 'block_end' 77 | } 78 | 79 | def filter_stream(self, stream): 80 | transform = self.context_class() 81 | lineno = 0 82 | skip_until_token = None 83 | for token in stream: 84 | if skip_until_token: 85 | yield token 86 | if token.type == skip_until_token: 87 | skip_until_token = None 88 | continue 89 | 90 | if token.type != 'data': 91 | for data in transform.finish(): 92 | yield self.token_class(lineno, 'data', data) 93 | yield token 94 | skip_until_token = self.block_tokens.get(token.type) 95 | continue 96 | 97 | if not transform.pending: 98 | lineno = token.lineno 99 | 100 | for data in transform.feed(token.value): 101 | yield self.token_class(lineno, 'data', data) 102 | lineno = token.lineno 103 | 104 | for data in transform.finish(): 105 | yield self.token_class(lineno, 'data', data) 106 | -------------------------------------------------------------------------------- /browsepy/widget.py: -------------------------------------------------------------------------------- 1 | ''' 2 | WARNING: deprecated module. 3 | 4 | API defined in this module has been deprecated in version 0.5 will likely be 5 | removed at 0.6. 6 | ''' 7 | import warnings 8 | 9 | from markupsafe import Markup 10 | from flask import url_for 11 | 12 | from .compat import deprecated 13 | 14 | 15 | warnings.warn('Deprecated module widget', category=DeprecationWarning) 16 | 17 | 18 | class WidgetBase(object): 19 | _type = 'base' 20 | place = None 21 | 22 | @deprecated('Deprecated widget API') 23 | def __new__(cls, *args, **kwargs): 24 | return super(WidgetBase, cls).__new__(cls) 25 | 26 | def __init__(self, *args, **kwargs): 27 | self.args = args 28 | self.kwargs = kwargs 29 | 30 | def for_file(self, file): 31 | return self 32 | 33 | @classmethod 34 | def from_file(cls, file): 35 | if not hasattr(cls, '__empty__'): 36 | cls.__empty__ = cls() 37 | return cls.__empty__.for_file(file) 38 | 39 | 40 | class LinkWidget(WidgetBase): 41 | _type = 'link' 42 | place = 'link' 43 | 44 | def __init__(self, text=None, css=None, icon=None): 45 | self.text = text 46 | self.css = css 47 | self.icon = icon 48 | super(LinkWidget, self).__init__() 49 | 50 | def for_file(self, file): 51 | if None in (self.text, self.icon): 52 | return self.__class__( 53 | file.name if self.text is None else self.text, 54 | self.css, 55 | self.icon if self.icon is not None else 56 | 'dir-icon' if file.is_directory else 57 | 'file-icon', 58 | ) 59 | return self 60 | 61 | 62 | class ButtonWidget(WidgetBase): 63 | _type = 'button' 64 | place = 'button' 65 | 66 | def __init__(self, html='', text='', css=''): 67 | self.content = Markup(html) if html else text 68 | self.css = css 69 | super(ButtonWidget, self).__init__() 70 | 71 | 72 | class StyleWidget(WidgetBase): 73 | _type = 'stylesheet' 74 | place = 'style' 75 | 76 | @property 77 | def href(self): 78 | return url_for(*self.args, **self.kwargs) 79 | 80 | 81 | class JavascriptWidget(WidgetBase): 82 | _type = 'script' 83 | place = 'javascript' 84 | 85 | @property 86 | def src(self): 87 | return url_for(*self.args, **self.kwargs) 88 | -------------------------------------------------------------------------------- /doc/.static/logo.css: -------------------------------------------------------------------------------- 1 | div.sphinxsidebar h3.logo{ 2 | line-height: 1.15em; 3 | font-family: sans; 4 | font-size: 2em; 5 | margin: 0; 6 | padding: 0 0 0.3em; 7 | display: block; 8 | color: black; 9 | 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; 10 | } 11 | div.sphinxsidebar h3.logo a{ 12 | color: black; 13 | } 14 | -------------------------------------------------------------------------------- /doc/.templates/layout.html: -------------------------------------------------------------------------------- 1 | {# layout.html #} 2 | {# Import the theme's layout. #} 3 | {% extends "!layout.html" %} 4 | 5 | {% set css_files = css_files + ['_static/logo.css'] %} 6 | -------------------------------------------------------------------------------- /doc/.templates/sidebar.html: -------------------------------------------------------------------------------- 1 |
    2 |

    The simple web file browser.

    3 |

    Useful Links

    4 | 15 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = .build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/browsepy.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/browsepy.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/browsepy" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/browsepy" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /doc/builtin_plugins.rst: -------------------------------------------------------------------------------- 1 | .. _builtin-plugins: 2 | 3 | Bultin Plugins 4 | ============== 5 | 6 | A player plugin is provided by default, and more are planned. 7 | 8 | Builtin-plugins serve as developers reference, while also being useful for 9 | unit-testing. 10 | 11 | .. _builtin-plugins-player: 12 | 13 | Player 14 | ------ 15 | 16 | Player plugin provides their own endpoints, widgets and extends both 17 | :class:`browsepy.file.File` and :class:`browsepy.file.Directory` so playlists 18 | and directories could be handled. 19 | 20 | At the client-side, a slighty tweaked `jPlayer `_ 21 | implementation is used. 22 | 23 | Sources are available at browsepy's `plugin.player`_ submodule. 24 | 25 | .. _plugin.player: https://github.com/ergoithz/browsepy/tree/master/browsepy/plugin/player 26 | 27 | .. _builtin-plugins-contributing: 28 | 29 | Contributing Builtin Plugins 30 | ---------------------------- 31 | 32 | Browsepy's team is open to contributions of any kind, even about adding 33 | built-in plugins, as long as they comply with the following requirements: 34 | 35 | * Plugins must be sufficiently covered by tests to avoid lowering browsepy's 36 | overall test coverage. 37 | * Plugins must not add external requirements to browsepy, optional 38 | requirements are allowed if plugin can work without them, even with 39 | limited functionality. 40 | * Plugins should avoid adding specific logic on browsepy itself, but extending 41 | browsepy's itself (specially via plugin interface) in a generic and useful 42 | way is definitely welcome. 43 | 44 | Said that, feel free to fork, code great stuff and fill pull requests at 45 | `GitHub `_. 46 | -------------------------------------------------------------------------------- /doc/compat.rst: -------------------------------------------------------------------------------- 1 | .. _compat: 2 | 3 | Compat Module 4 | ============= 5 | 6 | .. currentmodule:: browsepy.compat 7 | 8 | .. automodule:: browsepy.compat 9 | :show-inheritance: 10 | :members: 11 | :inherited-members: 12 | :undoc-members: 13 | :exclude-members: which, getdebug, deprecated, fsencode, fsdecode, getcwd, 14 | FS_ENCODING, PY_LEGACY, ENV_PATH, TRUE_VALUES 15 | 16 | .. attribute:: FS_ENCODING 17 | :annotation: = sys.getfilesystemencoding() 18 | 19 | Detected filesystem encoding: ie. `utf-8`. 20 | 21 | .. attribute:: PY_LEGACY 22 | :annotation: = sys.version_info < (3, ) 23 | 24 | True on Python 2, False on newer. 25 | 26 | .. attribute:: ENV_PATH 27 | :annotation: = ('/usr/local/bin', '/usr/bin', ... ) 28 | 29 | .. attribute:: ENV_PATHEXT 30 | :annotation: = ('.exe', '.bat', ... ) if os.name == 'nt' else ('',) 31 | 32 | List of paths where commands are located, taken and processed from 33 | :envvar:`PATH` environment variable. Used by :func:`which`. 34 | 35 | .. attribute:: TRUE_VALUES 36 | :annotation: = frozenset({'true', 'yes', '1', 'enable', 'enabled', True, 1}) 37 | 38 | Values which should be equivalent to True, used by :func:`getdebug` 39 | 40 | .. attribute:: FileNotFoundError 41 | :annotation: = OSError if PY_LEGACY else FileNotFoundError 42 | 43 | Convenience python exception type reference. 44 | 45 | .. attribute:: range 46 | :annotation: = xrange if PY_LEGACY else range 47 | 48 | Convenience python builtin function reference. 49 | 50 | .. attribute:: filter 51 | :annotation: = itertools.ifilter if PY_LEGACY else filter 52 | 53 | Convenience python builtin function reference. 54 | 55 | .. attribute:: basestring 56 | :annotation: = basestring if PY_LEGACY else str 57 | 58 | Convenience python type reference. 59 | 60 | .. attribute:: unicode 61 | :annotation: = unicode if PY_LEGACY else str 62 | 63 | Convenience python type reference. 64 | 65 | .. attribute:: scandir 66 | :annotation: = scandir.scandir or os.walk 67 | 68 | New scandir, either from scandir module or Python3.6+ os module. 69 | 70 | .. attribute:: walk 71 | :annotation: = scandir.walk or os.walk 72 | 73 | New walk, either from scandir module or Python3.6+ os module. 74 | 75 | .. autofunction:: pathconf(path) 76 | 77 | .. autofunction:: isexec(path) 78 | 79 | .. autofunction:: which(name, env_path=ENV_PATH, is_executable_fnc=isexec, path_join_fnc=os.path.join) 80 | 81 | .. autofunction:: getdebug(environ=os.environ, true_values=TRUE_VALUES) 82 | 83 | .. autofunction:: deprecated(func_or_text, environ=os.environ) 84 | 85 | .. autofunction:: usedoc(other) 86 | 87 | .. autofunction:: fsdecode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None) 88 | 89 | .. autofunction:: fsencode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None) 90 | 91 | .. autofunction:: getcwd(fs_encoding=FS_ENCODING, cwd_fnc=os.getcwd) 92 | 93 | .. autofunction:: re_escape(pattern, chars="()[]{}?*+|^$\\.-#") 94 | 95 | .. autofunction:: pathsplit(value, sep=os.pathsep) 96 | 97 | .. autofunction:: pathparse(value, sep=os.pathsep, os_sep=os.sep) 98 | -------------------------------------------------------------------------------- /doc/exceptions.rst: -------------------------------------------------------------------------------- 1 | .. _exceptions: 2 | 3 | Exceptions Module 4 | ================= 5 | 6 | Exceptions raised by browsepy classes. 7 | 8 | .. automodule:: browsepy.exceptions 9 | :show-inheritance: 10 | :members: 11 | :inherited-members: 12 | -------------------------------------------------------------------------------- /doc/exclude.rst: -------------------------------------------------------------------------------- 1 | .. _excluding-paths: 2 | 3 | Excluding paths 4 | =============== 5 | 6 | Starting from version **0.5.3**, browsepy accepts **--exclude** command line 7 | arguments expecting linux filename expansion strings, also known as globs. 8 | 9 | **Note (windows):** on nt platforms, the accepted glob syntax will be the same 10 | (``/`` for filepath separator and ``\`` used for character escapes), 11 | browsepy will transform them appropriately. 12 | 13 | They allow matching filemames using wildcards, being the most common `*` 14 | (matching any string, even empty) and `?` (matching a single character). See 15 | :ref:`glob-manpage` for further info. 16 | 17 | Please note that both collating symbols (like ``[.a-acute.]``) and 18 | equivalence class expressions (like ``[=a=]``) are currently unsupported. 19 | 20 | Excluded paths will be omitted from both directory listing and directory 21 | tarball downloads. 22 | 23 | As seen at :ref:`quickstart-usage`, the exclude parameter can be provided 24 | as follows: 25 | 26 | .. code-block:: bash 27 | 28 | browsepy --exclude=.* 29 | 30 | The above example will exclude all files prefixed with ``.``, which are 31 | considered hidden on POSIX systems. In other words, it will match ``.myfile`` 32 | and not ``my.file``. 33 | 34 | You can, alternatively, restrict the above exclusion to only top-level filenames: 35 | 36 | .. code-block:: bash 37 | 38 | browsepy --exclude=/.* 39 | 40 | 41 | The following example will hide all files ending with ``.ini``, but only on the 42 | base directory. 43 | 44 | .. code-block:: bash 45 | 46 | browsepy --exclude=/*.ini 47 | 48 | You will find this syntax very similar to definitions found in **.gitignore**, 49 | **.dockerignore** and others ignore definition files. As browsepy uses 50 | same format, you can pass them to browsepy using **--exclude-from** 51 | options. 52 | 53 | .. code-block:: bash 54 | 55 | browsepy --exclude-from=.gitignore 56 | 57 | .. _glob-manpage: 58 | 59 | Glob manpage 60 | ------------ 61 | 62 | As glob reference, this is returned by ``man glob.7``. 63 | 64 | :: 65 | 66 | GLOB(7) Linux Programmer's Manual GLOB(7) 67 | 68 | NAME 69 | glob - globbing pathnames 70 | 71 | DESCRIPTION 72 | Long ago, in UNIX V6, there was a program /etc/glob that 73 | would expand wildcard patterns. Soon afterward this became 74 | a shell built-in. 75 | 76 | These days there is also a library routine glob(3) that 77 | will perform this function for a user program. 78 | 79 | The rules are as follows (POSIX.2, 3.13). 80 | 81 | Wildcard matching 82 | A string is a wildcard pattern if it contains one of the 83 | characters '?', '*' or '['. Globbing is the operation that 84 | expands a wildcard pattern into the list of pathnames 85 | matching the pattern. Matching is defined by: 86 | 87 | A '?' (not between brackets) matches any single character. 88 | 89 | A '*' (not between brackets) matches any string, including 90 | the empty string. 91 | 92 | Character classes 93 | 94 | An expression "[...]" where the first character after the 95 | leading '[' is not an '!' matches a single character, 96 | namely any of the characters enclosed by the brackets. The 97 | string enclosed by the brackets cannot be empty; therefore 98 | ']' can be allowed between the brackets, provided that it 99 | is the first character. (Thus, "[][!]" matches the three 100 | characters '[', ']' and '!'.) 101 | 102 | Ranges 103 | 104 | There is one special convention: two characters separated 105 | by '-' denote a range. (Thus, "[A-Fa-f0-9]" is equivalent 106 | to "[ABCDEFabcdef0123456789]".) One may include '-' in its 107 | literal meaning by making it the first or last character 108 | between the brackets. (Thus, "[]-]" matches just the two 109 | characters ']' and '-', and "[--0]" matches the three char‐ 110 | acters '-', '.', '0', since '/' cannot be matched.) 111 | 112 | Complementation 113 | 114 | An expression "[!...]" matches a single character, namely 115 | any character that is not matched by the expression 116 | obtained by removing the first '!' from it. (Thus, 117 | "[!]a-]" matches any single character except ']', 'a' and 118 | '-'.) 119 | 120 | One can remove the special meaning of '?', '*' and '[' by 121 | preceding them by a backslash, or, in case this is part of 122 | a shell command line, enclosing them in quotes. Between 123 | brackets these characters stand for themselves. Thus, 124 | "[[?*\]" matches the four characters '[', '?', '*' and '\'. 125 | 126 | Pathnames 127 | Globbing is applied on each of the components of a pathname 128 | separately. A '/' in a pathname cannot be matched by a '?' 129 | or '*' wildcard, or by a range like "[.-0]". A range con‐ 130 | taining an explicit '/' character is syntactically incor‐ 131 | rect. (POSIX requires that syntactically incorrect pat‐ 132 | terns are left unchanged.) 133 | 134 | If a filename starts with a '.', this character must be 135 | matched explicitly. (Thus, rm * will not remove .profile, 136 | and tar c * will not archive all your files; tar c . is 137 | better.) 138 | 139 | Empty lists 140 | The nice and simple rule given above: "expand a wildcard 141 | pattern into the list of matching pathnames" was the origi‐ 142 | nal UNIX definition. It allowed one to have patterns that 143 | expand into an empty list, as in 144 | 145 | xv -wait 0 *.gif *.jpg 146 | 147 | where perhaps no *.gif files are present (and this is not 148 | an error). However, POSIX requires that a wildcard pattern 149 | is left unchanged when it is syntactically incorrect, or 150 | the list of matching pathnames is empty. With bash one can 151 | force the classical behavior using this command: 152 | 153 | shopt -s nullglob 154 | 155 | (Similar problems occur elsewhere. For example, where old 156 | scripts have 157 | 158 | rm `find . -name "*~"` 159 | 160 | new scripts require 161 | 162 | rm -f nosuchfile `find . -name "*~"` 163 | 164 | to avoid error messages from rm called with an empty argu‐ 165 | ment list.) 166 | 167 | NOTES 168 | Regular expressions 169 | Note that wildcard patterns are not regular expressions, 170 | although they are a bit similar. First of all, they match 171 | filenames, rather than text, and secondly, the conventions 172 | are not the same: for example, in a regular expression '*' 173 | means zero or more copies of the preceding thing. 174 | 175 | Now that regular expressions have bracket expressions where 176 | the negation is indicated by a '^', POSIX has declared the 177 | effect of a wildcard pattern "[^...]" to be undefined. 178 | 179 | Character classes and internationalization 180 | Of course ranges were originally meant to be ASCII ranges, 181 | so that "[ -%]" stands for "[ !"#$%]" and "[a-z]" stands 182 | for "any lowercase letter". Some UNIX implementations gen‐ 183 | eralized this so that a range X-Y stands for the set of 184 | characters with code between the codes for X and for Y. 185 | However, this requires the user to know the character cod‐ 186 | ing in use on the local system, and moreover, is not conve‐ 187 | nient if the collating sequence for the local alphabet dif‐ 188 | fers from the ordering of the character codes. Therefore, 189 | POSIX extended the bracket notation greatly, both for wild‐ 190 | card patterns and for regular expressions. In the above we 191 | saw three types of items that can occur in a bracket 192 | expression: namely (i) the negation, (ii) explicit single 193 | characters, and (iii) ranges. POSIX specifies ranges in an 194 | internationally more useful way and adds three more types: 195 | 196 | (iii) Ranges X-Y comprise all characters that fall between 197 | X and Y (inclusive) in the current collating sequence as 198 | defined by the LC_COLLATE category in the current locale. 199 | 200 | (iv) Named character classes, like 201 | 202 | [:alnum:] [:alpha:] [:blank:] [:cntrl:] 203 | [:digit:] [:graph:] [:lower:] [:print:] 204 | [:punct:] [:space:] [:upper:] [:xdigit:] 205 | 206 | so that one can say "[[:lower:]]" instead of "[a-z]", and 207 | have things work in Denmark, too, where there are three 208 | letters past 'z' in the alphabet. These character classes 209 | are defined by the LC_CTYPE category in the current locale. 210 | 211 | (v) Collating symbols, like "[.ch.]" or "[.a-acute.]", 212 | where the string between "[." and ".]" is a collating ele‐ 213 | ment defined for the current locale. Note that this may be 214 | a multicharacter element. 215 | 216 | (vi) Equivalence class expressions, like "[=a=]", where the 217 | string between "[=" and "=]" is any collating element from 218 | its equivalence class, as defined for the current locale. 219 | For example, "[[=a=]]" might be equivalent to "[aáàäâ]", 220 | that is, to "[a[.a-acute.][.a-grave.][.a-umlaut.][.a-cir‐ 221 | cumflex.]]". 222 | 223 | SEE ALSO 224 | sh(1), fnmatch(3), glob(3), locale(7), regex(7) 225 | 226 | COLOPHON 227 | This page is part of release 4.10 of the Linux man-pages 228 | project. A description of the project, information about 229 | reporting bugs, and the latest version of this page, can be 230 | found at https://www.kernel.org/doc/man-pages/. 231 | 232 | Linux 2016-10-08 GLOB(7) 233 | -------------------------------------------------------------------------------- /doc/file.rst: -------------------------------------------------------------------------------- 1 | .. _file: 2 | 3 | File Module 4 | =========== 5 | 6 | .. currentmodule:: browsepy.file 7 | 8 | For more advanced use-cases dealing with the filesystem, the browsepy's own 9 | classes (:class:`Node`, :class:`File` and :class:`Directory`) can be 10 | instantiated and inherited. 11 | 12 | :class:`Node` class is meant for implementing your own special filesystem 13 | nodes, via inheritance (it's abstract so shouldn't be instantiated directly). 14 | Just remember to overload its :attr:`Node.generic` attribute value to False. 15 | 16 | Both :class:`File` and :class:`Directory` classes can be instantiated or 17 | extended, via inheritance, with logic like different default widgets, virtual data (see player plugin code). 18 | 19 | .. _file-node: 20 | 21 | Node 22 | ---- 23 | 24 | .. currentmodule:: browsepy.file 25 | 26 | .. autoclass:: Node 27 | :members: 28 | :inherited-members: 29 | :undoc-members: 30 | 31 | .. _file-directory: 32 | 33 | Directory 34 | --------- 35 | 36 | .. autoclass:: Directory 37 | :show-inheritance: 38 | :members: 39 | :inherited-members: 40 | :undoc-members: 41 | 42 | .. _file-file: 43 | 44 | File 45 | ---- 46 | 47 | .. autoclass:: File 48 | :show-inheritance: 49 | :members: 50 | :inherited-members: 51 | :undoc-members: 52 | 53 | .. _file-util: 54 | 55 | Utility functions 56 | ----------------- 57 | 58 | .. autofunction:: fmt_size 59 | .. autofunction:: abspath_to_urlpath 60 | .. autofunction:: urlpath_to_abspath 61 | .. autofunction:: check_under_base 62 | .. autofunction:: check_base 63 | .. autofunction:: check_path 64 | .. autofunction:: secure_filename 65 | .. autofunction:: alternative_filename 66 | .. autofunction:: scandir 67 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. browsepy documentation master file, created by 2 | sphinx-quickstart on Thu Nov 17 11:54:15 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to browsepy's documentation! 7 | ==================================== 8 | 9 | Welcome to browsepy's documentation. It's recommended to start reading both 10 | :ref:`quickstart` and, specifically :ref:`quickstart-installation`, while more 11 | detailed tutorials about integrating :mod:`browsepy` as module or plugin 12 | development are also available. 13 | 14 | Browsepy has few dependencies: `Flask`_ and `Scandir`_. `Flask`_ is an awesome 15 | web microframework while `Scandir`_ is a directory listing library which `was 16 | included `_ in Python 3.5's 17 | standard library. 18 | 19 | If you want to dive into their documentation, check out the following links: 20 | 21 | * `Flask Documentation 22 | `_ 23 | * `Scandir Readme 24 | `_ 25 | * `Scandir Python Documentation 26 | `_ 27 | 28 | .. _Flask: http://jinja.pocoo.org/ 29 | .. _Scandir: http://werkzeug.pocoo.org/ 30 | 31 | User's Guide 32 | ============ 33 | Instructions for users, implementers and developers. 34 | 35 | .. toctree:: 36 | :maxdepth: 2 37 | 38 | quickstart 39 | exclude 40 | builtin_plugins 41 | plugins 42 | integrations 43 | 44 | API Reference 45 | ============= 46 | Specific information about functions, class or methods. 47 | 48 | .. toctree:: 49 | :maxdepth: 2 50 | 51 | manager 52 | file 53 | stream 54 | compat 55 | exceptions 56 | tests_utils 57 | 58 | Indices and tables 59 | ================== 60 | Random documentation content references. 61 | 62 | * :ref:`genindex` 63 | * :ref:`modindex` 64 | * :ref:`search` 65 | -------------------------------------------------------------------------------- /doc/integrations.rst: -------------------------------------------------------------------------------- 1 | .. _integrations: 2 | 3 | Integrations 4 | ============ 5 | 6 | Browsepy is a Flask application and python module, so it could be integrated 7 | anywhere python's `WSGI `_ protocol 8 | is supported. Also, browsepy's public API could be easily reused. 9 | 10 | Browsepy app config (available at :attr:`browsepy.app.config`) exposes the 11 | following configuration options. 12 | 13 | * **directory_base**: anything under this directory will be served, 14 | defaults to current path. 15 | * **directory_start**: directory will be served when accessing root URL 16 | * **directory_remove**: file removing will be available under this path, 17 | defaults to **None**. 18 | * **directory_upload**: file upload will be available under this path, 19 | defaults to **None**. 20 | * **directory_tar_buffsize**, directory tar streaming buffer size, 21 | defaults to **262144** and must be multiple of 512. 22 | * **directory_downloadable** whether enable directory download or not, 23 | defaults to **True**. 24 | * **use_binary_multiples** whether use binary units (bi-bytes, like KiB) 25 | instead of common ones (bytes, like KB), defaults to **True**. 26 | * **plugin_modules** list of module names (absolute or relative to 27 | plugin_namespaces) will be loaded. 28 | * **plugin_namespaces** prefixes for module names listed at plugin_modules 29 | where relative plugin_modules are searched. 30 | 31 | Please note: After editing `plugin_modules` value, plugin manager (available 32 | at module :data:`browsepy.plugin_manager` and 33 | :data:`browsepy.app.extensions['plugin_manager']`) should be reloaded using 34 | the :meth:`browsepy.plugin_manager.reload` instance method of :meth:`browsepy.manager.PluginManager.reload` for browsepy's plugin 35 | manager. 36 | 37 | The other way of loading a plugin programmatically is calling 38 | :meth:`browsepy.plugin_manager.load_plugin` instance method of 39 | :meth:`browsepy.manager.PluginManager.load_plugin` for browsepy's plugin 40 | manager. 41 | 42 | .. _integrations-cherrymusic: 43 | 44 | Cherrypy and Cherrymusic 45 | ------------------------- 46 | 47 | Startup script running browsepy inside the `cherrypy `_ 48 | server provided by `cherrymusic `_. 49 | 50 | .. code-block:: python 51 | 52 | #!/env/bin/python 53 | # -*- coding: UTF-8 -*- 54 | 55 | import os 56 | import sys 57 | import cherrymusicserver 58 | import cherrypy 59 | 60 | from os.path import expandvars, dirname, abspath, join as joinpath 61 | from browsepy import app as browsepy, plugin_manager 62 | 63 | 64 | class HTTPHandler(cherrymusicserver.httphandler.HTTPHandler): 65 | def autoLoginActive(self): 66 | return True 67 | 68 | class Root(object): 69 | pass 70 | 71 | cherrymusicserver.httphandler.HTTPHandler = HTTPHandler 72 | 73 | base_path = abspath(dirname(__file__)) 74 | static_path = joinpath(base_path, 'static') 75 | media_path = expandvars('$HOME/media') 76 | download_path = joinpath(media_path, 'downloads') 77 | root_config = { 78 | '/': { 79 | 'tools.staticdir.on': True, 80 | 'tools.staticdir.dir': static_path, 81 | 'tools.staticdir.index': 'index.html', 82 | } 83 | } 84 | cherrymusic_config = { 85 | 'server.rootpath': '/player', 86 | } 87 | browsepy.config.update( 88 | APPLICATION_ROOT = '/browse', 89 | directory_base = media_path, 90 | directory_start = media_path, 91 | directory_remove = media_path, 92 | directory_upload = media_path, 93 | plugin_modules = ['player'], 94 | ) 95 | plugin_manager.reload() 96 | 97 | if __name__ == '__main__': 98 | sys.stderr = open(joinpath(base_path, 'stderr.log'), 'w') 99 | sys.stdout = open(joinpath(base_path, 'stdout.log'), 'w') 100 | 101 | with open(joinpath(base_path, 'pidfile.pid'), 'w') as f: 102 | f.write('%d' % os.getpid()) 103 | 104 | cherrymusicserver.setup_config(cherrymusic_config) 105 | cherrymusicserver.setup_services() 106 | cherrymusicserver.migrate_databases() 107 | cherrypy.tree.graft(browsepy, '/browse') 108 | cherrypy.tree.mount(Root(), '/', config=root_config) 109 | 110 | try: 111 | cherrymusicserver.start_server(cherrymusic_config) 112 | finally: 113 | print('Exiting...') 114 | -------------------------------------------------------------------------------- /doc/manager.rst: -------------------------------------------------------------------------------- 1 | .. _manager: 2 | 3 | Manager Module 4 | ============== 5 | 6 | .. currentmodule:: browsepy.manager 7 | 8 | The browsepy's :doc:`manager` module centralizes all plugin-related 9 | logic, hook calls and component registration methods. 10 | 11 | For an extended explanation head over :doc:`plugins`. 12 | 13 | .. _manager-argument: 14 | 15 | Argument Plugin Manager 16 | ----------------------- 17 | 18 | This class represents a subset of :class:`PluginManager` functionality, and 19 | an instance of this class will be passed to :func:`register_arguments` plugin 20 | module-level functions. 21 | 22 | .. autoclass:: ArgumentPluginManager 23 | :members: 24 | :inherited-members: 25 | :undoc-members: 26 | 27 | .. _manager-plugin: 28 | 29 | Plugin Manager 30 | -------------- 31 | 32 | This class includes are the plugin registration functionality, and itself 33 | will be passed to :func:`register_plugin` plugin module-level functions. 34 | 35 | .. autoclass:: PluginManager 36 | :members: 37 | :inherited-members: 38 | :undoc-members: 39 | :exclude-members: register_action, get_actions, action_class, style_class, 40 | button_class, javascript_class, link_class, widget_types 41 | 42 | .. autoattribute:: widget_types 43 | 44 | Dictionary with widget type names and their corresponding class (based on 45 | namedtuple, see :func:`defaultsnamedtuple`) so it could be instanced and 46 | reused (see :meth:`register_widget`). 47 | 48 | .. _manager-util: 49 | 50 | Utility functions 51 | ----------------- 52 | 53 | .. autofunction:: defaultsnamedtuple 54 | -------------------------------------------------------------------------------- /doc/plugins.rst: -------------------------------------------------------------------------------- 1 | .. _plugins: 2 | 3 | Plugin Development 4 | ================== 5 | 6 | .. currentmodule:: browsepy.manager 7 | 8 | browsepy is extensible via a powerful plugin API. A plugin can register 9 | its own Flask blueprints, file browser widgets (filtering by file), mimetype 10 | detection functions and even its own command line arguments. 11 | 12 | A fully functional :mod:`browsepy.plugin.player` plugin module is provided as 13 | example. 14 | 15 | .. _plugins-namespace: 16 | 17 | Plugin Namespace 18 | ---------------- 19 | 20 | Plugins are regular python modules. They are loaded by `--plugin` 21 | :ref:`console argument `. 22 | 23 | Aiming to make plugin names shorter, browsepy try to load plugins 24 | using namespaces 25 | and prefixes defined on configuration's ``plugin_namespaces`` entry on 26 | :attr:`browsepy.app.config`. Its default value is browsepy's built-in module namespace 27 | `browsepy.plugins`, `browsepy_` prefix and an empty namespace (so any plugin could 28 | be used with its full module name). 29 | 30 | Summarizing, with default configuration: 31 | 32 | * Any python module inside `browsepy.plugin` can be loaded as plugin by its 33 | relative module name, ie. ``player`` instead of ``browsepy.plugin.player``. 34 | * Any python module prefixed by ``browsepy_`` can be loaded as plugin by its 35 | unprefixed name, ie. ``myplugin`` instead of ``browsepy_myplugin``. 36 | * Any python module can be loaded as plugin by its full module name. 37 | 38 | Said that, you can name your own plugin so it could be loaded easily. 39 | 40 | .. _plugins-namespace-examples: 41 | 42 | Examples 43 | ++++++++ 44 | 45 | Your built-in plugin, placed under `browsepy/plugins/` in your own 46 | browsepy fork: 47 | 48 | .. code-block:: bash 49 | 50 | browsepy --plugin=my_builtin_module 51 | 52 | Your prefixed plugin, a regular python module in python's library path, 53 | named `browsepy_prefixed_plugin`: 54 | 55 | .. code-block:: bash 56 | 57 | browsepy --plugin=prefixed_plugin 58 | 59 | Your plugin, a regular python module in python's library path, named `my_plugin`. 60 | 61 | .. code-block:: bash 62 | 63 | browsepy --plugin=my_plugin 64 | 65 | .. _plugins-protocol: 66 | 67 | Protocol 68 | -------- 69 | 70 | The plugin manager subsystem expects a `register_plugin` callable at module 71 | level (in your **__init__** module globals) which will be called with the 72 | manager itself (type :class:`PluginManager`) as first parameter. 73 | 74 | Plugin manager exposes several methods to register widgets and mimetype 75 | detection functions. 76 | 77 | A *sregister_plugin*s function looks like this (taken from player plugin): 78 | 79 | .. code-block:: python 80 | 81 | def register_plugin(manager): 82 | ''' 83 | Register blueprints and actions using given plugin manager. 84 | 85 | :param manager: plugin manager 86 | :type manager: browsepy.manager.PluginManager 87 | ''' 88 | manager.register_blueprint(player) 89 | manager.register_mimetype_function(detect_playable_mimetype) 90 | 91 | # add style tag 92 | manager.register_widget( 93 | place='styles', 94 | type='stylesheet', 95 | endpoint='player.static', 96 | filename='css/browse.css' 97 | ) 98 | 99 | # register link actions 100 | manager.register_widget( 101 | place='entry-link', 102 | type='link', 103 | endpoint='player.audio', 104 | filter=PlayableFile.detect 105 | ) 106 | manager.register_widget( 107 | place='entry-link', 108 | icon='playlist', 109 | type='link', 110 | endpoint='player.playlist', 111 | filter=PlayListFile.detect 112 | ) 113 | 114 | # register action buttons 115 | manager.register_widget( 116 | place='entry-actions', 117 | css='play', 118 | type='button', 119 | endpoint='player.audio', 120 | filter=PlayableFile.detect 121 | ) 122 | manager.register_widget( 123 | place='entry-actions', 124 | css='play', 125 | type='button', 126 | endpoint='player.playlist', 127 | filter=PlayListFile.detect 128 | ) 129 | 130 | # check argument (see `register_arguments`) before registering 131 | if manager.get_argument('player_directory_play'): 132 | # register header button 133 | manager.register_widget( 134 | place='header', 135 | type='button', 136 | endpoint='player.directory', 137 | text='Play directory', 138 | filter=PlayableDirectory.detect 139 | ) 140 | 141 | In case you need to add extra command-line-arguments to browsepy command, 142 | :func:`register_arguments` method can be also declared (like register_plugin, 143 | at your plugin's module level). It will receive a 144 | :class:`ArgumentPluginManager` instance, providing an argument-related subset of whole plugin manager's functionality. 145 | 146 | A simple `register_arguments` example (from player plugin): 147 | 148 | .. code-block:: python 149 | 150 | def register_arguments(manager): 151 | ''' 152 | Register arguments using given plugin manager. 153 | 154 | This method is called before `register_plugin`. 155 | 156 | :param manager: plugin manager 157 | :type manager: browsepy.manager.PluginManager 158 | ''' 159 | 160 | # Arguments are forwarded to argparse:ArgumentParser.add_argument, 161 | # https://docs.python.org/3.7/library/argparse.html#the-add-argument-method 162 | manager.register_argument( 163 | '--player-directory-play', action='store_true', 164 | help='enable directories as playlist' 165 | ) 166 | 167 | .. _widgets: 168 | 169 | Widgets 170 | ------- 171 | 172 | Widget registration is provided by :meth:`PluginManager.register_widget`. 173 | 174 | You can alternatively pass a widget object, via `widget` keyword 175 | argument, or use a pure functional approach by passing `place`, 176 | `type` and the widget-specific properties as keyword arguments. 177 | 178 | In addition to that, you can also define in which cases widget will be shown passing 179 | a callable to `filter` argument keyword, which will receive a 180 | :class:`browsepy.file.Node` (commonly a :class:`browsepy.file.File` or a 181 | :class:`browsepy.file.Directory`) instance. 182 | 183 | For those wanting the object-oriented approach, and for reference for those 184 | wanting to know widget properties for using the functional way, 185 | :attr:`WidgetPluginManager.widget_types` dictionary is 186 | available, containing widget namedtuples (see :func:`collections.namedtuple`) definitions. 187 | 188 | 189 | Here is the "widget_types" for reference. 190 | 191 | .. code-block:: python 192 | 193 | class WidgetPluginManager(RegistrablePluginManager): 194 | ''' ... ''' 195 | widget_types = { 196 | 'base': defaultsnamedtuple( 197 | 'Widget', 198 | ('place', 'type')), 199 | 'link': defaultsnamedtuple( 200 | 'Link', 201 | ('place', 'type', 'css', 'icon', 'text', 'endpoint', 'href'), 202 | { 203 | 'text': lambda f: f.name, 204 | 'icon': lambda f: f.category 205 | }), 206 | 'button': defaultsnamedtuple( 207 | 'Button', 208 | ('place', 'type', 'css', 'text', 'endpoint', 'href')), 209 | 'upload': defaultsnamedtuple( 210 | 'Upload', 211 | ('place', 'type', 'css', 'text', 'endpoint', 'action')), 212 | 'stylesheet': defaultsnamedtuple( 213 | 'Stylesheet', 214 | ('place', 'type', 'endpoint', 'filename', 'href')), 215 | 'script': defaultsnamedtuple( 216 | 'Script', 217 | ('place', 'type', 'endpoint', 'filename', 'src')), 218 | 'html': defaultsnamedtuple( 219 | 'Html', 220 | ('place', 'type', 'html')), 221 | } 222 | 223 | Function :func:`browsepy.file.defaultsnamedtuple` is a 224 | :func:`collections.namedtuple` which uses a third argument dictionary 225 | as default attribute values. None is assumed as implicit default. 226 | 227 | All attribute values can be either :class:`str` or a callable accepting 228 | a :class:`browsepy.file.Node` instance as argument and returning said :class:`str`, 229 | allowing dynamic widget content and behavior. 230 | 231 | Please note place and type are always required (otherwise widget won't be 232 | drawn), and this properties are mutually exclusive: 233 | 234 | * **link**: attribute href supersedes endpoint. 235 | * **button**: attribute href supersedes endpoint. 236 | * **upload**: attribute action supersedes endpoint. 237 | * **stylesheet**: attribute href supersedes endpoint and filename 238 | * **script**: attribute src supersedes endpoint and filename. 239 | 240 | Endpoints are Flask endpoint names, and endpoint handler functions must receive 241 | a "filename" parameter for stylesheet and script widgets (allowing it to point 242 | using with Flask's statics view) and a "path" argument for other use-cases. In the 243 | former case it is recommended to use 244 | :meth:`browsepy.file.Node.from_urlpath` static method to create the 245 | appropriate file/directory object (see :mod:`browsepy.file`). 246 | 247 | .. _plugins-considerations: 248 | 249 | Considerations 250 | -------------- 251 | 252 | Name your plugin wisely, look at `pypi `_ for 253 | conflicting module names. 254 | 255 | Always choose the less intrusive approach on plugin development, so new 256 | browsepy versions will not likely break it. That's why stuff like 257 | :meth:`PluginManager.register_blueprint` is provided and its usage is 258 | preferred over directly registering blueprints via plugin manager's app 259 | reference (or even module-level app reference). 260 | 261 | A good way to keep your plugin working on future browsepy releases is 262 | :ref:`upstreaming it ` onto browsepy itself. 263 | 264 | Feel free to hack everything you want. Pull requests are definitely welcome. 265 | -------------------------------------------------------------------------------- /doc/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | .. _quickstart-installation: 7 | 8 | Installation 9 | ------------ 10 | 11 | browsepy is available at the `Python Package Index `_ 12 | so you can use pip to install. Please remember `virtualenv`_ or :mod:`venv` 13 | usage, for Python 2 and Python 3 respectively, is highly recommended when 14 | working with pip. 15 | 16 | .. code-block:: bash 17 | 18 | pip install browsepy 19 | 20 | Alternatively, you can get the development version from our 21 | `github repository`_ using `git`_. Brench **master** will be 22 | pointing to current release while new versions will reside on 23 | their own branches. 24 | 25 | .. code-block:: bash 26 | 27 | pip install git+https://github.com/ergoithz/browsepy.git 28 | 29 | .. _virtualenv: https://virtualenv.pypa.io/ 30 | .. _github repository: https://github.com/ergoithz/browsepy 31 | .. _git: https://git-scm.com/ 32 | 33 | .. _quickstart-usage: 34 | 35 | Usage 36 | ----- 37 | 38 | These examples assume python's `bin` directory is in `PATH`, if not, 39 | replace `browsepy` with `python -m browsepy`. 40 | 41 | Serving ``$HOME/shared`` to all addresses: 42 | 43 | .. code-block:: bash 44 | 45 | browsepy 0.0.0.0 8080 --directory $HOME/shared 46 | 47 | Showing help: 48 | 49 | .. code-block:: bash 50 | 51 | browsepy --help 52 | 53 | And this is what is printed when you run `browsepy --help`, keep in 54 | mind that plugins (loaded with `plugin` argument) could add extra arguments to 55 | this list. 56 | 57 | :: 58 | 59 | usage: browsepy [-h] [--directory PATH] [--initial PATH] 60 | [--removable PATH] [--upload PATH] 61 | [--exclude PATTERN] [--exclude-from PATH] 62 | [--plugin MODULE] 63 | [host] [port] 64 | 65 | description: starts a browsepy web file browser 66 | 67 | positional arguments: 68 | host address to listen (default: 127.0.0.1) 69 | port port to listen (default: 8080) 70 | 71 | optional arguments: 72 | -h, --help show this help message and exit 73 | --directory PATH serving directory (default: current path) 74 | --initial PATH default directory (default: same as --directory) 75 | --removable PATH base directory allowing remove (default: none) 76 | --upload PATH base directory allowing upload (default: none) 77 | --exclude PATTERN exclude paths by pattern (multiple) 78 | --exclude-from PATH exclude paths by pattern file (multiple) 79 | --plugin MODULE load plugin module (multiple) 80 | 81 | Showing help including player plugin arguments: 82 | 83 | .. code-block:: bash 84 | 85 | browsepy --plugin player --help 86 | 87 | And this is what is printed when you run `browsepy --plugin player --help`. 88 | Please note the extra parameters below `player arguments`. 89 | 90 | :: 91 | 92 | usage: browsepy [-h] [--directory PATH] [--initial PATH] 93 | [--removable PATH] [--upload PATH] 94 | [--exclude PATTERN] [--exclude-from PATH] 95 | [--plugin MODULE] [--player-directory-play] 96 | [host] [port] 97 | 98 | description: starts a browsepy web file browser 99 | 100 | positional arguments: 101 | host address to listen (default: 127.0.0.1) 102 | port port to listen (default: 8080) 103 | 104 | optional arguments: 105 | -h, --help show this help message and exit 106 | --directory PATH serving directory (default: current path) 107 | --initial PATH default directory (default: same as --directory) 108 | --removable PATH base directory allowing remove (default: none) 109 | --upload PATH base directory allowing upload (default: none) 110 | --exclude PATTERN exclude paths by pattern (multiple) 111 | --exclude-from PATH exclude paths by pattern file (multiple) 112 | --plugin MODULE load plugin module (multiple) 113 | 114 | player arguments: 115 | --player-directory-play 116 | enable directories as playlist 117 | -------------------------------------------------------------------------------- /doc/screenshot.0.3.1-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railsonsousa106/browsepy/1612a930ef220fae507e1b152c531707e555bd92/doc/screenshot.0.3.1-0.png -------------------------------------------------------------------------------- /doc/stream.rst: -------------------------------------------------------------------------------- 1 | .. _file: 2 | 3 | Stream Module 4 | ============= 5 | 6 | .. currentmodule:: browsepy.stream 7 | 8 | This module provides a class for streaming directory tarballs. This is used 9 | by :meth:`browsepy.file.Directory.download` method. 10 | 11 | .. _tarfilestream-node: 12 | 13 | TarFileStream 14 | ---- 15 | 16 | .. currentmodule:: browsepy.stream 17 | 18 | .. autoclass:: TarFileStream 19 | :members: 20 | :inherited-members: 21 | :undoc-members: 22 | -------------------------------------------------------------------------------- /doc/tests_utils.rst: -------------------------------------------------------------------------------- 1 | .. _test-utils: 2 | 3 | Test Utility Module 4 | =================== 5 | 6 | Convenience functions for unit testing. 7 | 8 | .. automodule:: browsepy.tests.utils 9 | :show-inheritance: 10 | :members: 11 | :inherited-members: 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | unicategories 3 | 4 | # for python < 3.6 5 | scandir 6 | 7 | # for python < 3.3 8 | backports.shutil_get_terminal_size 9 | 10 | # for building 11 | setuptools 12 | wheel 13 | 14 | # for Makefile tasks (testing, coverage, docs) 15 | pep8 16 | flake8 17 | coverage 18 | pyaml 19 | sphinx 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | 4 | [wheel] 5 | universal = 1 6 | 7 | [egg_info] 8 | tag_build = -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | browsepy 4 | ======== 5 | 6 | Simple web file browser with directory gzipped tarball download, file upload, 7 | removal and plugins. 8 | 9 | More details on project's README and 10 | `github page `_. 11 | 12 | 13 | Development Version 14 | ------------------- 15 | 16 | The browsepy development version can be installed by cloning the git 17 | repository from `github`_:: 18 | 19 | git clone git@github.com:ergoithz/browsepy.git 20 | 21 | .. _github: https://github.com/ergoithz/browsepy 22 | 23 | License 24 | ------- 25 | MIT (see LICENSE file). 26 | """ 27 | 28 | import os 29 | import os.path 30 | import sys 31 | import shutil 32 | 33 | try: 34 | from setuptools import setup 35 | except ImportError: 36 | from distutils.core import setup 37 | 38 | sys_path = sys.path[:] 39 | sys.path[:] = (os.path.abspath('browsepy'),) 40 | __import__('__meta__') 41 | meta = sys.modules['__meta__'] 42 | sys.path[:] = sys_path 43 | 44 | with open('README.rst') as f: 45 | meta_doc = f.read() 46 | 47 | extra_requires = [] 48 | bdist = 'bdist' in sys.argv or any(a.startswith('bdist_') for a in sys.argv) 49 | if bdist or not hasattr(os, 'scandir'): 50 | extra_requires.append('scandir') 51 | 52 | if bdist or not hasattr(shutil, 'get_terminal_size'): 53 | extra_requires.append('backports.shutil_get_terminal_size') 54 | 55 | for debugger in ('ipdb', 'pudb', 'pdb'): 56 | opt = '--debug=%s' % debugger 57 | if opt in sys.argv: 58 | os.environ['UNITTEST_DEBUG'] = debugger 59 | sys.argv.remove(opt) 60 | 61 | setup( 62 | name=meta.app, 63 | version=meta.version, 64 | url=meta.url, 65 | download_url=meta.tarball, 66 | license=meta.license, 67 | author=meta.author_name, 68 | author_email=meta.author_mail, 69 | description=meta.description, 70 | long_description=meta_doc, 71 | classifiers=[ 72 | 'Development Status :: 4 - Beta', 73 | 'License :: OSI Approved :: MIT License', 74 | 'Operating System :: OS Independent', 75 | 'Programming Language :: Python', 76 | 'Programming Language :: Python :: 3', 77 | ], 78 | keywords=['web', 'file', 'browser'], 79 | packages=[ 80 | 'browsepy', 81 | 'browsepy.tests', 82 | 'browsepy.tests.deprecated', 83 | 'browsepy.tests.deprecated.plugin', 84 | 'browsepy.transform', 85 | 'browsepy.plugin', 86 | 'browsepy.plugin.player', 87 | ], 88 | entry_points={ 89 | 'console_scripts': ( 90 | 'browsepy=browsepy.__main__:main' 91 | ) 92 | }, 93 | package_data={ # ignored by sdist (see MANIFEST.in), used by bdist_wheel 94 | 'browsepy': [ 95 | 'templates/*', 96 | 'static/fonts/*', 97 | 'static/*.*', # do not capture directories 98 | ], 99 | 'browsepy.plugin.player': [ 100 | 'templates/*', 101 | 'static/*/*', 102 | ]}, 103 | install_requires=['flask', 'unicategories'] + extra_requires, 104 | test_suite='browsepy.tests', 105 | test_runner='browsepy.tests.runner:DebuggerTextTestRunner', 106 | zip_safe=False, 107 | platforms='any' 108 | ) 109 | --------------------------------------------------------------------------------