Skip to content

Commit

Permalink
Add filewatching support for templates
Browse files Browse the repository at this point in the history
For now only supports the following actions:
* `compile`
* `run`
But not `import_context`
  • Loading branch information
JakobGM committed Feb 14, 2018
1 parent c045b39 commit 7430765
Show file tree
Hide file tree
Showing 7 changed files with 251 additions and 23 deletions.
5 changes: 3 additions & 2 deletions astrality/filewatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ def start(self) -> None:

def stop(self) -> None:
"""Stop watching the directory."""
self.observer.stop()
self.observer.join()
if self.observer.is_alive():
self.observer.stop()
self.observer.join()


class DirectoryEventHandler(FileSystemEventHandler):
Expand Down
106 changes: 89 additions & 17 deletions astrality/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from astrality import compiler
from astrality.compiler import context
from astrality.config import ApplicationConfig, insert_into
from astrality.filewatcher import DirectoryWatcher
from astrality.timer import Timer, timer_factory
from astrality.utils import run_shell

Expand Down Expand Up @@ -192,6 +193,24 @@ def exit_commands(self) -> Tuple[str, ...]:
in exit_commands
)

def modified_commands(self, template_name: str) -> Tuple[str, ...]:
"""Commands to be run when a module template is modified."""
modified_commands: List[str] = self.module_config.get(
'on_modified',
{},
).get(template_name, {}).get('run', [])

if len(modified_commands) == 0:
logger.debug(f'[module/{self.name}] No modified command specified.')
return ()
else:
return tuple(
self.interpolate_string(command)
for command
in modified_commands
)


def context_section_imports(
self,
trigger: str,
Expand Down Expand Up @@ -288,6 +307,10 @@ def __init__(self, config: ApplicationConfig) -> None:
self.startup_done = False
self.last_module_periods: Dict[str, str] = {}

# Create a dictionary containing all managed templates, mapping to
# the tuple (module, template_shortname)
self.managed_templates: Dict[Path, Tuple[Module, str]] = {}

for section, options in config.items():
module_config = {section: options}
if Module.valid_class_section(module_config):
Expand All @@ -298,6 +321,17 @@ def __init__(self, config: ApplicationConfig) -> None:
)
self.modules[module.name] = module

# Insert the modules templates into the template Path map
for shortname, template in module.templates.items():
self.managed_templates[template['source']] = (module, shortname)


# Initialize the config directory watcher, but don't start it yet
self.directory_watcher = DirectoryWatcher(
directory=self.application_config['_runtime']['config_directory'],
on_modified=self.modified,
)

def __len__(self) -> int:
"""Return the number of managed modules."""
return len(self.modules)
Expand Down Expand Up @@ -378,23 +412,31 @@ def compile_templates(self, trigger: str) -> None:
assert trigger in ('on_startup', 'on_period_change', 'on_exit',)

for module in self.modules.values():
for compilation in module.module_config.get(trigger, {}).get('compile', []):

# What to compile is given by [module.]template_name
*_module, template_name = compilation.split('.')
if len(_module) == 1:
# Explicit module has been specified
module_name = _module[0]
else:
# No module has been specified, use the module itself
module_name = module.name

compiler.compile_template( # type: ignore
template=self.modules[module_name].templates[template_name]['source'],
target=self.modules[module_name].templates[template_name]['target'],
context=self.application_context,
shell_command_working_directory=self.application_config['_runtime']['config_directory'],
)
for shortname in module.module_config.get(trigger, {}).get('compile', []):
self.compile_template(module=module, shortname=shortname)


def compile_template(self, module: Module, shortname: str) -> None:
"""
Compile a single template given by its shortname.
A shortname is given either by shortname, implying that the module given
defines that template, or by module_name.shortname, making it explicit.
"""
*_module, template_name = shortname.split('.')
if len(_module) == 1:
# Explicit module has been specified
module_name = _module[0]
else:
# No module has been specified, use the module itself
module_name = module.name

compiler.compile_template( # type: ignore
template=self.modules[module_name].templates[template_name]['source'],
target=self.modules[module_name].templates[template_name]['target'],
context=self.application_context,
shell_command_working_directory=self.application_config['_runtime']['config_directory'],
)

def startup(self):
"""Run all startup commands specified by the managed modules."""
Expand All @@ -406,6 +448,9 @@ def startup(self):

self.startup_done = True

# Start watching config directory for file changes
self.directory_watcher.start()

def period_change(self):
"""Run all period change commands specified by the managed modules."""
for module in self.modules.values():
Expand All @@ -432,6 +477,33 @@ def exit(self):
for temp_file in module.temp_files:
temp_file.close()

# Stop watching config directory for file changes
self.directory_watcher.stop()

def modified(self, modified: Path):
"""
Callback for when files within the config directory are modified.
Run any context imports, compilations, and shell commands specified
within the on_modified event block of each module.
"""
if not modified in self.managed_templates:
# The modified file is not specified in any of the modules
return

module, template_shortname = self.managed_templates[modified]

if template_shortname in module.module_config.get('on_modified', {}):
for shortname in module.module_config['on_modified'][template_shortname].get('compile', []):
self.compile_template(
shortname=shortname,
module=module,
)

modified_commands = module.modified_commands(template_shortname)
for command in modified_commands:
logger.info(f'[module/{module.name}] Running modified command.')
self.run_shell(command, module.name)

def run_shell(self, command: str, module_name: str) -> None:
"""Run a shell command defined by a managed module."""
Expand Down
Empty file.
137 changes: 136 additions & 1 deletion astrality/tests/test_module.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta
import logging
import os
import time
from datetime import datetime, timedelta
from pathlib import Path

from freezegun import freeze_time
Expand Down Expand Up @@ -789,3 +790,137 @@ def test_that_shell_filter_is_run_from_config_directory(conf_path):
assert compiled.read() == str(conf_path)

os.remove(shell_filter_template_target)


@pytest.yield_fixture
def modules_config(conf_path):
empty_template = Path(__file__).parent / 'templates' / 'empty.template'
empty_template_target = Path('/tmp/astrality/empty_temp_template')
temp_directory = Path('/tmp/astrality')
touch_target = temp_directory / 'touched'

secondary_template = Path(__file__).parent / 'templates' / 'no_context.template'
secondary_template_target = temp_directory / 'secondary_template.tmp'

config = {
'module/A': {
'templates': {
'template1': {
'source': str(empty_template),
'target': str(empty_template_target),
},
},
'on_modified': {
'template1': {
'compile': ['template1', 'B.template1'],
'run': ['touch ' + str(touch_target)],
},
},
},
'module/B': {
'templates': {
'template1': {
'source': str(secondary_template),
'target': str(secondary_template_target),
},
},
},
'_runtime': {
'config_directory': Path(__file__).parent,
'temp_directory': temp_directory,
}
}
yield (
config,
empty_template,
empty_template_target,
touch_target,
secondary_template,
secondary_template_target,
)

# Cleanup all test files for next test iteration
if empty_template.is_file():
empty_template.write_text('')
if empty_template_target.is_file():
os.remove(empty_template_target)
if secondary_template_target.is_file():
os.remove(secondary_template_target)
if touch_target.is_file():
os.remove(touch_target)

class TestModuleFileWatching:
def test_modified_commands_of_module(self, modules_config):
config, empty_template, empty_template_target, touch_target, *_= modules_config
module_manager = ModuleManager(config)
assert module_manager.modules['A'].modified_commands('template1') == \
('touch ' + str(touch_target), )

def test_direct_invocation_of_modifed_method_of_module_manager(self, modules_config):
(
config,
empty_template,
empty_template_target,
touch_target,
secondary_template,
secondary_template_target,
) = modules_config
module_manager = ModuleManager(config)

# PS: Disabling the directory watcher is not necessary, as it is done in
# the startup method.

# Now write new text to the template
empty_template.write_text('new content')

# And trigger the modified method manually
module_manager.modified(empty_template)

# And assert that the new template has been compiled
assert empty_template_target.is_file()
with open(empty_template_target) as file:
assert file.read() == 'new content'

# And that the new file has been touched
assert touch_target.is_file()

def test_on_modified_event_in_module(self, modules_config):
(
config,
empty_template,
empty_template_target,
touch_target,
secondary_template,
secondary_template_target,
) = modules_config
module_manager = ModuleManager(config)

# Start the file watcher by invoking the startup command indirectly
# through finish_tasks() method
module_manager.finish_tasks()

# Assert that the template file is really empty as a sanity check
with open(empty_template) as file:
assert file.read() == ''

# And that target files are not created yet
assert not touch_target.is_file()
assert not empty_template_target.is_file()
assert not secondary_template_target.is_file()

# Trigger the on_modified event
empty_template.write_text('new content')
time.sleep(1)

# And assert that the new template has been compiled
assert empty_template_target.is_file()
with open(empty_template_target) as file:
assert file.read() == 'new content'

# Assert that also templates from other modules are compiled
assert secondary_template_target.is_file()
with open(secondary_template_target) as file:
assert file.read() == 'one\ntwo\nthree'

# And that the new file has been touched
assert touch_target.is_file()
21 changes: 20 additions & 1 deletion docs/modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ An example of module templates syntax:
Events
======

When you want to assign :ref:`tasks <actions>` for Astrality to perform, you have to define *when* to perform them. This is done by defining those ``actions`` in one of three available ``event`` blocks.
When you want to assign :ref:`tasks <actions>` for Astrality to perform, you have to define *when* to perform them. This is done by defining those ``actions`` in one of four available ``event`` blocks.

``on_startup``:
Tasks to be performed when Astrality first starts up.
Expand All @@ -101,11 +101,26 @@ When you want to assign :ref:`tasks <actions>` for Astrality to perform, you hav
This event will never be triggered when no module timer is defined.
More on timers follows in :ref:`the next section <timers>`.

``on_modified``:
Tasks to be performed when specific templates are modified on disk.
You specify a set of tasks to performed on a *per-template-basis*.
Useful for quick feedback when editing template files.

.. caution::
Only templates within ``$ASTRALITY_CONFIG_HOME/**/*`` are observed for modifications.
Also, :ref:`context imports <context_import_action>` are currently not supported in ``on_modified`` event blocks.

If any of this is a use case for you, please open an `issue <https://github.com/jakobgm/astrality/issues>`_!

Example of module event blocks:

.. code-block:: yaml
module/module_name:
templates:
some_template:
source: 'templates/some.template'
on_startup:
...startup actions...
Expand All @@ -115,6 +130,10 @@ Example of module event blocks:
on_exit:
...shutdow actions...
on_modified:
some_template:
...some_template modified actions...
.. note::
On Astrality startup, the ``on_startup`` event will be triggered, but **not** ``on_period_change``. The ``on_period_change`` event will only be triggered when the ``timer`` defined ``period`` changes *after* Astrality startup.

Expand Down
2 changes: 1 addition & 1 deletion docs/templating.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ You should now be able to insert context values into your templates. You can ref
home-directory = /home/{{ machine.user }}
machine-name = {{ machine.hostname }}
When Astrality :ref:`compiles your template <_template_how_to_compile>` the result would be:
When Astrality :ref:`compiles your template <template_how_to_compile>` the result would be:

.. code-block:: dosini
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ def readme():

setup(
name='astrality',
version='0.1',
version='0.2',
packages=find_packages(),
install_requires=[
'astral',
'pyyaml',
'Jinja2',
'watchdog',
],
python_requires='>=3.6',
scripts=['bin/astrality'],
Expand Down

0 comments on commit 7430765

Please sign in to comment.