├── dectate ├── tests │ ├── __init__.py │ ├── fixtures │ │ ├── __init__.py │ │ └── anapp.py │ ├── test_sentinel.py │ ├── test_helpers.py │ ├── test_toposort.py │ ├── test_logging.py │ ├── test_tool.py │ ├── test_error.py │ └── test_query.py ├── sentinel.py ├── __init__.py ├── toposort.py ├── sphinxext.py ├── error.py ├── app.py ├── query.py ├── tool.py └── config.py ├── scenarios ├── query │ ├── query │ │ ├── __init__.py │ │ ├── c.py │ │ ├── main.py │ │ ├── b.py │ │ └── a.py │ └── setup.py └── main_module │ ├── app2.py │ ├── app.py │ ├── README.txt │ └── config.py ├── doc ├── changes.rst ├── index.rst ├── api.rst ├── history.rst ├── developing.rst ├── Makefile ├── conf.py └── usage.rst ├── develop_requirements.txt ├── MANIFEST.in ├── pyproject.toml ├── CREDITS.txt ├── setup.cfg ├── .gitignore ├── .pre-commit-config.yaml ├── tox.ini ├── README.rst ├── setup.py ├── LICENSE.txt ├── .github └── workflows │ └── main.yml ├── query.txt ├── use_cases.txt └── CHANGES.txt /dectate/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scenarios/query/query/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dectate/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /doc/changes.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGES.txt 2 | -------------------------------------------------------------------------------- /scenarios/main_module/app2.py: -------------------------------------------------------------------------------- 1 | import app 2 | 3 | 4 | @app.App.foo(name="b") 5 | def g(): 6 | pass 7 | -------------------------------------------------------------------------------- /scenarios/query/query/c.py: -------------------------------------------------------------------------------- 1 | from .a import App 2 | 3 | 4 | @App.foo(name="lah") 5 | def x(): 6 | pass 7 | -------------------------------------------------------------------------------- /dectate/tests/test_sentinel.py: -------------------------------------------------------------------------------- 1 | from ..sentinel import NOT_FOUND 2 | 3 | 4 | def test_not_found(): 5 | assert repr(NOT_FOUND) == "" 6 | -------------------------------------------------------------------------------- /develop_requirements.txt: -------------------------------------------------------------------------------- 1 | # development 2 | -e '.[test,coverage,pep8,docs]' 3 | pre-commit 4 | tox >= 2.4.1 5 | radon 6 | 7 | # releaser 8 | zest.releaser[recommended] 9 | -------------------------------------------------------------------------------- /scenarios/query/query/main.py: -------------------------------------------------------------------------------- 1 | import dectate 2 | from . import a, b, c # noqa F401 3 | 4 | 5 | def query_tool(): 6 | dectate.commit(a.App, a.Other) 7 | dectate.query_tool([a.App, a.Other]) 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.rst *.cfg *.py *.ini *.toml *.yaml 2 | include .coveragerc 3 | exclude .installed.cfg 4 | recursive-include dectate *.py 5 | recursive-include doc *.rst Makefile *.py *.bat 6 | recursive-include scenarios *.py *.txt 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 80 3 | target-version = ['py34', 'py35', 'py36', 'py37', 'py38'] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | ( 7 | /( 8 | \.git 9 | | \.tox 10 | | env 11 | | build 12 | | dist 13 | )/ 14 | ) 15 | ''' 16 | -------------------------------------------------------------------------------- /scenarios/main_module/app.py: -------------------------------------------------------------------------------- 1 | from config import App 2 | import pprint 3 | import dectate 4 | import app2 # noqa 5 | 6 | 7 | @App.foo(name="a") 8 | def f(): 9 | pass 10 | 11 | 12 | if __name__ == "__main__": 13 | dectate.commit([App]) 14 | pprint.pprint(App.config.my) 15 | -------------------------------------------------------------------------------- /dectate/sentinel.py: -------------------------------------------------------------------------------- 1 | class Sentinel: 2 | def __init__(self, name): 3 | self.name = name 4 | 5 | def __repr__(self): 6 | return "<%s>" % self.name 7 | 8 | 9 | NOT_FOUND = Sentinel("NOT_FOUND") 10 | """Sentinel value returned if filter value cannot be found on action.""" 11 | -------------------------------------------------------------------------------- /scenarios/query/query/b.py: -------------------------------------------------------------------------------- 1 | from .a import App, Other 2 | 3 | 4 | @App.foo(name="alpha") 5 | def f(): 6 | pass 7 | 8 | 9 | @App.foo(name="beta") 10 | def g(): 11 | pass 12 | 13 | 14 | @App.foo(name="gamma") 15 | def h(): 16 | pass 17 | 18 | 19 | @Other.foo(name="alpha") 20 | def i(): 21 | pass 22 | -------------------------------------------------------------------------------- /CREDITS.txt: -------------------------------------------------------------------------------- 1 | CREDITS 2 | ======= 3 | 4 | * Martijn Faassen (creator and main developer) 5 | 6 | * Stefano Taschini (newer ``with_metaclass``, ``topological_sort`` now 7 | public) 8 | 9 | * Denis Krienbühl (testing and feedback) 10 | 11 | * Henri Hulski (build environment) 12 | 13 | * Jan Stürtz 14 | 15 | * Special thanks to CONTACT software. 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | show-source = True 3 | ignore = E203, W503 4 | max-line-length = 88 5 | 6 | [tool:pytest] 7 | testpaths = dectate 8 | 9 | [coverage:run] 10 | omit = dectate/tests/* 11 | source = dectate 12 | 13 | [coverage:report] 14 | show_missing = True 15 | 16 | [zest.releaser] 17 | create-wheel = yes 18 | 19 | [bdist_wheel] 20 | universal = 1 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized 2 | __pycache__/ 3 | *.py[co] 4 | 5 | # Distribution / packaging 6 | *.egg-info/ 7 | /build/ 8 | /dist/ 9 | pip-wheel-metadata 10 | 11 | # Virtualenv 12 | /env/ 13 | /src/ 14 | 15 | # Unit test / coverage reports 16 | .cache 17 | .pytest_cache 18 | .coverage 19 | htmlcov 20 | .tox 21 | 22 | # Sphinx documentation 23 | /doc/build/ 24 | -------------------------------------------------------------------------------- /scenarios/main_module/README.txt: -------------------------------------------------------------------------------- 1 | This scenario is based on 2 | 3 | http://docs.pylonsproject.org/projects/pyramid/en/latest/designdefense.html#application-programmers-don-t-control-the-module-scope-codepath-import-time-side-effects-are-evil 4 | 5 | To run it, add dectate to the PYTHONPATH and then do: 6 | 7 | $ python app.py 8 | 9 | You should see a ConflictError. 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 20.8b1 4 | hooks: 5 | - id: black 6 | - repo: https://github.com/PyCQA/flake8 7 | rev: "3.8.4" 8 | hooks: 9 | - id: flake8 10 | - repo: https://github.com/asottile/pyupgrade 11 | rev: v2.7.4 12 | hooks: 13 | - id: pyupgrade 14 | args: [--py36-plus] 15 | -------------------------------------------------------------------------------- /scenarios/main_module/config.py: -------------------------------------------------------------------------------- 1 | import dectate 2 | 3 | 4 | class App(dectate.App): 5 | pass 6 | 7 | 8 | @App.directive("foo") 9 | class FooAction(dectate.Action): 10 | config = {"my": list} 11 | 12 | def __init__(self, name): 13 | self.name = name 14 | 15 | def identifier(self, my): 16 | return self.name 17 | 18 | def perform(self, obj, my): 19 | my.append((self.name, obj)) 20 | -------------------------------------------------------------------------------- /dectate/tests/fixtures/anapp.py: -------------------------------------------------------------------------------- 1 | import dectate 2 | 3 | 4 | class FooAction(dectate.Action): 5 | def __init__(self, name): 6 | self.name = name 7 | 8 | def identifier(self): 9 | return self.name 10 | 11 | def perform(self, obj): 12 | pass 13 | 14 | 15 | class AnApp(dectate.App): 16 | known = "definitely not a directive" 17 | 18 | foo = dectate.directive(FooAction) 19 | 20 | 21 | def other(): 22 | pass 23 | 24 | 25 | class OtherClass: 26 | pass 27 | -------------------------------------------------------------------------------- /dectate/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .app import App, directive 3 | from .sentinel import Sentinel, NOT_FOUND 4 | from .config import commit, Action, Composite, CodeInfo 5 | from .error import ( 6 | ConfigError, 7 | DirectiveError, 8 | TopologicalSortError, 9 | DirectiveReportError, 10 | ConflictError, 11 | QueryError, 12 | ) 13 | from .query import Query 14 | from .tool import query_tool, convert_dotted_name, convert_bool, query_app 15 | from .toposort import topological_sort 16 | -------------------------------------------------------------------------------- /dectate/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from ..config import create_code_info 3 | 4 | 5 | def current_code_info(): 6 | return create_code_info(sys._getframe(1)) 7 | 8 | 9 | def test_create_code_info(): 10 | x = current_code_info() 11 | assert x.path == __file__ 12 | assert x.lineno == 10 13 | assert x.sourceline == "x = current_code_info()" 14 | 15 | x = eval("current_code_info()") 16 | assert x.path == "" 17 | assert x.lineno == 1 18 | assert x.sourceline is None 19 | -------------------------------------------------------------------------------- /scenarios/query/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="query", 5 | version="0.1.dev0", 6 | description="A test package with config info to query", 7 | author="Martijn Faassen", 8 | author_email="faassen@startifact.com", 9 | url="http://dectate.readthedocs.org", 10 | license="BSD", 11 | packages=find_packages(), 12 | include_package_data=True, 13 | zip_safe=False, 14 | entry_points={"console_scripts": ["decq = query.main:query_tool"]}, 15 | install_requires=["setuptools"], 16 | ) 17 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Dectate: Advanced Decorator Configuration System 2 | ================================================ 3 | 4 | Dectate is a Python library that lets you construct a decorator-based 5 | configuration system for frameworks. Configuration is associated with 6 | class objects. It supports configuration inheritance and overrides as 7 | well as conflict detection. 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | usage 13 | api 14 | developing 15 | history 16 | changes 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /scenarios/query/query/a.py: -------------------------------------------------------------------------------- 1 | import dectate 2 | 3 | 4 | class App(dectate.App): 5 | pass 6 | 7 | 8 | class Other(dectate.App): 9 | pass 10 | 11 | 12 | class R: 13 | pass 14 | 15 | 16 | @App.directive("foo") 17 | class FooAction(dectate.Action): 18 | def __init__(self, name): 19 | self.name = name 20 | 21 | def identifier(self): 22 | return self.name 23 | 24 | def perform(self, obj): 25 | pass 26 | 27 | 28 | @Other.directive("foo") 29 | class OtherFooAction(dectate.Action): 30 | def __init__(self, name): 31 | self.name = name 32 | 33 | def identifier(self): 34 | return self.name 35 | 36 | def perform(self, obj): 37 | pass 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, py39, pypy3, coverage, pre-commit, docs 3 | skipsdist = True 4 | skip_missing_interpreters = True 5 | 6 | [testenv] 7 | usedevelop = True 8 | extras = test 9 | commands = pytest {posargs} 10 | 11 | [testenv:coverage] 12 | basepython = python3 13 | extras = test 14 | coverage 15 | commands = 16 | coverage erase 17 | coverage run -m pytest {posargs} 18 | coverage report -m 19 | 20 | [testenv:pre-commit] 21 | deps = pre-commit 22 | skip_install = true 23 | commands = pre-commit run --all-files 24 | 25 | [testenv:docs] 26 | basepython = python3 27 | extras = docs 28 | commands = sphinx-build -b doctest doc {envtmpdir} 29 | 30 | [gh-actions] 31 | python = 32 | 3.6: py36 33 | 3.7: py37 34 | 3.8: py38 35 | 3.9: py39, pre-commit, mypy, coverage 36 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/morepath/dectate/workflows/CI/badge.svg?branch=master 2 | :target: https://github.com/morepath/dectate/actions?workflow=CI 3 | :alt: CI Status 4 | 5 | .. image:: https://coveralls.io/repos/github/morepath/dectate/badge.svg?branch=master 6 | :target: https://coveralls.io/github/morepath/dectate?branch=master 7 | 8 | .. image:: https://img.shields.io/pypi/v/dectate.svg 9 | :target: https://pypi.org/project/dectate/ 10 | 11 | .. image:: https://img.shields.io/pypi/pyversions/dectate.svg 12 | :target: https://pypi.org/project/dectate/ 13 | 14 | 15 | Dectate: a configuration engine for Python frameworks 16 | ======================================================= 17 | 18 | Dectate is a powerful configuration engine for Python frameworks. 19 | 20 | `Read the docs`_ 21 | 22 | .. _`Read the docs`: http://dectate.readthedocs.org 23 | 24 | It is used by Morepath_. 25 | 26 | .. _Morepath: http://morepath.readthedocs.org 27 | -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. py:module:: dectate 5 | 6 | .. autofunction:: commit 7 | 8 | .. autofunction:: topological_sort 9 | 10 | .. autoclass:: App 11 | :members: 12 | 13 | .. autoclass:: Action 14 | :members: 15 | 16 | .. autoclass:: Composite 17 | :members: 18 | 19 | .. autoclass:: Query 20 | :inherited-members: 21 | :members: 22 | 23 | .. autofunction:: directive 24 | 25 | .. autofunction:: query_tool 26 | 27 | .. autofunction:: query_app 28 | 29 | .. autofunction:: convert_dotted_name 30 | 31 | .. autofunction:: convert_bool 32 | 33 | .. autodata:: NOT_FOUND 34 | 35 | .. autoclass:: CodeInfo 36 | :members: 37 | 38 | .. autoexception:: ConfigError 39 | 40 | .. autoexception:: ConflictError 41 | :show-inheritance: 42 | 43 | .. autoexception:: DirectiveError 44 | :show-inheritance: 45 | 46 | .. autoexception:: DirectiveReportError 47 | :show-inheritance: 48 | 49 | .. autoexception:: TopologicalSortError 50 | :show-inheritance: 51 | -------------------------------------------------------------------------------- /dectate/toposort.py: -------------------------------------------------------------------------------- 1 | from .error import TopologicalSortError 2 | 3 | 4 | def topological_sort(l, get_depends): # noqa: E741 5 | """`Topological sort`_ 6 | 7 | .. _`Topological sort`: https://en.wikipedia.org/wiki/Topological_sorting 8 | 9 | Given a list of items that depend on each other, sort so that 10 | dependencies come before the dependent items. Dependency graph must 11 | be a DAG_. 12 | 13 | .. _DAG: https://en.wikipedia.org/wiki/Directed_acyclic_graph 14 | 15 | :param l: a list of items to sort 16 | :param get_depends: a function that given an item 17 | gives other items that this item depends on. This item 18 | will be sorted after the items it depends on. 19 | :return: the list sorted topologically. 20 | 21 | """ 22 | result = [] 23 | marked = set() 24 | temporary_marked = set() 25 | 26 | def visit(n): 27 | if n in marked: 28 | return 29 | if n in temporary_marked: 30 | raise TopologicalSortError("Not a DAG") 31 | temporary_marked.add(n) 32 | for m in get_depends(n): 33 | visit(m) 34 | marked.add(n) 35 | result.append(n) 36 | 37 | for n in l: 38 | visit(n) 39 | return result 40 | -------------------------------------------------------------------------------- /dectate/tests/test_toposort.py: -------------------------------------------------------------------------------- 1 | from dectate import topological_sort, TopologicalSortError 2 | 3 | import pytest 4 | 5 | 6 | def test_topological_sort_on_dcg(): 7 | adjacency = { 8 | "A": ["B", "C"], 9 | "B": ["C", "D"], 10 | "C": ["D"], 11 | "D": ["C"], 12 | "E": ["F"], 13 | "F": ["C"], 14 | } 15 | with pytest.raises(TopologicalSortError): 16 | topological_sort(adjacency.keys(), adjacency.__getitem__) 17 | 18 | 19 | def test_topological_sort_on_dag(): 20 | adjacency = { 21 | "A": ["B", "C"], 22 | "B": ["C", "D"], 23 | "C": ["D"], 24 | "D": [], 25 | "E": ["F"], 26 | "F": ["C"], 27 | } 28 | nodes = sorted(adjacency.keys()) 29 | # Topological ordering is not unique, and in this implementation 30 | # the resulting order depends on the initial ordering of the 31 | # nodes:: 32 | assert topological_sort(nodes, adjacency.__getitem__) == [ 33 | "D", 34 | "C", 35 | "B", 36 | "A", 37 | "F", 38 | "E", 39 | ] 40 | assert topological_sort(reversed(nodes), adjacency.__getitem__) == [ 41 | "D", 42 | "C", 43 | "F", 44 | "E", 45 | "B", 46 | "A", 47 | ] 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | long_description = "\n".join( 4 | ( 5 | open("README.rst", encoding="utf-8").read(), 6 | open("CHANGES.txt", encoding="utf-8").read(), 7 | ) 8 | ) 9 | 10 | setup( 11 | name="dectate", 12 | version="0.15.dev0", 13 | description="A configuration engine for Python frameworks", 14 | long_description=long_description, 15 | author="Martijn Faassen", 16 | author_email="faassen@startifact.com", 17 | url="http://dectate.readthedocs.io", 18 | license="BSD", 19 | packages=find_packages(), 20 | include_package_data=True, 21 | zip_safe=False, 22 | classifiers=[ 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: BSD License", 25 | "Topic :: Software Development :: Libraries :: Application Frameworks", 26 | "Programming Language :: Python :: 3.6", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: Implementation :: PyPy", 31 | "Development Status :: 5 - Production/Stable", 32 | ], 33 | keywords="configuration", 34 | install_requires=["setuptools"], 35 | extras_require=dict( 36 | test=["pytest >= 2.9.0", "pytest-remove-stale-bytecode"], 37 | coverage=["pytest-cov"], 38 | pep8=["flake8", "black"], 39 | docs=["sphinx"], 40 | ), 41 | ) 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Morepath Developers 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL FANSTATIC DEVELOPERS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /dectate/sphinxext.py: -------------------------------------------------------------------------------- 1 | """Sphinx extension to make sure directives have proper signatures. 2 | 3 | This is tricky as directives are added as methods to the ``App`` 4 | object using the directive decorator, and the signature needs to be 5 | obtained from the action class's ``__init__`` manually. 6 | """ 7 | import inspect 8 | 9 | 10 | def setup(app): # pragma: nocoverage 11 | # all inline to avoid dependency on sphinx.ext.autodoc which 12 | # would trip up scanning 13 | from sphinx.ext.autodoc import ModuleDocumenter, MethodDocumenter 14 | 15 | class DirectiveDocumenter(MethodDocumenter): 16 | objtype = "morepath_directive" 17 | priority = MethodDocumenter.priority + 1 18 | member_order = 49 19 | 20 | @classmethod 21 | def can_document_member(cls, member, membername, isattr, parent): 22 | return ( 23 | inspect.isroutine(member) 24 | and not isinstance(parent, ModuleDocumenter) 25 | and hasattr(member, "action_factory") 26 | ) 27 | 28 | def import_object(self): 29 | if not super().import_object(): 30 | return 31 | object = getattr(self.object, "action_factory", None) 32 | if object is None: 33 | return False 34 | self.object = object.__init__ 35 | self.directivetype = "classmethod" 36 | return True 37 | 38 | def decide_to_skip(app, what, name, obj, skip, options): 39 | if what != "class": 40 | return skip 41 | directive = getattr(obj, "action_factory", None) 42 | if directive is not None: 43 | return False 44 | return skip 45 | 46 | app.connect("autodoc-skip-member", decide_to_skip) 47 | app.add_autodocumenter(DirectiveDocumenter) 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the action will run. 4 | on: 5 | # Triggers the workflow on push or pull request events 6 | push: 7 | pull_request: 8 | schedule: 9 | - cron: '0 12 * * 0' # run once a week on Sunday 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | tests: 17 | name: "Python ${{ matrix.python-version }}" 18 | runs-on: "ubuntu-latest" 19 | 20 | strategy: 21 | matrix: 22 | python-version: ["3.6", "3.7", "3.8", "3.9", "pypy3"] 23 | 24 | # Steps represent a sequence of tasks that will be executed as part of the job 25 | steps: 26 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 27 | - uses: "actions/checkout@v2" 28 | - uses: "actions/setup-python@v2" 29 | with: 30 | python-version: "${{ matrix.python-version }}" 31 | - name: "Install dependencies" 32 | run: | 33 | set -xe 34 | python -VV 35 | python -m site 36 | python -m pip install --upgrade pip setuptools wheel 37 | python -m pip install --upgrade virtualenv tox tox-gh-actions 38 | - name: "Run tox targets for ${{ matrix.python-version }}" 39 | run: "python -m tox" 40 | 41 | - name: "Report to coveralls" 42 | # coverage is only created in the py39 environment 43 | # --service=github is a workaround for bug 44 | # https://github.com/coveralls-clients/coveralls-python/issues/251 45 | if: "matrix.python-version == '3.9'" 46 | run: | 47 | pip install coveralls 48 | coveralls --service=github 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /doc/history.rst: -------------------------------------------------------------------------------- 1 | History of Dectate 2 | ================== 3 | 4 | Dectate was extracted from Morepath and then extensively refactored 5 | and cleaned up. It is authored by me, Martijn Faassen. 6 | 7 | In the beginning (around 2001) there was `zope.configuration`_, part of 8 | the Zope 3 project. It features declarative XML configuration with 9 | conflict detection and overrides to assemble pieces of Python code. 10 | 11 | .. _`zope.configuration`: https://pypi.python.org/pypi/zope.configuration 12 | 13 | In 2006, I helped create the Grok project. This did away with the XML 14 | based configuration and instead used Python code. This in turn then 15 | drove `zope.configuration`. Grok did not use Python decorators but 16 | instead used specially annotated Python classes, which were 17 | recursively scanned from modules. Grok's configuration system was spun 18 | off as the Martian_ library. 19 | 20 | .. _Martian: https://pypi.python.org/pypi/martian 21 | 22 | Chris McDonough was then inspired by Martian to create Venusian_, a 23 | deferred decorator execution system. It is like Martian in that it 24 | imports Python modules recursively in order to find configuration. 25 | 26 | .. _Venusian: https://pypi.python.org/pypi/venusian 27 | 28 | I created the Morepath_ web framework, which uses decorators for 29 | configuration throughout and used Venusian. Morepath grew a 30 | configuration subsystem where configuration is associated with 31 | classes, and uses class inheritance to power configuration reuse and 32 | overrides. This configuration subsystem started to get a bit messy 33 | as requirements grew. 34 | 35 | .. _Morepath: http://morepath.readthedocs.io 36 | 37 | So in 2016 I extracted the configuration system from Morepath into its 38 | own library, Dectate. This allowed me to extensively refactor the code 39 | for clarity and features. Dectate does not use Venusian for 40 | configuration. Dectate still defers the execution of configuration 41 | actions to an explicit commit phase, so that conflict detection and 42 | overrides and such can take place. 43 | -------------------------------------------------------------------------------- /dectate/error.py: -------------------------------------------------------------------------------- 1 | class ConfigError(Exception): 2 | """Raised when configuration is bad.""" 3 | 4 | 5 | def conflict_keyfunc(action): 6 | code_info = action.code_info 7 | if code_info is None: 8 | return 0 9 | return (code_info.path, code_info.lineno) 10 | 11 | 12 | class ConflictError(ConfigError): 13 | """Raised when there is a conflict in configuration. 14 | 15 | Describes where in the code directives are in conflict. 16 | """ 17 | 18 | def __init__(self, actions): 19 | actions.sort(key=conflict_keyfunc) 20 | self.actions = actions 21 | result = ["Conflict between:"] 22 | for action in actions: 23 | code_info = action.code_info 24 | if code_info is None: 25 | continue 26 | result.append(" %s" % code_info.filelineno()) 27 | result.append(" %s" % code_info.sourceline) 28 | msg = "\n".join(result) 29 | super().__init__(msg) 30 | 31 | 32 | class DirectiveReportError(ConfigError): 33 | """Raised when there's a problem with a directive. 34 | 35 | Describes where in the code the problem occurred. 36 | """ 37 | 38 | def __init__(self, message, code_info): 39 | result = [message] 40 | if code_info is not None: 41 | result.append(" %s" % code_info.filelineno()) 42 | result.append(" %s" % code_info.sourceline) 43 | msg = "\n".join(result) 44 | super().__init__(msg) 45 | 46 | 47 | class DirectiveError(ConfigError): 48 | """Can be raised by user when there directive cannot be performed. 49 | 50 | Raise it in :meth:`Action.perform` with a message describing what 51 | the problem is:: 52 | 53 | raise DirectiveError("name should be a string, not None") 54 | 55 | This is automatically converted by Dectate to a 56 | :exc:`DirectiveReportError`. 57 | """ 58 | 59 | pass 60 | 61 | 62 | class TopologicalSortError(ValueError): 63 | """Raised if dependencies cannot be sorted topologically. 64 | 65 | This is due to circular dependencies. 66 | """ 67 | 68 | 69 | class QueryError(Exception): 70 | pass 71 | -------------------------------------------------------------------------------- /query.txt: -------------------------------------------------------------------------------- 1 | decq foo.App.view name=blah model=Foo 2 | 3 | 4 | 5 | 6 | decq view name=blah model=Foo 7 | 8 | should use the *default* app class and search all sub-classes known. 9 | 10 | decq --app=foo.App view name=blah 11 | 12 | will specifically only search in app. can be repeated. 13 | 14 | 15 | this means that the view action or directive needs more 16 | information about model, i.e. that it is a class. 17 | 18 | * how to deal with queries that are inverse, i.e. where is this 19 | path defined? that in fact could be done with: 20 | 21 | decq foo.App.view path=/foo 22 | 23 | * some queries need to process the value to match with other things 24 | too, like path queries with slash or no slash and class inheritance. 25 | 26 | * how do you query the composite directive? the directive info is 27 | okay but how do we know the actions to even query? I think the 28 | composite needs to declare what it generates so that dectate can 29 | find it. 30 | 31 | * multi-app queries? give me all apps that define this view? 32 | 33 | * what if you want to look for all registered views and you want therefore 34 | want all subclasses of view action? they are already grouped so should 35 | be okay. try it out in a test. 36 | 37 | * is there sorting of results? 38 | 39 | 40 | There are more general queries like what is this model used for that 41 | would need to investigate multiple directives. 42 | 43 | decq foo.App.view name=blah model=Foo 44 | 45 | the action can define per query parameter: 46 | 47 | * how to check there is a match. this includes converting the 48 | input. 49 | 50 | tool_filter = { 51 | 'model': lambda value: resolve_dotted_name(value) 52 | } 53 | 54 | query_filter = { 55 | 'model': lambda action, value: issubclass(action._model, value) 56 | } 57 | 58 | The filter system won't work for composite as what is being compared 59 | with is the real action and not the composite. So the composite should 60 | define how to map things: 61 | 62 | filter_classes = { 63 | SubAction: { 64 | 65 | } 66 | } 67 | 68 | Examples: 69 | 70 | setting_section section=foo 71 | 72 | filter 'section' is on underlying action 73 | 74 | path 75 | 76 | filters are all on underlying action. 77 | 78 | identity_policy 79 | 80 | no filter at all possible. 81 | 82 | verify_identity 83 | 84 | identity does not map to function action. so this cannot 85 | be queried. Could rewrite to register function directly and then 86 | this would be not a composite. 87 | 88 | 89 | -------------------------------------------------------------------------------- /dectate/tests/test_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dectate.app import App, directive 3 | from dectate.config import Action, commit 4 | 5 | 6 | class Handler(logging.Handler): 7 | def __init__(self, level=logging.NOTSET): 8 | super().__init__(level) 9 | self.records = [] 10 | 11 | def emit(self, record): 12 | self.records.append(record) 13 | 14 | 15 | def test_intercept_logging(): 16 | log = logging.getLogger("my_logger") 17 | 18 | test_handler = Handler() 19 | 20 | log.addHandler(test_handler) 21 | # default is NOTSET which would propagate log to parent 22 | # logger instead of handling it directly. 23 | log.setLevel(logging.DEBUG) 24 | log.debug("This is a log message") 25 | 26 | assert len(test_handler.records) == 1 27 | assert test_handler.records[0].getMessage() == "This is a log message" 28 | 29 | 30 | def test_simple_config_logging(): 31 | log = logging.getLogger("dectate.directive.foo") 32 | 33 | test_handler = Handler() 34 | 35 | log.addHandler(test_handler) 36 | log.setLevel(logging.DEBUG) 37 | 38 | class MyDirective(Action): 39 | config = {"my": list} 40 | 41 | def __init__(self, message): 42 | self.message = message 43 | 44 | def identifier(self, my): 45 | return self.message 46 | 47 | def perform(self, obj, my): 48 | my.append((self.message, obj)) 49 | 50 | class MyApp(App): 51 | foo = directive(MyDirective) 52 | 53 | @MyApp.foo("hello") 54 | def f(): 55 | pass 56 | 57 | commit(MyApp) 58 | 59 | messages = [r.getMessage() for r in test_handler.records] 60 | assert len(messages) == 1 61 | expected = ( 62 | "@dectate.tests.test_logging.MyApp.foo('hello') " 63 | "on dectate.tests.test_logging.f" 64 | ) 65 | 66 | assert messages[0] == expected 67 | 68 | 69 | def test_subclass_config_logging(): 70 | log = logging.getLogger("dectate.directive.foo") 71 | 72 | test_handler = Handler() 73 | 74 | log.addHandler(test_handler) 75 | log.setLevel(logging.DEBUG) 76 | 77 | class MyDirective(Action): 78 | config = {"my": list} 79 | 80 | def __init__(self, message): 81 | self.message = message 82 | 83 | def identifier(self, my): 84 | return self.message 85 | 86 | def perform(self, obj, my): 87 | my.append((self.message, obj)) 88 | 89 | class MyApp(App): 90 | foo = directive(MyDirective) 91 | 92 | class SubApp(MyApp): 93 | pass 94 | 95 | @MyApp.foo("hello") 96 | def f(): 97 | pass 98 | 99 | commit(MyApp, SubApp) 100 | 101 | messages = [r.getMessage() for r in test_handler.records] 102 | assert len(messages) == 2 103 | expected = ( 104 | "@dectate.tests.test_logging.MyApp.foo('hello') " 105 | "on dectate.tests.test_logging.f" 106 | ) 107 | 108 | assert messages[0] == expected 109 | 110 | expected = ( 111 | "@dectate.tests.test_logging.SubApp.foo('hello') " 112 | "on dectate.tests.test_logging.f " 113 | "(from dectate.tests.test_logging.MyApp)" 114 | ) 115 | 116 | assert messages[1] == expected 117 | 118 | 119 | def test_override_logger_name(): 120 | log = logging.getLogger("morepath.directive.foo") 121 | 122 | test_handler = Handler() 123 | 124 | log.addHandler(test_handler) 125 | log.setLevel(logging.DEBUG) 126 | 127 | class MyDirective(Action): 128 | config = {"my": list} 129 | 130 | def __init__(self, message): 131 | self.message = message 132 | 133 | def identifier(self, my): 134 | return self.message 135 | 136 | def perform(self, obj, my): 137 | my.append((self.message, obj)) 138 | 139 | class MyApp(App): 140 | logger_name = "morepath.directive" 141 | 142 | foo = directive(MyDirective) 143 | 144 | @MyApp.foo("hello") 145 | def f(): 146 | pass 147 | 148 | commit(MyApp) 149 | 150 | messages = [r.getMessage() for r in test_handler.records] 151 | assert len(messages) == 1 152 | expected = ( 153 | "@dectate.tests.test_logging.MyApp.foo('hello') " 154 | "on dectate.tests.test_logging.f" 155 | ) 156 | 157 | assert messages[0] == expected 158 | -------------------------------------------------------------------------------- /doc/developing.rst: -------------------------------------------------------------------------------- 1 | Developing Dectate 2 | ================== 3 | 4 | Install Dectate for development 5 | ------------------------------- 6 | 7 | .. highlight:: console 8 | 9 | Clone Dectate from github:: 10 | 11 | $ git clone git@github.com:morepath/dectate.git 12 | 13 | If this doesn't work and you get an error 'Permission denied (publickey)', 14 | you need to upload your ssh public key to github_. 15 | 16 | Then go to the dectate directory:: 17 | 18 | $ cd dectate 19 | 20 | Make sure you have virtualenv_ installed. 21 | 22 | Create a new virtualenv for Python 3 inside the dectate directory:: 23 | 24 | $ virtualenv -p python3 env/py3 25 | 26 | Activate the virtualenv:: 27 | 28 | $ source env/py3/bin/activate 29 | 30 | Make sure you have recent setuptools and pip installed:: 31 | 32 | $ pip install -U setuptools pip 33 | 34 | Install the various dependencies and development tools from 35 | develop_requirements.txt:: 36 | 37 | $ pip install -Ur develop_requirements.txt 38 | 39 | For upgrading the requirements just run the command again. 40 | 41 | .. note:: 42 | 43 | The following commands work only if you have the virtualenv activated. 44 | 45 | .. _github: https://help.github.com/articles/generating-an-ssh-key 46 | 47 | .. _virtualenv: https://pypi.python.org/pypi/virtualenv 48 | 49 | Install pre-commit hook for Black integration 50 | --------------------------------------------- 51 | 52 | We're using Black_ for formatting the code and it's recommended to 53 | install the `pre-commit hook`_ for Black integration before committing:: 54 | 55 | $ pre-commit install 56 | 57 | .. _`pre-commit hook`: https://black.readthedocs.io/en/stable/version_control_integration.html 58 | 59 | Running the tests 60 | ----------------- 61 | 62 | You can run the tests using `py.test`_:: 63 | 64 | $ py.test 65 | 66 | To generate test coverage information as HTML do:: 67 | 68 | $ py.test --cov --cov-report html 69 | 70 | You can then point your web browser to the ``htmlcov/index.html`` file 71 | in the project directory and click on modules to see detailed coverage 72 | information. 73 | 74 | .. _`py.test`: http://pytest.org/latest/ 75 | 76 | Black 77 | ----- 78 | 79 | To format the code with the `Black Code Formatter`_ run in the root directory:: 80 | 81 | $ black morepath 82 | 83 | Black has also integration_ for the most popular editors. 84 | 85 | .. _`Black Code Formatter`: https://black.readthedocs.io 86 | .. _integration: https://black.readthedocs.io/en/stable/editor_integration.html 87 | 88 | Running the documentation tests 89 | ------------------------------- 90 | 91 | The documentation contains code. To check these code snippets, you 92 | can run this code using this command:: 93 | 94 | (py3) $ sphinx-build -b doctest doc doc/build/doctest 95 | 96 | Or alternatively if you have ``Make`` installed:: 97 | 98 | (py3) $ cd doc 99 | (py3) $ make doctest 100 | 101 | Or from the Dectate project directory:: 102 | 103 | (py3) $ make -C doc doctest 104 | 105 | Building the HTML documentation 106 | ------------------------------- 107 | 108 | To build the HTML documentation (output in ``doc/build/html``), run:: 109 | 110 | $ sphinx-build doc doc/build/html 111 | 112 | Or alternatively if you have ``Make`` installed:: 113 | 114 | $ cd doc 115 | $ make html 116 | 117 | Or from the Dectate project directory:: 118 | 119 | $ make -C doc html 120 | 121 | Various checking tools 122 | ---------------------- 123 | 124 | flake8_ is a tool that can do various checks for common Python 125 | mistakes using pyflakes_, check for PEP8_ style compliance and 126 | can do `cyclomatic complexity`_ checking. To do pyflakes and pep8 127 | checking do:: 128 | 129 | $ flake8 dectate 130 | 131 | To also show cyclomatic complexity, use this command:: 132 | 133 | $ flake8 --max-complexity=10 dectate 134 | 135 | .. _flake8: https://pypi.python.org/pypi/flake8 136 | 137 | .. _pyflakes: https://pypi.python.org/pypi/pyflakes 138 | 139 | .. _pep8: http://www.python.org/dev/peps/pep-0008/ 140 | 141 | .. _`cyclomatic complexity`: https://en.wikipedia.org/wiki/Cyclomatic_complexity 142 | 143 | Tox 144 | --- 145 | 146 | With tox you can test Morepath under different Python environments. 147 | 148 | We have Travis continuous integration installed on Morepath's github 149 | repository and it runs the same tox tests after each checkin. 150 | 151 | First you should install all Python versions which you want to 152 | test. The versions which are not installed will be skipped. You should 153 | at least install Python 3.7 which is required by flake8, coverage and 154 | doctests. 155 | 156 | One tool you can use to install multiple versions of Python is pyenv_. 157 | 158 | To find out which test environments are defined for Morepath in tox.ini run:: 159 | 160 | $ tox -l 161 | 162 | You can run all tox tests with:: 163 | 164 | $ tox 165 | 166 | You can also specify a test environment to run e.g.:: 167 | 168 | $ tox -e py37 169 | $ tox -e pep8 170 | $ tox -e docs 171 | 172 | .. _pyenv: https://github.com/yyuu/pyenv 173 | -------------------------------------------------------------------------------- /dectate/app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .config import Configurable, Directive, commit, create_code_info 3 | 4 | 5 | class Config: 6 | """The object that contains the configurations. 7 | 8 | The configurations are specified by the :attr:`Action.config` 9 | class attribute of :class:`Action`. 10 | """ 11 | 12 | pass 13 | 14 | 15 | class AppMeta(type): 16 | """Dectate metaclass. 17 | 18 | Sets up ``config`` and ``dectate`` class attributes. 19 | """ 20 | 21 | def __new__(cls, name, bases, d): 22 | extends = [base.dectate for base in bases if hasattr(base, "dectate")] 23 | d["config"] = config = Config() 24 | d["dectate"] = configurable = Configurable(extends, config) 25 | result = super().__new__(cls, name, bases, d) 26 | configurable.app_class = result 27 | return result 28 | 29 | 30 | class App(metaclass=AppMeta): 31 | """A configurable application object. 32 | 33 | Subclass this in your framework and add directives using 34 | the :meth:`App.directive` decorator. 35 | 36 | Set the ``logger_name`` class attribute to the logging prefix 37 | that Dectate should log to. By default it is ``"dectate.directive"``. 38 | """ 39 | 40 | logger_name = "dectate.directive" 41 | """The prefix to use for directive debug logging.""" 42 | 43 | dectate = None 44 | """A dectate Configurable instance is installed here. 45 | 46 | This is installed when the class object is initialized, so during 47 | import-time when you use the ``class`` statement and subclass 48 | :class:`dectate.App`. 49 | 50 | This keeps tracks of the registrations done by using directives as long 51 | as committed configurations. 52 | """ 53 | 54 | config = None 55 | """Config object that contains the configuration after commit. 56 | 57 | This is installed when the class object is initialized, so during 58 | import-time when you use the ``class`` statement and subclass 59 | :class:`dectate.App`, but is only filled after you commit the 60 | configuration. 61 | 62 | This keeps the final configuration result after commit. It is 63 | a very dumb object that has no methods and is just a container for 64 | attributes that contain the real configuration. 65 | """ 66 | 67 | @classmethod 68 | def get_directive_methods(cls): 69 | for name in dir(cls): 70 | attr = getattr(cls, name) 71 | im_func = getattr(attr, "__func__", None) 72 | if im_func is None: 73 | continue 74 | if hasattr(im_func, "action_factory"): 75 | yield name, attr 76 | 77 | @classmethod 78 | def commit(cls): 79 | """Commit this class and any depending on it. 80 | 81 | This is intended to be overridden by subclasses if committing 82 | the class also commits other classes automatically, such as in 83 | the case in Morepath when one app is mounted into another. In 84 | such case it should return an iterable of all committed 85 | classes. 86 | 87 | :return: an iterable of committed classes 88 | """ 89 | commit(cls) 90 | return [cls] 91 | 92 | @classmethod 93 | def is_committed(cls): 94 | """True if this app class was ever committed. 95 | 96 | :return: bool that is ``True`` when the app was committed before. 97 | """ 98 | return cls.dectate.committed 99 | 100 | @classmethod 101 | def clean(cls): 102 | """A method that sets or restores the state of the class. 103 | 104 | Normally Dectate only sets up configuration into the ``config`` 105 | attribute, but in some cases you may touch other aspects of the 106 | class during configuration time. You can override this classmethod 107 | to set up the state of the class in its pristine condition. 108 | """ 109 | pass 110 | 111 | 112 | def directive(action_factory): 113 | """Create a classmethod to hook action to application class. 114 | 115 | You pass in a :class:`dectate.Action` or a 116 | :class:`dectate.Composite` subclass and can attach the result as a 117 | class method to an :class:`dectate.App` subclass:: 118 | 119 | class FooAction(dectate.Action): 120 | ... 121 | 122 | class MyApp(dectate.App): 123 | my_directive = dectate.directive(MyAction) 124 | 125 | Alternatively you can also define the direction inline using 126 | this as a decorator:: 127 | 128 | class MyApp(dectate.App): 129 | @directive 130 | class my_directive(dectate.Action): 131 | ... 132 | 133 | :param action_factory: an action class to use as the directive. 134 | :return: a class method that represents the directive. 135 | """ 136 | 137 | def method(cls, *args, **kw): 138 | frame = sys._getframe(1) 139 | code_info = create_code_info(frame) 140 | return Directive(action_factory, code_info, cls, args, kw) 141 | 142 | # sphinxext and App.get_action_classes need to recognize this 143 | method.action_factory = action_factory 144 | method.__doc__ = action_factory.__doc__ 145 | method.__module__ = action_factory.__module__ 146 | return classmethod(method) 147 | -------------------------------------------------------------------------------- /dectate/query.py: -------------------------------------------------------------------------------- 1 | from .config import Composite 2 | from .error import QueryError 3 | 4 | 5 | class Callable: 6 | def __call__(self, app_class): 7 | """Execute the query against an app class. 8 | 9 | :param app_class: a :class:`App` subclass to execute the query 10 | against. 11 | :return: iterable of ``(action, obj)`, where ``action`` is a 12 | :class:`Action` instance and `obj` is the function or class 13 | that was decorated. 14 | """ 15 | return self.execute(app_class.dectate) 16 | 17 | 18 | class Base(Callable): 19 | def filter(self, **kw): 20 | """Filter this query by keyword arguments. 21 | 22 | The keyword arguments are matched with attributes on the 23 | action. :attr:`Action.filter_name` is used to map keyword name 24 | to attribute name, by default they are the 25 | same. :meth:`Action.filter_get_value` can also be implemented 26 | for more complicated attribute access as a fallback. 27 | 28 | By default the keyword argument values are matched by equality, 29 | but you can override this using :attr:`Action.filter_compare`. 30 | 31 | Can be chained again with a new ``filter``. 32 | 33 | :param ``**kw``: keyword arguments to match against. 34 | :return: iterable of ``(action, obj)``. 35 | 36 | """ 37 | return Filter(self, **kw) 38 | 39 | def attrs(self, *names): 40 | """Extract attributes from resulting actions. 41 | 42 | The list of attribute names indicates which keys to include in 43 | the dictionary. Obeys :attr:`Action.filter_name` and 44 | :attr:`Action.filter_get_value`. 45 | 46 | :param: ``*names``: list of names to extract. 47 | :return: iterable of dictionaries. 48 | 49 | """ 50 | return Attrs(self, names) 51 | 52 | def obj(self): 53 | """Get objects from results. 54 | 55 | Throws away actions in the results and return an iterable of objects. 56 | 57 | :return: iterable of decorated objects. 58 | """ 59 | return Obj(self) 60 | 61 | 62 | class Query(Base): 63 | """An object representing a query. 64 | 65 | A query can be chained with :meth:`Query.filter`, :meth:`Query.attrs`, 66 | :meth:`Query.obj`. 67 | 68 | :param: ``*action_classes``: one or more action classes to query for. 69 | Can be instances of :class:`Action` or :class:`Composite`. Can 70 | also be strings indicating directive names, in which case they 71 | are looked up on the app class before execution. 72 | """ 73 | 74 | def __init__(self, *action_classes): 75 | self.action_classes = action_classes 76 | 77 | def execute(self, configurable): 78 | app_class = configurable.app_class 79 | action_classes = [] 80 | for action_class in self.action_classes: 81 | if isinstance(action_class, str): 82 | action_class = get_action_class(app_class, action_class) 83 | action_classes.append(action_class) 84 | return query_action_classes(configurable, action_classes) 85 | 86 | 87 | def expand_action_classes(action_classes): 88 | result = set() 89 | for action_class in action_classes: 90 | if issubclass(action_class, Composite): 91 | query_classes = action_class.query_classes 92 | if not query_classes: 93 | raise QueryError( 94 | "Query of composite action %r but no " 95 | "query_classes defined." % action_class 96 | ) 97 | for query_class in expand_action_classes(query_classes): 98 | result.add(query_class) 99 | else: 100 | group_class = action_class.group_class 101 | if group_class is None: 102 | result.add(action_class) 103 | else: 104 | result.add(group_class) 105 | return result 106 | 107 | 108 | def query_action_classes(configurable, action_classes): 109 | for action_class in expand_action_classes(action_classes): 110 | action_group = configurable.get_action_group(action_class) 111 | if action_group is None: 112 | raise QueryError( 113 | "%r is not an action of %r" 114 | % (action_class, configurable.app_class) 115 | ) 116 | yield from action_group.get_actions() 117 | 118 | 119 | def get_action_class(app_class, directive_name): 120 | directive_method = getattr(app_class, directive_name, None) 121 | if directive_method is None: 122 | raise QueryError( 123 | "No directive exists on %r with name: %s" 124 | % (app_class, directive_name) 125 | ) 126 | action_class = getattr(directive_method, "action_factory", None) 127 | if action_class is None: 128 | raise QueryError( 129 | f"{directive_name!r} on {app_class!r} is not a directive" 130 | ) 131 | return action_class 132 | 133 | 134 | def compare_equality(compared, value): 135 | return compared == value 136 | 137 | 138 | class Filter(Base): 139 | def __init__(self, query, **kw): 140 | self.query = query 141 | self.kw = kw 142 | 143 | def execute(self, configurable): 144 | for action, obj in self.query.execute(configurable): 145 | for name, value in sorted(self.kw.items()): 146 | compared = action.get_value_for_filter(name) 147 | compare_func = action.filter_compare.get(name, compare_equality) 148 | if not compare_func(compared, value): 149 | break 150 | else: 151 | yield action, obj 152 | 153 | 154 | class Attrs(Callable): 155 | def __init__(self, query, names): 156 | self.query = query 157 | self.names = names 158 | 159 | def execute(self, configurable): 160 | for action, obj in self.query.execute(configurable): 161 | attrs = {} 162 | for name in self.names: 163 | attrs[name] = action.get_value_for_filter(name) 164 | yield attrs 165 | 166 | 167 | class Obj(Callable): 168 | def __init__(self, query): 169 | self.query = query 170 | 171 | def execute(self, configurable): 172 | for action, obj in self.query.execute(configurable): 173 | yield obj 174 | -------------------------------------------------------------------------------- /dectate/tool.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import inspect 3 | from .query import Query, get_action_class 4 | from .error import QueryError 5 | from .app import App 6 | 7 | 8 | class ToolError(Exception): 9 | pass 10 | 11 | 12 | def query_tool(app_classes): 13 | """Command-line query tool for dectate. 14 | 15 | Uses command-line arguments to do the query and prints the results. 16 | 17 | usage: decq [-h] [--app APP] directive 18 | 19 | Query all directives named ``foo`` in given app classes:: 20 | 21 | $ decq foo 22 | 23 | Query directives ``foo`` with ``name`` attribute set to ``alpha``:: 24 | 25 | $ decq foo name=alpha 26 | 27 | Query directives ``foo`` specifically in given app:: 28 | 29 | $ decq --app=myproject.App foo 30 | 31 | :param app_classes: a list of :class:`App` subclasses to query by default. 32 | """ 33 | parser = argparse.ArgumentParser(description="Query Dectate actions") 34 | parser.add_argument( 35 | "--app", 36 | help="Dotted name for App subclass.", 37 | type=parse_app_class, 38 | action="append", 39 | ) 40 | parser.add_argument("directive", help="Name of the directive.") 41 | 42 | args, filters = parser.parse_known_args() 43 | 44 | if args.app: 45 | app_classes = args.app 46 | 47 | filters = parse_filters(filters) 48 | 49 | try: 50 | lines = list(query_tool_output(app_classes, args.directive, filters)) 51 | except ToolError as e: 52 | parser.error(str(e)) 53 | 54 | for line in lines: 55 | print(line) 56 | 57 | 58 | def query_tool_output(app_classes, directive, filters): 59 | for app_class in app_classes: 60 | if not app_class.is_committed(): 61 | raise ToolError("App %r was not committed." % app_class) 62 | 63 | actions = list(query_app(app_class, directive, **filters)) 64 | 65 | if not actions: 66 | continue 67 | 68 | yield "App: %r" % app_class 69 | 70 | for action, obj in actions: 71 | if action.directive is None: 72 | continue # XXX handle this case 73 | yield " %s" % action.directive.code_info.filelineno() 74 | yield " %s" % action.directive.code_info.sourceline 75 | yield "" 76 | 77 | 78 | def query_app(app_class, directive, **filters): 79 | """Query a single app with raw filters. 80 | 81 | This function is especially useful for writing unit tests that 82 | test the conversion behavior. 83 | 84 | :param app_class: a :class:`App` subclass to query. 85 | :param directive: name of directive to query. 86 | :param ``**filters``: raw (unconverted) filter values. 87 | :return: iterable of ``action, obj`` tuples. 88 | """ 89 | action_class = parse_directive(app_class, directive) 90 | if action_class is not None: 91 | filter_kw = convert_filters(action_class, filters) 92 | query = Query(action_class).filter(**filter_kw) 93 | else: 94 | query = Query() # empty query 95 | return query(app_class) 96 | 97 | 98 | def parse_directive(app_class, directive_name): 99 | try: 100 | return get_action_class(app_class, directive_name) 101 | except QueryError: 102 | return None 103 | 104 | 105 | def parse_app_class(s): 106 | try: 107 | app_class = resolve_dotted_name(s) 108 | except ImportError: 109 | raise argparse.ArgumentTypeError("Cannot resolve dotted name: %r" % s) 110 | if not inspect.isclass(app_class): 111 | raise argparse.ArgumentTypeError("%r is not a class" % s) 112 | if not issubclass(app_class, App): 113 | raise argparse.ArgumentTypeError( 114 | "%r is not a subclass of dectate.App" % s 115 | ) 116 | return app_class 117 | 118 | 119 | def convert_default(s): 120 | return s 121 | 122 | 123 | def convert_dotted_name(s): 124 | """Convert input string to an object in a module. 125 | 126 | Takes a dotted name: ``pkg.module.attr`` gets ``attr`` 127 | from module ``module`` which is in package ``pkg``. 128 | 129 | To refer to builtin objects such as ``int`` or ``object`` 130 | prefix them with ``builtins.``, so ``builtins.int`` or 131 | ``builtins.None``. 132 | 133 | Raises ``ValueError`` if it cannot be imported. 134 | 135 | """ 136 | try: 137 | return resolve_dotted_name(s) 138 | except ImportError: 139 | raise ToolError("Cannot resolve dotted name: %s" % s) 140 | 141 | 142 | def convert_bool(s): 143 | """Convert input string to boolean. 144 | 145 | Input string must either be ``True`` or ``False``. 146 | """ 147 | if s == "True": 148 | return True 149 | elif s == "False": 150 | return False 151 | else: 152 | raise ValueError("Cannot convert bool: %r" % s) 153 | 154 | 155 | def parse_filters(entries): 156 | result = {} 157 | for entry in entries: 158 | try: 159 | name, value = entry.split("=") 160 | except ValueError: 161 | raise ToolError("Cannot parse query filter, no =.") 162 | name = name.strip() 163 | result[name] = value.strip() 164 | return result 165 | 166 | 167 | def convert_filters(action_class, filters): 168 | filter_convert = action_class.filter_convert 169 | 170 | result = {} 171 | 172 | for key, value in filters.items(): 173 | parse = filter_convert.get(key, convert_default) 174 | try: 175 | result[key] = parse(value.strip()) 176 | except ValueError as e: 177 | raise ToolError(str(e)) 178 | 179 | return result 180 | 181 | 182 | def resolve_dotted_name(name, module=None): 183 | """Adapted from zope.dottedname""" 184 | name = name.split(".") 185 | if not name[0]: 186 | if module is None: 187 | raise ValueError("relative name without base module") 188 | module = module.split(".") 189 | name.pop(0) 190 | while not name[0]: 191 | module.pop() 192 | name.pop(0) 193 | name = module + name 194 | 195 | used = name.pop(0) 196 | found = __import__(used) 197 | for n in name: 198 | used += "." + n 199 | try: 200 | found = getattr(found, n) 201 | except AttributeError: 202 | __import__(used) 203 | found = getattr(found, n) 204 | 205 | return found 206 | -------------------------------------------------------------------------------- /use_cases.txt: -------------------------------------------------------------------------------- 1 | 2 | * Different types of actions need to group together so they 3 | conflict/override. Currently uses group_key method, renamed to 4 | group_class for clarity. (Stick to this, as class is needed to sort 5 | actions by dependency) (done) 6 | 7 | * An action has an identifier for conflicts and overrides. Prefixed by 8 | group key so we don't need to distinguish between actions in 9 | different groups.(done) 10 | 11 | * Extra discriminator list to make more conflicts happen. (done) 12 | 13 | * Generate sub-actions instead of performing things directly. This is 14 | done in prepare right now. (done) 15 | 16 | * Directive abbreviations. (done) 17 | 18 | * Abbreviated directives should have correct frame info for error 19 | reporting. (done) 20 | 21 | * Do checks on input parameters depending on what object is being 22 | decorated (class versus function). Done in prepare, such as for path 23 | directive. Should now move it into perform and raise errors if 24 | things are wrong. Check on error reporting. (done) 25 | 26 | * Dependencies between actions. (done) 27 | 28 | * Subclasses of App can have new directives through the directive 29 | directive (done) 30 | 31 | * Prepare now only works one level deep: you can't implement a 32 | directive as multiple actions which then in turn are actually other 33 | actions. This has been fixed with Composite actions (done) 34 | 35 | * Not use Venusian: a directive should register with the configuration 36 | system (or better: the App) immediately upon import as it's easier to 37 | explain. (done) 38 | 39 | * We have some use cases where we'd like to execute some code after 40 | all directives of a particular kind are done executing. This way we 41 | could initialize the predicates for all external predicates 42 | functions, for instance. (done) 43 | 44 | * Have line number information for error reporting. (done) 45 | 46 | * Apps could have multiple independent registries (or at least 47 | registries APIs; many will build on the reg registry). Perhaps 48 | actions can somehow declare which registry APIs they concern 49 | themselves with. This way we could avoid mixing a lot of registry 50 | mixins into a larger registry. (done) 51 | 52 | * The config object should get the configurables explicitly. Or apps? 53 | And then allow a commit. (done) 54 | 55 | * rename to something else as Confidant as now on PyPI. Now dectate. (done) 56 | 57 | * Sometimes during prepare it'd be useful to have access to the registry. But 58 | since prepare is gone, not relevant anymore. (done) 59 | 60 | * separate "configurations" object from configurable. (done) 61 | 62 | * there's also an implicit list of configurables kept globally (done) 63 | 64 | * Directive logging. (done) 65 | 66 | * The normal conflict detection will report correctly that there's 67 | a conflict if a module is imported twice. (done) 68 | 69 | * should we store the *directives* on the configurable instead of the 70 | actions to prevent any user code execution that can do too much? (done) 71 | 72 | * but if a module is imported twice and a variable is increased upon 73 | import that causes a non-conflict, a ConflictError won't be raised... 74 | How contrived is that scenario? Could happen if you generate information 75 | dynamically. This is now documented. (done) 76 | 77 | * Ensure that morepath directives get registered before we start using them 78 | in imports. Appears to be not a problem. (done) 79 | 80 | * Directives have documentation that shows up in the sphinx docs (done) 81 | 82 | * Implement scanning using recursive import. Done in Morepath using 83 | importscan module. (done) 84 | 85 | * Support configuration isolation: re-run configuration multiple times. 86 | (done) 87 | 88 | * Determine that two toplevel directives are actually the same one by 89 | looking at line number information. Not done: we rely on a different 90 | mechanism, conflict detection, to detect this now. this is documented. 91 | (done) 92 | 93 | * Refuse to support classmethod & staticmethod? Or refuse to support 94 | methods? Conclusion: no classmethod, do support staticmethod. (done) 95 | 96 | * the implicit list of global configurables can be reset for testing 97 | purposes. Conclusion: don't allow this as it's useless for testing. (done) 98 | 99 | * is it possible to set up the registries before the action group is 100 | executed? Maybe not, as action group could still contain multiple 101 | types of action. Unless we make `config` behave like `before` and 102 | `after`. Or should we simply set up *all* registries, including 103 | those for directives we never encountered? Should we also do before/after 104 | for those registries without content? Conclusion: we set up the 105 | registries for all action groups, and only action group main class 106 | has a config. (done) 107 | 108 | * should also do the above for action classes defined by base classes 109 | and not used. (done) 110 | 111 | * Configure logger by directive directive? This is now done in the App 112 | base class with logger_name. (done) 113 | 114 | * refactor so that action groups are set up *before* the rest of the 115 | commit, so we don't have to create action groups just in time while 116 | going through actions. Base it off the action groups we have 117 | registered. an action group has a before, after and a config. 118 | there's a shortcut way to register an action without an action 119 | group, but an action with both an action group *and* shortcut 120 | methods is an error. (done) 121 | 122 | * the action_classes in setup_config should be sorted so that behavior 123 | is consistent even if registries are set up inconsistently. (done) 124 | 125 | * handle TypeError if action_factory is called with wrong parameters. (done) 126 | 127 | * write a test for TypeError handling. (done) 128 | 129 | * Registry dependencies: 130 | 131 | a registry can be initialized with another registry as the constructor. 132 | 133 | if the registry is listed it will be used. the other registry must 134 | be listed in the config *or* be initialized by one of the depends. 135 | 136 | { 137 | 'template_engine_registry': TemplateEngineRegistry, ('settings',) 138 | } 139 | 140 | Now implemented as factory_arguments. (done) 141 | 142 | * rethink the way action groups work; right now they're by action 143 | class, but the implementation gets confusing as we have the true 144 | action class used to do the config setup and also the action group 145 | class used for before/after. Either use the action group class for 146 | everything, including config setup, or use another mechanism for 147 | grouping. Done: now the action group is in charge of before, after, 148 | config. (done) 149 | 150 | * group_class for a group_class should be an error. (done) 151 | 152 | * if a group_class class defines before or after or config, that should 153 | be an error. But subclassing it is okay. (done) 154 | 155 | * Deal properly with an action or composite that has no 156 | __init__. Error. Fixed by providing a base __init__.(done) 157 | -------------------------------------------------------------------------------- /dectate/tests/test_tool.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from argparse import ArgumentTypeError 3 | 4 | from dectate.config import Action, commit 5 | from dectate.app import App, directive 6 | from dectate.tool import ( 7 | parse_app_class, 8 | parse_directive, 9 | parse_filters, 10 | convert_filters, 11 | convert_dotted_name, 12 | convert_bool, 13 | query_tool_output, 14 | query_app, 15 | ToolError, 16 | ) 17 | 18 | 19 | def test_parse_app_class_main(): 20 | from dectate.tests.fixtures import anapp 21 | 22 | app_class = parse_app_class("dectate.tests.fixtures.anapp.AnApp") 23 | assert app_class is anapp.AnApp 24 | 25 | 26 | def test_parse_app_class_cannot_import(): 27 | with pytest.raises(ArgumentTypeError): 28 | parse_app_class("dectate.tests.fixtures.nothere.AnApp") 29 | 30 | 31 | def test_parse_app_class_not_a_class(): 32 | with pytest.raises(ArgumentTypeError): 33 | parse_app_class("dectate.tests.fixtures.anapp.other") 34 | 35 | 36 | def test_parse_app_class_no_app_class(): 37 | with pytest.raises(ArgumentTypeError): 38 | parse_app_class("dectate.tests.fixtures.anapp.OtherClass") 39 | 40 | 41 | def test_parse_directive_main(): 42 | from dectate.tests.fixtures import anapp 43 | 44 | action_class = parse_directive(anapp.AnApp, "foo") 45 | assert action_class is anapp.FooAction 46 | 47 | 48 | def test_parse_directive_no_attribute(): 49 | from dectate.tests.fixtures import anapp 50 | 51 | assert parse_directive(anapp.AnApp, "unknown") is None 52 | 53 | 54 | def test_parse_directive_not_a_directive(): 55 | from dectate.tests.fixtures import anapp 56 | 57 | assert parse_directive(anapp.AnApp, "known") is None 58 | 59 | 60 | def test_parse_filters_main(): 61 | assert parse_filters(["a=b", "c = d", "e=f ", " g=h"]) == { 62 | "a": "b", 63 | "c": "d", 64 | "e": "f", 65 | "g": "h", 66 | } 67 | 68 | 69 | def test_parse_filters_error(): 70 | with pytest.raises(ToolError): 71 | parse_filters(["a"]) 72 | 73 | 74 | def test_convert_filters_main(): 75 | class MyAction(Action): 76 | filter_convert = {"model": convert_dotted_name} 77 | 78 | converted = convert_filters( 79 | MyAction, {"model": "dectate.tests.fixtures.anapp.OtherClass"} 80 | ) 81 | assert len(converted) == 1 82 | from dectate.tests.fixtures.anapp import OtherClass 83 | 84 | assert converted["model"] is OtherClass 85 | 86 | 87 | def test_convert_filters_default(): 88 | class MyAction(Action): 89 | pass 90 | 91 | converted = convert_filters(MyAction, {"name": "foo"}) 92 | assert len(converted) == 1 93 | assert converted["name"] == "foo" 94 | 95 | 96 | def test_convert_filters_error(): 97 | class MyAction(Action): 98 | filter_convert = {"model": convert_dotted_name} 99 | 100 | with pytest.raises(ToolError): 101 | convert_filters( 102 | MyAction, {"model": "dectate.tests.fixtures.anapp.DoesntExist"} 103 | ) 104 | 105 | 106 | def test_convert_filters_value_error(): 107 | class MyAction(Action): 108 | filter_convert = {"count": int} 109 | 110 | assert convert_filters(MyAction, {"count": "3"}) == {"count": 3} 111 | 112 | with pytest.raises(ToolError): 113 | convert_filters(MyAction, {"count": "a"}) 114 | 115 | 116 | def test_query_tool_output(): 117 | class FooAction(Action): 118 | def __init__(self, name): 119 | self.name = name 120 | 121 | def identifier(self): 122 | return self.name 123 | 124 | def perform(self, obj): 125 | pass 126 | 127 | class MyApp(App): 128 | foo = directive(FooAction) 129 | 130 | @MyApp.foo("a") 131 | def f(): 132 | pass 133 | 134 | @MyApp.foo("b") 135 | def g(): 136 | pass 137 | 138 | commit(MyApp) 139 | 140 | li = list(query_tool_output([MyApp], "foo", {"name": "a"})) 141 | 142 | # we are not going to assert too much about the content of things 143 | # here as we probably want to tweak for a while, just assert that 144 | # we successfully produce output 145 | assert li 146 | 147 | 148 | def test_query_tool_output_multiple_apps(): 149 | class FooAction(Action): 150 | def __init__(self, name): 151 | self.name = name 152 | 153 | def identifier(self): 154 | return self.name 155 | 156 | def perform(self, obj): 157 | pass 158 | 159 | class Base(App): 160 | foo = directive(FooAction) 161 | 162 | class AlphaApp(Base): 163 | pass 164 | 165 | class BetaApp(Base): 166 | pass 167 | 168 | class GammaApp(Base): 169 | pass 170 | 171 | @AlphaApp.foo("a") 172 | def f(): 173 | pass 174 | 175 | @GammaApp.foo("b") 176 | def g(): 177 | pass 178 | 179 | commit(AlphaApp, BetaApp, GammaApp) 180 | 181 | li = list(query_tool_output([AlphaApp, BetaApp, GammaApp], "foo", {})) 182 | 183 | assert len(li) == 8 184 | 185 | 186 | def test_query_app(): 187 | class FooAction(Action): 188 | filter_convert = {"count": int} 189 | 190 | def __init__(self, count): 191 | self.count = count 192 | 193 | def identifier(self): 194 | return self.count 195 | 196 | def perform(self, obj): 197 | pass 198 | 199 | class MyApp(App): 200 | foo = directive(FooAction) 201 | 202 | @MyApp.foo(1) 203 | def f(): 204 | pass 205 | 206 | @MyApp.foo(2) 207 | def g(): 208 | pass 209 | 210 | commit(MyApp) 211 | 212 | li = list(query_app(MyApp, "foo", count="1")) 213 | assert len(li) == 1 214 | assert li[0][0].count == 1 215 | 216 | 217 | def test_query_tool_uncommitted(): 218 | class FooAction(Action): 219 | def __init__(self, name): 220 | self.name = name 221 | 222 | def identifier(self): 223 | return self.name 224 | 225 | def perform(self, obj): 226 | pass 227 | 228 | class MyApp(App): 229 | foo = directive(FooAction) 230 | 231 | @MyApp.foo("a") 232 | def f(): 233 | pass 234 | 235 | @MyApp.foo("b") 236 | def g(): 237 | pass 238 | 239 | with pytest.raises(ToolError): 240 | list(query_tool_output([MyApp], "foo", {"name": "a"})) 241 | 242 | 243 | def test_convert_bool(): 244 | assert convert_bool("True") 245 | assert not convert_bool("False") 246 | with pytest.raises(ValueError): 247 | convert_bool("flurb") 248 | 249 | 250 | def test_convert_dotted_name_builtin(): 251 | assert convert_dotted_name("builtins.int") is int 252 | assert convert_dotted_name("builtins.object") is object 253 | 254 | 255 | def test_app_without_directive(): 256 | class MyApp(App): 257 | pass 258 | 259 | commit(MyApp) 260 | 261 | li = list(query_app(MyApp, "foo", count="1")) 262 | assert li == [] 263 | 264 | 265 | def test_inheritance(): 266 | class FooAction(Action): 267 | filter_convert = {"count": int} 268 | 269 | def __init__(self, count): 270 | self.count = count 271 | 272 | def identifier(self): 273 | return self.count 274 | 275 | def perform(self, obj): 276 | pass 277 | 278 | class MyApp(App): 279 | foo = directive(FooAction) 280 | 281 | class SubApp(MyApp): 282 | pass 283 | 284 | @MyApp.foo(1) 285 | def f(): 286 | pass 287 | 288 | @MyApp.foo(2) 289 | def g(): 290 | pass 291 | 292 | commit(SubApp) 293 | 294 | li = list(query_app(SubApp, "foo")) 295 | 296 | assert len(li) == 2 297 | -------------------------------------------------------------------------------- /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 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/dectate.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/dectate.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/dectate" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/dectate" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ******* 3 | 4 | 0.15 (unreleased) 5 | ================= 6 | 7 | - Fix Flake8 errors. 8 | 9 | - Apply Black code formatter. 10 | 11 | - Drop support for Python 3.4 and 3.5. 12 | 13 | - Add support for Python 3.9. 14 | 15 | - Use GitHub Actions instead of Travis for CI. 16 | (Travis no longer gives free credits to open source projects.) 17 | 18 | - Cleanup old Python 2 code with pyupgrade_. 19 | 20 | .. _pyupgrade: https://github.com/asottile/pyupgrade 21 | 22 | 23 | 24 | 0.14 (2020-01-29) 25 | ================= 26 | 27 | - **Removed**: Removed support for Python 2. 28 | 29 | You have to upgrade to Python 3 if you want to use this version. 30 | 31 | - Dropped support for Python 3.3. 32 | 33 | - Added support for Python 3.5, 3.6, 3.7, 3.8 and PyPy 3.6. 34 | 35 | - Make Python 3.7 the default testing environment. 36 | 37 | - Don't compute expensive logging information if logging is disabled. 38 | 39 | - Add integration for the Black code formatter. 40 | 41 | 42 | 0.13 (2016-12-23) 43 | ================= 44 | 45 | - Add a Sentinel class, used for the ``NOT_FOUND`` object. 46 | 47 | - Upload universal wheels to pypi during release. 48 | 49 | - make ``directive_name`` property available on ``Directive`` instances. 50 | 51 | 0.12 (2016-10-04) 52 | ================= 53 | 54 | - **Breaking changes**: previously you defined new directives using the 55 | ``App.directive`` directive. This would lead to import confusion: you 56 | *have* to import the modules that define directives before you can actually 57 | use them, even though you've already imported your app class. 58 | 59 | In this version of Dectate we've changed the way you define directives. 60 | Instead of:: 61 | 62 | class MyApp(dectate.App): 63 | pass 64 | 65 | @MyApp.directive('foo') 66 | class FooAction(dectate.Action): 67 | ... 68 | 69 | You now write this:: 70 | 71 | class FooAction(directive.Action) 72 | ... 73 | 74 | class MyApp(dectate.App): 75 | foo = directive(FooAction) 76 | 77 | So, you define the directives directly on the app class that needs 78 | them. 79 | 80 | Uses of ``private_action_class`` should be replaced by an underscored 81 | directive definition:: 82 | 83 | class MyApp(dectate.App): 84 | _my_private_thing = directive(PrivateAction) 85 | 86 | - Use the same Git ignore file used in other Morepath projects. 87 | 88 | - If you set the ``app_class_arg`` class attribute to ``True`` on an 89 | action, then an ``app_class`` is passed along to ``perform``, 90 | ``identifier``, etc. This way you can affect the app class directly 91 | instead of just its underlying configuration in the ``config`` 92 | attribute. 93 | 94 | - Similarly if you set the ``app_class_arg`` attribute ``True`` on a 95 | factory class, it is passed in. 96 | 97 | - Add a ``clean`` method to the ``App`` class. You can override this 98 | to introduce your own cleanup policy for aspects of the class that are 99 | not contained in the ``config`` attribute. 100 | 101 | - We now use virtualenv and pip instead of buildout to set up the 102 | development environment. The development documentation has been 103 | updated accordingly. 104 | 105 | - Include doctests in Tox and Travis. 106 | 107 | 0.11 (2016-07-18) 108 | ================= 109 | 110 | - **Removed**: ``autocommit`` was removed from the Dectate API. Rely 111 | on the ``commit`` class method of the ``App`` class instead for a 112 | more explicit alternative. 113 | 114 | - **Removed**: ``auto_query_tool`` was removed from the Dectate API. 115 | Use ``query_tool(App.commit())`` instead. 116 | 117 | - Fix ``repr`` of directives so that you can at least see their name. 118 | 119 | - the execution order of filters is now reproducible, to ensure 120 | consistent test coverage reports. 121 | 122 | - Use abstract base classes from the standard library for the ``Action`` 123 | and ``Composite`` classes. 124 | 125 | - Use feature detection instead of version detection to ensure Python 126 | 2/3 compatibility. 127 | 128 | - Increased test coverage. 129 | 130 | - Set up Travis CI and Coverall as continuous integration services for 131 | quality assurance purposes. 132 | 133 | - Add support for Python 3.3 and 3.5. 134 | 135 | - Make Python 3.5 the default testing environment. 136 | 137 | 0.10.2 (2016-04-26) 138 | =================== 139 | 140 | - If nothing is found for an app in the query tool, don't mention it 141 | in the output so as to avoid cluttering the results. 142 | 143 | - Fix a major bug in the query tool where if an app resulted in no 144 | results, any subsequent apps weren't even searched. 145 | 146 | 0.10.1 (2016-04-26) 147 | =================== 148 | 149 | - Create proper deprecation warnings instead of plain warnings for 150 | ``autocommit`` and ``auto_query_tool``. 151 | 152 | 0.10 (2016-04-25) 153 | ================= 154 | 155 | - **Deprecated** The ``autocommit`` function is deprecated. Rely on 156 | the ``commit`` class method of the ``App`` class instead for a more 157 | explicit alternative. 158 | 159 | - **Deprecated** The ``auto_query_tool`` function is deprecated. Rely 160 | on ``dectate.query_tool(MyApp.commit())`` instead. Since the commit 161 | method returns an iterable of ``App`` classes that are required to 162 | commit the app class it is invoked on, this returns the right 163 | information. 164 | 165 | - ``topological_sort`` function is exposed as the public API. 166 | 167 | - A ``commit`` class method on ``App`` classes. 168 | 169 | - Report on inconsistent uses of factories between different directives' 170 | ``config`` settings as well as ``factory_arguments`` for registries. This 171 | prevents bugs where a new directive introduces the wrong factory for 172 | an existing directive. 173 | 174 | - Expanded internals documentation. 175 | 176 | 0.9.1 (2016-04-19) 177 | ================== 178 | 179 | - Fix a subtle bug introduced in the last release. If 180 | ``factory_arguments`` were in use with a config name only created in 181 | that context, it was not properly cleaned up, which in some cases 182 | can make a commit of a subclass get the same config object as that 183 | of the base class. 184 | 185 | 0.9 (2016-04-19) 186 | ================ 187 | 188 | - Change the behavior of ``query_tool`` so that if it cannot find an 189 | action class for the directive name the query result is empty 190 | instead of making this an error. This makes ``auto_query_tool`` work 191 | better. 192 | 193 | - Introduce ``auto_query_tool`` which uses the automatically found 194 | app classes as the default app classes to query. 195 | 196 | - Fix tests that use ``__builtin__`` that were failing on Python 3. 197 | 198 | - Dependencies only listed in ``factory_arguments`` are also created 199 | during config creation. 200 | 201 | 0.8 (2016-04-12) 202 | ================ 203 | 204 | - Document how to refer to builtins in Python 3. 205 | 206 | - Expose ``is_committed`` method on ``App`` subclasses. 207 | 208 | 0.7 (2016-04-11) 209 | ================ 210 | 211 | - Fix a few documentation issues. 212 | 213 | - Expose ``convert_dotted_name`` and document it. 214 | 215 | - Implement new ``convert_bool``. 216 | 217 | - Allow use of directive name instead of Action subclass as argument 218 | to Query. 219 | 220 | - A ``query_app`` function which is especially helpful when writing 221 | tests for the query tool -- it takes unconverted filter arguments. 222 | 223 | - Use newer version of ``with_metaclass`` from six. 224 | 225 | - Expose ``NOT_FOUND`` and document it. 226 | 227 | - Introduce a new ``filter_get_value`` method you can implement if the 228 | normal attribute getting and ``filter_name`` are not enough. 229 | 230 | 0.6 (2016-04-06) 231 | ================ 232 | 233 | - Introduce a query system for actions and a command-line tool that 234 | lets you query actions. 235 | 236 | 0.5 (2016-04-04) 237 | ================ 238 | 239 | - **Breaking change** The signature of ``commit`` has changed. Just 240 | pass in one or more arguments you want to commit instead of a list. See 241 | #8. 242 | 243 | 0.4 (2016-04-01) 244 | ================ 245 | 246 | - Expose ``code_info`` attribute for action. The ``path`` in 247 | particular can be useful in implementing a directive such as 248 | Morepath's ``template_directory``. Expose it for composite too. 249 | 250 | - Report a few more errors; you cannot use ``config``, ``before`` or 251 | ``after`` after in an action class if ``group_class`` is set. 252 | 253 | - Raise a DirectiveReportError if a DirectiveError is raised in a 254 | composite ``actions`` method. 255 | 256 | 0.3 (2016-03-30) 257 | ================ 258 | 259 | - Document ``importscan`` package that can be used in combination with 260 | this one. 261 | 262 | - Introduced ``factory_arguments`` feature on ``config`` factories, 263 | which can be used to create dependency relationships between 264 | configuration. 265 | 266 | - Fix a bug where config items were not always properly reused. Now 267 | only the first one in the action class dependency order is used, and 268 | it is not recreated. 269 | 270 | 0.2 (2016-03-29) 271 | ================ 272 | 273 | - Remove clear_autocommit as it was useless during testing anyway. 274 | In tests just use explicit commit. 275 | 276 | - Add a ``dectate.sphinxext`` module that can be plugged into Sphinx 277 | so that directives are documented properly. 278 | 279 | - Document how Dectate deals with double imports. 280 | 281 | 0.1 (2016-03-29) 282 | ================ 283 | 284 | - Initial public release. 285 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # dectate documentation build configuration file 2 | # 3 | # This file is execfile()d with the current directory set to its 4 | # containing dir. 5 | # 6 | # Note that not all possible configuration values are present in this 7 | # autogenerated file. 8 | # 9 | # All configuration values have a default; values that are commented out 10 | # serve to show the default. 11 | 12 | import os 13 | import pkg_resources 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # sys.path.insert(0, os.path.abspath('.')) 19 | 20 | # -- General configuration ------------------------------------------------ 21 | 22 | # If your documentation needs a minimal Sphinx version, state it here. 23 | # needs_sphinx = '1.0' 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be extensions 26 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 27 | extensions = [ 28 | "sphinx.ext.doctest", 29 | "sphinx.ext.autodoc", 30 | "sphinx.ext.intersphinx", 31 | ] 32 | 33 | autoclass_content = "both" 34 | 35 | autodoc_member_order = "groupwise" 36 | 37 | intersphinx_mapping = { 38 | "reg": ("http://reg.readthedocs.io/en/latest", None), 39 | "webob": ("http://docs.webob.org/en/latest", None), 40 | "bowerstatic": ("http://bowerstatic.readthedocs.io/en/latest", None), 41 | } 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = ".rst" 50 | 51 | # The encoding of source files. 52 | # source_encoding = 'utf-8-sig' 53 | 54 | # The master toctree document. 55 | master_doc = "index" 56 | 57 | # General information about the project. 58 | project = "Dectate" 59 | copyright = "2016, Martijn Faassen" 60 | author = "Martijn Faassen" 61 | 62 | # The version info for the project you're documenting, acts as replacement for 63 | # |version| and |release|, also used in various other places throughout the 64 | # built documents. 65 | # 66 | # The short X.Y version. 67 | version = pkg_resources.get_distribution("dectate").version 68 | # The full version, including alpha/beta/rc tags. 69 | release = version 70 | 71 | 72 | # The language for content autogenerated by Sphinx. Refer to documentation 73 | # for a list of supported languages. 74 | # 75 | # This is also used if you do content translation via gettext catalogs. 76 | # Usually you set "language" from the command line for these cases. 77 | language = None 78 | 79 | # There are two options for replacing |today|: either, you set today to some 80 | # non-false value, then it is used: 81 | # today = '' 82 | # Else, today_fmt is used as the format for a strftime call. 83 | # today_fmt = '%B %d, %Y' 84 | 85 | # List of patterns, relative to source directory, that match files and 86 | # directories to ignore when looking for source files. 87 | exclude_patterns = ["_build"] 88 | 89 | # The reST default role (used for this markup: `text`) to use for all 90 | # documents. 91 | # default_role = None 92 | 93 | # If true, '()' will be appended to :func: etc. cross-reference text. 94 | # add_function_parentheses = True 95 | 96 | # If true, the current module name will be prepended to all description 97 | # unit titles (such as .. function::). 98 | # add_module_names = True 99 | 100 | # If true, sectionauthor and moduleauthor directives will be shown in the 101 | # output. They are ignored by default. 102 | # show_authors = False 103 | 104 | # The name of the Pygments (syntax highlighting) style to use. 105 | pygments_style = "sphinx" 106 | 107 | # A list of ignored prefixes for module index sorting. 108 | # modindex_common_prefix = [] 109 | 110 | # If true, keep warnings as "system message" paragraphs in the built documents. 111 | # keep_warnings = False 112 | 113 | # If true, `todo` and `todoList` produce output, else they produce nothing. 114 | todo_include_todos = False 115 | 116 | 117 | # -- Options for HTML output ---------------------------------------------- 118 | 119 | # The theme to use for HTML and HTML Help pages. See the documentation for 120 | # a list of builtin themes. 121 | html_theme = "default" 122 | 123 | # Theme options are theme-specific and customize the look and feel of a theme 124 | # further. For a list of options available for each theme, see the 125 | # documentation. 126 | # html_theme_options = {} 127 | 128 | # Add any paths that contain custom themes here, relative to this directory. 129 | # html_theme_path = [] 130 | 131 | # The name for this set of Sphinx documents. If None, it defaults to 132 | # " v documentation". 133 | # html_title = None 134 | 135 | # A shorter title for the navigation bar. Default is the same as html_title. 136 | # html_short_title = None 137 | 138 | # The name of an image file (relative to this directory) to place at the top 139 | # of the sidebar. 140 | # html_logo = None 141 | 142 | # The name of an image file (relative to this directory) to use as a favicon of 143 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 144 | # pixels large. 145 | # html_favicon = None 146 | 147 | # Add any paths that contain custom static files (such as style sheets) here, 148 | # relative to this directory. They are copied after the builtin static files, 149 | # so a file named "default.css" will overwrite the builtin "default.css". 150 | html_static_path = [] 151 | 152 | # Add any extra paths that contain custom files (such as robots.txt or 153 | # .htaccess) here, relative to this directory. These files are copied 154 | # directly to the root of the documentation. 155 | # html_extra_path = [] 156 | 157 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 158 | # using the given strftime format. 159 | # html_last_updated_fmt = '%b %d, %Y' 160 | 161 | # If true, SmartyPants will be used to convert quotes and dashes to 162 | # typographically correct entities. 163 | # html_use_smartypants = True 164 | 165 | # Custom sidebar templates, maps document names to template names. 166 | # html_sidebars = {} 167 | 168 | # Additional templates that should be rendered to pages, maps page names to 169 | # template names. 170 | # html_additional_pages = {} 171 | 172 | # If false, no module index is generated. 173 | # html_domain_indices = True 174 | 175 | # If false, no index is generated. 176 | # html_use_index = True 177 | 178 | # If true, the index is split into individual pages for each letter. 179 | # html_split_index = False 180 | 181 | # If true, links to the reST sources are added to the pages. 182 | # html_show_sourcelink = True 183 | 184 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 185 | # html_show_sphinx = True 186 | 187 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 188 | # html_show_copyright = True 189 | 190 | # If true, an OpenSearch description file will be output, and all pages will 191 | # contain a tag referring to it. The value of this option must be the 192 | # base URL from which the finished HTML is served. 193 | # html_use_opensearch = '' 194 | 195 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 196 | # html_file_suffix = None 197 | 198 | # Language to be used for generating the HTML full-text search index. 199 | # Sphinx supports the following languages: 200 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 201 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 202 | # html_search_language = 'en' 203 | 204 | # A dictionary with options for the search language support, empty by default. 205 | # Now only 'ja' uses this config value 206 | # html_search_options = {'type': 'default'} 207 | 208 | # The name of a javascript file (relative to the configuration directory) that 209 | # implements a search results scorer. If empty, the default will be used. 210 | # html_search_scorer = 'scorer.js' 211 | 212 | # Output file base name for HTML help builder. 213 | htmlhelp_basename = "dectatedoc" 214 | 215 | # -- Options for LaTeX output --------------------------------------------- 216 | 217 | latex_elements = { 218 | # The paper size ('letterpaper' or 'a4paper'). 219 | # 'papersize': 'letterpaper', 220 | # The font size ('10pt', '11pt' or '12pt'). 221 | # 'pointsize': '10pt', 222 | # Additional stuff for the LaTeX preamble. 223 | # 'preamble': '', 224 | # Latex figure (float) alignment 225 | # 'figure_align': 'htbp', 226 | } 227 | 228 | # Grouping the document tree into LaTeX files. List of tuples 229 | # (source start file, target name, title, 230 | # author, documentclass [howto, manual, or own class]). 231 | latex_documents = [ 232 | ( 233 | master_doc, 234 | "dectate.tex", 235 | "Dectate Documentation", 236 | "Martijn Faassen", 237 | "manual", 238 | ), 239 | ] 240 | 241 | # The name of an image file (relative to this directory) to place at the top of 242 | # the title page. 243 | # latex_logo = None 244 | 245 | # For "manual" documents, if this is true, then toplevel headings are parts, 246 | # not chapters. 247 | # latex_use_parts = False 248 | 249 | # If true, show page references after internal links. 250 | # latex_show_pagerefs = False 251 | 252 | # If true, show URL addresses after external links. 253 | # latex_show_urls = False 254 | 255 | # Documents to append as an appendix to all manuals. 256 | # latex_appendices = [] 257 | 258 | # If false, no module index is generated. 259 | # latex_domain_indices = True 260 | 261 | 262 | # -- Options for manual page output --------------------------------------- 263 | 264 | # One entry per manual page. List of tuples 265 | # (source start file, name, description, authors, manual section). 266 | man_pages = [(master_doc, "dectate", "Dectate Documentation", [author], 1)] 267 | 268 | # If true, show URL addresses after external links. 269 | # man_show_urls = False 270 | 271 | 272 | # -- Options for Texinfo output ------------------------------------------- 273 | 274 | # Grouping the document tree into Texinfo files. List of tuples 275 | # (source start file, target name, title, author, 276 | # dir menu entry, description, category) 277 | texinfo_documents = [ 278 | ( 279 | master_doc, 280 | "dectate", 281 | "Dectate Documentation", 282 | author, 283 | "dectate", 284 | "Decorator Configuration System.", 285 | "Miscellaneous", 286 | ), 287 | ] 288 | 289 | # Documents to append as an appendix to all manuals. 290 | # texinfo_appendices = [] 291 | 292 | # If false, no module index is generated. 293 | # texinfo_domain_indices = True 294 | 295 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 296 | # texinfo_show_urls = 'footnote' 297 | 298 | doctest_path = [os.path.abspath("..")] 299 | 300 | 301 | # Example configuration for intersphinx: refer to the Python standard library. 302 | intersphinx_mapping = {"https://docs.python.org/3/": None} 303 | -------------------------------------------------------------------------------- /dectate/tests/test_error.py: -------------------------------------------------------------------------------- 1 | from dectate.app import App, directive 2 | from dectate.config import commit, Action, Composite 3 | 4 | from dectate.error import ( 5 | ConflictError, 6 | ConfigError, 7 | DirectiveError, 8 | DirectiveReportError, 9 | ) 10 | 11 | import pytest 12 | 13 | 14 | def test_directive_error_in_action(): 15 | class FooDirective(Action): 16 | def __init__(self, name): 17 | self.name = name 18 | 19 | def identifier(self): 20 | return self.name 21 | 22 | def perform(self, obj): 23 | raise DirectiveError("A real problem") 24 | 25 | class MyApp(App): 26 | foo = directive(FooDirective) 27 | 28 | @MyApp.foo("hello") 29 | def f(): 30 | pass 31 | 32 | with pytest.raises(DirectiveReportError) as e: 33 | commit(MyApp) 34 | 35 | value = str(e.value) 36 | assert value.startswith("A real problem") 37 | assert value.endswith(' @MyApp.foo("hello")') 38 | assert "/test_error.py" in value 39 | 40 | 41 | def test_directive_error_in_composite(): 42 | class FooDirective(Composite): 43 | def __init__(self, name): 44 | self.name = name 45 | 46 | def actions(self, obj): 47 | raise DirectiveError("Something went wrong") 48 | 49 | class MyApp(App): 50 | foo = directive(FooDirective) 51 | 52 | @MyApp.foo("hello") 53 | def f(): 54 | pass 55 | 56 | with pytest.raises(DirectiveReportError) as e: 57 | commit(MyApp) 58 | 59 | value = str(e.value) 60 | assert value.startswith("Something went wrong") 61 | assert value.endswith(' @MyApp.foo("hello")') 62 | assert "/test_error.py" in value 63 | 64 | 65 | def test_conflict_error(): 66 | class FooDirective(Action): 67 | def __init__(self, name): 68 | self.name = name 69 | 70 | def identifier(self): 71 | return self.name 72 | 73 | def perform(self, obj): 74 | raise DirectiveError("A real problem") 75 | 76 | class MyApp(App): 77 | foo = directive(FooDirective) 78 | 79 | @MyApp.foo("hello") 80 | def f(): 81 | pass 82 | 83 | @MyApp.foo("hello") 84 | def g(): 85 | pass 86 | 87 | with pytest.raises(ConflictError) as e: 88 | commit(MyApp) 89 | 90 | value = str(e.value) 91 | assert value.startswith("Conflict between:") 92 | assert ", line " in value 93 | assert '@MyApp.foo("hello")' in value 94 | assert "/test_error.py" in value 95 | 96 | 97 | def test_with_statement_error(): 98 | class FooDirective(Action): 99 | def __init__(self, model, name): 100 | self.model = model 101 | self.name = name 102 | 103 | def identifier(self): 104 | return (self.model, self.name) 105 | 106 | def perform(self, obj): 107 | raise DirectiveError("A real problem") 108 | 109 | class MyApp(App): 110 | foo = directive(FooDirective) 111 | 112 | class Dummy: 113 | pass 114 | 115 | with MyApp.foo(model=Dummy) as foo: 116 | 117 | @foo(name="a") 118 | def f(): 119 | pass 120 | 121 | @foo(name="b") 122 | def g(): 123 | pass 124 | 125 | with pytest.raises(DirectiveReportError) as e: 126 | commit(MyApp) 127 | 128 | value = str(e.value) 129 | 130 | assert value.startswith("A real problem") 131 | assert value.endswith(' @foo(name="a")') 132 | assert "/test_error.py" in value 133 | 134 | 135 | def test_composite_codeinfo_propagation(): 136 | class SubDirective(Action): 137 | config = {"my": list} 138 | 139 | def __init__(self, message): 140 | self.message = message 141 | 142 | def identifier(self, my): 143 | return self.message 144 | 145 | def perform(self, obj, my): 146 | my.append((self.message, obj)) 147 | 148 | class CompositeDirective(Composite): 149 | def __init__(self, messages): 150 | self.messages = messages 151 | 152 | def actions(self, obj): 153 | return [(SubDirective(message), obj) for message in self.messages] 154 | 155 | class MyApp(App): 156 | _sub = directive(SubDirective) 157 | composite = directive(CompositeDirective) 158 | 159 | @MyApp.composite(["a"]) 160 | def f(): 161 | pass 162 | 163 | @MyApp.composite(["a"]) 164 | def g(): 165 | pass 166 | 167 | with pytest.raises(ConflictError) as e: 168 | commit(MyApp) 169 | 170 | value = str(e.value) 171 | 172 | assert '@MyApp.composite(["a"])' in value 173 | assert "/test_error.py" in value 174 | 175 | 176 | def test_type_error_not_enough_arguments(): 177 | class MyDirective(Action): 178 | config = {"my": list} 179 | 180 | def __init__(self, message): 181 | self.message = message 182 | 183 | def identifier(self, my): 184 | return self.message 185 | 186 | def perform(self, obj, my): 187 | my.append((self.message, obj)) 188 | 189 | class MyApp(App): 190 | foo = directive(MyDirective) 191 | 192 | # not enough arguments 193 | @MyApp.foo() 194 | def f(): 195 | pass 196 | 197 | with pytest.raises(DirectiveReportError) as e: 198 | commit(MyApp) 199 | 200 | value = str(e.value) 201 | assert "@MyApp.foo()" in value 202 | 203 | 204 | def test_type_error_too_many_arguments(): 205 | class MyDirective(Action): 206 | config = {"my": list} 207 | 208 | def __init__(self, message): 209 | self.message = message 210 | 211 | def identifier(self, my): 212 | return self.message 213 | 214 | def perform(self, obj, my): 215 | my.append((self.message, obj)) 216 | 217 | class MyApp(App): 218 | foo = directive(MyDirective) 219 | 220 | # too many arguments 221 | @MyApp.foo("a", "b") 222 | def f(): 223 | pass 224 | 225 | with pytest.raises(DirectiveReportError) as e: 226 | commit(MyApp) 227 | 228 | value = str(e.value) 229 | assert 'MyApp.foo("a", "b")' in value 230 | 231 | 232 | def test_cannot_group_class_group_class(): 233 | class FooDirective(Action): 234 | config = {"foo": list} 235 | 236 | def __init__(self, message): 237 | self.message = message 238 | 239 | def identifier(self, foo): 240 | return self.message 241 | 242 | def perform(self, obj, foo): 243 | foo.append((self.message, obj)) 244 | 245 | class BarDirective(Action): 246 | group_class = FooDirective 247 | 248 | def __init__(self, message): 249 | pass 250 | 251 | class QuxDirective(Action): 252 | group_class = BarDirective # should go to FooDirective instead 253 | 254 | def __init__(self, message): 255 | pass 256 | 257 | class MyApp(App): 258 | foo = directive(FooDirective) 259 | bar = directive(BarDirective) 260 | qux = directive(QuxDirective) 261 | 262 | with pytest.raises(ConfigError): 263 | commit(MyApp) 264 | 265 | 266 | def test_cannot_use_config_with_group_class(): 267 | class FooDirective(Action): 268 | config = {"foo": list} 269 | 270 | def __init__(self, message): 271 | self.message = message 272 | 273 | def identifier(self, foo): 274 | return self.message 275 | 276 | def perform(self, obj, foo): 277 | foo.append((self.message, obj)) 278 | 279 | class BarDirective(Action): 280 | config = {"bar": list} 281 | 282 | group_class = FooDirective 283 | 284 | def __init__(self, message): 285 | pass 286 | 287 | class MyApp(App): 288 | foo = directive(FooDirective) 289 | bar = directive(BarDirective) 290 | 291 | with pytest.raises(ConfigError): 292 | commit(MyApp) 293 | 294 | 295 | def test_cann_inherit_config_with_group_class(): 296 | class FooDirective(Action): 297 | config = {"foo": list} 298 | 299 | def __init__(self, message): 300 | self.message = message 301 | 302 | def identifier(self, foo): 303 | return self.message 304 | 305 | def perform(self, obj, foo): 306 | foo.append((self.message, obj)) 307 | 308 | class BarDirective(FooDirective): 309 | group_class = FooDirective 310 | 311 | def __init__(self, message): 312 | pass 313 | 314 | class MyApp(App): 315 | foo = directive(FooDirective) 316 | bar = directive(BarDirective) 317 | 318 | commit(MyApp) 319 | 320 | 321 | def test_cannot_use_before_with_group_class(): 322 | class FooDirective(Action): 323 | config = {"foo": list} 324 | 325 | def __init__(self, message): 326 | self.message = message 327 | 328 | def identifier(self, foo): 329 | return self.message 330 | 331 | def perform(self, obj, foo): 332 | foo.append((self.message, obj)) 333 | 334 | class BarDirective(Action): 335 | group_class = FooDirective 336 | 337 | @staticmethod 338 | def before(): 339 | pass 340 | 341 | class MyApp(App): 342 | foo = directive(FooDirective) 343 | bar = directive(BarDirective) 344 | 345 | with pytest.raises(ConfigError): 346 | commit(MyApp) 347 | 348 | 349 | def test_can_inherit_before_with_group_class(): 350 | class FooDirective(Action): 351 | config = {"foo": list} 352 | 353 | def __init__(self, message): 354 | self.message = message 355 | 356 | def identifier(self, foo): 357 | return self.message 358 | 359 | def perform(self, obj, foo): 360 | foo.append((self.message, obj)) 361 | 362 | @staticmethod 363 | def before(foo): 364 | pass 365 | 366 | class BarDirective(FooDirective): 367 | group_class = FooDirective 368 | 369 | class MyApp(App): 370 | foo = directive(FooDirective) 371 | bar = directive(BarDirective) 372 | 373 | commit(MyApp) 374 | 375 | 376 | def test_cannot_use_after_with_group_class(): 377 | class FooDirective(Action): 378 | config = {"foo": list} 379 | 380 | def __init__(self, message): 381 | self.message = message 382 | 383 | def identifier(self, foo): 384 | return self.message 385 | 386 | def perform(self, obj, foo): 387 | foo.append((self.message, obj)) 388 | 389 | class BarDirective(Action): 390 | group_class = FooDirective 391 | 392 | @staticmethod 393 | def after(): 394 | pass 395 | 396 | class MyApp(App): 397 | foo = directive(FooDirective) 398 | bar = directive(BarDirective) 399 | 400 | with pytest.raises(ConfigError): 401 | commit(MyApp) 402 | 403 | 404 | def test_action_without_init(): 405 | class FooDirective(Action): 406 | config = {"foo": list} 407 | 408 | def identifier(self, foo): 409 | return () 410 | 411 | def perform(self, obj, foo): 412 | foo.append(obj) 413 | 414 | class MyApp(App): 415 | foo = directive(FooDirective) 416 | 417 | @MyApp.foo() 418 | def f(): 419 | pass 420 | 421 | commit(MyApp) 422 | 423 | assert MyApp.config.foo == [f] 424 | 425 | 426 | def test_composite_without_init(): 427 | class SubDirective(Action): 428 | config = {"my": list} 429 | 430 | def __init__(self, message): 431 | self.message = message 432 | 433 | def identifier(self, my): 434 | return self.message 435 | 436 | def perform(self, obj, my): 437 | my.append((self.message, obj)) 438 | 439 | class CompositeDirective(Composite): 440 | def actions(self, obj): 441 | return [(SubDirective(message), obj) for message in ["a", "b"]] 442 | 443 | class MyApp(App): 444 | _sub = directive(SubDirective) 445 | composite = directive(CompositeDirective) 446 | 447 | commit(MyApp) 448 | 449 | @MyApp.composite() 450 | def f(): 451 | pass 452 | 453 | commit(MyApp) 454 | -------------------------------------------------------------------------------- /dectate/tests/test_query.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dectate import ( 4 | Query, 5 | App, 6 | Action, 7 | Composite, 8 | directive, 9 | commit, 10 | QueryError, 11 | NOT_FOUND, 12 | ) 13 | 14 | 15 | def test_query(): 16 | class FooAction(Action): 17 | config = {"registry": list} 18 | 19 | def __init__(self, name): 20 | self.name = name 21 | 22 | def identifier(self, registry): 23 | return self.name 24 | 25 | def perform(self, obj, registry): 26 | registry.append((self.name, obj)) 27 | 28 | class MyApp(App): 29 | foo = directive(FooAction) 30 | 31 | @MyApp.foo("a") 32 | def f(): 33 | pass 34 | 35 | @MyApp.foo("b") 36 | def g(): 37 | pass 38 | 39 | commit(MyApp) 40 | 41 | q = Query(FooAction).attrs("name") 42 | 43 | assert list(q(MyApp)) == [{"name": "a"}, {"name": "b"}] 44 | 45 | 46 | def test_query_directive_name(): 47 | class FooAction(Action): 48 | config = {"registry": list} 49 | 50 | def __init__(self, name): 51 | self.name = name 52 | 53 | def identifier(self, registry): 54 | return self.name 55 | 56 | def perform(self, obj, registry): 57 | registry.append((self.name, obj)) 58 | 59 | class MyApp(App): 60 | foo = directive(FooAction) 61 | 62 | @MyApp.foo("a") 63 | def f(): 64 | pass 65 | 66 | @MyApp.foo("b") 67 | def g(): 68 | pass 69 | 70 | commit(MyApp) 71 | 72 | q = Query("foo").attrs("name") 73 | 74 | assert list(q(MyApp)) == [{"name": "a"}, {"name": "b"}] 75 | 76 | 77 | def test_multi_action_query(): 78 | class FooAction(Action): 79 | config = {"registry": list} 80 | 81 | def __init__(self, name): 82 | self.name = name 83 | 84 | def identifier(self, registry): 85 | return self.name 86 | 87 | def perform(self, obj, registry): 88 | registry.append((self.name, obj)) 89 | 90 | class BarAction(Action): 91 | config = {"registry": list} 92 | 93 | def __init__(self, name): 94 | self.name = name 95 | 96 | def identifier(self, registry): 97 | return self.name 98 | 99 | def perform(self, obj, registry): 100 | registry.append((self.name, obj)) 101 | 102 | class MyApp(App): 103 | foo = directive(FooAction) 104 | bar = directive(BarAction) 105 | 106 | @MyApp.foo("a") 107 | def f(): 108 | pass 109 | 110 | @MyApp.bar("b") 111 | def g(): 112 | pass 113 | 114 | commit(MyApp) 115 | 116 | q = Query(FooAction, BarAction).attrs("name") 117 | 118 | assert sorted(list(q(MyApp)), key=lambda d: d["name"]) == [ 119 | {"name": "a"}, 120 | {"name": "b"}, 121 | ] 122 | 123 | 124 | def test_filter(): 125 | class FooAction(Action): 126 | config = {"registry": list} 127 | 128 | def __init__(self, name): 129 | self.name = name 130 | 131 | def identifier(self, registry): 132 | return self.name 133 | 134 | def perform(self, obj, registry): 135 | registry.append((self.name, obj)) 136 | 137 | class MyApp(App): 138 | foo = directive(FooAction) 139 | 140 | @MyApp.foo("a") 141 | def f(): 142 | pass 143 | 144 | @MyApp.foo("b") 145 | def g(): 146 | pass 147 | 148 | commit(MyApp) 149 | 150 | q = Query(FooAction).filter(name="a").attrs("name") 151 | 152 | assert list(q(MyApp)) == [ 153 | {"name": "a"}, 154 | ] 155 | 156 | 157 | def test_filter_multiple_fields(): 158 | class FooAction(Action): 159 | config = {"registry": list} 160 | 161 | filter_compare = {"model": issubclass} 162 | 163 | def __init__(self, model, name): 164 | self.model = model 165 | self.name = name 166 | 167 | def identifier(self, registry): 168 | return (self.model, self.name) 169 | 170 | def perform(self, obj, registry): 171 | registry.append((self.model, self.name, obj)) 172 | 173 | class MyApp(App): 174 | foo = directive(FooAction) 175 | 176 | class Alpha: 177 | pass 178 | 179 | class Beta: 180 | pass 181 | 182 | @MyApp.foo(model=Alpha, name="a") 183 | def f(): 184 | pass 185 | 186 | @MyApp.foo(model=Alpha, name="b") 187 | def g(): 188 | pass 189 | 190 | @MyApp.foo(model=Beta, name="a") 191 | def h(): 192 | pass 193 | 194 | @MyApp.foo(model=Beta, name="b") 195 | def i(): 196 | pass 197 | 198 | commit(MyApp) 199 | 200 | q = Query(FooAction) 201 | 202 | assert list(q.filter(model=Alpha, name="a").obj()(MyApp)) == [f] 203 | assert list(q.filter(model=Alpha, name="b").obj()(MyApp)) == [g] 204 | assert list(q.filter(model=Beta, name="a").obj()(MyApp)) == [h] 205 | assert list(q.filter(model=Beta, name="b").obj()(MyApp)) == [i] 206 | 207 | 208 | def test_filter_not_found(): 209 | class FooAction(Action): 210 | config = {"registry": list} 211 | 212 | def __init__(self, name): 213 | self.name = name 214 | 215 | def identifier(self, registry): 216 | return self.name 217 | 218 | def perform(self, obj, registry): 219 | registry.append((self.name, obj)) 220 | 221 | class MyApp(App): 222 | foo = directive(FooAction) 223 | 224 | @MyApp.foo("a") 225 | def f(): 226 | pass 227 | 228 | @MyApp.foo("b") 229 | def g(): 230 | pass 231 | 232 | commit(MyApp) 233 | 234 | q = Query(FooAction).filter(unknown="a").attrs("name") 235 | 236 | assert list(q(MyApp)) == [] 237 | 238 | 239 | def test_filter_different_attribute_name(): 240 | class FooAction(Action): 241 | config = {"registry": list} 242 | 243 | filter_name = {"name": "_name"} 244 | 245 | def __init__(self, name): 246 | self._name = name 247 | 248 | def identifier(self, registry): 249 | return self._name 250 | 251 | def perform(self, obj, registry): 252 | registry.append((self._name, obj)) 253 | 254 | class MyApp(App): 255 | foo = directive(FooAction) 256 | 257 | @MyApp.foo("a") 258 | def f(): 259 | pass 260 | 261 | @MyApp.foo("b") 262 | def g(): 263 | pass 264 | 265 | commit(MyApp) 266 | 267 | q = Query(FooAction).filter(name="a").attrs("name") 268 | 269 | assert list(q(MyApp)) == [{"name": "a"}] 270 | 271 | 272 | def test_filter_get_value(): 273 | class FooAction(Action): 274 | def filter_get_value(self, name): 275 | return self.kw.get(name, NOT_FOUND) 276 | 277 | def __init__(self, **kw): 278 | self.kw = kw 279 | 280 | def identifier(self): 281 | return tuple(sorted(self.kw.items())) 282 | 283 | def perform(self, obj): 284 | pass 285 | 286 | class MyApp(App): 287 | foo = directive(FooAction) 288 | 289 | @MyApp.foo(x="a", y="b") 290 | def f(): 291 | pass 292 | 293 | @MyApp.foo(x="a", y="c") 294 | def g(): 295 | pass 296 | 297 | commit(MyApp) 298 | 299 | q = Query(FooAction).filter(x="a").attrs("x", "y") 300 | 301 | assert list(q(MyApp)) == [{"x": "a", "y": "b"}, {"x": "a", "y": "c"}] 302 | 303 | q = Query(FooAction).filter(y="b").attrs("x", "y") 304 | 305 | assert list(q(MyApp)) == [{"x": "a", "y": "b"}] 306 | 307 | 308 | def test_filter_name_and_get_value(): 309 | class FooAction(Action): 310 | filter_name = {"name": "_name"} 311 | 312 | def filter_get_value(self, name): 313 | return self.kw.get(name, NOT_FOUND) 314 | 315 | def __init__(self, name, **kw): 316 | self._name = name 317 | self.kw = kw 318 | 319 | def identifier(self): 320 | return tuple(sorted(self.kw.items())) 321 | 322 | def perform(self, obj): 323 | pass 324 | 325 | class MyApp(App): 326 | foo = directive(FooAction) 327 | 328 | @MyApp.foo(name="hello", x="a", y="b") 329 | def f(): 330 | pass 331 | 332 | @MyApp.foo(name="bye", x="a", y="c") 333 | def g(): 334 | pass 335 | 336 | commit(MyApp) 337 | 338 | q = Query(FooAction).filter(name="hello").attrs("name", "x", "y") 339 | 340 | assert list(q(MyApp)) == [{"x": "a", "y": "b", "name": "hello"}] 341 | 342 | 343 | def test_filter_get_value_and_default(): 344 | class FooAction(Action): 345 | def filter_get_value(self, name): 346 | return self.kw.get(name, NOT_FOUND) 347 | 348 | def __init__(self, name, **kw): 349 | self.name = name 350 | self.kw = kw 351 | 352 | def identifier(self): 353 | return tuple(sorted(self.kw.items())) 354 | 355 | def perform(self, obj): 356 | pass 357 | 358 | class MyApp(App): 359 | foo = directive(FooAction) 360 | 361 | @MyApp.foo(name="hello", x="a", y="b") 362 | def f(): 363 | pass 364 | 365 | @MyApp.foo(name="bye", x="a", y="c") 366 | def g(): 367 | pass 368 | 369 | commit(MyApp) 370 | 371 | q = Query(FooAction).filter(name="hello").attrs("name", "x", "y") 372 | 373 | assert list(q(MyApp)) == [{"x": "a", "y": "b", "name": "hello"}] 374 | 375 | 376 | def test_filter_class(): 377 | class ViewAction(Action): 378 | config = {"registry": list} 379 | 380 | filter_compare = {"model": issubclass} 381 | 382 | def __init__(self, model): 383 | self.model = model 384 | 385 | def identifier(self, registry): 386 | return self.model 387 | 388 | def perform(self, obj, registry): 389 | registry.append((self.model, obj)) 390 | 391 | class MyApp(App): 392 | view = directive(ViewAction) 393 | 394 | class Alpha: 395 | pass 396 | 397 | class Beta: 398 | pass 399 | 400 | class Gamma(Beta): 401 | pass 402 | 403 | class Delta(Gamma): 404 | pass 405 | 406 | @MyApp.view(model=Alpha) 407 | def f(): 408 | pass 409 | 410 | @MyApp.view(model=Beta) 411 | def g(): 412 | pass 413 | 414 | @MyApp.view(model=Gamma) 415 | def h(): 416 | pass 417 | 418 | @MyApp.view(model=Delta) 419 | def i(): 420 | pass 421 | 422 | commit(MyApp) 423 | 424 | assert list(Query(ViewAction).filter(model=Alpha).obj()(MyApp)) == [f] 425 | 426 | assert list(Query(ViewAction).filter(model=Beta).obj()(MyApp)) == [g, h, i] 427 | 428 | assert list(Query(ViewAction).filter(model=Gamma).obj()(MyApp)) == [h, i] 429 | 430 | assert list(Query(ViewAction).filter(model=Delta).obj()(MyApp)) == [i] 431 | 432 | 433 | def test_query_group_class(): 434 | class FooAction(Action): 435 | config = {"registry": list} 436 | 437 | def __init__(self, name): 438 | self.name = name 439 | 440 | def identifier(self, registry): 441 | return self.name 442 | 443 | def perform(self, obj, registry): 444 | registry.append((self.name, obj)) 445 | 446 | class BarAction(FooAction): 447 | group_class = FooAction 448 | 449 | class MyApp(App): 450 | foo = directive(FooAction) 451 | bar = directive(BarAction) 452 | 453 | @MyApp.foo("a") 454 | def f(): 455 | pass 456 | 457 | @MyApp.bar("b") 458 | def g(): 459 | pass 460 | 461 | commit(MyApp) 462 | 463 | q = Query(FooAction).attrs("name") 464 | 465 | assert list(q(MyApp)) == [{"name": "a"}, {"name": "b"}] 466 | 467 | 468 | def test_query_on_group_class_action(): 469 | class FooAction(Action): 470 | config = {"registry": list} 471 | 472 | def __init__(self, name): 473 | self.name = name 474 | 475 | def identifier(self, registry): 476 | return self.name 477 | 478 | def perform(self, obj, registry): 479 | registry.append((self.name, obj)) 480 | 481 | class BarAction(FooAction): 482 | group_class = FooAction 483 | 484 | class MyApp(App): 485 | foo = directive(FooAction) 486 | bar = directive(BarAction) 487 | 488 | @MyApp.foo("a") 489 | def f(): 490 | pass 491 | 492 | @MyApp.bar("b") 493 | def g(): 494 | pass 495 | 496 | commit(MyApp) 497 | 498 | q = Query(BarAction).attrs("name") 499 | 500 | assert list(q(MyApp)) == [{"name": "a"}, {"name": "b"}] 501 | 502 | 503 | def test_multi_query_on_group_class_action(): 504 | class FooAction(Action): 505 | config = {"registry": list} 506 | 507 | def __init__(self, name): 508 | self.name = name 509 | 510 | def identifier(self, registry): 511 | return self.name 512 | 513 | def perform(self, obj, registry): 514 | registry.append((self.name, obj)) 515 | 516 | class BarAction(FooAction): 517 | group_class = FooAction 518 | 519 | class MyApp(App): 520 | foo = directive(FooAction) 521 | bar = directive(BarAction) 522 | 523 | @MyApp.foo("a") 524 | def f(): 525 | pass 526 | 527 | @MyApp.bar("b") 528 | def g(): 529 | pass 530 | 531 | commit(MyApp) 532 | 533 | q = Query(FooAction, BarAction).attrs("name") 534 | 535 | assert sorted(list(q(MyApp)), key=lambda d: d["name"]) == [ 536 | {"name": "a"}, 537 | {"name": "b"}, 538 | ] 539 | 540 | 541 | def test_inheritance(): 542 | class FooAction(Action): 543 | config = {"registry": list} 544 | 545 | def __init__(self, name): 546 | self.name = name 547 | 548 | def identifier(self, registry): 549 | return self.name 550 | 551 | def perform(self, obj, registry): 552 | registry.append((self.name, obj)) 553 | 554 | class MyApp(App): 555 | foo = directive(FooAction) 556 | 557 | class SubApp(MyApp): 558 | pass 559 | 560 | @MyApp.foo("a") 561 | def f(): 562 | pass 563 | 564 | @SubApp.foo("b") 565 | def g(): 566 | pass 567 | 568 | commit(SubApp) 569 | 570 | q = Query(FooAction).attrs("name") 571 | 572 | assert list(q(SubApp)) == [{"name": "a"}, {"name": "b"}] 573 | 574 | 575 | def test_composite_action(): 576 | class SubAction(Action): 577 | config = {"registry": list} 578 | 579 | def __init__(self, name): 580 | self.name = name 581 | 582 | def identifier(self, registry): 583 | return self.name 584 | 585 | def perform(self, obj, registry): 586 | registry.append((self.name, obj)) 587 | 588 | class CompositeAction(Composite): 589 | query_classes = [SubAction] 590 | 591 | def __init__(self, names): 592 | self.names = names 593 | 594 | def actions(self, obj): 595 | return [(SubAction(name), obj) for name in self.names] 596 | 597 | class MyApp(App): 598 | _sub = directive(SubAction) 599 | composite = directive(CompositeAction) 600 | 601 | @MyApp.composite(["a", "b"]) 602 | def f(): 603 | pass 604 | 605 | commit(MyApp) 606 | 607 | q = Query(CompositeAction).attrs("name") 608 | 609 | assert list(q(MyApp)) == [{"name": "a"}, {"name": "b"}] 610 | 611 | 612 | def test_composite_action_without_query_classes(): 613 | class SubAction(Action): 614 | config = {"registry": list} 615 | 616 | def __init__(self, name): 617 | self.name = name 618 | 619 | def identifier(self, registry): 620 | return self.name 621 | 622 | def perform(self, obj, registry): 623 | registry.append((self.name, obj)) 624 | 625 | class CompositeAction(Composite): 626 | def __init__(self, names): 627 | self.names = names 628 | 629 | def actions(self, obj): 630 | return [(SubAction(name), obj) for name in self.names] 631 | 632 | class MyApp(App): 633 | _sub = directive(SubAction) 634 | composite = directive(CompositeAction) 635 | 636 | @MyApp.composite(["a", "b"]) 637 | def f(): 638 | pass 639 | 640 | commit(MyApp) 641 | 642 | q = Query(CompositeAction).attrs("name") 643 | 644 | with pytest.raises(QueryError): 645 | list(q(MyApp)) 646 | 647 | 648 | def test_nested_composite_action(): 649 | class SubSubAction(Action): 650 | config = {"registry": list} 651 | 652 | def __init__(self, name): 653 | self.name = name 654 | 655 | def identifier(self, registry): 656 | return self.name 657 | 658 | def perform(self, obj, registry): 659 | registry.append((self.name, obj)) 660 | 661 | class SubAction(Composite): 662 | query_classes = [SubSubAction] 663 | 664 | def __init__(self, names): 665 | self.names = names 666 | 667 | def actions(self, obj): 668 | return [(SubSubAction(name), obj) for name in self.names] 669 | 670 | class CompositeAction(Composite): 671 | query_classes = [SubAction] 672 | 673 | def __init__(self, amount): 674 | self.amount = amount 675 | 676 | def actions(self, obj): 677 | for i in range(self.amount): 678 | yield SubAction(["a%s" % i, "b%s" % i]), obj 679 | 680 | class MyApp(App): 681 | _subsub = directive(SubSubAction) 682 | _sub = directive(SubAction) 683 | composite = directive(CompositeAction) 684 | 685 | @MyApp.composite(2) 686 | def f(): 687 | pass 688 | 689 | commit(MyApp) 690 | 691 | q = Query(CompositeAction).attrs("name") 692 | 693 | assert sorted(list(q(MyApp)), key=lambda d: d["name"]) == [ 694 | {"name": "a0"}, 695 | {"name": "a1"}, 696 | {"name": "b0"}, 697 | {"name": "b1"}, 698 | ] 699 | 700 | 701 | def test_query_action_for_other_app(): 702 | class FooAction(Action): 703 | config = {"registry": list} 704 | 705 | def __init__(self, name): 706 | self.name = name 707 | 708 | def identifier(self, registry): 709 | return self.name 710 | 711 | def perform(self, obj, registry): 712 | registry.append((self.name, obj)) 713 | 714 | class BarAction(Action): 715 | config = {"registry": list} 716 | 717 | def __init__(self, name): 718 | self.name = name 719 | 720 | def identifier(self, registry): 721 | return self.name 722 | 723 | def perform(self, obj, registry): 724 | registry.append((self.name, obj)) 725 | 726 | class MyApp(App): 727 | foo = directive(FooAction) 728 | 729 | class OtherApp(App): 730 | bar = directive(BarAction) 731 | 732 | @MyApp.foo("a") 733 | def f(): 734 | pass 735 | 736 | @MyApp.foo("b") 737 | def g(): 738 | pass 739 | 740 | commit(MyApp) 741 | 742 | q = Query(BarAction) 743 | 744 | with pytest.raises(QueryError): 745 | list(q(MyApp)) 746 | -------------------------------------------------------------------------------- /dectate/config.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import logging 3 | import sys 4 | import inspect 5 | from .error import ( 6 | ConflictError, 7 | ConfigError, 8 | DirectiveError, 9 | DirectiveReportError, 10 | ) 11 | from .toposort import topological_sort 12 | from .sentinel import NOT_FOUND 13 | 14 | 15 | order_count = 0 16 | 17 | 18 | class Configurable: 19 | """Object to which configuration actions apply. 20 | 21 | This object is normally tucked away as the ``dectate`` class 22 | attribute on an :class:`dectate.App` subclass. 23 | 24 | Actions are registered per configurable during the import phase; 25 | actions are not actually created or performed, so their 26 | ``__init__`` and ``perform`` are not yet called. 27 | 28 | During the commit phase the configurable is executed. This expands 29 | any composite actions, groups actions into action groups and sorts 30 | them by depends so that they are executed in the correct order, 31 | and then executes each action group, which performs them. 32 | """ 33 | 34 | app_class = None 35 | 36 | def __init__(self, extends, config): 37 | """ 38 | :param extends: 39 | the configurables that this configurable extends. 40 | :type extends: list of configurables. 41 | :param config: 42 | the object that will contains the actual configuration. 43 | Normally it's the ``config`` class attribute of the 44 | :class:`dectate.App` subclass. 45 | """ 46 | self.extends = extends 47 | self.config = config 48 | # all action classes known 49 | self._action_classes = {} 50 | # directives used with configurable 51 | self._directives = [] 52 | # have we ever been committed 53 | self.committed = False 54 | 55 | def register_directive(self, directive, obj): 56 | """Register a directive with this configurable. 57 | 58 | Called during import time when directives are used. 59 | 60 | :param directive: the directive instance, which contains 61 | information about the arguments, line number it was invoked, etc. 62 | :param obj: the object the directive was invoked on; typically 63 | a function or a class it was invoked on as a decorator. 64 | """ 65 | self._directives.append((directive, obj)) 66 | 67 | def _fixup_directive_names(self): 68 | """Set up correct name for directives.""" 69 | app_class = self.app_class 70 | for name, method in app_class.get_directive_methods(): 71 | func = method.__func__ 72 | func.__name__ = name 73 | # As of Python 3.5, the repr of bound methods uses 74 | # __qualname__ instead of __name__. 75 | # See http://bugs.python.org/issue21389#msg217566 76 | if hasattr(func, "__qualname__"): 77 | func.__qualname__ = type(app_class).__name__ + "." + name 78 | 79 | def get_action_classes(self): 80 | """Get all action classes registered for this app. 81 | 82 | This includes action classes registered for its base class. 83 | 84 | :return: a dict with action class keys and name values. 85 | """ 86 | result = {} 87 | app_class = self.app_class 88 | for name, method in app_class.get_directive_methods(): 89 | result[method.__func__.action_factory] = name 90 | 91 | # add any action classes defined by base classes 92 | for configurable in self.extends: 93 | for action_class, name in configurable._action_classes.items(): 94 | if action_class not in result: 95 | result[action_class] = name 96 | return result 97 | 98 | def setup(self): 99 | """Set up config object and action groups. 100 | 101 | This happens during the start of the commit phase. 102 | 103 | Takes inheritance of apps into account. 104 | """ 105 | self._fixup_directive_names() 106 | self._action_classes = self.get_action_classes() 107 | 108 | grouped_action_classes = sort_action_classes( 109 | group_action_classes(self._action_classes.keys()) 110 | ) 111 | 112 | # delete any old configuration in case we run this a second time 113 | for action_class in grouped_action_classes: 114 | self.delete_config(action_class) 115 | 116 | # now we create ActionGroup objects for each action class group 117 | self._action_groups = d = {} 118 | # and we track what config factories we've seen for consistency 119 | # checking 120 | self._factories_seen = {} 121 | for action_class in grouped_action_classes: 122 | self.setup_config(action_class) 123 | d[action_class] = ActionGroup( 124 | action_class, self.action_extends(action_class) 125 | ) 126 | 127 | def setup_config(self, action_class): 128 | """Set up the config objects on the ``config`` attribute. 129 | 130 | :param action_class: the action subclass to setup config for. 131 | """ 132 | # sort the items in order of creation 133 | items = topological_sort(action_class.config.items(), factory_key) 134 | # this introduces all dependencies, including those only 135 | # mentioned in factory_arguments. we want to create those too 136 | # if they weren't already created 137 | seen = self._factories_seen 138 | 139 | config = self.config 140 | for name, factory in items: 141 | # if we already have this set up, we don't want to create 142 | # it anew 143 | configured = getattr(config, name, None) 144 | if configured is not None: 145 | if seen[name] is not factory: 146 | raise ConfigError( 147 | "Inconsistent factories for config %r (%r and %r)" 148 | % ((name, seen[name], factory)) 149 | ) 150 | continue 151 | seen[name] = factory 152 | kw = get_factory_arguments( 153 | action_class, config, factory, self.app_class 154 | ) 155 | setattr(config, name, factory(**kw)) 156 | 157 | def delete_config(self, action_class): 158 | """Delete config objects on the ``config`` attribute. 159 | 160 | :param action_class: the action class subclass to delete config for. 161 | """ 162 | config = self.config 163 | for name, factory in action_class.config.items(): 164 | if hasattr(config, name): 165 | delattr(config, name) 166 | factory_arguments = getattr(factory, "factory_arguments", None) 167 | if factory_arguments is None: 168 | continue 169 | for name in factory_arguments.keys(): 170 | if hasattr(config, name): 171 | delattr(config, name) 172 | 173 | def group_actions(self): 174 | """Groups actions for this configurable into action groups.""" 175 | # turn directives into actions 176 | actions = [ 177 | (directive.action(), obj) for (directive, obj) in self._directives 178 | ] 179 | 180 | # add the actions for this configurable to the action group 181 | d = self._action_groups 182 | 183 | for action, obj in expand_actions(actions): 184 | action_class = action.group_class 185 | if action_class is None: 186 | action_class = action.__class__ 187 | d[action_class].add(action, obj) 188 | 189 | def get_action_group(self, action_class): 190 | """Return ActionGroup for ``action_class`` or ``None`` if not found. 191 | 192 | :param action_class: the action class to find the action group of. 193 | :return: an ``ActionGroup`` instance. 194 | """ 195 | return self._action_groups.get(action_class, None) 196 | 197 | def action_extends(self, action_class): 198 | """Get ActionGroup for action class in ``extends``. 199 | 200 | :param action_class: the action class 201 | :return: list of ``ActionGroup`` instances that this action group 202 | extends. 203 | """ 204 | return [ 205 | configurable._action_groups.get( 206 | action_class, ActionGroup(action_class, []) 207 | ) 208 | for configurable in self.extends 209 | ] 210 | 211 | def execute(self): 212 | """Execute actions for configurable.""" 213 | self.app_class.clean() 214 | self.setup() 215 | self.group_actions() 216 | for action_class in sort_action_classes(self._action_groups.keys()): 217 | self._action_groups[action_class].execute(self) 218 | self.committed = True 219 | 220 | 221 | class ActionGroup: 222 | """A group of actions. 223 | 224 | Grouped actions are all performed together. 225 | 226 | Normally actions are grouped by their class, but actions can also 227 | indicate another action class to group with using ``group_class``. 228 | """ 229 | 230 | def __init__(self, action_class, extends): 231 | """ 232 | :param action_class: 233 | the action_class that identifies this action group. 234 | :param extends: 235 | list of action groups extended by this action group. 236 | """ 237 | self.action_class = action_class 238 | self._actions = [] 239 | self._action_map = {} 240 | self.extends = extends 241 | 242 | def add(self, action, obj): 243 | """Add an action and the object this action is to be performed on. 244 | 245 | :param action: an :class:`Action` instance. 246 | :param obj: the function or class the action should be performed for. 247 | """ 248 | self._actions.append((action, obj)) 249 | 250 | def prepare(self, configurable): 251 | """Prepare the action group for a configurable. 252 | 253 | Detect any conflicts between actions. 254 | Merges in configuration of what this action extends. 255 | 256 | :param configurable: The :class:`Configurable` option to prepare for. 257 | """ 258 | # check for conflicts and fill action map 259 | discriminators = {} 260 | self._action_map = action_map = {} 261 | 262 | for action, obj in self._actions: 263 | kw = action._get_config_kw(configurable) 264 | id = action.identifier(**kw) 265 | discs = [id] 266 | discs.extend(action.discriminators(**kw)) 267 | for disc in discs: 268 | other_action = discriminators.get(disc) 269 | if other_action is not None: 270 | raise ConflictError([action, other_action]) 271 | discriminators[disc] = action 272 | action_map[id] = action, obj 273 | # inherit from extends 274 | for extend in self.extends: 275 | self.combine(extend) 276 | 277 | def get_actions(self): 278 | """Get all actions registered for this action group. 279 | 280 | :return: list of action instances in registration order. 281 | """ 282 | result = list(self._action_map.values()) 283 | result.sort(key=lambda value: value[0].order or 0) 284 | return result 285 | 286 | def combine(self, actions): 287 | """Combine another prepared actions with this one. 288 | 289 | Those configuration actions that would conflict are taken to 290 | have precedence over those being combined with this one. This 291 | allows the extending actions to override actions in 292 | extended actions. 293 | 294 | :param actions: list of :class:`ActionGroup` objects to 295 | combine with this one. 296 | """ 297 | to_combine = actions._action_map.copy() 298 | to_combine.update(self._action_map) 299 | self._action_map = to_combine 300 | 301 | def execute(self, configurable): 302 | """Perform actions for configurable. 303 | 304 | :param configurable: the :class:`Configurable` instance to execute 305 | the actions against. 306 | """ 307 | self.prepare(configurable) 308 | 309 | kw = self.action_class._get_config_kw(configurable) 310 | 311 | # run the group class before operation 312 | self.action_class.before(**kw) 313 | 314 | # perform the actual actions 315 | for action, obj in self.get_actions(): 316 | try: 317 | action._log(configurable, obj) 318 | action.perform(obj, **kw) 319 | except DirectiveError as e: 320 | raise DirectiveReportError(f"{e}", action.code_info) 321 | 322 | # run the group class after operation 323 | self.action_class.after(**kw) 324 | 325 | 326 | class Action(metaclass=abc.ABCMeta): 327 | """A configuration action. 328 | 329 | Base class of configuration actions. 330 | 331 | A configuration action is performed for an object (typically a 332 | function or a class object) and affects one or more configuration 333 | objects. 334 | 335 | Actions can conflict with each other based on their identifier and 336 | discriminators. Actions can override each other based on their 337 | identifier. Actions can only be in conflict with actions of the 338 | same action class or actions with the same ``action_group``. 339 | """ 340 | 341 | config = {} 342 | """Describe configuration. 343 | 344 | A dict mapping configuration names to factory functions. The 345 | resulting configuration objects are passed into 346 | :meth:`Action.identifier`, :meth:`Action.discriminators`, 347 | :meth:`Action.perform`, and :meth:`Action.before` and 348 | :meth:`Action.after`. 349 | 350 | After commit completes, the configured objects are found 351 | as attributes on :class:`App.config`. 352 | """ 353 | 354 | app_class_arg = False 355 | """Pass in app class as argument. 356 | 357 | In addition to the arguments defined in :attr:`Action.config`, 358 | pass in the app class itself as an argument into 359 | :meth:`Action.identifier`, :meth:`Action.discriminators`, 360 | :meth:`Action.perform`, and :meth:`Action.before` and 361 | :meth:`Action.after`. 362 | """ 363 | 364 | depends = [] 365 | 366 | """List of other action classes to be executed before this one. 367 | 368 | The ``depends`` class attribute contains a list of other action 369 | classes that need to be executed before this one is. Actions which 370 | depend on another will be executed after those actions are 371 | executed. 372 | 373 | Omit if you don't care about the order. 374 | """ 375 | 376 | group_class = None 377 | """Action class to group with. 378 | 379 | This class attribute can be supplied with the class of another 380 | action that this action should be grouped with. Only actions in 381 | the same group can be in conflict. Actions in the same group share 382 | the ``config`` and ``before`` and ``after`` of the action class 383 | indicated by ``group_class``. 384 | 385 | By default an action only groups with others of its same class. 386 | """ 387 | 388 | filter_name = {} 389 | """Map of names used in query filter to attribute names. 390 | 391 | If for instance you want to be able to filter the attribute 392 | ``_foo`` using ``foo`` in the query, you can map ``foo`` to 393 | ``_foo``:: 394 | 395 | filter_name = { 396 | 'foo': '_foo' 397 | } 398 | 399 | If a filter name is omitted the filter name is assumed to be the 400 | same as the attribute name. 401 | """ 402 | 403 | def filter_get_value(self, name): 404 | """A function to get the filter value. 405 | 406 | Takes two arguments, action and name. Should return the 407 | value on the filter. 408 | 409 | This function is called if the name cannot be determined by 410 | looking for the attribute directly using 411 | :attr:`Action.filter_name`. 412 | 413 | The function should return :attr:`NOT_FOUND` if no value with that 414 | name can be found. 415 | 416 | For example if the filter values are stored on ``key_dict``:: 417 | 418 | def filter_get_value(self, name): 419 | return self.key_dict.get(name, dectate.NOT_FOUND) 420 | 421 | :param name: the name of the filter. 422 | :return: the value to filter on. 423 | """ 424 | return NOT_FOUND 425 | 426 | filter_compare = {} 427 | """Map of names used in query filter to comparison functions. 428 | 429 | If for instance you want to be able check whether the value of 430 | ``model`` on the action is a subclass of the value provided in the 431 | filter, you can provide it here:: 432 | 433 | filter_compare = { 434 | 'model': issubclass 435 | } 436 | 437 | The default filter compare is an equality comparison. 438 | """ 439 | 440 | filter_convert = {} 441 | """Map of names to convert functions. 442 | 443 | The query tool that can be generated for a Dectate-based 444 | application uses this information to parse filter input into 445 | actual objects. If omitted it defaults to passing through the 446 | string unchanged. 447 | 448 | A conversion function takes a string as input and outputs a Python 449 | object. The conversion function may raise ``ValueError`` if the 450 | conversion failed. 451 | 452 | A useful conversion function is provided that can be used to refer 453 | to an object in a module using a dotted name: 454 | :func:`convert_dotted_name`. 455 | """ 456 | 457 | # the directive that was used gets stored on the instance 458 | directive = None 459 | 460 | # this is here to make update_wrapper work even when an __init__ 461 | # is not provided by the subclass 462 | def __init__(self): 463 | pass 464 | 465 | @property 466 | def code_info(self): 467 | """Info about where in the source code the action was invoked. 468 | 469 | Is an instance of :class:`CodeInfo`. 470 | 471 | Can be ``None`` if action does not have an associated directive 472 | but was created manually. 473 | """ 474 | if self.directive is None: 475 | return None 476 | return self.directive.code_info 477 | 478 | def _log(self, configurable, obj): 479 | """Log this directive for configurable given configured obj.""" 480 | if self.directive is None: 481 | return 482 | self.directive.log(configurable, obj) 483 | 484 | def get_value_for_filter(self, name): 485 | """Get value. Takes into account ``filter_name``, ``filter_get_value`` 486 | 487 | Used by the query system. You can override it if your action 488 | has a different way storing values altogether. 489 | 490 | :param name: the filter name to get the value for. 491 | :return: the value to filter on. 492 | """ 493 | actual_name = self.filter_name.get(name, name) 494 | value = getattr(self, actual_name, NOT_FOUND) 495 | if value is not NOT_FOUND: 496 | return value 497 | if self.filter_get_value is None: 498 | return value 499 | return self.filter_get_value(name) 500 | 501 | @classmethod 502 | def _get_config_kw(cls, configurable): 503 | """Get the config objects set up for this configurable into a dict. 504 | 505 | This dict can then be passed as keyword parameters (using ``**``) 506 | into the relevant methods such as :meth:`Action.perform`. 507 | 508 | :param configurable: the configurable object to get the config 509 | dict for. 510 | :return: a dict of config values. 511 | """ 512 | result = {} 513 | config = configurable.config 514 | group_class = cls.group_class 515 | if group_class is None: 516 | group_class = cls 517 | # check if we want to have an app_class argument 518 | if group_class.app_class_arg: 519 | result["app_class"] = configurable.app_class 520 | # add the config items themselves 521 | for name, factory in group_class.config.items(): 522 | result[name] = getattr(config, name) 523 | return result 524 | 525 | @abc.abstractmethod 526 | def identifier(self, **kw): 527 | """Returns an immutable that uniquely identifies this config. 528 | 529 | Needs to be implemented by the :class:`Action` subclass. 530 | 531 | Used for overrides and conflict detection. 532 | 533 | If two actions in the same group have the same identifier in 534 | the same configurable, those two actions are in conflict and a 535 | :class:`ConflictError` is raised during :func:`commit`. 536 | 537 | If an action in an extending configurable has the same 538 | identifier as the configurable being extended, that action 539 | overrides the original one in the extending configurable. 540 | 541 | :param ``**kw``: a dictionary of configuration objects as specified 542 | by the ``config`` class attribute. 543 | :return: an immutable value uniquely identifying this action. 544 | """ 545 | 546 | def discriminators(self, **kw): 547 | """Returns an iterable of immutables to detect conflicts. 548 | 549 | Can be implemented by the :class:`Action` subclass. 550 | 551 | Used for additional configuration conflict detection. 552 | 553 | :param ``**kw``: a dictionary of configuration objects as specified 554 | by the ``config`` class attribute. 555 | :return: an iterable of immutable values. 556 | """ 557 | return [] 558 | 559 | @abc.abstractmethod 560 | def perform(self, obj, **kw): 561 | """Do whatever configuration is needed for ``obj``. 562 | 563 | Needs to be implemented by the :class:`Action` subclass. 564 | 565 | Raise a :exc:`DirectiveError` to indicate that the action 566 | cannot be performed due to incorrect configuration. 567 | 568 | :param obj: the object that the action should be performed 569 | for. Typically a function or a class object. 570 | :param ``**kw``: a dictionary of configuration objects as specified 571 | by the ``config`` class attribute. 572 | """ 573 | 574 | @staticmethod 575 | def before(**kw): 576 | """Do setup just before actions in a group are performed. 577 | 578 | Can be implemented as a static method by the :class:`Action` 579 | subclass. 580 | 581 | :param ``**kw``: a dictionary of configuration objects as specified 582 | by the ``config`` class attribute. 583 | """ 584 | pass 585 | 586 | @staticmethod 587 | def after(**kw): 588 | """Do setup just after actions in a group are performed. 589 | 590 | Can be implemented as a static method by the :class:`Action` 591 | subclass. 592 | 593 | :param ``**kw``: a dictionary of configuration objects as specified 594 | by the ``config`` class attribute. 595 | """ 596 | pass 597 | 598 | 599 | class Composite(metaclass=abc.ABCMeta): 600 | """A composite configuration action. 601 | 602 | Base class of composite actions. 603 | 604 | Composite actions are very simple: implement the ``action`` 605 | method and return a iterable of actions in there. 606 | """ 607 | 608 | query_classes = [] 609 | """A list of actual action classes that this composite can generate. 610 | 611 | This is to allow the querying of composites. If the list if empty 612 | (the default) the query system refuses to query the 613 | composite. Note that if actions of the same action class can also 614 | be generated in another way they are in the same query result. 615 | """ 616 | 617 | filter_convert = {} 618 | """Map of names to convert functions. 619 | 620 | The query tool that can be generated for a Dectate-based 621 | application uses this information to parse filter input into 622 | actual objects. If omitted it defaults to passing through the 623 | string unchanged. 624 | 625 | A conversion function takes a string as input and outputs a Python 626 | object. The conversion function may raise ``ValueError`` if the 627 | conversion failed. 628 | 629 | A useful conversion function is provided that can be used to refer 630 | to an object in a module using a dotted name: 631 | :func:`convert_dotted_name`. 632 | """ 633 | 634 | # this is here to make update_wrapper work even when an __init__ 635 | # is not provided by the subclass 636 | def __init__(self): 637 | pass 638 | 639 | @property 640 | def code_info(self): 641 | """Info about where in the source code the action was invoked. 642 | 643 | Is an instance of :class:`CodeInfo`. 644 | 645 | Can be ``None`` if action does not have an associated directive 646 | but was created manually. 647 | """ 648 | if self.directive is None: 649 | return None 650 | return self.directive.code_info 651 | 652 | @abc.abstractmethod 653 | def actions(self, obj): 654 | """Specify a iterable of actions to perform for ``obj``. 655 | 656 | The iteratable should yield ``action, obj`` tuples, 657 | where ``action`` is an instance of 658 | class :class:`Action` or :class:`Composite` and ``obj`` 659 | is the object to perform the action with. 660 | 661 | Needs to be implemented by the :class:`Composite` subclass. 662 | 663 | :param obj: the obj that the composite action was performed on. 664 | :return: iterable of ``action, obj`` tuples. 665 | """ 666 | 667 | 668 | class Directive: 669 | """Decorator to use for configuration. 670 | 671 | Can also be used as a context manager for a Python ``with`` 672 | statement. This can be used to provide defaults for the directives 673 | used within the ``with`` statements context. 674 | 675 | When used as a decorator this tracks where in the source code 676 | the directive was used for the purposes of error reporting. 677 | """ 678 | 679 | def __init__(self, action_factory, code_info, app_class, args, kw): 680 | """ 681 | :param action_factory: function that constructs an action instance. 682 | :code_info: a :class:`CodeInfo` instance describing where this 683 | directive was invoked. 684 | :param app_class: the :class:`dectate.App` subclass that this 685 | directive is used on. 686 | :args: the positional arguments passed into the directive. 687 | :kw: the keyword arguments passed into the directive. 688 | """ 689 | self.action_factory = action_factory 690 | self.code_info = code_info 691 | self.app_class = app_class 692 | self.configurable = app_class.dectate 693 | self.args = args 694 | self.kw = kw 695 | self.argument_info = (args, kw) 696 | 697 | @property 698 | def directive_name(self): 699 | return self.configurable._action_classes[self.action_factory] 700 | 701 | def action(self): 702 | """Get the :class:`Action` instance represented by this directive. 703 | 704 | :return: :class:`dectate.Action` instance. 705 | """ 706 | try: 707 | result = self.action_factory(*self.args, **self.kw) 708 | except TypeError as e: 709 | raise DirectiveReportError(f"{e}", self.code_info) 710 | 711 | # store the directive used on the action, useful for error reporting 712 | result.directive = self 713 | return result 714 | 715 | def __enter__(self): 716 | return DirectiveAbbreviation(self) 717 | 718 | def __exit__(self, type, value, tb): 719 | pass 720 | 721 | def __call__(self, wrapped): 722 | """Call with function or class to decorate. 723 | 724 | The decorated object is returned unchanged. 725 | 726 | :param wrapped: the object decorated, typically function or class. 727 | :return: the ``wrapped`` argument. 728 | """ 729 | self.configurable.register_directive(self, wrapped) 730 | return wrapped 731 | 732 | def log(self, configurable, obj): 733 | """Log this directive. 734 | 735 | :configurable: the configurable that this directive is logged for. 736 | :obj: the function or class object to that this directive is used 737 | on. 738 | """ 739 | directive_name = self.directive_name 740 | logger = logging.getLogger( 741 | f"{configurable.app_class.logger_name}.{directive_name}" 742 | ) 743 | 744 | if not logger.isEnabledFor(logging.DEBUG): 745 | return 746 | 747 | target_dotted_name = dotted_name(configurable.app_class) 748 | is_same = self.app_class is configurable.app_class 749 | 750 | if inspect.isfunction(obj): 751 | func_dotted_name = f"{obj.__module__}.{obj.__name__}" 752 | else: 753 | func_dotted_name = repr(obj) 754 | 755 | args, kw = self.argument_info 756 | arguments = ", ".join([repr(arg) for arg in args]) 757 | 758 | if kw: 759 | if arguments: 760 | arguments += ", " 761 | arguments += ", ".join( 762 | [f"{key}={value!r}" for key, value in sorted(kw.items())] 763 | ) 764 | 765 | message = "@{}.{}({}) on {}".format( 766 | target_dotted_name, 767 | directive_name, 768 | arguments, 769 | func_dotted_name, 770 | ) 771 | 772 | if not is_same: 773 | message += " (from %s)" % dotted_name(self.app_class) 774 | 775 | logger.debug(message) 776 | 777 | 778 | class DirectiveAbbreviation: 779 | """An abbreviated directive to be used with the ``with`` statement.""" 780 | 781 | def __init__(self, directive): 782 | self.directive = directive 783 | 784 | def __call__(self, *args, **kw): 785 | """Combine the args and kw from the directive with supplied ones.""" 786 | frame = sys._getframe(1) 787 | code_info = create_code_info(frame) 788 | directive = self.directive 789 | 790 | combined_args = directive.args + args 791 | combined_kw = directive.kw.copy() 792 | combined_kw.update(kw) 793 | return Directive( 794 | action_factory=directive.action_factory, 795 | code_info=code_info, 796 | app_class=directive.app_class, 797 | args=combined_args, 798 | kw=combined_kw, 799 | ) 800 | 801 | 802 | def commit(*apps): 803 | """Commit one or more app classes 804 | 805 | A commit causes the configuration actions to be performed. The 806 | resulting configuration information is stored under the 807 | ``.config`` class attribute of each :class:`App` subclass 808 | supplied. 809 | 810 | This function may safely be invoked multiple times -- each time 811 | the known configuration is recommitted. 812 | 813 | :param `*apps`: one or more :class:`App` subclasses to perform 814 | configuration actions on. 815 | """ 816 | configurables = [] 817 | for c in apps: 818 | if isinstance(c, Configurable): 819 | configurables.append(c) 820 | else: 821 | configurables.append(c.dectate) 822 | 823 | for configurable in sort_configurables(configurables): 824 | configurable.execute() 825 | 826 | 827 | def sort_configurables(configurables): 828 | """Sort configurables topologically by ``extends``. 829 | 830 | :param configurables: an iterable of configurables to sort. 831 | :return: a topologically sorted list of configurables. 832 | """ 833 | return topological_sort(configurables, lambda c: c.extends) 834 | 835 | 836 | def sort_action_classes(action_classes): 837 | """Sort action classes topologically by depends. 838 | 839 | :param action_classes: iterable of :class:`Action` subclasses 840 | class objects. 841 | :return: a topologically sorted list of action_classes. 842 | """ 843 | return topological_sort(action_classes, lambda c: c.depends) 844 | 845 | 846 | def group_action_classes(action_classes): 847 | """Group action classes by ``group_class``. 848 | 849 | :param action_classes: iterable of action classes 850 | :return: set of action classes grouped together. 851 | """ 852 | # we want to have use group_class for each true Action class 853 | result = set() 854 | for action_class in action_classes: 855 | if not issubclass(action_class, Action): 856 | continue 857 | group_class = action_class.group_class 858 | if group_class is None: 859 | group_class = action_class 860 | else: 861 | if group_class.group_class is not None: 862 | raise ConfigError( 863 | "Cannot use group_class on another action class " 864 | "that uses group_class: %r" % action_class 865 | ) 866 | if "config" in action_class.__dict__: 867 | raise ConfigError( 868 | "Cannot use config class attribute when you use " 869 | "group_class: %r" % action_class 870 | ) 871 | if "before" in action_class.__dict__: 872 | raise ConfigError( 873 | "Cannot define before method when you use " 874 | "group_class: %r" % action_class 875 | ) 876 | if "after" in action_class.__dict__: 877 | raise ConfigError( 878 | "Cannot define after method when you use " 879 | "group_class: %r" % action_class 880 | ) 881 | result.add(group_class) 882 | return result 883 | 884 | 885 | def expand_actions(actions): 886 | """Expand any :class:`Composite` instances into :class:`Action` instances. 887 | 888 | Expansion is recursive; composites that return composites are expanded 889 | again. 890 | 891 | :param actions: an iterable of :class:`Composite` and :class:`Action` 892 | instances. 893 | :return: an iterable of :class:`Action` instances. 894 | """ 895 | for action, obj in actions: 896 | if isinstance(action, Composite): 897 | # make sure all sub actions propagate originating directive 898 | # info 899 | try: 900 | sub_actions = [] 901 | for sub_action, sub_obj in action.actions(obj): 902 | sub_action.directive = action.directive 903 | sub_actions.append((sub_action, sub_obj)) 904 | except DirectiveError as e: 905 | raise DirectiveReportError(f"{e}", action.code_info) 906 | yield from expand_actions(sub_actions) 907 | else: 908 | if not hasattr(action, "order"): 909 | global order_count 910 | action.order = order_count 911 | order_count += 1 912 | yield action, obj 913 | 914 | 915 | class CodeInfo: 916 | """Information about where code was invoked. 917 | 918 | The ``path`` attribute gives the path to the Python module that the 919 | code was invoked in. 920 | 921 | The ``lineno`` attribute gives the linenumber in that file. 922 | 923 | The ``sourceline`` attribute contains the actual source line that 924 | did the invocation. 925 | """ 926 | 927 | def __init__(self, path, lineno, sourceline): 928 | self.path = path 929 | self.lineno = lineno 930 | self.sourceline = sourceline 931 | 932 | def filelineno(self): 933 | return f'File "{self.path}", line {self.lineno}' 934 | 935 | 936 | def create_code_info(frame): 937 | """Return code information about a frame. 938 | 939 | Returns a :class:`CodeInfo` instance. 940 | """ 941 | frameinfo = inspect.getframeinfo(frame) 942 | 943 | try: 944 | sourceline = frameinfo.code_context[0].strip() 945 | except Exception: 946 | # if no source file exists, e.g., due to eval 947 | sourceline = frameinfo.code_context 948 | 949 | return CodeInfo( 950 | path=frameinfo.filename, lineno=frameinfo.lineno, sourceline=sourceline 951 | ) 952 | 953 | 954 | def factory_key(item): 955 | """Helper for topological sort of factories. 956 | 957 | :param item: a ``name, factory`` tuple to generate the key for. 958 | :return: iterable of ``name, factory`` tuples that factory in item 959 | depends on for construction. 960 | """ 961 | name, factory = item 962 | arguments = getattr(factory, "factory_arguments", None) 963 | if arguments is None: 964 | return [] 965 | return arguments.items() 966 | 967 | 968 | def get_factory_arguments(action_class, config, factory, app_class): 969 | """Get arguments needed to construct factory. 970 | 971 | Factories can define a ``factory_arguments`` attribute to control 972 | what other factories are needed to be passed into it to construct 973 | it. 974 | 975 | :param action: the :class:`dectate.Action` subclass this factory needs 976 | to build a registry for. 977 | :param config: the ``config`` attribute to get the configuration items 978 | from. 979 | :param factory: the factory for the object that is going to be constructed. 980 | :param app_class: the application class that the object constructed 981 | is stored in. 982 | :return: a dict with arguments to pass to the factory. 983 | """ 984 | arguments = getattr(factory, "factory_arguments", None) 985 | app_class_arg = getattr(factory, "app_class_arg", False) 986 | 987 | result = {} 988 | if app_class_arg: 989 | result["app_class"] = app_class 990 | 991 | if arguments is None: 992 | return result 993 | 994 | for name in arguments.keys(): 995 | value = getattr(config, name, None) 996 | if value is None: 997 | raise ConfigError( 998 | ( 999 | "Cannot find factory argument %r for " 1000 | "factory %r in action class %r" 1001 | ) 1002 | % (name, factory, action_class) 1003 | ) 1004 | result[name] = getattr(config, name, None) 1005 | return result 1006 | 1007 | 1008 | def dotted_name(cls): 1009 | """Dotted name for a class. 1010 | 1011 | Example: ``my.module.MyClass``. 1012 | 1013 | :param cls: the class to generate a dotted name for. 1014 | :return: a dotted name to the class. 1015 | """ 1016 | return f"{cls.__module__}.{cls.__name__}" 1017 | -------------------------------------------------------------------------------- /doc/usage.rst: -------------------------------------------------------------------------------- 1 | Using Dectate 2 | ============= 3 | 4 | Introduction 5 | ------------ 6 | 7 | Dectate is a configuration system that can help you construct Python 8 | frameworks. A framework needs to record some information about the 9 | functions and classes that the user supplies. We call this process 10 | *configuration*. 11 | 12 | Imagine for instance a framework that supports a certain kind of 13 | plugins. The user registers each plugin with a decorator:: 14 | 15 | from framework import plugin 16 | 17 | @plugin(name="foo") 18 | def foo_plugin(...): 19 | ... 20 | 21 | Here the framework registers as a plugin the function ``foo_plugin`` 22 | under the name ``foo``. 23 | 24 | You can implement the ``plugin`` decorator as follows:: 25 | 26 | plugins = {} 27 | 28 | class plugin(name): 29 | def __init__(self, name): 30 | self.name = name 31 | 32 | def __call__(self, f): 33 | plugins[self.name] = f 34 | 35 | In the user application the user makes sure to import all modules that 36 | use the ``plugin`` decorator. As a result, the ``plugins`` dict 37 | contains the names as keys and the functions as values. Your framework 38 | can then use this information to do whatever you need to do. 39 | 40 | There are a lot of examples of code configuration in frameworks. In a 41 | web framework for instance the user can declare routes and assemble 42 | middleware. 43 | 44 | You may be okay constructing a framework with the simple decorator 45 | technique described above. But advanced frameworks need a lot more 46 | that the basic decorator system described above cannot offer. You may 47 | for instance want to allow the user to reuse configuration, override 48 | it, do more advanced error checking, and execute configuration in a 49 | particular order. 50 | 51 | Dectate supports such advanced use cases. It was extracted from the 52 | Morepath_ web framework. 53 | 54 | .. _Morepath: http://morepath.readthedocs.io 55 | 56 | Features 57 | -------- 58 | 59 | Here are some features of Dectate: 60 | 61 | * Decorator-based configuration -- users declare things by using 62 | Python decorators on functions and classes: we call these decorators 63 | *directives*, which issue configuration *actions*. 64 | 65 | * Dectate detects conflicts between configuration actions in user code 66 | and reports what pieces of code are in conflict. 67 | 68 | * Users can easily reuse and extend configuration: it's just Python 69 | class inheritance. 70 | 71 | * Users can easily override configurations in subclasses. 72 | 73 | * You can compose configuration actions from other, simpler ones. 74 | 75 | * You can control the order in which configuration actions are 76 | executed. This is unrelated to where the user uses the directives in 77 | code. You do this by declaring *dependencies* between types of 78 | configuration actions, and by *grouping* configuration actions 79 | together. 80 | 81 | * You can declare exactly what objects are used by a type of 82 | configuration action to register the configuration -- different 83 | types of actions can use different registries. 84 | 85 | * Unlike normal decorators, configuration actions aren't performed 86 | immediately when a module is imported. Instead configuration actions 87 | are executed only when the user explicitly *commits* the 88 | configuration. This way, all configuration actions are known when 89 | they are performed. 90 | 91 | * Dectate-based decorators always return the function or class object 92 | that is decorated unchanged, which makes the code more predictable 93 | for a Python programmer -- the user can use the decorated function 94 | or class directly in their Python code, just like any other. 95 | 96 | * Dectate-based configuration systems are themselves easily extensible 97 | with new directives and registries. 98 | 99 | * Dectate-based configuration systems can be queried. Dectate also 100 | provides the infrastructure to easily construct command-line tools 101 | for querying configuration. 102 | 103 | Actions 104 | ------- 105 | 106 | In Dectate, the simple `plugins` example above looks like this: 107 | 108 | .. testcode:: 109 | 110 | import dectate 111 | 112 | class PluginAction(dectate.Action): 113 | config = { 114 | 'plugins': dict 115 | } 116 | def __init__(self, name): 117 | self.name = name 118 | 119 | def identifier(self, plugins): 120 | return self.name 121 | 122 | def perform(self, obj, plugins): 123 | plugins[self.name] = obj 124 | 125 | We have formulated a configuration action that affects a ``plugins`` 126 | dict. 127 | 128 | App classes 129 | ----------- 130 | 131 | Configuration in Dectate is associated with special *classes* which 132 | derive from :class:`dectate.App`. We also associate the action with 133 | it as a directive: 134 | 135 | .. testcode:: 136 | 137 | class PluginApp(dectate.App): 138 | plugin = dectate.directive(PluginAction) 139 | 140 | Let's use it now: 141 | 142 | .. testcode:: 143 | 144 | @PluginApp.plugin('a') 145 | def f(): 146 | pass # do something interesting 147 | 148 | @PluginApp.plugin('b') 149 | def g(): 150 | pass # something else interesting 151 | 152 | We have registered the function ``f`` on ``PluginApp``. The ``name`` 153 | argument is ``'a'``. We've registered ``g`` under ``'b'``. 154 | 155 | We can now commit the configuration for ``PluginApp``: 156 | 157 | .. testcode:: 158 | 159 | dectate.commit(PluginApp) 160 | 161 | Once the commit has successfully completed, we can take a look at the 162 | configuration: 163 | 164 | .. doctest:: 165 | 166 | >>> sorted(PluginApp.config.plugins.items()) 167 | [('a', ), ('b', )] 168 | 169 | What are the changes between this and the simple plugins example? 170 | 171 | The main difference is that the ``plugin`` decorator is associated with a 172 | class and so is the resulting configuration, which gets stored as the 173 | ``plugins`` attribute of :attr:`dectate.App.config`. The other 174 | difference is that we provide an ``identifier`` method in the action 175 | definition. These differences support configuration *reuse*, 176 | *conflicts*, *extension*, *overrides* and *isolation*. 177 | 178 | Reuse 179 | ~~~~~ 180 | 181 | You can reuse configuration by simply subclassing ``PluginApp``: 182 | 183 | .. testcode:: 184 | 185 | class SubApp(PluginApp): 186 | pass 187 | 188 | We commit both classes: 189 | 190 | .. testcode:: 191 | 192 | dectate.commit(PluginApp, SubApp) 193 | 194 | ``SubClass`` now contains all the configuration declared for ``PluginApp``: 195 | 196 | >>> sorted(SubApp.config.plugins.items()) 197 | [('a', ), ('b', )] 198 | 199 | So class inheritance lets us reuse configuration, which allows 200 | *extension* and *overrides*, which we discuss below. 201 | 202 | Conflicts 203 | ~~~~~~~~~ 204 | 205 | Consider this example: 206 | 207 | .. testcode:: 208 | 209 | class ConflictingApp(PluginApp): 210 | pass 211 | 212 | @ConflictingApp.plugin('foo') 213 | def f(): 214 | pass 215 | 216 | @ConflictingApp.plugin('foo') 217 | def g(): 218 | pass 219 | 220 | Which function should be registered for ``foo``, ``f`` or ``g``? We should 221 | refuse to guess and instead raise an error that the configuration is 222 | in conflict. This is exactly what Dectate does: 223 | 224 | .. doctest:: 225 | 226 | >>> dectate.commit(ConflictingApp) 227 | Traceback (most recent call last): 228 | ... 229 | ConflictError: Conflict between: 230 | File "...", line 4 231 | @ConflictingApp.plugin('foo') 232 | File "...", line 8 233 | @ConflictingApp.plugin('foo') 234 | 235 | As you can see, Dectate reports the lines in which the conflicting 236 | configurations occurs. 237 | 238 | How does Dectate know that these configurations are in conflict? This 239 | is what the ``identifier`` method in our action definition did:: 240 | 241 | def identifier(self, plugins): 242 | return self.name 243 | 244 | We say here that the configuration is uniquely identified by its 245 | ``name`` attribute. If two configurations exist with the same name, 246 | the configuration is considered to be in conflict. 247 | 248 | Extension 249 | ~~~~~~~~~ 250 | 251 | When you subclass configuration, you can also *extend* ``SubApp`` with 252 | additional configuration actions: 253 | 254 | .. testcode:: 255 | 256 | @SubApp.plugin('c') 257 | def h(): 258 | pass # do something interesting 259 | 260 | dectate.commit(PluginApp, SubApp) 261 | 262 | ``SubApp`` now has the additional plugin ``c``: 263 | 264 | .. doctest:: 265 | 266 | >>> sorted(SubApp.config.plugins.items()) 267 | [('a', ), ('b', ), ('c', )] 268 | 269 | But ``PluginApp`` is unaffected: 270 | 271 | .. doctest:: 272 | 273 | >>> sorted(PluginApp.config.plugins.items()) 274 | [('a', ), ('b', )] 275 | 276 | Overrides 277 | ~~~~~~~~~ 278 | 279 | What if you wanted to override a piece of configuration? You can do 280 | this in ``SubApp`` by simply reusing the same ``name``: 281 | 282 | .. testcode:: 283 | 284 | @SubApp.plugin('a') 285 | def x(): 286 | pass 287 | 288 | dectate.commit(PluginApp, SubApp) 289 | 290 | In ``SubApp`` we now have changed the configuration for ``a`` to 291 | register the function ``x`` instead of ``f``. If we had done this for 292 | ``MyApp`` this would have been a conflict, but doing so in a subclass 293 | lets you override configuration instead: 294 | 295 | .. doctest:: 296 | 297 | >>> sorted(SubApp.config.plugins.items()) 298 | [('a', ), ('b', ), ('c', )] 299 | 300 | But ``PluginApp`` still uses ``f``: 301 | 302 | >>> sorted(PluginApp.config.plugins.items()) 303 | [('a', ), ('b', )] 304 | 305 | Isolation 306 | ~~~~~~~~~ 307 | 308 | We have already seen in the inheritance and override examples that 309 | ``PluginApp`` is isolated from configuration extension and overrides done 310 | for ``SubApp``. We can in fact entirely isolate configuration from 311 | each other. 312 | 313 | We first set up a new base class with a directive, independently 314 | from everything before: 315 | 316 | .. testcode:: 317 | 318 | class PluginAction2(dectate.Action): 319 | config = { 320 | 'plugins': dict 321 | } 322 | def __init__(self, name): 323 | self.name = name 324 | 325 | def identifier(self, plugins): 326 | return self.name 327 | 328 | def perform(self, obj, plugins): 329 | plugins[self.name] = obj 330 | 331 | class BaseApp(dectate.App): 332 | plugin = dectate.directive(PluginAction2) 333 | 334 | We don't set up any configuration for ``BaseApp``; it's intended to be 335 | part of our framework. Now we create two subclasses: 336 | 337 | .. testcode:: 338 | 339 | class OneApp(BaseApp): 340 | pass 341 | 342 | class TwoApp(BaseApp): 343 | pass 344 | 345 | As you can see ``OneApp`` and ``TwoApp`` are completely isolated from 346 | each other; the only thing they share is a common ``BaseApp``. 347 | 348 | We register a plugin for ``OneApp``: 349 | 350 | .. testcode:: 351 | 352 | @OneApp.plugin('a') 353 | def f(): 354 | pass 355 | 356 | This won't affect ``TwoApp`` in any way: 357 | 358 | .. testcode:: 359 | 360 | dectate.commit(OneApp, TwoApp) 361 | 362 | .. doctest:: 363 | 364 | >>> sorted(OneApp.config.plugins.items()) 365 | [('a', )] 366 | >>> sorted(TwoApp.config.plugins.items()) 367 | [] 368 | 369 | ``OneApp`` and ``TwoApp`` are isolated, so configurations are 370 | independent, and cannot conflict or override. 371 | 372 | The Anatomy of an Action 373 | ------------------------ 374 | 375 | Let's consider the plugin action in detail:: 376 | 377 | class PluginAction(dectate.Action): 378 | config = { 379 | 'plugins': dict 380 | } 381 | def __init__(self, name): 382 | self.name = name 383 | 384 | def identifier(self, plugins): 385 | return self.name 386 | 387 | def perform(self, obj, plugins): 388 | plugins[self.name] = obj 389 | 390 | What is going on here? 391 | 392 | * We implement a custom class called ``PluginAction`` that inherits 393 | from :class:`dectate.Action`. 394 | 395 | * ``config`` (:attr:`dectate.Action.config`) specifies that this 396 | directive has a configuration effect on ``plugins``. We declare that 397 | ``plugins`` is created using the ``dict`` factory, so our registry 398 | is a plain dictionary. You provide any factory function you like 399 | here. 400 | 401 | * ``__init__`` specifies the parameters the directive should take and 402 | how to store them on the action object. You can use default 403 | parameters and such, but otherwise ``__init__`` should be very 404 | simple and not do any registration or validation. That logic should 405 | be in ``perform``. 406 | 407 | * ``identifier`` (:meth:`dectate.Action.identifier`) takes the 408 | configuration objects specified by ``config`` as keyword 409 | arguments. It returns an immutable that is unique for this 410 | action. This is used to detect conflicts and determine how 411 | configurations override each other. 412 | 413 | * ``perform`` (:meth:`dectate.Action.perform`) takes ``obj``, which is 414 | the function or class that the decorator is used on, and the 415 | arguments specified in ``config``. It should use ``obj`` and the 416 | information on ``self`` to configure the configuration objects. In 417 | this case we store ``obj`` under the key ``self.name`` in the 418 | ``plugins`` dict. 419 | 420 | We then associate the action with a class as a directive:: 421 | 422 | class PluginApp(dectate.App): 423 | plugin = dectate.directive(PluginAction) 424 | 425 | Once we have declared the directive for our framework we can tell 426 | programmers to use it. 427 | 428 | Directives have absolutely no effect until *commit* is called, which 429 | we do with ``dectate.commit``. This performs the actions and we can 430 | then find the result ``PluginApp.config`` 431 | (:attr:`dectate.App.config`). 432 | 433 | The results are in ``PluginApp.config.plugins`` as we set this up with 434 | ``config`` in our ``PluginAction``. 435 | 436 | Depends 437 | ------- 438 | 439 | In some cases you want to make sure that one type of directive has 440 | been executed before the other -- the configuration of the second type 441 | of directive depends on the former. You can make sure this happens by 442 | using the ``depends`` (:attr:`dectate.Action.depends`) class 443 | attribute. 444 | 445 | First we set up a ``FooAction`` that registers into a ``foos`` 446 | dict: 447 | 448 | .. testcode:: 449 | 450 | class FooAction(dectate.Action): 451 | config = { 452 | 'foos': dict 453 | } 454 | def __init__(self, name): 455 | self.name = name 456 | 457 | def identifier(self, foos): 458 | return self.name 459 | 460 | def perform(self, obj, foos): 461 | foos[self.name] = obj 462 | 463 | Now we create a ``BarAction`` directive that depends on ``FooAction`` 464 | and uses information in the ``foos`` dict: 465 | 466 | .. testcode:: 467 | 468 | class BarAction(dectate.Action): 469 | depends = [FooAction] 470 | 471 | config = { 472 | 'foos': dict, # also use the foos dict 473 | 'bars': list 474 | } 475 | def __init__(self, name): 476 | self.name = name 477 | 478 | def identifier(self, foos, bars): 479 | return self.name 480 | 481 | def perform(self, obj, foos, bars): 482 | in_foo = self.name in foos 483 | bars.append((self.name, obj, in_foo)) 484 | 485 | In order to use them we need to hook up the actions as directives 486 | onto an app class: 487 | 488 | .. testcode:: 489 | 490 | class DependsApp(dectate.App): 491 | foo = dectate.directive(FooAction) 492 | bar = dectate.directive(BarAction) 493 | 494 | 495 | Using ``depends`` we have ensured that ``BarAction`` actions are 496 | performed after ``FooAction`` action, no matter what order we use 497 | them: 498 | 499 | .. testcode:: 500 | 501 | @DependsApp.bar('a') 502 | def f(): 503 | pass 504 | 505 | @DependsApp.bar('b') 506 | def g(): 507 | pass 508 | 509 | @DependsApp.foo('a') 510 | def x(): 511 | pass 512 | 513 | dectate.commit(DependsApp) 514 | 515 | We expect ``in_foo`` to be ``True`` for ``a`` but to be ``False`` for 516 | ``b``: 517 | 518 | .. doctest:: 519 | 520 | >>> DependsApp.config.bars 521 | [('a', , True), ('b', , False)] 522 | 523 | config dependencies 524 | ------------------- 525 | 526 | In the example above, the items in ``bars`` depend on the items in 527 | ``foos`` and we've implemented this dependency in the ``perform`` of 528 | ``BarAction``. 529 | 530 | We can instead make the configuration object for the ``BarAction`` 531 | depend on ``foos``. This way ``BarAction`` does not need to know 532 | about ``foos``. You can declare a dependency between config objects 533 | with the ``factory_arguments`` attribute of the config factory. Any 534 | config object that is created in earlier dependencies of this action, 535 | or in the action itself, can be listed in ``factory_arguments``. The 536 | key and value in ``factory_arguments`` have to match the key and value 537 | in ``config`` of that earlier action. 538 | 539 | First we create a ``FooAction`` that sets up a ``foos`` config item as 540 | before: 541 | 542 | .. testcode:: 543 | 544 | class FooAction(dectate.Action): 545 | config = { 546 | 'foos': dict 547 | } 548 | def __init__(self, name): 549 | self.name = name 550 | 551 | def identifier(self, foos): 552 | return self.name 553 | 554 | def perform(self, obj, foos): 555 | foos[self.name] = obj 556 | 557 | Now we create a ``Bar`` class that also depends on the ``foos`` dict by 558 | listing it in ``factory_arguments``: 559 | 560 | .. testcode:: 561 | 562 | class Bar: 563 | factory_arguments = { 564 | 'foos': dict 565 | } 566 | 567 | def __init__(self, foos): 568 | self.foos = foos 569 | self.l = [] 570 | 571 | def add(self, name, obj): 572 | in_foo = name in self.foos 573 | self.l.append((name, obj, in_foo)) 574 | 575 | We create a ``BarAction`` that depends on the ``FooAction`` (so that 576 | ``foos`` is created first) and that uses the ``Bar`` factory: 577 | 578 | .. testcode:: 579 | 580 | class BarAction(dectate.Action): 581 | depends = [FooAction] 582 | 583 | config = { 584 | 'bar': Bar 585 | } 586 | 587 | def __init__(self, name): 588 | self.name = name 589 | 590 | def identifier(self, bar): 591 | return self.name 592 | 593 | def perform(self, obj, bar): 594 | bar.add(self.name, obj) 595 | 596 | 597 | And we set them up as directives: 598 | 599 | .. testcode:: 600 | 601 | class ConfigDependsApp(dectate.App): 602 | foo = dectate.directive(FooAction) 603 | bar = dectate.directive(BarAction) 604 | 605 | When we use our directives: 606 | 607 | .. testcode:: 608 | 609 | @ConfigDependsApp.bar('a') 610 | def f(): 611 | pass 612 | 613 | @ConfigDependsApp.bar('b') 614 | def g(): 615 | pass 616 | 617 | @ConfigDependsApp.foo('a') 618 | def x(): 619 | pass 620 | 621 | dectate.commit(ConfigDependsApp) 622 | 623 | we get the same result as before: 624 | 625 | .. doctest:: 626 | 627 | >>> ConfigDependsApp.config.bar.l 628 | [('a', , True), ('b', , False)] 629 | 630 | app_class_arg 631 | ------------- 632 | 633 | In some cases what you want to configure is not on in the config 634 | object (``app_class.config``), but is associated with the app class in 635 | another way. You can get the app class passed in as an argument to 636 | :meth:`dectate.Action.perform`, :meth:`dectate.Action.identifier`, and 637 | so on by setting the special ``app_class_arg`` class attribute: 638 | 639 | .. testcode:: 640 | 641 | class PluginAction(dectate.Action): 642 | config = { 643 | 'plugins': dict 644 | } 645 | app_class_arg = True 646 | 647 | def __init__(self, name): 648 | self.name = name 649 | 650 | def identifier(self, plugins, app_class): 651 | return self.name 652 | 653 | def perform(self, obj, plugins, app_class): 654 | plugins[self.name] = obj 655 | app_class.touched = True 656 | 657 | class MyApp(dectate.App): 658 | plugin_with_app_class = dectate.directive(PluginAction) 659 | 660 | When we now perform this directive: 661 | 662 | .. testcode:: 663 | 664 | @MyApp.plugin_with_app_class('a') 665 | def f(): 666 | pass # do something interesting 667 | 668 | dectate.commit(MyApp) 669 | 670 | We can see the app class was indeed affected: 671 | 672 | .. doctest:: 673 | 674 | >>> MyApp.touched 675 | True 676 | 677 | You can also use ``app_class_arg`` on a factory so that Dectate passes 678 | in the ``app_class`` factory argument. 679 | 680 | before and after 681 | ---------------- 682 | 683 | It can be useful to do some additional setup just before all actions 684 | of a certain type are performed, or just afterwards. You can do this 685 | using ``before`` (:meth:`dectate.Action.before`) and ``after`` 686 | (:meth:`dectate.Action.after`) static methods on the Action class: 687 | 688 | .. testcode:: 689 | 690 | class FooAction(dectate.Action): 691 | config = { 692 | 'foos': list 693 | } 694 | def __init__(self, name): 695 | self.name = name 696 | 697 | @staticmethod 698 | def before(foos): 699 | print("before:", foos) 700 | 701 | @staticmethod 702 | def after(foos): 703 | print("after:", foos) 704 | 705 | def identifier(self, foos): 706 | return self.name 707 | 708 | def perform(self, obj, foos): 709 | foos.append((self.name, obj)) 710 | 711 | class BeforeAfterApp(dectate.App): 712 | foo = dectate.directive(FooAction) 713 | 714 | @BeforeAfterApp.foo('a') 715 | def f(): 716 | pass 717 | 718 | @BeforeAfterApp.foo('b') 719 | def g(): 720 | pass 721 | 722 | This executes ``before`` just before ``a`` and ``b`` are configured, 723 | and then executes ``after``: 724 | 725 | .. doctest:: 726 | 727 | >>> dectate.commit(BeforeAfterApp) 728 | before: [] 729 | after: [('a', ), ('b', )] 730 | 731 | grouping actions 732 | ---------------- 733 | 734 | Different actions normally don't conflict with each other. It can be 735 | useful to group different actions together in a group so that they do 736 | affect each other. You can do this with the ``group_class`` 737 | (:attr:`dectate.Action.group_class`) class attribute. Grouped classes 738 | share their ``config`` and their ``before`` and ``after`` methods. 739 | 740 | .. testcode:: 741 | 742 | class FooAction(dectate.Action): 743 | config = { 744 | 'foos': list 745 | } 746 | def __init__(self, name): 747 | self.name = name 748 | 749 | def identifier(self, foos): 750 | return self.name 751 | 752 | def perform(self, obj, foos): 753 | foos.append((self.name, obj)) 754 | 755 | We now create a ``BarAction`` that groups with ``FooAction``: 756 | 757 | .. testcode:: 758 | 759 | class BarAction(dectate.Action): 760 | group_class = FooAction 761 | 762 | def __init__(self, name): 763 | self.name = name 764 | 765 | def identifier(self, foos): 766 | return self.name 767 | 768 | def perform(self, obj, foos): 769 | foos.append((self.name, obj)) 770 | 771 | class GroupApp(dectate.App): 772 | foo = dectate.directive(FooAction) 773 | bar = dectate.directive(BarAction) 774 | 775 | It reuses the ``config`` from ``FooAction``. This means that ``foo`` 776 | and ``bar`` can be in conflict: 777 | 778 | .. testcode:: 779 | 780 | @GroupApp.foo('a') 781 | def f(): 782 | pass 783 | 784 | @GroupApp.bar('a') 785 | def g(): 786 | pass 787 | 788 | .. doctest:: 789 | 790 | >>> dectate.commit(GroupApp) 791 | Traceback (most recent call last): 792 | ... 793 | ConflictError: Conflict between: 794 | File "...", line 4 795 | @GroupApp.foo('a') 796 | File "...", line 8 797 | @GroupApp.bar('a') 798 | 799 | Additional discriminators 800 | ------------------------- 801 | 802 | In some cases an action should conflict with *multiple* other actions 803 | all at once. You can take care of this with the ``discriminators`` 804 | (:meth:`dectate.Action.discriminators`) method on your action: 805 | 806 | .. testcode:: 807 | 808 | class FooAction(dectate.Action): 809 | config = { 810 | 'foos': dict 811 | } 812 | def __init__(self, name, extras): 813 | self.name = name 814 | self.extras = extras 815 | 816 | def identifier(self, foos): 817 | return self.name 818 | 819 | def discriminators(self, foos): 820 | return self.extras 821 | 822 | def perform(self, obj, foos): 823 | foos[self.name] = obj 824 | 825 | 826 | class DiscriminatorsApp(dectate.App): 827 | foo = dectate.directive(FooAction) 828 | 829 | An action now conflicts with an action of the same name *and* with 830 | any action that is in the ``extra`` list: 831 | 832 | .. testcode:: 833 | 834 | # example 835 | @DiscriminatorsApp.foo('a', ['b', 'c']) 836 | def f(): 837 | pass 838 | 839 | @DiscriminatorsApp.foo('b', []) 840 | def g(): 841 | pass 842 | 843 | And then: 844 | 845 | .. doctest:: 846 | 847 | >>> dectate.commit(DiscriminatorsApp) 848 | Traceback (most recent call last): 849 | ... 850 | ConflictError: Conflict between: 851 | File "...", line 2: 852 | @DiscriminatorsApp.foo('a', ['b', 'c']) 853 | File "...", line 6 854 | @DiscriminatorsApp.foo('b', []) 855 | 856 | Composite actions 857 | ----------------- 858 | 859 | When you can define an action entirely in terms of other actions, you 860 | can subclass :class:`dectate.Composite`. 861 | 862 | First we define a normal ``SubAction`` to use in the composite action 863 | later: 864 | 865 | .. testcode:: 866 | 867 | class SubAction(dectate.Action): 868 | config = { 869 | 'my': list 870 | } 871 | 872 | def __init__(self, name): 873 | self.name = name 874 | 875 | def identifier(self, my): 876 | return self.name 877 | 878 | def perform(self, obj, my): 879 | my.append((self.name, obj)) 880 | 881 | Now we can define a special :class:`dectate.Composite` subclass that 882 | uses ``SubAction`` in an ``actions`` 883 | (:meth:`dectate.Composite.actions`) method: 884 | 885 | .. testcode:: 886 | 887 | class CompositeAction(dectate.Composite): 888 | def __init__(self, names): 889 | self.names = names 890 | 891 | def actions(self, obj): 892 | return [(SubAction(name), obj) for name in self.names] 893 | 894 | class CompositeApp(dectate.App): 895 | _sub = dectate.directive(SubAction) 896 | composite = dectate.directive(CompositeAction) 897 | 898 | Note that even though ``_sub`` is not intended to be a public part of 899 | the API we still need to include it in our :class:`dectate.App` 900 | subclass, as Dectate does need to know it exists. 901 | 902 | We can now use it: 903 | 904 | .. testcode:: 905 | 906 | @CompositeApp.composite(['a', 'b', 'c']) 907 | def f(): 908 | pass 909 | 910 | dectate.commit(CompositeApp) 911 | 912 | And ``SubAction`` is performed three times as a result: 913 | 914 | .. doctest:: 915 | 916 | >>> CompositeApp.config.my 917 | [('a', ), ('b', ), ('c', )] 918 | 919 | ``with`` statement 920 | ------------------ 921 | 922 | Sometimes you want to issue a lot of similar actions at once. You can 923 | use the ``with`` statement to do so with less repetition: 924 | 925 | .. testcode:: 926 | 927 | class FooAction(dectate.Action): 928 | config = { 929 | 'my': list 930 | } 931 | 932 | def __init__(self, a, b): 933 | self.a = a 934 | self.b = b 935 | 936 | def identifier(self, my): 937 | return (self.a, self.b) 938 | 939 | def perform(self, obj, my): 940 | my.append((self.a, self.b, obj)) 941 | 942 | 943 | class WithApp(dectate.App): 944 | foo = dectate.directive(FooAction) 945 | 946 | Instead of this: 947 | 948 | .. testcode:: 949 | 950 | class VerboseWithApp(WithApp): 951 | pass 952 | 953 | @VerboseWithApp.foo('a', 'x') 954 | def f(): 955 | pass 956 | 957 | @VerboseWithApp.foo('a', 'y') 958 | def g(): 959 | pass 960 | 961 | @VerboseWithApp.foo('a', 'z') 962 | def h(): 963 | pass 964 | 965 | You can instead write: 966 | 967 | .. testcode:: 968 | 969 | class SuccinctWithApp(WithApp): 970 | pass 971 | 972 | with SuccinctWithApp.foo('a') as foo: 973 | @foo('x') 974 | def f(): 975 | pass 976 | 977 | @foo('y') 978 | def g(): 979 | pass 980 | 981 | @foo('z') 982 | def h(): 983 | pass 984 | 985 | And this has the same configuration effect: 986 | 987 | .. doctest:: 988 | 989 | >>> dectate.commit(VerboseWithApp, SuccinctWithApp) 990 | >>> VerboseWithApp.config.my 991 | [('a', 'x', ), ('a', 'y', ), ('a', 'z', )] 992 | >>> SuccinctWithApp.config.my 993 | [('a', 'x', ), ('a', 'y', ), ('a', 'z', )] 994 | 995 | importing recursively 996 | --------------------- 997 | 998 | When you use dectate-based decorators across a package, it can be 999 | useful to just import *all* modules in it at once. This way the user 1000 | cannot forget to import a module with decorators in it. 1001 | 1002 | Dectate itself does not offer this facility, but you can use the 1003 | importscan_ library to do this recursive import. Simply do something 1004 | like:: 1005 | 1006 | import my_package 1007 | 1008 | importscan.scan(my_package, ignore=['.tests']) 1009 | 1010 | This imports every module in ``my_package``, except for the ``tests`` 1011 | sub package. 1012 | 1013 | .. _importscan: http://importscan.readthedocs.io/en/latest/ 1014 | 1015 | logging 1016 | ------- 1017 | 1018 | Dectate logs information about the performed actions as debug log 1019 | messages. By default this goes to the 1020 | ``dectate.directive.`` log. You can use the standard 1021 | Python :mod:`logging` module function to make this information go 1022 | to a log file. 1023 | 1024 | If you want to override the name of the log you can set 1025 | ``logger_name`` (:attr:`dectate.App.logger_name`) on the app class:: 1026 | 1027 | class MorepathApp(dectate.App): 1028 | logger_name = 'morepath.directive' 1029 | 1030 | querying 1031 | -------- 1032 | 1033 | Dectate keeps a database of committed actions that can be queried by 1034 | using :class:`dectate.Query`. 1035 | 1036 | Here is an example of a query for all the plugin actions on ``PluginApp``: 1037 | 1038 | .. testcode:: 1039 | 1040 | q = dectate.Query('plugin') 1041 | 1042 | We can now run the query: 1043 | 1044 | .. doctest:: 1045 | :options: +NORMALIZE_WHITESPACE 1046 | 1047 | >>> list(q(PluginApp)) 1048 | [(, ), 1049 | (, )] 1050 | 1051 | We can also filter the query for attributes of the action: 1052 | 1053 | .. doctest:: 1054 | 1055 | >>> list(q.filter(name='a')(PluginApp)) 1056 | [(, )] 1057 | 1058 | Sometimes the attribute on the action is not the same as the name you 1059 | may want to use in the filter. You can use 1060 | :attr:`dectate.Action.filter_name` to create a mapping to the correct 1061 | attribute. 1062 | 1063 | By default the filter does an equality comparison. You can define your 1064 | own comparison function for an attribute using 1065 | :attr:`dectate.Action.filter_compare`. 1066 | 1067 | If you want to allow a query on a :class:`Composite` action you need 1068 | to give it some help by defining 1069 | xs:attr:`dectate.Composite.query_classes`. 1070 | 1071 | .. _query_tool: 1072 | 1073 | query tool 1074 | ---------- 1075 | 1076 | Dectate also includes a command-line tool that lets you issue queries. You 1077 | need to configure it for your application. For instance, in the module 1078 | ``main.py`` of your project:: 1079 | 1080 | import dectate 1081 | 1082 | def query_tool(): 1083 | # make sure to scan or import everything needed at this point 1084 | dectate.query_tool(SomeApp.commit()) 1085 | 1086 | In this function you should commit any :class:`dectate.App` subclasses 1087 | your application normally uses, and then provide an iterable of them 1088 | to :func:`dectate.query_tool`. These are the applications that are 1089 | queried by default if you don't specify ``--app``. We do it all in one 1090 | here as we can get the app class that were committed from the result 1091 | of :meth:`App.commit`. 1092 | 1093 | Then in ``setup.py`` of your project:: 1094 | 1095 | entry_points={ 1096 | 'console_scripts': [ 1097 | 'decq = query.main:query_tool', 1098 | ] 1099 | }, 1100 | 1101 | When you re-install this project you have a command-line tool called 1102 | ``decq`` that lets you issues queries. For instance, this query 1103 | returns all uses of directive ``foo`` in the apps you provided to 1104 | ``query_tool``:: 1105 | 1106 | $ decq foo 1107 | App: 1108 | File ".../query/b.py", line 4 1109 | @App.foo(name='alpha') 1110 | 1111 | File ".../query/b.py", line 9 1112 | @App.foo(name='beta') 1113 | 1114 | File ".../query/b.py", line 14 1115 | @App.foo(name='gamma') 1116 | 1117 | File ".../query/c.py", line 4 1118 | @App.foo(name='lah') 1119 | 1120 | App: 1121 | File ".../query/b.py", line 19 1122 | @Other.foo(name='alpha') 1123 | 1124 | And this query filters by ``name``:: 1125 | 1126 | $ decq foo name=alpha 1127 | App: 1128 | File ".../query/b.py", line 4 1129 | @App.foo(name='alpha') 1130 | 1131 | App: 1132 | File ".../query/b.py", line 19 1133 | @Other.foo(name='alpha') 1134 | 1135 | You can also explicit provide the app classes to query with the 1136 | ``--app`` option; the default list of app classes is ignored in this 1137 | case:: 1138 | 1139 | $ bin/decq --app query.a.App foo name=alpha 1140 | App: 1141 | File ".../query/b.py", line 4 1142 | @App.foo(name='alpha') 1143 | 1144 | You need to give ``--app`` a dotted name of the :class:`dectate.App` 1145 | subclass to query. You can repeat the ``--app`` option to query 1146 | multiple apps. 1147 | 1148 | Not all things you would wish to query on are string attributes. You 1149 | can provide a conversion function that takes the string input and 1150 | converts it to the underlying object you want to compare to using 1151 | :attr:`dectate.Action.filter_convert`. 1152 | 1153 | A working example is in ``scenarios/query`` of the Dectate project. 1154 | 1155 | Sphinx Extension 1156 | ---------------- 1157 | 1158 | If you use Sphinx_ to document your project and you use the 1159 | ``sphinx.ext.autodoc`` extension to document your API, you need to 1160 | install a Sphinx extension so that directives are documented 1161 | properly. In your Sphinx ``conf.py`` add ``'dectate.sphinxext'`` to 1162 | the ``extensions`` list. 1163 | 1164 | .. _Sphinx: http://www.sphinx-doc.org 1165 | 1166 | ``__main__`` and conflicts 1167 | -------------------------- 1168 | 1169 | .. sidebar:: Import-time side effects are evil 1170 | 1171 | This scenario is based on the one described in `Application 1172 | programmers don't control the module-scope codepath`_ in the 1173 | Pyramid design defense document. If you're curious, look under 1174 | ``scenarios/main_module`` in the Dectate project for a Dectate 1175 | version. 1176 | 1177 | Dectate makes a different compromise than Venusian -- it reports an 1178 | error if a directive is executed because of a double import, so it 1179 | won't get you into trouble. But since Dectate's directives cause 1180 | registrations to happen immediately (but defer configuration), you 1181 | can dynamically generate them inside Python function, which won't 1182 | work with with Venusian. 1183 | 1184 | .. _`Application programmers don't control the module-scope codepath`: http://docs.pylonsproject.org/projects/pyramid/en/latest/designdefense.html#application-programmers-don-t-control-the-module-scope-codepath-import-time-side-effects-are-evil 1185 | 1186 | In certain scenarios where you run your code like this:: 1187 | 1188 | $ python app.py 1189 | 1190 | and you use ``__name__ == '__main__'`` to determine whether the module 1191 | should run:: 1192 | 1193 | if __name__ == '__main__': 1194 | import another_module 1195 | dectate.commit(App) 1196 | 1197 | you might get a :exc:`ConflictError` from Dectate that looks somewhat 1198 | like this:: 1199 | 1200 | Traceback (most recent call last): 1201 | ... 1202 | dectate.error.ConflictError: Conflict between: 1203 | File "/path/to/app.py", line 6 1204 | @App.foo(name='a') 1205 | File "app.py", line 6 1206 | @App.foo(name='a') 1207 | 1208 | The same line shows up on *both* sides of the configuration conflict, 1209 | but the path is absolute on one side and relative on the other. 1210 | 1211 | This happens because in some scenarios involving ``__main__``, Python 1212 | imports a module *twice* (`more about this`_). Dectate refuses to 1213 | operate in this case until you change your imports so that this 1214 | doesn't happen anymore. 1215 | 1216 | .. _`more about this`: http://python-notes.curiousefficiency.org/en/latest/python_concepts/import_traps.html#executing-the-main-module-twice 1217 | 1218 | How to avoid this scenario? If you use setuptools `automatic script 1219 | creation`_ this problem is avoided entirely. 1220 | 1221 | .. _`automatic script creation`: https://pythonhosted.org/setuptools/setuptools.html#automatic-script-creation 1222 | 1223 | .. sidebar:: Fooling Dectate after all 1224 | 1225 | It *is* possible to fool Dectate into accepting a double import 1226 | without conflicts, but you'd need to work hard. You need to use a 1227 | global variable that gets modified during import time and then use 1228 | it as a directive argument. If you want to dynamically generate 1229 | directives then don't do that in module-scope -- do it in a function. 1230 | 1231 | If you want to use the ``if __name__ == '__main__'`` system, keep your 1232 | main module tiny and just import the main function you want to run 1233 | from elsewhere. 1234 | 1235 | So, Dectate warns you if you do it wrong, so don't worry about it. 1236 | --------------------------------------------------------------------------------