From 1e6456bf54bb37743d22fe564b475f10bc13281e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 30 Jun 2014 22:50:56 +0200 Subject: Initial commit. --- .gitignore | 6 + .travis.yml | 22 ++ LICENSE | 31 +++ MANIFEST.in | 8 + Makefile | 8 + README | 27 ++ docs/Makefile | 130 +++++++++ docs/_static/itsdangerous.png | Bin 0 -> 18971 bytes docs/_themes/.gitignore | 3 + docs/_themes/LICENSE | 37 +++ docs/_themes/README | 31 +++ docs/_themes/flask_small/layout.html | 25 ++ docs/_themes/flask_small/static/flasky.css_t | 284 ++++++++++++++++++++ docs/_themes/flask_small/theme.conf | 10 + docs/_themes/flask_theme_support.py | 86 ++++++ docs/conf.py | 214 +++++++++++++++ docs/index.rst | 155 +++++++++++ docs/make.bat | 155 +++++++++++ pluginbase.py | 382 +++++++++++++++++++++++++++ setup.py | 21 ++ tests/conftest.py | 19 ++ tests/dummy.py | 1 + tests/plugins/hello.py | 12 + tests/plugins/hello2.py | 2 + tests/test_basics.py | 91 +++++++ tox.ini | 7 + 26 files changed, 1767 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README create mode 100644 docs/Makefile create mode 100644 docs/_static/itsdangerous.png create mode 100644 docs/_themes/.gitignore create mode 100644 docs/_themes/LICENSE create mode 100644 docs/_themes/README create mode 100644 docs/_themes/flask_small/layout.html create mode 100644 docs/_themes/flask_small/static/flasky.css_t create mode 100644 docs/_themes/flask_small/theme.conf create mode 100644 docs/_themes/flask_theme_support.py create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 pluginbase.py create mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/dummy.py create mode 100644 tests/plugins/hello.py create mode 100644 tests/plugins/hello2.py create mode 100644 tests/test_basics.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5983dcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +*.pyo +*.egg-info +dist +build +docs/_build diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2dc83fe --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: python +python: + - "2.6" + - "2.7" + - "3.3" + - "3.4" + - "pypy" + +install: + - pip install --editable . + +script: make test + +notifications: + email: false + irc: + channels: + - "chat.freenode.net#pocoo" + on_success: change + on_failure: always + use_notice: true + skip_join: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..abbc48e --- /dev/null +++ b/LICENSE @@ -0,0 +1,31 @@ +Copyright (c) 2014 by Armin Ronacher. + +Some rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * The names of the contributors may not be used to endorse or + promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1cd3a86 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include Makefile LICENSE +recursive-include tests * +recursive-include docs * +recursive-exclude docs *.pyc +recursive-exclude docs *.pyo +recursive-exclude tests *.pyc +recursive-exclude tests *.pyo +prune docs/_build diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c725c3b --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +test: + @cd tests; PYTHONPATH=.. py.test + +upload-docs: + $(MAKE) -C docs dirhtml + rsync -a docs/_build/dirhtml/* flow.srv.pocoo.org:/srv/websites/pluginbase.pocoo.org/static/ + +.PHONY: test upload-docs diff --git a/README b/README new file mode 100644 index 0000000..86738b3 --- /dev/null +++ b/README @@ -0,0 +1,27 @@ + + { pluginbase } + + PluginBase is a module for Python that enables the + development of flexible plugin systems in Python. + + Step 1: + + from pluginbase import PluginBase + plugin_base = PluginBase(package='yourapplication.plugins') + + Step 2: + + plugin_source = plugin_base.make_plugin_source( + searchpath=['./path/to/plugins', './path/to/more/plugins']) + + Step 3: + + with plugin_source: + from yourapplication.plugins import my_plugin + my_plugin.do_something_cool() + + Or alternatively: + + my_plugin = plugin_source.load_plugin('my_plugin') + my_plugin.do_something_cool() + diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..9bca27f --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,130 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Classy.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Classy.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Classy" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Classy" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ + "run these through (pdf)latex." + +latexpdf: latex + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + make -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/_static/itsdangerous.png b/docs/_static/itsdangerous.png new file mode 100644 index 0000000..3879e85 Binary files /dev/null and b/docs/_static/itsdangerous.png differ diff --git a/docs/_themes/.gitignore b/docs/_themes/.gitignore new file mode 100644 index 0000000..66b6e4c --- /dev/null +++ b/docs/_themes/.gitignore @@ -0,0 +1,3 @@ +*.pyc +*.pyo +.DS_Store diff --git a/docs/_themes/LICENSE b/docs/_themes/LICENSE new file mode 100644 index 0000000..8daab7e --- /dev/null +++ b/docs/_themes/LICENSE @@ -0,0 +1,37 @@ +Copyright (c) 2010 by Armin Ronacher. + +Some rights reserved. + +Redistribution and use in source and binary forms of the theme, with or +without modification, are permitted provided that the following conditions +are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +* The names of the contributors may not be used to endorse or + promote products derived from this software without specific + prior written permission. + +We kindly ask you to only use these themes in an unmodified manner just +for Flask and Flask-related products, not for unrelated projects. If you +like the visual style and want to use it for your own projects, please +consider making some larger changes to the themes (such as changing +font faces, sizes, colors or margins). + +THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/_themes/README b/docs/_themes/README new file mode 100644 index 0000000..b3292bd --- /dev/null +++ b/docs/_themes/README @@ -0,0 +1,31 @@ +Flask Sphinx Styles +=================== + +This repository contains sphinx styles for Flask and Flask related +projects. To use this style in your Sphinx documentation, follow +this guide: + +1. put this folder as _themes into your docs folder. Alternatively + you can also use git submodules to check out the contents there. +2. add this to your conf.py: + + sys.path.append(os.path.abspath('_themes')) + html_theme_path = ['_themes'] + html_theme = 'flask' + +The following themes exist: + +- 'flask' - the standard flask documentation theme for large + projects +- 'flask_small' - small one-page theme. Intended to be used by + very small addon libraries for flask. + +The following options exist for the flask_small theme: + + [options] + index_logo = '' filename of a picture in _static + to be used as replacement for the + h1 in the index.rst file. + index_logo_height = 120px height of the index logo + github_fork = '' repository name on github for the + "fork me" badge diff --git a/docs/_themes/flask_small/layout.html b/docs/_themes/flask_small/layout.html new file mode 100644 index 0000000..7d4d997 --- /dev/null +++ b/docs/_themes/flask_small/layout.html @@ -0,0 +1,25 @@ +{% extends "basic/layout.html" %} +{% block header %} + + {{ super() }} + {% if pagename == 'index' %} +
+ {% endif %} +{% endblock %} +{% block footer %} + {% if pagename == 'index' %} +
+ {% endif %} +{% endblock %} +{# do not display relbars #} +{% block relbar1 %}{% endblock %} +{% block relbar2 %} + {% if theme_github_fork %} + Fork me on GitHub + {% endif %} +{% endblock %} +{% block sidebar1 %}{% endblock %} +{% block sidebar2 %}{% endblock %} diff --git a/docs/_themes/flask_small/static/flasky.css_t b/docs/_themes/flask_small/static/flasky.css_t new file mode 100644 index 0000000..ffc853d --- /dev/null +++ b/docs/_themes/flask_small/static/flasky.css_t @@ -0,0 +1,284 @@ +/* + * flasky.css_t + * ~~~~~~~~~~~~ + * + * Sphinx stylesheet -- flasky theme based on nature theme. + * + * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. + * :license: BSD, see LICENSE for details. + * + */ + +@import url("basic.css"); +@import url(http://fonts.googleapis.com/css?family=Karla:400); +{% set font_family = "'Karla', sans-serif" %} + +/* -- page layout ----------------------------------------------------------- */ + +body { + font-family: {{ font_family }}; + font-weight: normal; + font-size: 17px; + color: #000; + background: white; + margin: 0; + padding: 0; +} + +div.documentwrapper { + float: left; + width: 100%; +} + +div.bodywrapper { + margin: 40px auto 0 auto; + width: 700px; +} + +hr { + border: 1px solid #B1B4B6; +} + +div.body { + background-color: #ffffff; + color: #3E4349; + padding: 0 30px 30px 30px; +} + +img.floatingflask { + padding: 0 0 10px 10px; + float: right; +} + +div.footer { + text-align: right; + color: #888; + padding: 10px; + font-size: 14px; + width: 650px; + margin: 0 auto 40px auto; +} + +div.footer a { + color: #888; + text-decoration: underline; +} + +div.related { + line-height: 32px; + color: #888; +} + +div.related ul { + padding: 0 0 0 10px; +} + +div.related a { + color: #444; +} + +/* -- body styles ----------------------------------------------------------- */ + +a { + color: #215974; + text-decoration: underline; +} + +a:hover { + color: #888; + text-decoration: underline; +} + +div.body { + padding-bottom: 40px; /* saved for footer */ +} + +div.body h1, +div.body h2, +div.body h3, +div.body h4, +div.body h5, +div.body h6 { + font-family: {{ font_family }}; + font-weight: normal; + margin: 30px 0px 10px 0px; + padding: 0; + color: black; +} + +div.body h1 { font-size: 240%; } +div.body h2 { font-size: 180%; } +div.body h3 { font-size: 150%; } +div.body h4 { font-size: 130%; } +div.body h5 { font-size: 100%; } +div.body h6 { font-size: 100%; } + +a.headerlink { + color: white; + padding: 0 4px; + text-decoration: none; +} + +a.headerlink:hover { + color: #444; + background: #eaeaea; +} + +div.body p, div.body dd, div.body li, div.body blockquote { + line-height: 1.3em; +} + +div.admonition { + background: #fafafa; + margin: 20px -30px; + padding: 10px 30px; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; +} + +div.admonition p.admonition-title { + font-family: 'Garamond', 'Georgia', serif; + font-weight: normal; + font-size: 24px; + margin: 0 0 10px 0; + padding: 0; + line-height: 1; +} + +div.admonition p.last { + margin-bottom: 0; +} + +div.highlight{ + background-color: white; +} + +dt:target, .highlight { + background: #FAF3E8; +} + +div.note { + background-color: #eee; + border: 1px solid #ccc; +} + +div.seealso { + background-color: #ffc; + border: 1px solid #ff6; +} + +div.topic { + background-color: #eee; +} + +div.warning { + background-color: #ffe4e4; + border: 1px solid #f66; +} + +p.admonition-title { + display: inline; +} + +p.admonition-title:after { + content: ":"; +} + +pre, tt { + font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; + font-size: 0.85em; +} + +img.screenshot { +} + +tt.descname, tt.descclassname { + font-size: 0.95em; +} + +tt.descname { + padding-right: 0.08em; +} + +img.screenshot { + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils { + border: 1px solid #888; + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils td, table.docutils th { + border: 1px solid #888; + padding: 0.25em 0.7em; +} + +table.field-list, table.footnote { + border: none; + -moz-box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +table.footnote { + margin: 15px 0; + width: 100%; + border: 1px solid #eee; +} + +table.field-list th { + padding: 0 0.8em 0 0; +} + +table.field-list td { + padding: 0; +} + +table.footnote td { + padding: 0.5em; +} + +dl { + margin: 0; + padding: 0; +} + +dl dd { + margin-left: 30px; +} + +pre { + padding: 0; + margin: 15px -30px; + padding: 8px; + line-height: 1.3em; + padding: 7px 30px; + background: #eee; + border-radius: 2px; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; +} + +dl pre { + margin-left: -60px; + padding-left: 60px; +} + +tt { + background-color: #ecf0f3; + color: #222; + /* padding: 1px 2px; */ +} + +tt.xref, a tt { + background-color: #FBFBFB; +} + +a:hover tt { + background: #EEE; +} diff --git a/docs/_themes/flask_small/theme.conf b/docs/_themes/flask_small/theme.conf new file mode 100644 index 0000000..51ed3a4 --- /dev/null +++ b/docs/_themes/flask_small/theme.conf @@ -0,0 +1,10 @@ +[theme] +inherit = basic +stylesheet = flasky.css +nosidebar = true +pygments_style = flask_theme_support.FlaskyStyle + +[options] +index_logo = '' +index_logo_height = 160px +github_fork = diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py new file mode 100644 index 0000000..33f4744 --- /dev/null +++ b/docs/_themes/flask_theme_support.py @@ -0,0 +1,86 @@ +# flasky extensions. flasky pygments style based on tango style +from pygments.style import Style +from pygments.token import Keyword, Name, Comment, String, Error, \ + Number, Operator, Generic, Whitespace, Punctuation, Other, Literal + + +class FlaskyStyle(Style): + background_color = "#f8f8f8" + default_style = "" + + styles = { + # No corresponding class for the following: + #Text: "", # class: '' + Whitespace: "underline #f8f8f8", # class: 'w' + Error: "#a40000 border:#ef2929", # class: 'err' + Other: "#000000", # class 'x' + + Comment: "italic #8f5902", # class: 'c' + Comment.Preproc: "noitalic", # class: 'cp' + + Keyword: "bold #004461", # class: 'k' + Keyword.Constant: "bold #004461", # class: 'kc' + Keyword.Declaration: "bold #004461", # class: 'kd' + Keyword.Namespace: "bold #004461", # class: 'kn' + Keyword.Pseudo: "bold #004461", # class: 'kp' + Keyword.Reserved: "bold #004461", # class: 'kr' + Keyword.Type: "bold #004461", # class: 'kt' + + Operator: "#582800", # class: 'o' + Operator.Word: "bold #004461", # class: 'ow' - like keywords + + Punctuation: "bold #000000", # class: 'p' + + # because special names such as Name.Class, Name.Function, etc. + # are not recognized as such later in the parsing, we choose them + # to look the same as ordinary variables. + Name: "#000000", # class: 'n' + Name.Attribute: "#c4a000", # class: 'na' - to be revised + Name.Builtin: "#004461", # class: 'nb' + Name.Builtin.Pseudo: "#3465a4", # class: 'bp' + Name.Class: "#000000", # class: 'nc' - to be revised + Name.Constant: "#000000", # class: 'no' - to be revised + Name.Decorator: "#888", # class: 'nd' - to be revised + Name.Entity: "#ce5c00", # class: 'ni' + Name.Exception: "bold #cc0000", # class: 'ne' + Name.Function: "#000000", # class: 'nf' + Name.Property: "#000000", # class: 'py' + Name.Label: "#f57900", # class: 'nl' + Name.Namespace: "#000000", # class: 'nn' - to be revised + Name.Other: "#000000", # class: 'nx' + Name.Tag: "bold #004461", # class: 'nt' - like a keyword + Name.Variable: "#000000", # class: 'nv' - to be revised + Name.Variable.Class: "#000000", # class: 'vc' - to be revised + Name.Variable.Global: "#000000", # class: 'vg' - to be revised + Name.Variable.Instance: "#000000", # class: 'vi' - to be revised + + Number: "#990000", # class: 'm' + + Literal: "#000000", # class: 'l' + Literal.Date: "#000000", # class: 'ld' + + String: "#4e9a06", # class: 's' + String.Backtick: "#4e9a06", # class: 'sb' + String.Char: "#4e9a06", # class: 'sc' + String.Doc: "italic #8f5902", # class: 'sd' - like a comment + String.Double: "#4e9a06", # class: 's2' + String.Escape: "#4e9a06", # class: 'se' + String.Heredoc: "#4e9a06", # class: 'sh' + String.Interpol: "#4e9a06", # class: 'si' + String.Other: "#4e9a06", # class: 'sx' + String.Regex: "#4e9a06", # class: 'sr' + String.Single: "#4e9a06", # class: 's1' + String.Symbol: "#4e9a06", # class: 'ss' + + Generic: "#000000", # class: 'g' + Generic.Deleted: "#a40000", # class: 'gd' + Generic.Emph: "italic #000000", # class: 'ge' + Generic.Error: "#ef2929", # class: 'gr' + Generic.Heading: "bold #000080", # class: 'gh' + Generic.Inserted: "#00A000", # class: 'gi' + Generic.Output: "#888", # class: 'go' + Generic.Prompt: "#745334", # class: 'gp' + Generic.Strong: "bold #000000", # class: 'gs' + Generic.Subheading: "bold #800080", # class: 'gu' + Generic.Traceback: "bold #a40000", # class: 'gt' + } diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..e0a7161 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +# +# pluginbase documentation build configuration file, created by +# sphinx-quickstart on Mon Apr 26 19:53:01 2010. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.append(os.path.abspath('_themes')) +sys.path.append(os.path.abspath('..')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'pluginbase' +copyright = u'2014, Armin Ronacher' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '1.0' +# The full version, including alpha/beta/rc tags. +release = '1.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +html_theme = 'flask_small' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +html_theme_options = { + 'github_fork': 'mitsuhiko/pluginbase' +} + +# Add any paths that contain custom themes here, relative to this directory. +html_theme_path = ['_themes'] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +html_title = 'pluginbase' + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = '' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'pluginbasedoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'pluginbase.tex', u'pluginbase documentation', + u'Armin Ronacher', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'pluginbase', u'pluginbase documentation', + [u'Armin Ronacher'], 1) +] + +intersphinx_mapping = { + 'http://docs.python.org/dev': None +} diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..c4d4aec --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,155 @@ +PluginBase +========== + +.. module:: pluginbase + +Ever tried creating a plugin system for a Python application and you +discovered fighting against the import system? Me too. This is where +PluginBase comes in. + +PluginBase is a module for Python which extends the import system for +the most common form of plugin usage which is providing a consistent +import experience for plugins from a variety of sources. Essentially it +allows you to build very flexible plugin based applications which pull in +plugins from bundled sources as well as application specific ones without +bypassing the Python import system. + +How does it work? It's super simple: + +Step 1: + + Create a "plugin base" object. It defines a pseudo package under + which all your plugins will reside. For instance it could be + ``yourapplication.plugins``:: + + from pluginbase import PluginBase + plugin_base = PluginBase(package='yourapplication.plugins') + +Step 2: + + Now that you have a plugin base, you can define a plugin source + which is the list of all sources which provide plugins:: + + plugin_source = plugin_base.make_plugin_source( + searchpath=['./path/to/plugins', './path/to/more/plugins']) + +Step 3: + + To import a plugin all you need to do is to use the regular import + system. The only change is that you need to import the plugin + source through the ``with`` statement:: + + with plugin_source: + from yourapplication.plugins import my_plugin + my_plugin.do_something_cool() + +Alternatively you can also import plugins programmatically instead of +using the import statement:: + + my_plugin = plugin_source.load_plugin('my_plugin') + +Installation +------------ + +You can get the library directly from PyPI:: + + pip install pluginbase + + +FAQ +--- + +Q: Why is there a plugin base and a plugin source class? + + This decision was taken so that multiple applications can co-exist + together. For instance imagine you have an application that + implements a wiki but you want multiple instances of that wiki + to exist in the same Python interpreter. The plugin sources split + out the load paths of the different applications. + + Each instance of the wiki would have its own plugin source and they + can work independently of each other. + +Q: Do plugins pollute ``sys.modules``? + + While a plugin source is alive the plugins do indeed reside in + ``sys.modules``. This decision was make conciously so that as little + as possible of the Python library ecosystem breaks. However when the + plugin source gets garbage collected all loaded plugins will + also get garbage collected. + +Q: How does PluginBase deal with different versions of the same plugin? + + Each plugin source works indepdenently of each other. The way this + works is by internally translating the module name. By default that + module name is a random number but it can also be forced to a hash of + a specific value to make it stable across restarts which allows + pickle and similar libraries to work. + + This internal module renaming means that + ``yourapplication.module.foo`` will internally be called + ``pluginbase._internalspace._sp7...be4`` for instance. The same + plugin loaded from another plugin source will have a different + internal name. + +Q: What happens if a plugin wants to import other modules? + + All fine. Plugins can import from itself as well as other plugins + that can be located. + +Q: Does PluginBase support pickle? + + Yes, pickle works fine for plugins but it does require defining a + stable identifier when creating a plugin source. This could for + instance be a file system path:: + + plugin_source = base.make_plugin_source( + searchpath=[app.plugin_path], + identifier=app.config_filename) + +Q: What happens if I import from the plugin module without the +plugin source activated through the ``with`` statement? + + The import will fail with a descriptive error message explaining + that a plugin source needs to be activated. + +Q: Can I automatically discover all modules that are available? + + Yes you can. Just use the :meth:`PluginSource.list_plugins` method + which returns a list of all plugins that a source can import. + + +API +--- + +High Level +`````````` + +.. autoclass:: PluginBase + :members: + +.. autoclass:: PluginSource + :members: + +.. autofunction:: get_plugin_source + +Import Hook Control +``````````````````` + +.. autofunction:: pluginbase.import_hook.enable + +.. autofunction:: pluginbase.import_hook.disable + +.. data:: pluginbase.import_hook.enabled + + Indicates if the import hook is currently active or not. + +Internals +````````` + +.. autodata:: pluginbase._internalspace + + This module is where pluginbase keeps track of all loaded plugins. + Generally one can completely ignore the existence of it, but in some + situations it might be useful to discover currently loaded modules + through this when debugging. diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..1e941d8 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,155 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Classy.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Classy.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/pluginbase.py b/pluginbase.py new file mode 100644 index 0000000..9f11abf --- /dev/null +++ b/pluginbase.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +""" + pluginbase + ~~~~~~~~~~ + + Pluginbase is a module for Python that provides a system for building + plugin based applications. + + :copyright: (c) Copyright 2014 by Armin Ronacher. + :license: BSD, see LICENSE for more details. +""" +import sys +import uuid +import pkgutil +import hashlib +import threading + +from types import ModuleType +from weakref import ref as weakref + + +PY2 = sys.version_info[0] == 2 +if PY2: + text_type = unicode + string_types = (unicode, str) +else: + text_type = str + string_types = (str,) + + +_local = threading.local() + +_internalspace = ModuleType(__name__ + '._internalspace') +_internalspace.__path__ = [] +sys.modules[_internalspace.__name__] = _internalspace + + +def get_plugin_source(module=None, stacklevel=None): + """Returns the :class:`PluginSource` for the current module or the given + module. The module can be provided by name (in which case an import + will be attempted) or as a module object. + + If no plugin source can be discovered, the return value from this method + is `None`. + + This function can be very useful if additional data has been attached + to the plugin source. For instance this could allow plugins to get + access to a back reference to the application that created them. + + :param module: optionally the module to locate the plugin source of. + :param stacklevel: defines how many levels up the module should search + for before it discovers the plugin frame. The + default is 0. This can be useful for writing wrappers + around this function. + """ + if module is None: + frm = sys._getframe((stacklevel or 0) + 1) + name = frm.f_globals['__name__'] + glob = frm.f_globals + elif isinstance(module, string_types): + frm = sys._getframe(1) + name = module + glob = __import__(module, frm.f_globals, + frm.f_locals, ['__dict__']).__dict__ + else: + name = module.__name__ + glob = module.__dict__ + return _discover_space(name, glob) + + +def _discover_space(name, globals): + try: + return _local.space_stack[-1] + except (AttributeError, IndexError): + pass + + if '__pluginbase_state__' in globals: + return globals['__pluginbase_state__'].source + + mod_name = globals.get('__name__') + if mod_name is not None and \ + mod_name.startswith(_internalspace.__name__ + '.'): + end = mod_name.find('.', len(_internalspace.__name__) + 1) + space = sys.modules.get(mod_name[:end]) + if space is not None: + return space.__pluginbase_state__.source + + +def _shutdown_module(mod): + members = list(mod.__dict__.items()) + for key, value in members: + if key[:1] != '_': + setattr(mod, key, None) + for key, value in members: + setattr(mod, key, None) + + +def _to_bytes(s): + if isinstance(s, text_type): + return s.encode('utf-8') + return s + + +class _IntentionallyEmptyModule(ModuleType): + + def __getattr__(self, name): + try: + return ModuleType.__getattr__(self, name) + except AttributeError: + if name[:2] == '__': + raise + raise RuntimeError( + 'Attempted to import from a plugin base module (%s) without ' + 'having a plugin source activated. To solve this error ' + 'you have to move the import into a "with" block of the ' + 'associated plugin source.' % self.__name__) + + +class _PluginSourceModule(ModuleType): + + def __init__(self, source): + modname = '%s.%s' % (_internalspace.__name__, source.spaceid) + ModuleType.__init__(self, modname) + self.__pluginbase_state__ = PluginBaseState(source) + + @property + def __path__(self): + try: + ps = self.__pluginbase_state__.source + except AttributeError: + return [] + return ps.searchpath + ps.base.searchpath + + +def _setup_base_package(module_name): + try: + mod = __import__(module_name, None, None, ['__name__']) + except ImportError: + mod = None + if '.' in module_name: + parent_mod = __import__(module_name.rsplit('.', 1)[0], + None, None, ['__name__']) + else: + parent_mod = None + + if mod is None: + mod = _IntentionallyEmptyModule(module_name) + if parent_mod is not None: + setattr(parent_mod, module_name.rsplit('.', 1)[-1], mod) + sys.modules[module_name] = mod + + +class PluginBase(object): + """The plugin base acts as a control object around a dummy Python + package that acts as a container for plugins. Usually each + application creates exactly one base object for all plugins. + + :param package: the name of the package that acts as the plugin base. + Usually this module does not exist. Unless you know + what you are doing you should not create this module + on the file system. + :param searchpath: optionally a shared search path for modules that + will be used by all plugin sources registered. + """ + + def __init__(self, package, searchpath=None): + #: the name of the dummy package. + self.package = package + if searchpath is None: + searchpath = [] + #: the default search path shared by all plugins as list. + self.searchpath = searchpath + _setup_base_package(package) + + def make_plugin_source(self, *args, **kwargs): + """Creats a plugin source for this plugin base and returns it. + All parameters are forwarded to :class:`PluginSource`. + """ + return PluginSource(self, *args, **kwargs) + + +class PluginSource(object): + """The plugin source is what ultimately decides where plugins are + loaded from. Plugin bases can have multiple plugin sources which act + as isolation layer. While this is not a security system it generally + is not possible for plugins from different sources to accidentally + cross talk. + + Once a plugin source has been created it can be used in a ``with`` + statement to change the behavior of the ``import`` statement in the + block to define which source to load the plugins from:: + + plugin_source = plugin_base.make_plugin_source( + searchpath=['./path/to/plugins', './path/to/more/plugins']) + + with plugin_source: + from myapplication.plugins import my_plugin + + :param base: the base this plugin source belongs to. + :param identifier: optionally a stable identifier. If it's not defined + a random identifier is picked. It's useful to set this + to a stable value to have consistent tracebacks + between restarts and to support pickle. + :param searchpath: a list of paths where plugins are looked for. + :param persist: optionally this can be set to `True` and the plugins + will not be cleaned up when the plugin source gets + garbage collected. + """ + # Set these here to false by default so that a completely failing + # constructor does not fuck up the destructor. + persist = False + mod = None + + def __init__(self, base, identifier=None, searchpath=None, + persist=False): + #: indicates if this plugin source persists or not. + self.persist = persist + if identifier is None: + identifier = str(uuid.uuid4()) + #: the identifier for this source. + self.identifier = identifier + #: A reference to the plugin base that created this source. + self.base = base + #: a list of paths where plugins are searched in. + self.searchpath = searchpath + #: The internal module name of the plugin source as it appears + #: in the :mod:`pluginsource._internalspace`. + self.spaceid = '_sp' + hashlib.md5( + _to_bytes(self.base.package) + b'|' + + _to_bytes(identifier), + ).hexdigest() + #: a reference to the module on the internal + #: :mod:`pluginsource._internalspace`. + self.mod = _PluginSourceModule(self) + + if hasattr(_internalspace, self.spaceid): + raise RuntimeError('This plugin source already exists.') + sys.modules[self.mod.__name__] = self.mod + setattr(_internalspace, self.spaceid, self.mod) + + def __del__(self): + if not self.persist: + self.cleanup() + + def list_plugins(self): + """Returns a sorted list of all plugins that are available in this + plugin source. This can be useful to automatically discover plugins + that are available and is usually used together with + :meth:`load_plugin`. + """ + rv = [] + for _, modname, ispkg in pkgutil.iter_modules(self.mod.__path__): + rv.append(modname) + return sorted(rv) + + def load_plugin(self, name): + """This automatically loads a plugin by the given name from the + current source and returns the module. This is a convenient + alternative to the import statement and saves you from invoking + ``__import__`` or a similar function yourself. + + :param name: the name of the plugin to load. + """ + with self: + return __import__(self.base.package + '.' + name, + globals(), {}, ['__name__']) + + def cleanup(self, _sys=sys): + """Cleans up all loaded plugins manually. This is necessary to + call only if :attr:`persist` is enabled. Otherwise this happens + automatically when the source gets garbage collected. + + :param _sys: the sys module to use for cleaning up. This parameter + seems useless a the default is always the right one + anyways but it supports the shutdown when the + interpreter terminates. + """ + if self.mod is None: + return + modname = self.mod.__name__ + self.mod.__pluginbase_state__ = None + self.mod = None + try: + delattr(_internalspace, self.spaceid) + except AttributeError: + pass + prefix = modname + '.' + _sys.modules.pop(modname) + for key, value in list(_sys.modules.items()): + if not key.startswith(prefix): + continue + mod = _sys.modules.pop(key, None) + if mod is None: + continue + _shutdown_module(mod) + + def __assert_not_cleaned_up(self): + if self.mod is None: + raise RuntimeError('The plugin source was already cleaned up.') + + def __enter__(self): + self.__assert_not_cleaned_up() + _local.__dict__.setdefault('space_stack', []).append(self) + return self + + def __exit__(self, exc_type, exc_value, tb): + try: + _local.space_stack.pop() + except (AttributeError, IndexError): + pass + + def _rewrite_module_path(self, modname): + self.__assert_not_cleaned_up() + if modname == self.base.package: + return self.mod.__name__ + elif modname.startswith(self.base.package + '.'): + pieces = modname.split('.') + return self.mod.__name__ + '.' + '.'.join( + pieces[self.base.package.count('.') + 1:]) + + +class PluginBaseState(object): + __slots__ = ('_source',) + + def __init__(self, source): + if source.persist: + self._source = lambda: source + else: + self._source = weakref(source) + + @property + def source(self): + rv = self._source() + if rv is None: + raise AttributeError('Plugin source went away') + return rv + + +class _ImportHook(ModuleType): + + def __init__(self, name, system_import): + ModuleType.__init__(self, name) + self._system_import = system_import + self.enabled = True + + def enable(self): + """Enables the import hook which drives the plugin base system. + This is the default. + """ + self.enabled = True + + def disable(self): + """Disables the import hook and restores the default import system + behavior. This effectively breaks pluginbase but can be useful + for testing purposes. + """ + self.enabled = False + + def plugin_import(self, name, globals=None, locals=None, + fromlist=None, level=0): + import_name = name + if self.enabled: + ref_globals = globals + if ref_globals is None: + ref_globals = sys._getframe(1).f_globals + space = _discover_space(name, ref_globals) + if space is not None: + actual_name = space._rewrite_module_path(name) + if actual_name is not None: + import_name = actual_name + return self._system_import(import_name, globals, locals, + fromlist, level) + + +try: + import __builtin__ as builtins +except ImportError: + import builtins +import_hook = _ImportHook(__name__ + '.import_hook', builtins.__import__) +builtins.__import__ = import_hook.plugin_import +sys.modules[import_hook.__name__] = import_hook +del builtins diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..328cce9 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +try: + from setuptools import setup +except ImportError: + from distutils.core import setup + + +setup( + name='pluginbase', + author='Armin Ronacher', + author_email='armin.ronacher@active-4.com', + version='0.1', + url='http://github.com/mitsuhiko/pluginbase', + py_modules=['pluginbase'], + description='A support library for building plugins sytems in Python.', + zip_safe=False, + classifiers=[ + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + ], +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9226228 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +import pytest + +from pluginbase import PluginBase + + +@pytest.fixture(scope='function') +def base(): + return PluginBase(package='dummy.plugins') + + +@pytest.fixture(scope='function') +def dummy_internal_name(): + return 'pluginbase._internalspace._sp7bb7d8da1d24ae5a5205609c951b8be4' + + +@pytest.fixture(scope='function') +def source(base): + return base.make_plugin_source(searchpath=['./plugins'], + identifier='demo') diff --git a/tests/dummy.py b/tests/dummy.py new file mode 100644 index 0000000..4ef4ca0 --- /dev/null +++ b/tests/dummy.py @@ -0,0 +1 @@ +# make sure there is a module we can base our modules against. diff --git a/tests/plugins/hello.py b/tests/plugins/hello.py new file mode 100644 index 0000000..a113f41 --- /dev/null +++ b/tests/plugins/hello.py @@ -0,0 +1,12 @@ +def import_self(): + from dummy.plugins import hello + return hello + + +def get_plugin_source(): + from pluginbase import get_plugin_source + return get_plugin_source() + + +def demo_func(): + return 42 diff --git a/tests/plugins/hello2.py b/tests/plugins/hello2.py new file mode 100644 index 0000000..58335c3 --- /dev/null +++ b/tests/plugins/hello2.py @@ -0,0 +1,2 @@ +def awesome_stuff(): + pass diff --git a/tests/test_basics.py b/tests/test_basics.py new file mode 100644 index 0000000..3130d49 --- /dev/null +++ b/tests/test_basics.py @@ -0,0 +1,91 @@ +import sys + +from pluginbase import get_plugin_source + + +def test_basic_plugin(source, dummy_internal_name): + # When the source is active the import gives us a module + with source: + from dummy.plugins import hello + + # Which because of a stable identifier has a predictable name. + assert hello.__name__ == dummy_internal_name + '.hello' + + # And can continue to import from itself. + assert hello.import_self() is hello + + # On the other hand without a source will fall flat on the floor. + try: + from dummy.plugins import hello + except RuntimeError: + pass + else: + assert False, 'Expected a runtime error but managed to ' \ + 'import hello (%s)' % hello + + +def test_fetching_plugin_source(source): + # Finding the plugin source outside of a plugin and without a with + # block of a plugin source returns None. + assert get_plugin_source() is None + + # Inside a source block we can find the source through mere calling. + with source: + assert get_plugin_source() is source + + # A module can always find its own source as well (the hello module + # calls get_plugin_source() itself). + with source: + from dummy.plugins import hello + assert hello.get_plugin_source() is source + + # Last but not least the plugin source can be found by module names + # (in a plugin source block by the import name and in any case by + # the internal name) + with source: + assert get_plugin_source('dummy.plugins.hello') is source + assert get_plugin_source(hello.__name__) is source + + # As well as by module object. + assert get_plugin_source(hello) is source + + +def test_cleanup(base): + new_source = base.make_plugin_source(searchpath=['./plugins']) + mod_name = new_source.mod.__name__ + assert sys.modules.get(mod_name) is new_source.mod + + with new_source: + from dummy.plugins import hello + + new_source = None + assert sys.modules.get(mod_name) is None + assert hello.import_self is None + + +def test_persist(base): + new_source = base.make_plugin_source(searchpath=['./plugins'], + persist=True) + mod_name = new_source.mod.__name__ + assert sys.modules.get(mod_name) is new_source.mod + + with new_source: + from dummy.plugins import hello + + new_source = None + assert sys.modules.get(mod_name) is not None + assert hello.import_self is not None + sys.modules[mod_name].__pluginbase_state__.source.cleanup() + assert sys.modules.get(mod_name) is None + assert hello.import_self is None + + +def test_list_plugins(source): + plugins = source.list_plugins() + hello_plugins = [x for x in plugins if x.startswith('hello')] + assert hello_plugins == ['hello', 'hello2'] + + +def test_load_plugin(source): + hello = source.load_plugin('hello') + assert hello.demo_func() == 42 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6daf642 --- /dev/null +++ b/tox.ini @@ -0,0 +1,7 @@ +[tox] +envlist = py26,py27,py33,py34,pypy + +[testenv] +commands = make test +deps = +whitelist_externals = make -- cgit v1.2.1