├── .python-version ├── browsepy ├── tests │ ├── __init__.py │ ├── deprecated │ │ ├── __init__.py │ │ └── plugin │ │ │ ├── __init__.py │ │ │ └── player.py │ ├── utils.py │ ├── test_app.py │ ├── runner.py │ ├── test_extensions.py │ ├── test_transform.py │ ├── test_plugins.py │ ├── test_main.py │ └── test_compat.py ├── plugin │ ├── __init__.py │ └── player │ │ ├── static │ │ ├── js │ │ │ ├── jquery.jplayer.swf │ │ │ ├── base.js │ │ │ └── jplayer.playlist.min.js │ │ ├── css │ │ │ ├── browse.css │ │ │ ├── base.css │ │ │ └── jplayer.blue.monday.min.css │ │ └── image │ │ │ ├── jplayer.blue.monday.jpg │ │ │ ├── jplayer.blue.monday.seeking.gif │ │ │ └── jplayer.blue.monday.video.play.png │ │ ├── templates │ │ └── audio.player.html │ │ ├── __init__.py │ │ └── playable.py ├── static │ ├── giorgio.jpg │ ├── fonts │ │ ├── icomoon.eot │ │ ├── icomoon.ttf │ │ └── icomoon.woff │ ├── browse.directory.head.js │ ├── browse.directory.body.js │ └── base.css ├── templates │ ├── 404.html │ ├── remove.html │ ├── base.html │ ├── 400.html │ └── browse.html ├── __meta__.py ├── mimetype.py ├── appconfig.py ├── widget.py ├── exceptions.py ├── transform │ ├── htmlcompress.py │ ├── __init__.py │ └── glob.py ├── stream.py ├── __main__.py ├── __init__.py └── compat.py ├── .eslintignore ├── doc ├── screenshot.0.3.1-0.png ├── .templates │ ├── layout.html │ └── sidebar.html ├── exceptions.rst ├── tests_utils.rst ├── stream.rst ├── .static │ └── logo.css ├── manager.rst ├── file.rst ├── builtin_plugins.rst ├── index.rst ├── compat.rst ├── quickstart.rst ├── integrations.rst ├── Makefile ├── plugins.rst └── exclude.rst ├── setup.cfg ├── MANIFEST.in ├── .scrutinizer.yml ├── requirements.txt ├── .gitignore ├── .editorconfig ├── .eslintrc.json ├── .travis.yml ├── LICENSE ├── Makefile ├── .appveyor.yml ├── setup.py └── README.rst /.python-version: -------------------------------------------------------------------------------- 1 | 3.5.2 2 | -------------------------------------------------------------------------------- /browsepy/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.min.js 2 | -------------------------------------------------------------------------------- /browsepy/tests/deprecated/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /browsepy/tests/deprecated/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /browsepy/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | -------------------------------------------------------------------------------- /browsepy/static/giorgio.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergoithz/browsepy/HEAD/browsepy/static/giorgio.jpg -------------------------------------------------------------------------------- /doc/screenshot.0.3.1-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergoithz/browsepy/HEAD/doc/screenshot.0.3.1-0.png -------------------------------------------------------------------------------- /browsepy/static/fonts/icomoon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergoithz/browsepy/HEAD/browsepy/static/fonts/icomoon.eot -------------------------------------------------------------------------------- /browsepy/static/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergoithz/browsepy/HEAD/browsepy/static/fonts/icomoon.ttf -------------------------------------------------------------------------------- /browsepy/static/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergoithz/browsepy/HEAD/browsepy/static/fonts/icomoon.woff -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | 4 | [wheel] 5 | universal = 1 6 | 7 | [egg_info] 8 | tag_build = -------------------------------------------------------------------------------- /browsepy/plugin/player/static/js/jquery.jplayer.swf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergoithz/browsepy/HEAD/browsepy/plugin/player/static/js/jquery.jplayer.swf -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /browsepy/plugin/player/static/image/jplayer.blue.monday.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergoithz/browsepy/HEAD/browsepy/plugin/player/static/image/jplayer.blue.monday.jpg -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /browsepy/plugin/player/static/image/jplayer.blue.monday.seeking.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergoithz/browsepy/HEAD/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/ergoithz/browsepy/HEAD/browsepy/plugin/player/static/image/jplayer.blue.monday.video.play.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /doc/.templates/sidebar.html: -------------------------------------------------------------------------------- 1 | 2 |

The simple web file browser.

3 |

Useful Links

4 | 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/tests/utils.py: -------------------------------------------------------------------------------- 1 | 2 | import flask 3 | 4 | 5 | def clear_localstack(stack): 6 | ''' 7 | Clear given werkzeug LocalStack instance. 8 | 9 | :param ctx: local stack instance 10 | :type ctx: werkzeug.local.LocalStack 11 | ''' 12 | while stack.pop(): 13 | pass 14 | 15 | 16 | def clear_flask_context(): 17 | ''' 18 | Clear flask current_app and request globals. 19 | 20 | When using :meth:`flask.Flask.test_client`, even as context manager, 21 | the flask's globals :attr:`flask.current_app` and :attr:`flask.request` 22 | are left dirty, so testing code relying on them will probably fail. 23 | 24 | This function clean said globals, and should be called after testing 25 | with :meth:`flask.Flask.test_client`. 26 | ''' 27 | clear_localstack(flask._app_ctx_stack) 28 | clear_localstack(flask._request_ctx_stack) 29 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/plugin/player/templates/audio.player.html: -------------------------------------------------------------------------------- 1 | {% extends "browse.html" %} 2 | 3 | {% block styles %} 4 | {{ super() }} 5 | 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/tests/test_transform.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | import unittest 6 | import warnings 7 | 8 | from browsepy.compat import unicode 9 | 10 | import browsepy.transform 11 | import browsepy.transform.glob 12 | 13 | 14 | def fu(c): 15 | if isinstance(c, unicode): 16 | return c 17 | return c.decode('utf-8') 18 | 19 | 20 | class TestStateMachine(unittest.TestCase): 21 | module = browsepy.transform 22 | 23 | def test_nearest_error(self): 24 | m = self.module.StateMachine() 25 | self.assertRaises(KeyError, lambda: m.nearest) 26 | 27 | 28 | class TestGlob(unittest.TestCase): 29 | module = browsepy.transform.glob 30 | translate = staticmethod(module.translate) 31 | 32 | def assertSubclass(self, cls, base): 33 | self.assertIn(base, cls.mro()) 34 | 35 | def test_glob(self): 36 | translations = [ 37 | ('/a', r'^/a(/|$)'), 38 | ('a', r'/a(/|$)'), 39 | ('/a*', r'^/a[^/]*(/|$)'), 40 | ('/a**', r'^/a.*(/|$)'), 41 | ('a?', r'/a[^/](/|$)'), 42 | ('/a{b,c}', r'^/a(b|c)(/|$)'), 43 | ('/a[a,b]', r'^/a[a,b](/|$)'), 44 | ('/a[!b]', r'^/a[^b](/|$)'), 45 | ('/a[!/]', r'^/a[^/](/|$)'), 46 | ('/a[]]', r'^/a[\]](/|$)'), 47 | ('/a\\*', r'^/a\*(/|$)'), 48 | ('a{,.{txt,py[!od]}}', r'/a(|\.(txt|py[^od]))(/|$)'), 49 | ('a,a', r'/a,a(/|$)'), 50 | ] 51 | self.assertListEqual( 52 | [self.translate(g, sep='/') for g, r in translations], 53 | [r for g, r in translations] 54 | ) 55 | 56 | translations = [ 57 | ('/a', r'^\\a(\\|$)'), 58 | ('a', r'\\a(\\|$)'), 59 | ('/a*', r'^\\a[^\\]*(\\|$)'), 60 | ('/a**', r'^\\a.*(\\|$)'), 61 | ('a?', r'\\a[^\\](\\|$)'), 62 | ('/a{b,c}', r'^\\a(b|c)(\\|$)'), 63 | ('/a[a,b]', r'^\\a[a,b](\\|$)'), 64 | ('/a[!b]', r'^\\a[^b](\\|$)'), 65 | ('/a[!/]', r'^\\a[^\\](\\|$)'), 66 | ('/a[]]', r'^\\a[\]](\\|$)'), 67 | ('/a\\*', r'^\\a\*(\\|$)'), 68 | ] 69 | self.assertListEqual( 70 | [self.translate(g, sep='\\') for g, r in translations], 71 | [r for g, r in translations] 72 | ) 73 | 74 | def test_unicode(self): 75 | tests = [ 76 | ('/[[:alpha:][:digit:]]', ( 77 | '/a', 78 | '/ñ', 79 | '/1', 80 | ), ( 81 | '/_', 82 | )), 83 | ('/[[:alpha:]0-5]', ( 84 | '/a', 85 | '/á', 86 | ), ( 87 | '/6', 88 | '/_', 89 | )), 90 | ] 91 | for pattern, matching, nonmatching in tests: 92 | pattern = re.compile(self.translate(pattern, sep='/')) 93 | for test in matching: 94 | self.assertTrue(pattern.match(fu(test))) 95 | for test in nonmatching: 96 | self.assertFalse(pattern.match(fu(test))) 97 | 98 | def test_unsupported(self): 99 | translations = [ 100 | ('[[.a-acute.]]a', '/.a(/|$)'), 101 | ('/[[=a=]]a', '^/.a(/|$)'), 102 | ('/[[=a=]\d]a', '^/.a(/|$)'), 103 | ('[[:non-existent-class:]]a', '/.a(/|$)'), 104 | ] 105 | for source, result in translations: 106 | with warnings.catch_warnings(record=True) as w: 107 | warnings.simplefilter("always") 108 | self.assertEqual(self.translate(source, sep='/'), result) 109 | self.assertSubclass(w[-1].category, Warning) 110 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /browsepy/tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | import flask 4 | 5 | import browsepy 6 | import browsepy.manager 7 | import browsepy.tests.utils as test_utils 8 | 9 | from browsepy.plugin.player.tests import * # noqa 10 | 11 | 12 | class FileMock(object): 13 | def __init__(self, **kwargs): 14 | self.__dict__.update(kwargs) 15 | 16 | 17 | class TestMimetypePluginManager(unittest.TestCase): 18 | module = browsepy.manager 19 | 20 | def test_mimetype(self): 21 | manager = self.module.MimetypePluginManager() 22 | self.assertEqual( 23 | manager.get_mimetype('potato'), 24 | 'application/octet-stream' 25 | ) 26 | self.assertEqual( 27 | manager.get_mimetype('potato.txt'), 28 | 'text/plain' 29 | ) 30 | manager.register_mimetype_function( 31 | lambda x: 'application/xml' if x == 'potato' else None 32 | ) 33 | self.assertEqual( 34 | manager.get_mimetype('potato.txt'), 35 | 'text/plain' 36 | ) 37 | self.assertEqual( 38 | manager.get_mimetype('potato'), 39 | 'application/xml' 40 | ) 41 | 42 | 43 | class TestPlugins(unittest.TestCase): 44 | app_module = browsepy 45 | manager_module = browsepy.manager 46 | 47 | def setUp(self): 48 | self.app = self.app_module.app 49 | self.original_namespaces = self.app.config['plugin_namespaces'] 50 | self.plugin_namespace, self.plugin_name = __name__.rsplit('.', 1) 51 | self.app.config['plugin_namespaces'] = (self.plugin_namespace,) 52 | self.manager = self.manager_module.PluginManager(self.app) 53 | 54 | def tearDown(self): 55 | self.app.config['plugin_namespaces'] = self.original_namespaces 56 | self.manager.clear() 57 | test_utils.clear_flask_context() 58 | 59 | def test_manager(self): 60 | self.manager.load_plugin(self.plugin_name) 61 | self.assertTrue(self.manager._plugin_loaded) 62 | 63 | endpoints = sorted( 64 | action.endpoint 65 | for action in self.manager.get_widgets(FileMock(mimetype='a/a')) 66 | ) 67 | 68 | self.assertEqual( 69 | endpoints, 70 | sorted(('test_x_x', 'test_a_x', 'test_x_a', 'test_a_a')) 71 | ) 72 | self.assertEqual( 73 | self.app.view_functions['test_plugin.root'](), 74 | 'test_plugin_root' 75 | ) 76 | self.assertIn('test_plugin', self.app.blueprints) 77 | 78 | self.assertRaises( 79 | self.manager_module.PluginNotFoundError, 80 | self.manager.load_plugin, 81 | 'non_existent_plugin_module' 82 | ) 83 | 84 | self.assertRaises( 85 | self.manager_module.InvalidArgumentError, 86 | self.manager.register_widget 87 | ) 88 | 89 | def test_namespace_prefix(self): 90 | self.assertTrue(self.manager.import_plugin(self.plugin_name)) 91 | self.app.config['plugin_namespaces'] = ( 92 | self.plugin_namespace + '.test_', 93 | ) 94 | self.assertTrue(self.manager.import_plugin('module')) 95 | 96 | 97 | def register_plugin(manager): 98 | manager._plugin_loaded = True 99 | manager.register_widget( 100 | type='button', 101 | place='entry-actions', 102 | endpoint='test_x_x', 103 | filter=lambda f: True 104 | ) 105 | manager.register_widget( 106 | type='button', 107 | place='entry-actions', 108 | endpoint='test_a_x', 109 | filter=lambda f: f.mimetype.startswith('a/') 110 | ) 111 | manager.register_widget( 112 | type='button', 113 | place='entry-actions', 114 | endpoint='test_x_a', 115 | filter=lambda f: f.mimetype.endswith('/a') 116 | ) 117 | manager.register_widget( 118 | type='button', 119 | place='entry-actions', 120 | endpoint='test_a_a', 121 | filter=lambda f: f.mimetype == 'a/a' 122 | ) 123 | manager.register_widget( 124 | type='button', 125 | place='entry-actions', 126 | endpoint='test_b_x', 127 | filter=lambda f: f.mimetype.startswith('b/') 128 | ) 129 | 130 | test_plugin_blueprint = flask.Blueprint( 131 | 'test_plugin', 132 | __name__, 133 | url_prefix='/test_plugin_blueprint') 134 | test_plugin_blueprint.add_url_rule( 135 | '/', 136 | endpoint='root', 137 | view_func=lambda: 'test_plugin_root') 138 | 139 | manager.register_blueprint(test_plugin_blueprint) 140 | -------------------------------------------------------------------------------- /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/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/transform/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class StateMachine(object): 4 | ''' 5 | Abstract character-driven finite state machine implementation, used to 6 | chop down and transform strings. 7 | 8 | Useful for implementig simple transpilators, compressors and so on. 9 | 10 | Important: when implementing this class, you must set the :attr:`current` 11 | attribute to a key defined in :attr:`jumps` dict. 12 | ''' 13 | jumps = {} # finite state machine jumps 14 | start = '' # character which started current state 15 | current = '' # current state (an initial value must be set) 16 | pending = '' # unprocessed remaining data 17 | streaming = False # stream mode toggle 18 | 19 | @property 20 | def nearest(self): 21 | ''' 22 | Get the next state jump. 23 | 24 | The next jump is calculated looking at :attr:`current` state 25 | and its possible :attr:`jumps` to find the nearest and bigger 26 | option in :attr:`pending` data. 27 | 28 | If none is found, the returned next state label will be None. 29 | 30 | :returns: tuple with index, substring and next state label 31 | :rtype: tuple 32 | ''' 33 | try: 34 | options = self.jumps[self.current] 35 | except KeyError: 36 | raise KeyError( 37 | 'Current state %r not defined in %s.jumps.' 38 | % (self.current, self.__class__) 39 | ) 40 | offset = len(self.start) 41 | index = len(self.pending) 42 | if self.streaming: 43 | index -= max(map(len, options)) 44 | key = (index, 1) 45 | result = (index, '', None) 46 | for amark, anext in options.items(): 47 | asize = len(amark) 48 | aindex = self.pending.find(amark, offset, index + asize) 49 | if aindex > -1: 50 | index = aindex 51 | akey = (aindex, -asize) 52 | if akey < key: 53 | key = akey 54 | result = (aindex, amark, anext) 55 | return result 56 | 57 | def __init__(self, data=''): 58 | ''' 59 | :param data: content will be added to pending data 60 | :type data: str 61 | ''' 62 | self.pending += data 63 | 64 | def __iter__(self): 65 | ''' 66 | Yield over result chunks, consuming :attr:`pending` data. 67 | 68 | On :attr:`streaming` mode, yield only finished states. 69 | 70 | On non :attr:`streaming` mode, yield last state's result chunk 71 | even if unfinished, consuming all pending data. 72 | 73 | :yields: transformation result chunka 74 | :ytype: str 75 | ''' 76 | index, mark, next = self.nearest 77 | while next is not None: 78 | data = self.transform(self.pending[:index], mark, next) 79 | self.start = mark 80 | self.current = next 81 | self.pending = self.pending[index:] 82 | if data: 83 | yield data 84 | index, mark, next = self.nearest 85 | if not self.streaming: 86 | data = self.transform(self.pending, mark, next) 87 | self.start = '' 88 | self.pending = '' 89 | if data: 90 | yield data 91 | 92 | def transform(self, data, mark, next): 93 | ''' 94 | Apply the appropriate transformation function on current state data, 95 | which is supposed to end at this point. 96 | 97 | It is expected transformation logic makes use of :attr:`start`, 98 | :attr:`current` and :attr:`streaming` instance attributes to 99 | bettee know the state is being left. 100 | 101 | :param data: string to transform (includes start) 102 | :type data: str 103 | :param mark: string producing the new state jump 104 | :type mark: str 105 | :param next: state is about to star, None on finish 106 | :type next: str or None 107 | 108 | :returns: transformed data 109 | :rtype: str 110 | ''' 111 | method = getattr(self, 'transform_%s' % self.current, None) 112 | return method(data, mark, next) if method else data 113 | 114 | def feed(self, data=''): 115 | ''' 116 | Optionally add pending data, switch into streaming mode, and yield 117 | result chunks. 118 | 119 | :yields: result chunks 120 | :ytype: str 121 | ''' 122 | self.streaming = True 123 | self.pending += data 124 | for i in self: 125 | yield i 126 | 127 | def finish(self, data=''): 128 | ''' 129 | Optionally add pending data, turn off streaming mode, and yield 130 | result chunks, which implies all pending data will be consumed. 131 | 132 | :yields: result chunks 133 | :ytype: str 134 | ''' 135 | self.pending += data 136 | self.streaming = False 137 | for i in self: 138 | yield i 139 | -------------------------------------------------------------------------------- /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/tests/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import os.path 4 | import tempfile 5 | import shutil 6 | import browsepy.__main__ 7 | 8 | 9 | class TestMain(unittest.TestCase): 10 | module = browsepy.__main__ 11 | 12 | def setUp(self): 13 | self.app = browsepy.app 14 | self.parser = self.module.ArgParse(sep=os.sep) 15 | self.base = tempfile.mkdtemp() 16 | self.exclude_file = os.path.join(self.base, '.ignore') 17 | with open(self.exclude_file, 'w') as f: 18 | f.write('.ignore\n') 19 | 20 | def tearDown(self): 21 | shutil.rmtree(self.base) 22 | 23 | def test_defaults(self): 24 | result = self.parser.parse_args([]) 25 | self.assertEqual(result.host, '127.0.0.1') 26 | self.assertEqual(result.port, 8080) 27 | self.assertEqual(result.directory, os.getcwd()) 28 | self.assertEqual(result.initial, None) 29 | self.assertEqual(result.removable, None) 30 | self.assertEqual(result.upload, None) 31 | self.assertListEqual(result.exclude, []) 32 | self.assertListEqual(result.exclude_from, []) 33 | self.assertEqual(result.plugin, []) 34 | 35 | def test_params(self): 36 | plugins = ['plugin_1', 'plugin_2', 'namespace.plugin_3'] 37 | result = self.parser.parse_args([ 38 | '127.1.1.1', 39 | '5000', 40 | '--directory=%s' % self.base, 41 | '--initial=%s' % self.base, 42 | '--removable=%s' % self.base, 43 | '--upload=%s' % self.base, 44 | '--exclude=a', 45 | '--exclude-from=%s' % self.exclude_file, 46 | ] + [ 47 | '--plugin=%s' % plugin 48 | for plugin in plugins 49 | ]) 50 | self.assertEqual(result.host, '127.1.1.1') 51 | self.assertEqual(result.port, 5000) 52 | self.assertEqual(result.directory, self.base) 53 | self.assertEqual(result.initial, self.base) 54 | self.assertEqual(result.removable, self.base) 55 | self.assertEqual(result.upload, self.base) 56 | self.assertListEqual(result.exclude, ['a']) 57 | self.assertListEqual(result.exclude_from, [self.exclude_file]) 58 | self.assertEqual(result.plugin, plugins) 59 | 60 | result = self.parser.parse_args([ 61 | '--directory', self.base, 62 | '--plugin', ','.join(plugins), 63 | '--exclude', '/.*' 64 | ]) 65 | self.assertEqual(result.directory, self.base) 66 | self.assertEqual(result.plugin, plugins) 67 | self.assertListEqual(result.exclude, ['/.*']) 68 | 69 | result = self.parser.parse_args([ 70 | '--directory=%s' % self.base, 71 | '--initial=' 72 | ]) 73 | self.assertEqual(result.host, '127.0.0.1') 74 | self.assertEqual(result.port, 8080) 75 | self.assertEqual(result.directory, self.base) 76 | self.assertIsNone(result.initial) 77 | self.assertIsNone(result.removable) 78 | self.assertIsNone(result.upload) 79 | self.assertListEqual(result.exclude, []) 80 | self.assertListEqual(result.exclude_from, []) 81 | self.assertListEqual(result.plugin, []) 82 | 83 | self.assertRaises( 84 | SystemExit, 85 | self.parser.parse_args, 86 | ['--directory=%s' % __file__] 87 | ) 88 | 89 | self.assertRaises( 90 | SystemExit, 91 | self.parser.parse_args, 92 | ['--exclude-from=non-existing'] 93 | ) 94 | 95 | def test_exclude(self): 96 | result = self.parser.parse_args([ 97 | '--exclude', '/.*', 98 | '--exclude-from', self.exclude_file, 99 | ]) 100 | extra = self.module.collect_exclude_patterns(result.exclude_from) 101 | self.assertListEqual(extra, ['.ignore']) 102 | match = self.module.create_exclude_fnc( 103 | result.exclude + extra, '/b', sep='/') 104 | self.assertTrue(match('/b/.a')) 105 | self.assertTrue(match('/b/.a/b')) 106 | self.assertFalse(match('/b/a/.a')) 107 | self.assertTrue(match('/b/a/.ignore')) 108 | 109 | match = self.module.create_exclude_fnc( 110 | result.exclude + extra, 'C:\\b', sep='\\') 111 | self.assertTrue(match('C:\\b\\.a')) 112 | self.assertTrue(match('C:\\b\\.a\\b')) 113 | self.assertFalse(match('C:\\b\\a\\.a')) 114 | self.assertTrue(match('C:\\b\\a\\.ignore')) 115 | 116 | def test_main(self): 117 | params = {} 118 | self.module.main( 119 | argv=[], 120 | run_fnc=lambda app, **kwargs: params.update(kwargs) 121 | ) 122 | 123 | defaults = { 124 | 'host': '127.0.0.1', 125 | 'port': 8080, 126 | 'debug': False, 127 | 'threaded': True 128 | } 129 | params_subset = {k: v for k, v in params.items() if k in defaults} 130 | self.assertEqual(defaults, params_subset) 131 | 132 | def test_filter_union(self): 133 | fu = self.module.filter_union 134 | self.assertIsNone(fu()) 135 | self.assertIsNone(fu(None)) 136 | self.assertIsNone(fu(None, None)) 137 | 138 | def fnc1(path): 139 | return False 140 | 141 | self.assertEqual(fu(fnc1), fnc1) 142 | 143 | def fnc2(path): 144 | return True 145 | 146 | self.assertTrue(fu(fnc1, fnc2)('a')) 147 | -------------------------------------------------------------------------------- /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/__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/transform/glob.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import warnings 4 | 5 | from unicategories import categories as unicat, RangeGroup as ranges 6 | 7 | from ..compat import re_escape, chr 8 | from . import StateMachine 9 | 10 | 11 | class GlobTransform(StateMachine): 12 | jumps = { 13 | 'start': { 14 | '': 'text', 15 | '/': 'sep', 16 | }, 17 | 'text': { 18 | '*': 'wildcard', 19 | '**': 'wildcard', 20 | '?': 'wildcard', 21 | '[': 'range', 22 | '[!': 'range', 23 | '[]': 'range', 24 | '{': 'group', 25 | ',': 'group', 26 | '}': 'group', 27 | '\\': 'literal', 28 | '/': 'sep', 29 | }, 30 | 'sep': { 31 | '': 'text', 32 | }, 33 | 'literal': { 34 | c: 'text' for c in ('\\', '*', '?', '[', '{', '}', ',', '/', '') 35 | }, 36 | 'wildcard': { 37 | '': 'text', 38 | }, 39 | 'range': { 40 | '/': 'range_sep', 41 | ']': 'range_close', 42 | '[.': 'posix_collating_symbol', 43 | '[:': 'posix_character_class', 44 | '[=': 'posix_equivalence_class', 45 | }, 46 | 'range_sep': { 47 | '': 'range', 48 | }, 49 | 'range_ignore': { 50 | '': 'range', 51 | }, 52 | 'range_close': { 53 | '': 'text', 54 | }, 55 | 'posix_collating_symbol': { 56 | '.]': 'range_ignore', 57 | }, 58 | 'posix_character_class': { 59 | ':]': 'range_ignore', 60 | }, 61 | 'posix_equivalence_class': { 62 | '=]': 'range_ignore', 63 | }, 64 | 'group': { 65 | '': 'text', 66 | }, 67 | } 68 | character_classes = { 69 | 'alnum': ( 70 | # [\p{L}\p{Nl}\p{Nd}] 71 | unicat['L'] + unicat['Nl'] + unicat['Nd'] 72 | ), 73 | 'alpha': ( 74 | # \p{L}\p{Nl} 75 | unicat['L'] + unicat['Nl'] 76 | ), 77 | 'ascii': ( 78 | # [\x00-\x7F] 79 | ranges(((0, 0x80),)) 80 | ), 81 | 'blank': ( 82 | # [\p{Zs}\t] 83 | unicat['Zs'] + ranges(((9, 10),)) 84 | ), 85 | 'cntrl': ( 86 | # \p{Cc} 87 | unicat['Cc'] 88 | ), 89 | 'digit': ( 90 | # \p{Nd} 91 | unicat['Nd'] 92 | ), 93 | 'graph': ( 94 | # [^\p{Z}\p{C}] 95 | unicat['M'] + unicat['L'] + unicat['N'] + unicat['P'] + unicat['S'] 96 | ), 97 | 'lower': ( 98 | # \p{Ll} 99 | unicat['Ll'] 100 | ), 101 | 'print': ( 102 | # \P{C} 103 | unicat['C'] 104 | ), 105 | 'punct': ( 106 | # \p{P} 107 | unicat['P'] 108 | ), 109 | 'space': ( 110 | # [\p{Z}\t\n\v\f\r] 111 | unicat['Z'] + ranges(((9, 14),)) 112 | ), 113 | 'upper': ( 114 | # \p{Lu} 115 | unicat['Lu'] 116 | ), 117 | 'word': ( 118 | # [\p{L}\p{Nl}\p{Nd}\p{Pc}] 119 | unicat['L'] + unicat['Nl'] + unicat['Nd'] + unicat['Pc'] 120 | ), 121 | 'xdigit': ( 122 | # [0-9A-Fa-f] 123 | ranges(((48, 58), (65, 71), (97, 103))) 124 | ), 125 | } 126 | current = 'start' 127 | deferred = False 128 | 129 | def __init__(self, data, sep=os.sep, base=None): 130 | self.sep = sep 131 | self.base = base or '' 132 | self.deferred_data = [] 133 | self.deep = 0 134 | super(GlobTransform, self).__init__(data) 135 | 136 | def transform(self, data, mark, next): 137 | data = super(GlobTransform, self).transform(data, mark, next) 138 | if self.deferred: 139 | self.deferred_data.append(data) 140 | data = '' 141 | elif self.deferred_data: 142 | data = ''.join(self.deferred_data) + data 143 | self.deferred_data[:] = () 144 | return data 145 | 146 | def transform_posix_collating_symbol(self, data, mark, next): 147 | warnings.warn( 148 | 'Posix collating symbols (like %s%s) are not supported.' 149 | % (data, mark)) 150 | return None 151 | 152 | def transform_posix_character_class(self, data, mark, next): 153 | name = data[len(self.start):] 154 | if name not in self.character_classes: 155 | warnings.warn( 156 | 'Posix character class %s is not supported.' 157 | % name) 158 | return None 159 | return ''.join( 160 | chr(start) 161 | if 1 == end - start else 162 | '%s-%s' % (chr(start), chr(end - 1)) 163 | for start, end in self.character_classes[name] 164 | ) 165 | 166 | def transform_posix_equivalence_class(self, data, mark, next): 167 | warnings.warn( 168 | 'Posix equivalence class expresions (like %s%s) are not supported.' 169 | % (data, mark)) 170 | return None 171 | 172 | def transform_wildcard(self, data, mark, next): 173 | if self.start == '**': 174 | return '.*' 175 | if self.start == '*': 176 | return '[^%s]*' % re_escape(self.sep) 177 | return '[^%s]' % re_escape(self.sep) 178 | 179 | def transform_text(self, data, mark, next): 180 | if next is None: 181 | return '%s(%s|$)' % (re_escape(data), re_escape(self.sep)) 182 | return re_escape(data) 183 | 184 | def transform_sep(self, data, mark, next): 185 | return re_escape(self.sep) 186 | 187 | def transform_literal(self, data, mark, next): 188 | return data[len(self.start):] 189 | 190 | def transform_range(self, data, mark, next): 191 | self.deferred = True 192 | if self.start == '[!': 193 | return '[^%s' % data[2:] 194 | if self.start == '[]': 195 | return '[\\]%s' % data[2:] 196 | return data 197 | 198 | def transform_range_sep(self, data, mark, next): 199 | return re_escape(self.sep) 200 | 201 | def transform_range_close(self, data, mark, next): 202 | self.deferred = False 203 | if None in self.deferred_data: 204 | self.deferred_data[:] = () 205 | return '.' 206 | return data 207 | 208 | def transform_range_ignore(self, data, mark, next): 209 | return '' 210 | 211 | def transform_group(self, data, mark, next): 212 | if self.start == '{': 213 | self.deep += 1 214 | return '(' 215 | if self.start == ',' and self.deep: 216 | return '|' 217 | if self.start == '}' and self.deep: 218 | self.deep -= 1 219 | return ')' 220 | return data 221 | 222 | def transform_start(self, data, mark, next): 223 | if mark == '/': 224 | return '^%s' % re_escape(self.base) 225 | return re_escape(self.sep) 226 | 227 | 228 | def translate(data, sep=os.sep, base=None): 229 | self = GlobTransform(data, sep, base) 230 | return ''.join(self) 231 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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} -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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`. 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/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 | -------------------------------------------------------------------------------- /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/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 | import errno 15 | 16 | FS_ENCODING = sys.getfilesystemencoding() 17 | PY_LEGACY = sys.version_info < (3, ) 18 | TRUE_VALUES = frozenset(('true', 'yes', '1', 'enable', 'enabled', True, 1)) 19 | 20 | try: 21 | from os import scandir, walk 22 | except ImportError: 23 | from scandir import scandir, walk # noqa 24 | 25 | try: 26 | from shutil import get_terminal_size 27 | except ImportError: 28 | from backports.shutil_get_terminal_size import get_terminal_size # noqa 29 | 30 | 31 | def isexec(path): 32 | ''' 33 | Check if given path points to an executable file. 34 | 35 | :param path: file path 36 | :type path: str 37 | :return: True if executable, False otherwise 38 | :rtype: bool 39 | ''' 40 | return os.path.isfile(path) and os.access(path, os.X_OK) 41 | 42 | 43 | def fsdecode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None): 44 | ''' 45 | Decode given path. 46 | 47 | :param path: path will be decoded if using bytes 48 | :type path: bytes or str 49 | :param os_name: operative system name, defaults to os.name 50 | :type os_name: str 51 | :param fs_encoding: current filesystem encoding, defaults to autodetected 52 | :type fs_encoding: str 53 | :return: decoded path 54 | :rtype: str 55 | ''' 56 | if not isinstance(path, bytes): 57 | return path 58 | if not errors: 59 | use_strict = PY_LEGACY or os_name == 'nt' 60 | errors = 'strict' if use_strict else 'surrogateescape' 61 | return path.decode(fs_encoding, errors=errors) 62 | 63 | 64 | def fsencode(path, os_name=os.name, fs_encoding=FS_ENCODING, errors=None): 65 | ''' 66 | Encode given path. 67 | 68 | :param path: path will be encoded if not using bytes 69 | :type path: bytes or str 70 | :param os_name: operative system name, defaults to os.name 71 | :type os_name: str 72 | :param fs_encoding: current filesystem encoding, defaults to autodetected 73 | :type fs_encoding: str 74 | :return: encoded path 75 | :rtype: bytes 76 | ''' 77 | if isinstance(path, bytes): 78 | return path 79 | if not errors: 80 | use_strict = PY_LEGACY or os_name == 'nt' 81 | errors = 'strict' if use_strict else 'surrogateescape' 82 | return path.encode(fs_encoding, errors=errors) 83 | 84 | 85 | def getcwd(fs_encoding=FS_ENCODING, cwd_fnc=os.getcwd): 86 | ''' 87 | Get current work directory's absolute path. 88 | Like os.getcwd but garanteed to return an unicode-str object. 89 | 90 | :param fs_encoding: filesystem encoding, defaults to autodetected 91 | :type fs_encoding: str 92 | :param cwd_fnc: callable used to get the path, defaults to os.getcwd 93 | :type cwd_fnc: Callable 94 | :return: path 95 | :rtype: str 96 | ''' 97 | path = fsdecode(cwd_fnc(), fs_encoding=fs_encoding) 98 | return os.path.abspath(path) 99 | 100 | 101 | def getdebug(environ=os.environ, true_values=TRUE_VALUES): 102 | ''' 103 | Get if app is expected to be ran in debug mode looking at environment 104 | variables. 105 | 106 | :param environ: environment dict-like object 107 | :type environ: collections.abc.Mapping 108 | :returns: True if debug contains a true-like string, False otherwise 109 | :rtype: bool 110 | ''' 111 | return environ.get('DEBUG', '').lower() in true_values 112 | 113 | 114 | def deprecated(func_or_text, environ=os.environ): 115 | ''' 116 | Decorator used to mark functions as deprecated. It will result in a 117 | warning being emmitted hen the function is called. 118 | 119 | Usage: 120 | 121 | >>> @deprecated 122 | ... def fnc(): 123 | ... pass 124 | 125 | Usage (custom message): 126 | 127 | >>> @deprecated('This is deprecated') 128 | ... def fnc(): 129 | ... pass 130 | 131 | :param func_or_text: message or callable to decorate 132 | :type func_or_text: callable 133 | :param environ: optional environment mapping 134 | :type environ: collections.abc.Mapping 135 | :returns: nested decorator or new decorated function (depending on params) 136 | :rtype: callable 137 | ''' 138 | def inner(func): 139 | message = ( 140 | 'Deprecated function {}.'.format(func.__name__) 141 | if callable(func_or_text) else 142 | func_or_text 143 | ) 144 | 145 | @functools.wraps(func) 146 | def new_func(*args, **kwargs): 147 | with warnings.catch_warnings(): 148 | if getdebug(environ): 149 | warnings.simplefilter('always', DeprecationWarning) 150 | warnings.warn(message, category=DeprecationWarning, 151 | stacklevel=3) 152 | return func(*args, **kwargs) 153 | return new_func 154 | return inner(func_or_text) if callable(func_or_text) else inner 155 | 156 | 157 | def usedoc(other): 158 | ''' 159 | Decorator which copies __doc__ of given object into decorated one. 160 | 161 | Usage: 162 | 163 | >>> def fnc1(): 164 | ... """docstring""" 165 | ... pass 166 | >>> @usedoc(fnc1) 167 | ... def fnc2(): 168 | ... pass 169 | >>> fnc2.__doc__ 170 | 'docstring'collections.abc.D 171 | 172 | :param other: anything with a __doc__ attribute 173 | :type other: any 174 | :returns: decorator function 175 | :rtype: callable 176 | ''' 177 | def inner(fnc): 178 | fnc.__doc__ = fnc.__doc__ or getattr(other, '__doc__') 179 | return fnc 180 | return inner 181 | 182 | 183 | def pathsplit(value, sep=os.pathsep): 184 | ''' 185 | Get enviroment PATH elements as list. 186 | 187 | This function only cares about spliting across OSes. 188 | 189 | :param value: path string, as given by os.environ['PATH'] 190 | :type value: str 191 | :param sep: PATH separator, defaults to os.pathsep 192 | :type sep: str 193 | :yields: every path 194 | :ytype: str 195 | ''' 196 | for part in value.split(sep): 197 | if part[:1] == part[-1:] == '"' or part[:1] == part[-1:] == '\'': 198 | part = part[1:-1] 199 | yield part 200 | 201 | 202 | def pathparse(value, sep=os.pathsep, os_sep=os.sep): 203 | ''' 204 | Get enviroment PATH directories as list. 205 | 206 | This function cares about spliting, escapes and normalization of paths 207 | across OSes. 208 | 209 | :param value: path string, as given by os.environ['PATH'] 210 | :type value: str 211 | :param sep: PATH separator, defaults to os.pathsep 212 | :type sep: str 213 | :param os_sep: OS filesystem path separator, defaults to os.sep 214 | :type os_sep: str 215 | :yields: every path 216 | :ytype: str 217 | ''' 218 | escapes = [] 219 | normpath = ntpath.normpath if os_sep == '\\' else posixpath.normpath 220 | if '\\' not in (os_sep, sep): 221 | escapes.extend(( 222 | ('\\\\', '', '\\'), 223 | ('\\"', '', '"'), 224 | ('\\\'', '', '\''), 225 | ('\\%s' % sep, '', sep), 226 | )) 227 | for original, escape, unescape in escapes: 228 | value = value.replace(original, escape) 229 | for part in pathsplit(value, sep=sep): 230 | if part[-1:] == os_sep and part != os_sep: 231 | part = part[:-1] 232 | for original, escape, unescape in escapes: 233 | part = part.replace(escape, unescape) 234 | yield normpath(fsdecode(part)) 235 | 236 | 237 | def pathconf(path, 238 | os_name=os.name, 239 | isdir_fnc=os.path.isdir, 240 | pathconf_fnc=getattr(os, 'pathconf', None), 241 | pathconf_names=getattr(os, 'pathconf_names', ())): 242 | ''' 243 | Get all pathconf variables for given path. 244 | 245 | :param path: absolute fs path 246 | :type path: str 247 | :returns: dictionary containing pathconf keys and their values (both str) 248 | :rtype: dict 249 | ''' 250 | 251 | if pathconf_fnc and pathconf_names: 252 | pathconf_output = {} 253 | for key in pathconf_names: 254 | try: 255 | pathconf_output[key] = pathconf_fnc(path, key) 256 | except OSError as exc: 257 | if exc.errno != errno.EINVAL: 258 | raise 259 | return pathconf_output 260 | if os_name == 'nt': 261 | maxpath = 246 if isdir_fnc(path) else 259 # 260 minus 262 | else: 263 | maxpath = 255 # conservative sane default 264 | return { 265 | 'PC_PATH_MAX': maxpath, 266 | 'PC_NAME_MAX': maxpath - len(path), 267 | } 268 | 269 | 270 | ENV_PATH = tuple(pathparse(os.getenv('PATH', ''))) 271 | ENV_PATHEXT = tuple(pathsplit(os.getenv('PATHEXT', ''))) 272 | 273 | 274 | def which(name, 275 | env_path=ENV_PATH, 276 | env_path_ext=ENV_PATHEXT, 277 | is_executable_fnc=isexec, 278 | path_join_fnc=os.path.join, 279 | os_name=os.name): 280 | ''' 281 | Get command absolute path. 282 | 283 | :param name: name of executable command 284 | :type name: str 285 | :param env_path: OS environment executable paths, defaults to autodetected 286 | :type env_path: list of str 287 | :param is_executable_fnc: callable will be used to detect if path is 288 | executable, defaults to `isexec` 289 | :type is_executable_fnc: Callable 290 | :param path_join_fnc: callable will be used to join path components 291 | :type path_join_fnc: Callable 292 | :param os_name: os name, defaults to os.name 293 | :type os_name: str 294 | :return: absolute path 295 | :rtype: str or None 296 | ''' 297 | for path in env_path: 298 | for suffix in env_path_ext: 299 | exe_file = path_join_fnc(path, name) + suffix 300 | if is_executable_fnc(exe_file): 301 | return exe_file 302 | return None 303 | 304 | 305 | def re_escape(pattern, chars=frozenset("()[]{}?*+|^$\\.-#")): 306 | ''' 307 | Escape all special regex characters in pattern. 308 | Logic taken from regex module. 309 | 310 | :param pattern: regex pattern to escape 311 | :type patterm: str 312 | :returns: escaped pattern 313 | :rtype: str 314 | ''' 315 | escape = '\\{}'.format 316 | return ''.join( 317 | escape(c) if c in chars or c.isspace() else 318 | '\\000' if c == '\x00' else c 319 | for c in pattern 320 | ) 321 | 322 | 323 | if PY_LEGACY: 324 | FileNotFoundError = OSError # noqa 325 | range = xrange # noqa 326 | filter = itertools.ifilter 327 | basestring = basestring # noqa 328 | unicode = unicode # noqa 329 | chr = unichr # noqa 330 | bytes = str # noqa 331 | else: 332 | FileNotFoundError = FileNotFoundError 333 | range = range 334 | filter = filter 335 | basestring = str 336 | unicode = str 337 | chr = chr 338 | bytes = bytes 339 | --------------------------------------------------------------------------------