├── tests ├── __init__.py ├── my_grammar │ ├── __init__.py │ └── fruit.py ├── testutils.py ├── conftest.py ├── test_elements.py ├── test_command_context.py ├── test_loading.py ├── test_top_level.py └── test_merger.py ├── version.txt ├── breathe ├── grammar │ ├── __init__.py │ ├── subgrammar.py │ ├── helpers.py │ └── master.py ├── rules │ ├── __init__.py │ ├── simple_rule.py │ └── context_switcher.py ├── elements │ ├── true_context.py │ ├── __init__.py │ ├── command_context.py │ ├── bound_compound.py │ └── commands_ref.py └── __init__.py ├── requirements.txt ├── Makefile ├── .github └── workflows │ └── tests.yml ├── setup.py ├── .gitignore ├── README.md └── LICENSE.txt /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 0.2.4 -------------------------------------------------------------------------------- /breathe/grammar/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/my_grammar/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dragonfly2 2 | -------------------------------------------------------------------------------- /breathe/rules/__init__.py: -------------------------------------------------------------------------------- 1 | from .simple_rule import SimpleRule 2 | from .context_switcher import ContextSwitcher -------------------------------------------------------------------------------- /tests/testutils.py: -------------------------------------------------------------------------------- 1 | from dragonfly import ActionBase 2 | 3 | class DoNothing(ActionBase): 4 | def _execute(self, data): 5 | pass 6 | -------------------------------------------------------------------------------- /breathe/elements/true_context.py: -------------------------------------------------------------------------------- 1 | from dragonfly import Context 2 | 3 | class TrueContext(Context): 4 | def matches(self, *args, **kwargs): 5 | return True -------------------------------------------------------------------------------- /breathe/elements/__init__.py: -------------------------------------------------------------------------------- 1 | from .bound_compound import BoundCompound 2 | from .commands_ref import CommandsRef, Exec 3 | from .command_context import CommandContext 4 | from .true_context import TrueContext -------------------------------------------------------------------------------- /tests/my_grammar/fruit.py: -------------------------------------------------------------------------------- 1 | 2 | from breathe import Breathe 3 | from ..testutils import DoNothing 4 | 5 | Breathe.add_commands( 6 | None, 7 | { 8 | "parsnip": DoNothing(), 9 | } 10 | ) 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | test: 4 | python3 -m pytest --cov-report term-missing --cov=breathe tests/ 5 | 6 | clean: 7 | rm -r dist build 8 | 9 | dist: 10 | python3 setup.py sdist bdist_wheel 11 | python27 setup.py bdist_wheel 12 | 13 | package: dist 14 | twine upload dist/* 15 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def pytest_configure(config): 4 | os.environ['BREATHE_TESTING'] = "True" 5 | os.environ['BREATHE_REBUILD_COMMAND'] = "rebuild everything test" 6 | 7 | def pytest_unconfigure(config): 8 | del os.environ['BREATHE_TESTING'] 9 | del os.environ['BREATHE_REBUILD_COMMAND'] -------------------------------------------------------------------------------- /breathe/__init__.py: -------------------------------------------------------------------------------- 1 | from .grammar.master import Master 2 | from .elements import CommandContext, CommandsRef, Exec 3 | import os 4 | 5 | # Some discussion here of whether this pattern is a good idea, I think it's fine for now. 6 | # https://stackoverflow.com/questions/9561042/python-init-py-and-initialization-of-objects-in-a-code 7 | 8 | if os.getenv('BREATHE_TESTING'): 9 | from dragonfly import get_engine 10 | Breathe = Master(engine=get_engine("text")) 11 | else: 12 | Breathe = Master() 13 | -------------------------------------------------------------------------------- /breathe/elements/command_context.py: -------------------------------------------------------------------------------- 1 | from dragonfly import Context 2 | 3 | class CommandContext(Context): 4 | 5 | def __init__(self, name, enabled=False): 6 | Context.__init__(self) 7 | self._enabled = enabled 8 | self.name = name 9 | self._str = name 10 | 11 | def enable(self): 12 | self._enabled = True 13 | 14 | def disable(self): 15 | self._enabled = False 16 | 17 | def matches(self, executable, title, handle): 18 | return self._enabled 19 | -------------------------------------------------------------------------------- /breathe/grammar/subgrammar.py: -------------------------------------------------------------------------------- 1 | from dragonfly import Grammar 2 | 3 | class SubGrammar(Grammar): 4 | 5 | def process_begin(self, executable, title, handle): 6 | """ 7 | Enabling and disabling this grammar is handled 8 | by the master 9 | """ 10 | pass 11 | 12 | def _process_begin(self): 13 | 14 | if self._enabled: 15 | [r.activate() for r in self._rules if not r.active] 16 | else: 17 | [r.deactivate() for r in self._rules if r.active] 18 | 19 | -------------------------------------------------------------------------------- /tests/test_elements.py: -------------------------------------------------------------------------------- 1 | from dragonfly import * 2 | 3 | from breathe.elements import BoundCompound 4 | 5 | def test_bound_compound(): 6 | c1 = BoundCompound( 7 | spec="test []", 8 | extras=[IntegerRef("n", 1, 10), Dictation("text", "")], 9 | value=Text("test %(text)s")*Repeat("n") 10 | ) 11 | 12 | bound_value = c1._value.copy_bind({"n": "3", "text": "test"}) 13 | assert isinstance(bound_value, ActionBase) 14 | assert bound_value._data == {"n": "3", "text": "test"} 15 | assert bound_value._action == c1._value -------------------------------------------------------------------------------- /breathe/rules/simple_rule.py: -------------------------------------------------------------------------------- 1 | from dragonfly import Rule 2 | 3 | 4 | class SimpleRule(Rule): 5 | def __init__( 6 | self, name=None, element=None, context=None, imported=False, exported=True 7 | ): 8 | Rule.__init__( 9 | self, 10 | name=name, 11 | element=element, 12 | context=context, 13 | imported=imported, 14 | exported=exported, 15 | ) 16 | 17 | def process_recognition(self, node): 18 | value = node.value() 19 | if isinstance(value, list): 20 | for action in node.value(): 21 | action.execute() 22 | else: 23 | value.execute() 24 | -------------------------------------------------------------------------------- /breathe/elements/bound_compound.py: -------------------------------------------------------------------------------- 1 | from dragonfly import Compound as CompoundBase 2 | 3 | class BoundCompound(CompoundBase): 4 | """ 5 | Compound class whose value property will be an Action. 6 | 7 | When value() is called this action will be bound with the relevant extras and passed up to be executed. 8 | """ 9 | def value(self, node): 10 | extras = {"_node": node} 11 | for name, element in self._extras.items(): 12 | extra_node = node.get_child_by_name(name, shallow=True) 13 | if extra_node: 14 | extras[name] = extra_node.value() 15 | elif element.has_default(): 16 | extras[name] = element.default 17 | if self._value is not None: 18 | return self._value.copy_bind(extras) 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Breathe tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [2.7, 3.7, 3.8] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install . 23 | pip install pytest pytest-cov codecov 24 | - name: Test with pytest 25 | run: pytest --cov-report term-missing --cov=breathe tests/ 26 | - name: Code coverage 27 | run: codecov -------------------------------------------------------------------------------- /breathe/rules/context_switcher.py: -------------------------------------------------------------------------------- 1 | from dragonfly import DictListRef, Function, Alternative 2 | from .simple_rule import SimpleRule 3 | from ..elements import BoundCompound 4 | 5 | class ContextSwitcher(SimpleRule): 6 | 7 | def __init__(self, dictlist): 8 | ref = DictListRef("context", dictlist) 9 | enable = BoundCompound( 10 | "enable ", 11 | extras=[ref], 12 | value=Function(lambda context: context.enable()) 13 | ) 14 | disable = BoundCompound( 15 | "disable ", 16 | extras=[ref], 17 | value=Function(lambda context: context.disable()) 18 | ) 19 | SimpleRule.__init__(self, element=Alternative(children=(enable, disable))) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import os 3 | 4 | def read(*names): 5 | return open(os.path.join(os.path.dirname(__file__), *names)).read() 6 | 7 | setup( 8 | name="dfly-breathe", 9 | version=read("version.txt"), 10 | description="Dragonfly command API", 11 | author="Mike Roberts", 12 | author_email="mike.roberts.2k10@googlemail.com", 13 | license="LICENSE.txt", 14 | url="https://github.com/mrob95/Breathe", 15 | long_description = read("README.md"), 16 | long_description_content_type='text/markdown', 17 | packages=find_packages(exclude=("tests", "tests.*")), 18 | install_requires=["dragonfly2"], 19 | classifiers=[ 20 | "Environment :: Win32 (MS Windows)", 21 | "Environment :: X11 Applications", 22 | "Development Status :: 3 - Alpha", 23 | "License :: OSI Approved :: " 24 | "GNU Library or Lesser General Public License (LGPL)", 25 | "Operating System :: Microsoft :: Windows", 26 | "Programming Language :: Python :: 2.7", 27 | "Programming Language :: Python :: 3.7", 28 | ], 29 | ) 30 | 31 | -------------------------------------------------------------------------------- /breathe/elements/commands_ref.py: -------------------------------------------------------------------------------- 1 | from dragonfly import Repetition, ActionBase, Empty 2 | 3 | class CommandsRef(Repetition): 4 | """ 5 | An extra which references an arbitrary sequence of breathe CCR commands. 6 | Initially this is just a placeholder, it is populated with commands 7 | when a sub grammar is created. 8 | 9 | Adjust "max" and "min" to control how many commands may be recognised 10 | in the sequence. 11 | """ 12 | def __init__(self, name, max=4, min=1, default=None): 13 | Repetition.__init__( 14 | self, child=Empty(), min=min, max=max+1, name=name, default=default 15 | ) 16 | 17 | 18 | class Exec(ActionBase): 19 | """ 20 | Dragonfly Action which executes a sequence of commands in a top-level rule. 21 | If the sequence is optional and is omitted then this will do nothing. 22 | 23 | Use this as a template if you want to do more complex things like 24 | recording commands before execution. 25 | """ 26 | def __init__(self, name): 27 | self._name = name 28 | ActionBase.__init__(self) 29 | 30 | def _execute(self, data=None): 31 | if self._name not in data: 32 | return 33 | action_list = data[self._name] 34 | if not isinstance(action_list, list): 35 | raise TypeError 36 | for action in action_list: 37 | action.execute() 38 | -------------------------------------------------------------------------------- /tests/test_command_context.py: -------------------------------------------------------------------------------- 1 | from .testutils import DoNothing 2 | import pytest 3 | from dragonfly import get_engine, MimicFailure, AppContext 4 | from breathe import Breathe, CommandContext 5 | 6 | engine = get_engine("text") 7 | 8 | def test_manual_context(): 9 | Breathe.add_commands( 10 | CommandContext("test"), 11 | {"pizza": DoNothing(), 12 | "curry": DoNothing(), 13 | } 14 | ) 15 | # Fails because the rule isn't enabled yet 16 | with pytest.raises(MimicFailure): 17 | engine.mimic(["pizza", "pizza"]) 18 | engine.mimic(["enable", "test"]) 19 | engine.mimic(["pizza", "curry", "pizza"]) 20 | 21 | def test_manual_context_noccr(): 22 | Breathe.add_commands( 23 | CommandContext("test") | AppContext("italy"), 24 | {"spaghetti": DoNothing()}, 25 | ccr=False 26 | ) 27 | engine.mimic(["enable", "test"]) 28 | engine.mimic(["spaghetti"]) 29 | engine.mimic(["disable", "test"]) 30 | with pytest.raises(MimicFailure): 31 | engine.mimic(["spaghetti"]) 32 | engine.mimic(["pizza", "curry"]) 33 | engine.mimic(["spaghetti"], executable="italy") 34 | 35 | def test_negated_context(): 36 | Breathe.add_commands( 37 | ~(CommandContext("america") | AppContext("england")), 38 | {"steak": DoNothing(), 39 | } 40 | ) 41 | engine.mimic(["steak"]) 42 | with pytest.raises(MimicFailure): 43 | engine.mimic(["steak"], executable="england") 44 | engine.mimic(["enable", "america"]) 45 | with pytest.raises(MimicFailure): 46 | engine.mimic(["steak"]) 47 | 48 | 49 | def test_clear(): 50 | Breathe.clear() 51 | -------------------------------------------------------------------------------- /tests/test_loading.py: -------------------------------------------------------------------------------- 1 | from .testutils import DoNothing 2 | import pytest, os 3 | from dragonfly import get_engine, MimicFailure, AppContext 4 | from breathe import Breathe, CommandContext 5 | from six import PY2 6 | engine = get_engine("text") 7 | 8 | script_dir = os.path.dirname(__file__) 9 | file_path = os.path.join(script_dir, "my_grammar/fruit.py") 10 | 11 | 12 | def test_loading_failure(): 13 | test_clear() 14 | with open(file_path, "w") as f: 15 | f.write(""" 16 | from breathe import Breathe 17 | from ..testutils import DoNothing 18 | 19 | Breathe.add_commands(,,, 20 | None, 21 | { 22 | "apple": DoNothing(), 23 | } 24 | ) 25 | """ 26 | ) 27 | modules = { 28 | "tests": { 29 | "my_grammar": ["fruit"], 30 | } 31 | } 32 | Breathe.load_modules(modules) 33 | assert len(Breathe.modules) == 1 34 | assert len(Breathe.core_commands) == 0 35 | 36 | def test_loading(): 37 | with open(file_path, "w") as f: 38 | f.write(""" 39 | from breathe import Breathe 40 | from ..testutils import DoNothing 41 | 42 | Breathe.add_commands( 43 | None, 44 | { 45 | "apple": DoNothing(), 46 | } 47 | ) 48 | """ 49 | ) 50 | engine.mimic("rebuild everything test") 51 | engine.mimic("apple") 52 | 53 | def test_reloading(): 54 | with open(file_path, "w") as f: 55 | f.write(""" 56 | from breathe import Breathe 57 | from ..testutils import DoNothing 58 | 59 | Breathe.add_commands( 60 | None, 61 | { 62 | "parsnip": DoNothing(), 63 | } 64 | ) 65 | """ 66 | ) 67 | # I have no idea why this is necessary, it's a total hack 68 | if PY2: 69 | os.remove(file_path + "c") 70 | engine.mimic("rebuild everything test") 71 | with pytest.raises(MimicFailure): 72 | engine.mimic("apple") 73 | engine.mimic("parsnip") 74 | assert len(Breathe.modules) == 1 75 | 76 | 77 | def test_clear(): 78 | Breathe.clear() 79 | Breathe.modules = [] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | .vscode/ 3 | .idea/ 4 | 5 | # Vagrant 6 | .vagrant/ 7 | 8 | # Mac/OSX 9 | .DS_Store 10 | 11 | # Windows 12 | Thumbs.db 13 | 14 | # Source for the following rules: https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .nox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *.cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | .python-version 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json -------------------------------------------------------------------------------- /tests/test_top_level.py: -------------------------------------------------------------------------------- 1 | from dragonfly import ( 2 | get_engine, 3 | Dictation, 4 | IntegerRef, 5 | MimicFailure, 6 | AppContext, 7 | Choice, 8 | Repeat, 9 | ) 10 | from .testutils import DoNothing 11 | 12 | import pytest 13 | from breathe import Breathe, CommandContext 14 | from breathe.elements import CommandsRef, Exec 15 | import warnings 16 | 17 | engine = get_engine("text") 18 | 19 | 20 | def test_top_level_command(): 21 | Breathe.add_commands(None, {"orange": DoNothing(), "grapefruit": DoNothing()}) 22 | Breathe.add_commands( 23 | AppContext("notepad"), {"lemon": DoNothing(), "banana": DoNothing()} 24 | ) 25 | Breathe.add_commands( 26 | AppContext("notepad"), 27 | { 28 | "fruit from and [] []": DoNothing() 29 | + Exec("sequence1") 30 | + DoNothing() 31 | + Exec("sequence2")* Repeat("n") 32 | }, 33 | extras=[CommandsRef("sequence1"), CommandsRef("sequence2", 2), IntegerRef("n", 1, 10, 1)], 34 | top_level=True, 35 | ) 36 | 37 | def test_top_level_command2(): 38 | Breathe.add_commands( 39 | AppContext(title="chrome"), {"pear": DoNothing(), "grape": DoNothing()} 40 | ) 41 | 42 | def test_global_top_level(): 43 | Breathe.add_commands( 44 | None, 45 | { 46 | " are preferable to ": DoNothing() 47 | + Exec("sequence1") 48 | + DoNothing() 49 | + Exec("sequence2") 50 | }, 51 | extras=[CommandsRef("sequence1"), CommandsRef("sequence2", 3)], 52 | top_level=True, 53 | ) 54 | 55 | def test_recognition(): 56 | engine.mimic("lemon", executable="notepad") 57 | engine.mimic("fruit from lemon banana orange and five", executable="notepad") 58 | 59 | engine.mimic( 60 | "fruit from pear banana orange and grapefruit", 61 | executable="notepad", 62 | title="chrome", 63 | ) 64 | with pytest.raises(MimicFailure): 65 | engine.mimic( 66 | "fruit from pear banana orange and grapefruit", executable="notepad" 67 | ) 68 | 69 | engine.mimic("orange grapefruit are preferable to grapefruit") 70 | engine.mimic("orange grapefruit are preferable to lemon banana", executable="notepad") 71 | assert len(Breathe.top_level_commands) == 2 72 | 73 | def test_top_level_command_failure(): 74 | Breathe.add_commands( 75 | AppContext("china"), 76 | { 77 | "not marked top level and []": DoNothing() 78 | + Exec("sequence1") 79 | + DoNothing() 80 | + Exec("sequence2")* Repeat("n") 81 | }, 82 | extras=[CommandsRef("sequence1"), CommandsRef("sequence2", 2), IntegerRef("n", 1, 10, 1)], 83 | top_level=False, 84 | ) 85 | assert len(Breathe.top_level_commands) == 2 86 | 87 | def test_clear(): 88 | Breathe.clear() 89 | -------------------------------------------------------------------------------- /tests/test_merger.py: -------------------------------------------------------------------------------- 1 | from dragonfly import ( 2 | get_engine, 3 | Dictation, 4 | IntegerRef, 5 | MimicFailure, 6 | AppContext, 7 | Choice, 8 | Repeat, 9 | ) 10 | from .testutils import DoNothing 11 | 12 | import pytest 13 | from breathe import Breathe, CommandContext 14 | 15 | engine = get_engine("text") 16 | 17 | 18 | def test_global_extras(): 19 | Breathe.add_global_extras(Dictation("text")) 20 | assert len(Breathe.global_extras) == 1 21 | assert "text" in Breathe.global_extras 22 | Breathe.add_global_extras([Choice("abc", {"def": "ghi"})]) 23 | # Check that this is overridden 24 | Breathe.add_global_extras(IntegerRef("n", 1, 2, 1)) 25 | 26 | 27 | def test_core_commands(): 28 | Breathe.add_commands( 29 | None, 30 | { 31 | "test one": DoNothing(), 32 | "test two": DoNothing(), 33 | "test three": DoNothing(), 34 | "banana []": DoNothing() * Repeat("n"), 35 | }, 36 | [IntegerRef("n", 1, 10, 1)], 37 | ) 38 | engine.mimic(["test", "three", "test", "two", "banana", "five"]) 39 | 40 | 41 | def test_context_commands(): 42 | Breathe.add_commands( 43 | AppContext("notepad"), 44 | {"test []": lambda num: DoNothing().execute()}, 45 | [Choice("num", {"four": "4", "five": "5", "six": "6"})], 46 | {"num": ""}, 47 | ) 48 | with pytest.raises(MimicFailure): 49 | engine.mimic(["test", "three", "test", "four"]) 50 | engine.mimic(["test", "three", "test", "four"], executable="notepad") 51 | 52 | 53 | def test_noccr_commands(): 54 | Breathe.add_commands( 55 | AppContext("firefox"), 56 | {"dictation ": DoNothing(), "testing static": DoNothing()}, 57 | ccr=False, 58 | ) 59 | engine.mimic(["testing", "static"], executable="firefox") 60 | with pytest.raises(MimicFailure): 61 | engine.mimic(["dictation", "TESTING"]) 62 | engine.mimic(["testing", "static", "testing", "static"], executable="firefox") 63 | engine.mimic(["dictation", "TESTING"], executable="firefox") 64 | 65 | 66 | def test_grammar_numbers(): 67 | engine.mimic(["test", "three"]) 68 | # Ensure that we are not adding more grammars than necessary 69 | assert len(engine.grammars) == 4 70 | 71 | 72 | def test_nomapping_commands(): 73 | Breathe.add_commands(AppContext("code.exe"), {}) 74 | 75 | 76 | def test_invalid(): 77 | Breathe.add_commands( 78 | AppContext("code.exe"), 79 | { 80 | "test that ": DoNothing(), 81 | 1: DoNothing(), 82 | }, 83 | ) 84 | assert len(Breathe.contexts) == 1 85 | assert len(Breathe.context_commands) == 1 86 | 87 | 88 | def test_kaldi_weight_passthrough(): 89 | Breathe.add_commands( 90 | None, 91 | { 92 | "test weight": DoNothing(), 93 | }, 94 | weight=10.0, 95 | ) 96 | assert Breathe.core_commands[-1].weight == 10.0 97 | 98 | # This should probably be done as set up and tear down, eh 99 | def test_clear(): 100 | Breathe.clear() 101 | -------------------------------------------------------------------------------- /breathe/grammar/helpers.py: -------------------------------------------------------------------------------- 1 | from dragonfly import ElementBase, Function, Repetition 2 | from ..elements import BoundCompound, CommandContext, CommandsRef 3 | from six import string_types, PY2 4 | import sys 5 | import importlib 6 | import logging 7 | import traceback 8 | 9 | logger = logging.getLogger("breathe_master") 10 | 11 | def construct_extras(extras=None, defaults=None, global_extras=None, top_level=False): 12 | """ 13 | Takes a list of extras provided by the user, and merges it with all global 14 | extras to produce the {name: extra} dictionary that dragonfly expects. 15 | 16 | In naming conflicts global extras will always be overridden, otherwise 17 | the later extra will win. 18 | """ 19 | full_extras = global_extras.copy() if global_extras else {} 20 | defaults = defaults or {} 21 | if not extras: 22 | return full_extras 23 | assert isinstance(extras, (list, tuple)) 24 | assert isinstance(defaults, dict) 25 | 26 | for e in extras: 27 | assert isinstance(e, ElementBase) 28 | if not top_level and isinstance(e, CommandsRef): 29 | # Trying to add top level commands amongst normal CCR commands 30 | # seems like a likely mistake so it needs to fail gracefully. 31 | msg = "Attempting to use '%s' in commands which are not" \ 32 | "marked as top level. Separate these commands from normal commands" \ 33 | "and add them using 'Breathe.add_commands(..., top_level=True)'." 34 | logger.error(msg, e) 35 | continue 36 | if not e.has_default() and e.name in defaults: 37 | e._default = defaults[e.name] 38 | full_extras[e.name] = e 39 | return full_extras 40 | 41 | 42 | def construct_commands(mapping, extras=None): 43 | """ 44 | Constructs a list of BoundCompound objects from a mapping and an 45 | extras dict. 46 | 47 | Also automatically converts all callables to dragonfly Function objects, 48 | allowing e.g. 49 | 50 | mapping = {"foo []": lambda n: foo(n),} 51 | """ 52 | children = [] 53 | assert isinstance(mapping, dict) 54 | for spec, value in mapping.items(): 55 | if callable(value): 56 | value = Function(value) 57 | try: 58 | assert isinstance(spec, string_types) 59 | c = BoundCompound(spec, extras=extras, value=value) 60 | children.append(c) 61 | except Exception as e: 62 | logger.error("Exception raised while processing '%s', command will be skipped.", spec) 63 | logger.exception(e) 64 | return children 65 | 66 | 67 | def process_top_level_commands(command_lists, alts): 68 | """ 69 | Once we have begun creating a new subgrammar, we have access to 70 | all of the ccr commands which will be active in this context (alts). 71 | 72 | We now use these to replace the CommandsRef placeholders with 73 | Repetition(alts) in a new extras dict, and recreate the top level 74 | commands using this dict. 75 | """ 76 | commands = [] 77 | for command_list in command_lists: 78 | new_extras = {} 79 | for n, e in command_list[0]._extras.items(): 80 | if isinstance(e, CommandsRef): 81 | new_extras[n] = Repetition(alts, e.min, e.max, e.name, e.default) 82 | else: 83 | new_extras[n] = e 84 | new_command_list = [ 85 | BoundCompound(c._spec, new_extras, value=c._value) 86 | for c in command_list 87 | ] 88 | commands.extend(new_command_list) 89 | return commands 90 | 91 | 92 | def check_for_manuals(context, command_dictlist): 93 | """ 94 | Slightly horrible recursive function which handles the adding of command contexts. 95 | 96 | If we haven't seen it before, we need to add the name of the context to our DictList 97 | so it can be accessed by the "enable" command. 98 | 99 | If we have seen it before, we need to ensure that there is only the one command 100 | context object being referenced from multiple rules, rather than one for each. 101 | 102 | This has to be done not only for CommandContext objects but also for ones 103 | embedded in the children of an e.g. LogicOrContext. 104 | """ 105 | if isinstance(context, CommandContext): 106 | if context.name in command_dictlist: 107 | context = command_dictlist[context.name] 108 | else: 109 | command_dictlist[context.name] = context 110 | elif hasattr(context, "_children"): 111 | new_children = [ 112 | check_for_manuals(c, command_dictlist) for c in context._children 113 | ] 114 | context._children = tuple(new_children) 115 | elif hasattr(context, "_child"): 116 | context._child = check_for_manuals(context._child, command_dictlist) 117 | return context 118 | 119 | 120 | def load_or_reload(module_name): 121 | try: 122 | if module_name not in sys.modules: 123 | importlib.import_module(module_name) 124 | else: 125 | module = sys.modules[module_name] 126 | if PY2: 127 | reload(module) 128 | else: 129 | importlib.reload(module) 130 | except Exception as e: 131 | logger.error("Import of '%s' failed with", module_name) 132 | logger.exception(e) 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Breathe 2 | [![Build Status](https://travis-ci.org/mrob95/Breathe.svg?branch=master)](https://travis-ci.org/mrob95/Breathe) [![codecov](https://codecov.io/gh/mrob95/Breathe/branch/master/graph/badge.svg)](https://codecov.io/gh/mrob95/Breathe) 3 | 4 | A convenient API for creating [dragonfly](https://github.com/dictation-toolbox/dragonfly) grammars with automatic CCR (continuous command recognition). 5 | 6 | * Very quick start-up 7 | * Command activity can be controlled either using dragonfly contexts or using "enable" and "disable" commands. 8 | * All commands which match the current context may be chained together in any order in the same utterance. 9 | 10 | 11 | ## Installation 12 | ``` 13 | pip install dfly-breathe 14 | ``` 15 | 16 | ## Instructions 17 | * If you are creating a command set from scratch, start by cloning the 18 | [Breathe skeleton project](https://github.com/mrob95/breathe_skeleton), 19 | which will give you a file structure to start with. 20 | 21 | ### Adding commands 22 | 23 | ```python 24 | from dragonfly import * 25 | from breathe import Breathe, CommandContext 26 | 27 | Breathe.add_commands( 28 | # Commands will be active either when we are editing a python file 29 | # or after we say "enable python". pass None for the commands to be global. 30 | context = AppContext(title=".py") | CommandContext("python"), 31 | mapping = { 32 | "for each" : Text("for in :") + Key("left:5"), 33 | "for loop" : Text("for i in range():") + Key("left:2"), 34 | "from import" : Text("from import ") + Key("home, right:5"), 35 | "function" : Text("def ():") + Key("left:3"), 36 | "(iffae | iffy)" : Text("if :") + Key("left"), 37 | "iffae not" : Text("if not :") + Key("left"), 38 | "import" : Text("import "), 39 | "lambda" : Text("lambda :") + Key("left"), 40 | "while loop" : Text("while :") + Key("left"), 41 | "shell iffae" : Text("elif :") + Key("left"), 42 | "shells" : Text("else:"), 43 | "return" : Text("return "), 44 | # ------------------------------------------------ 45 | "method " : Text("def %(snaketext)s(self):") + Key("left:2"), 46 | "function []": Text("def %(snaketext)s():") + Key("left:2"), 47 | "selfie []" : Text("self.%(snaketext)s"), 48 | "pointer []" : Text(".%(snaketext)s"), 49 | "classy []" : Text("class %(classtext)s:") + Key("left"), 50 | }, 51 | extras = [ 52 | Dictation("snaketext", default="").lower().replace(" ", "_"), 53 | Dictation("classtext", default="").title().replace(" ", ""), 54 | ] 55 | ) 56 | ``` 57 | 58 | For full details of the available contexts, actions and extras you can use, see the [dragonfly documentation](https://dragonfly.readthedocs.io/en/latest/). 59 | 60 | ### Loading command files 61 | Breathe provides the command "rebuild everything" for reloading all of your commands, 62 | allowing you to modify commands without restarting the engine. In order for this to work, 63 | your command files need to be loaded by giving your directory structure to 64 | `Breathe.load_modules()`. 65 | 66 | For example, given a directory set up like this: 67 | ``` 68 | | _main.py 69 | | __init__.py 70 | +---my_commands 71 | | | __init__.py 72 | | +---apps 73 | | | chrome.py 74 | | | notepad.py 75 | | | __init__.py 76 | | +---core 77 | | | alphabet.py 78 | | | keys.py 79 | | | __init__.py 80 | | +---language 81 | | | c.py 82 | | | python.py 83 | | | __init__.py 84 | ``` 85 | 86 | Inside `_main.py`, the file which will be loaded by the engine, we load all of our command 87 | files by passing a dictionary with keys representing folder names and values being either a 88 | single module to import, a list of modules to import, or another dictionary. Like so: 89 | ```python 90 | from breathe import Breathe 91 | 92 | Breathe.load_modules( 93 | { 94 | "my_commands": { 95 | "apps": ["chrome", "notepad"], 96 | "language": ["python", "c"], 97 | "core": ["keys", "alphabet"], 98 | } 99 | } 100 | ) 101 | ``` 102 | 103 | Given this setup, calling the "rebuild everything" command will reload all of your command 104 | files, making any changes available. 105 | 106 | ### Custom top level commands 107 | **Advanced feature, if you are just getting started you should ignore this.** 108 | 109 | Top level commands allow you to embed sequences of breathe 110 | CCR commands inside other commands. This gives finer control over 111 | the way in which commands are recognised and executed. 112 | 113 | Top level commands should be added in a separate `add_commands` call 114 | with the `top_level` option set to `True`. A couple of new elements - 115 | `Exec` and `CommandsRef` - are required to control them. 116 | 117 | For example in the following, 118 | the first command implements "greedy" dictation by creating 119 | a top level command which recognises between zero and twelve of the commands 120 | which are active in the current context, followed by a dictation command 121 | which will consume the rest of the utterance. The second allows an arbitrary sequence of commands to be repeated a 122 | given number of times. 123 | 124 | ```python 125 | from dragonfly import * 126 | from breathe import Breathe, CommandsRef, Exec 127 | 128 | Breathe.add_commands( 129 | None, 130 | { 131 | "[] dictate ": 132 | Exec("sequence_of_commands") + Text("%(text)s"), 133 | " and repeat that times": 134 | Exec("sequence_of_commands") * Repeat("n"), 135 | }, 136 | [ 137 | Dictation("text"), 138 | IntegerRef("n", 1, 100), 139 | CommandsRef("sequence_of_commands", 12) 140 | ], 141 | top_level=True 142 | ) 143 | ``` 144 | 145 | ## Examples 146 | * [My commands](https://github.com/mrob95/MR-commands) 147 | * [Mathfly](https://github.com/mrob95/mathfly) 148 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /breathe/grammar/master.py: -------------------------------------------------------------------------------- 1 | from dragonfly import ( 2 | Alternative, 3 | Context, 4 | DictList, 5 | ElementBase, 6 | Function, 7 | Grammar, 8 | Repetition, 9 | ) 10 | from .subgrammar import SubGrammar 11 | from .helpers import ( 12 | construct_commands, 13 | construct_extras, 14 | check_for_manuals, 15 | load_or_reload, 16 | process_top_level_commands 17 | ) 18 | from ..rules import SimpleRule, ContextSwitcher 19 | from ..elements import BoundCompound, CommandContext, TrueContext, CommandsRef 20 | 21 | from six import string_types 22 | import os, time 23 | import logging 24 | 25 | """ 26 | Example: 27 | 28 | We have added a set of core commands (context=None) and two sets of context commands, 29 | all ccr. This produces a list of core commands, a list of lists of context commands, 30 | and a list of contexts. 31 | 32 | self.core_commands = [BoundCompound(...), ...] 33 | self.context_commands = [[BoundCompound(...), ...], [...]] 34 | self.contexts = [AppContext("notepad"), AppContext("chrome")] 35 | 36 | We now start an utterance in notepad. process_begin is called, context matches are 37 | (True, False). We look this up in the grammar map and since we haven't seen this 38 | combination of contexts before, we need to add a new grammar for it. We combine the 39 | core commands with the notepad command list from context_commands, create a repeat 40 | rule and load it in a new sub grammar. We add this subgrammar to the grammar map 41 | so that we never need to create it again. We continue in this way, adding subgrammars 42 | on-the-fly for whatever combination of contexts comes up. 43 | """ 44 | 45 | 46 | class Master(Grammar): 47 | 48 | MAX_REPETITIONS = 16 49 | 50 | def __init__(self, **kwargs): 51 | Grammar.__init__(self, "Merger", context=None, **kwargs) 52 | 53 | self._log = logging.getLogger("breathe_master") 54 | 55 | self.count = 0 56 | # List[Compound] 57 | self.core_commands = [] 58 | # List[List[Compound]] 59 | self.context_commands = [] 60 | # List[Context] 61 | self.contexts = [] 62 | 63 | self.top_level_commands = [] 64 | self.top_level_contexts = [] 65 | 66 | # Dict[Tuple[bool], SubGrammar] 67 | # Key of dictionary is the contexts the rule matched 68 | self.grammar_map = {} 69 | # List[Grammar] 70 | self.non_ccr_grammars = [] 71 | 72 | # Dict[str, ElementBase] 73 | self.global_extras = {} 74 | 75 | # List[str] - module names 76 | self.modules = [] 77 | 78 | self.add_builtin_rules() 79 | self.load() 80 | 81 | # ------------------------------------------------ 82 | # API 83 | 84 | def add_commands( 85 | self, 86 | context=None, 87 | mapping=None, 88 | extras=None, 89 | defaults=None, 90 | ccr=True, 91 | top_level=False, 92 | weight=None, 93 | ): 94 | """Add a set of commands which can be recognised continuously. 95 | 96 | Keyword Arguments: 97 | context (Context) -- Context in which these commands will be active, if None, commands will be global (default: None) 98 | mapping (dict) -- Dictionary of rule specs to dragonfly Actions (default: None) 99 | extras (list) -- Extras which will be available for these commands (default: None) 100 | defaults (dict) -- Defaults for the extras, if necessary (default: None) 101 | ccr (bool) -- Whether these commands should be recognised continuously (default: True) 102 | top_level (bool) -- Whether these commands our top level, referencing sequences of normal commands (default: False) 103 | weight (float) -- Kaldi only. The recognition weight assigned to a group of commands (default None (kaldi default is 1.0)) 104 | """ 105 | if not (context is None or isinstance(context, Context)): 106 | self._log.error("Context must be None or dragonfly Context subclass, not '%s'", str(context)) 107 | return 108 | full_extras = construct_extras(extras, defaults, self.global_extras, top_level) 109 | children = construct_commands(mapping, full_extras) 110 | if not children: 111 | return 112 | 113 | if context is not None: 114 | context = check_for_manuals( 115 | context, self.command_context_dictlist 116 | ) 117 | 118 | if weight is not None: 119 | for c in children: 120 | c.weight = float(weight) 121 | 122 | if not top_level: 123 | if not ccr: 124 | rule = SimpleRule(element=Alternative(children), context=context) 125 | grammar = Grammar("NonCCR" + self.counter()) 126 | grammar.add_rule(rule) 127 | grammar.load() 128 | self.non_ccr_grammars.append(grammar) 129 | elif context is None: 130 | self.core_commands.extend(children) 131 | else: 132 | self.context_commands.append(children) 133 | self.contexts.append(context) 134 | self._pad_matches() 135 | else: 136 | if context is None: 137 | context = TrueContext() 138 | self.top_level_commands.append(children) 139 | self.top_level_contexts.append(context) 140 | 141 | def add_global_extras(self, *extras): 142 | """ 143 | Global extras will be available to all commands, 144 | but must be added before the commands which use them. 145 | 146 | Defaults should be assigned on the extras themselves. 147 | """ 148 | if len(extras) == 1 and isinstance(extras[0], list): 149 | extras = extras[0] 150 | for e in extras: 151 | assert isinstance(e, ElementBase) 152 | self.global_extras[e.name] = e 153 | 154 | def load_modules(self, modules, namespace=""): 155 | """ 156 | Loads a set of modules into breathe, and makes them available for reloading 157 | using the "rebuild everything" command. 158 | 159 | Modules should be passed as a dictionary, with keys representing folder names 160 | and values being either a single module to import, a list of modules to import, 161 | or another dictionary. These can be nested arbitrarily deep. e.g. 162 | 163 | Breathe.load_modules( 164 | { 165 | "my_commands": { 166 | "apps": ["chrome", "notepad"], 167 | "language": ["python", "c"], 168 | "core": ["keys", "alphabet"], 169 | } 170 | } 171 | ) 172 | 173 | Called from _main.py in a folder structure that looks like: 174 | | _main.py 175 | | __init__.py 176 | +---my_commands 177 | | | __init__.py 178 | | +---apps 179 | | | chrome.py 180 | | | notepad.py 181 | | | __init__.py 182 | | +---core 183 | | | alphabet.py 184 | | | keys.py 185 | | | __init__.py 186 | | +---language 187 | | | c.py 188 | | | python.py 189 | | | __init__.py 190 | """ 191 | if isinstance(modules, dict): 192 | for k, v in modules.items(): 193 | deeper_namespace = "%s.%s" % (namespace, k) if namespace else k 194 | self.load_modules(v, deeper_namespace) 195 | elif isinstance(modules, list): 196 | for module in modules: 197 | self.load_modules(module, namespace) 198 | elif isinstance(modules, string_types): 199 | module_name = "%s.%s" % (namespace, modules) if namespace else modules 200 | self.modules.append(module_name) 201 | print("Loading module %s" % module_name) 202 | load_or_reload(module_name) 203 | 204 | # ------------------------------------------------ 205 | # Loading helpers 206 | def reload_modules(self): 207 | """ 208 | Reload all modules loaded using load_modules. 209 | """ 210 | if not self.modules: 211 | self._log.warning("Nothing found to reload. For modules to be reloadable they must be loaded using 'Breathe.load_modules()'") 212 | else: 213 | self.clear() 214 | for module_name in self.modules: 215 | load_or_reload(module_name) 216 | print("All modules reloaded.") 217 | 218 | def clear(self): 219 | """ 220 | Removes all added rules, unloads all grammars, etc. 221 | """ 222 | self.core_commands = [] 223 | self.context_commands = [] 224 | self.contexts = [] 225 | self.top_level_commands = [] 226 | self.top_level_contexts = [] 227 | for subgrammar in self.grammar_map.values(): 228 | subgrammar.unload() 229 | for grammar in self.non_ccr_grammars: 230 | grammar.unload() 231 | self.grammar_map = {} 232 | self.non_ccr_grammars = [] 233 | self.global_extras = {} 234 | self.command_context_dictlist.clear() 235 | self.count = 0 236 | 237 | def counter(self): 238 | """ 239 | Generate numbers for unique naming of rules and grammars 240 | """ 241 | self.count += 1 242 | return str(self.count) 243 | 244 | def _pad_matches(self): 245 | """ 246 | If a new context is added after we have already started creating subgrammars, 247 | then to avoid pointlessly recreating them we use the already existing grammars 248 | when the new context is inactive. 249 | """ 250 | for k, v in self.grammar_map.copy().items(): 251 | matches = tuple(list(k) + [False]) 252 | if matches not in self.grammar_map: 253 | self.grammar_map[matches] = v 254 | 255 | def add_builtin_rules(self): 256 | # The DictList makes it easy to add new mappings from command context names 257 | # which will be recognised by the "enable/disable" command to the contexts themselves 258 | self.command_context_dictlist = DictList( 259 | "manual_contexts" 260 | ) 261 | self.add_rule(ContextSwitcher(self.command_context_dictlist)) 262 | rebuild_command = os.getenv("BREATHE_REBUILD_COMMAND") or "rebuild everything" 263 | self.add_rule( 264 | SimpleRule( 265 | name="rebuilder", 266 | element=BoundCompound( 267 | rebuild_command, value=Function(lambda: self.reload_modules()) 268 | ), 269 | ) 270 | ) 271 | 272 | # ------------------------------------------------ 273 | # Runtime grammar management 274 | 275 | def _add_repeater(self, matches, top_level_matches): 276 | """ 277 | Takes a tuple of bools, corresponding to which contexts were matched, 278 | and loads a SubGrammar containing a RepeatRule with all relevant commands in. 279 | """ 280 | matched_commands = [] 281 | for command_list in [l for (l, b) in zip(self.context_commands, matches) if b]: 282 | matched_commands.extend(command_list) 283 | matched_commands.extend(self.core_commands) 284 | if not matched_commands: 285 | return 286 | alts = Alternative(matched_commands) 287 | repeater = SimpleRule( 288 | name="Repeater%s" % self.counter(), 289 | element=Repetition(alts, min=1, max=self.MAX_REPETITIONS), 290 | context=None, 291 | ) 292 | subgrammar = SubGrammar("SG%s" % self.counter()) 293 | subgrammar.add_rule(repeater) 294 | 295 | if top_level_matches: 296 | command_lists = [ 297 | l for (l, b) in zip(self.top_level_commands, top_level_matches) if b 298 | ] 299 | matched_top_level_commands = process_top_level_commands(command_lists, alts) 300 | top_level_rules = SimpleRule( 301 | name="TopLevel%s" % self.counter(), 302 | element=Alternative(matched_top_level_commands), 303 | ) 304 | subgrammar.add_rule(top_level_rules) 305 | 306 | subgrammar.load() 307 | self.grammar_map[matches] = subgrammar 308 | 309 | def process_begin(self, executable, title, handle): 310 | """ 311 | Check which of our contexts the current window matches and look this up in our grammar map. 312 | 313 | If we haven't seen this combination before, add a new subgrammar for it. 314 | 315 | Enable the subgrammar which matches the window, and disable all others. 316 | """ 317 | active_contexts = tuple( 318 | [c.matches(executable, title, handle) for c in self.contexts] 319 | ) 320 | 321 | if active_contexts not in self.grammar_map: 322 | matched_top_level_contexts = [ 323 | c.matches(executable, title, handle) for c in self.top_level_contexts 324 | ] 325 | self._add_repeater(active_contexts, matched_top_level_contexts) 326 | 327 | for contexts, subgrammar in self.grammar_map.items(): 328 | if active_contexts == contexts: 329 | subgrammar.enable() 330 | else: 331 | subgrammar.disable() 332 | subgrammar._process_begin() --------------------------------------------------------------------------------