Skip to content

Commit

Permalink
Add module requires option
Browse files Browse the repository at this point in the history
  • Loading branch information
JakobGM committed Feb 16, 2018
1 parent 479ca14 commit 0ed7b58
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 28 deletions.
1 change: 1 addition & 0 deletions astrality/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
'hot_reload': False,
'startup_delay': 0,
'run_timeout': 0,
'requires_timeout': 1,
}}


Expand Down
6 changes: 6 additions & 0 deletions astrality/config/astrality.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ settings/astrality:
# You can set the number of seconds Astrality waits for the shell commands to exit.
run_timeout: 0

# Modules can require successfull shell commands (non-zero exit codes) in order
# to be enabled. You can specify the timeout for such checks, given in seconds.
requires_timeout: 1


module/wallpaper:
# A module for changing your wallpaper based on the suns position in the sky.
enabled: true
requires: command -v feh

timer:
# The solar timer provides the following periods:
Expand Down Expand Up @@ -49,6 +54,7 @@ module/wallpaper:

module/conky:
enabled: true
requires: command -v conky

templates:
time:
Expand Down
44 changes: 32 additions & 12 deletions astrality/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,11 @@ def interpolate_string(self, string: str) -> str:
return string

@staticmethod
def valid_class_section(section: ModuleConfig) -> bool:
def valid_class_section(
section: ModuleConfig,
requires_timeout: Union[int, float],
requires_working_directory: Path,
) -> bool:
"""Check if the given dict represents a valid enabled module."""

if not len(section) == 1:
Expand All @@ -352,19 +356,32 @@ def valid_class_section(section: ModuleConfig) -> bool:
module_name = next(iter(section.keys()))
valid_module_name = module_name.split('/')[0] == 'module' # type: ignore
enabled = section[module_name].get('enabled', True)
enabled = enabled not in (
'false',
'off',
'disabled',
'not',
'0',
False,
)
if not (valid_module_name and enabled):
return False

return valid_module_name and enabled
except KeyError:
return False

# The module is enabled, now check if all requirements are satisfied
requires = section[module_name].get('requires')
if not requires:
return True
else:
if isinstance(requires, str):
requires = [requires]

for requirement in requires:
if run_shell(command=requirement, fallback=False) is False:
logger.warning(
f'[{module_name}] Module does not satisfy requirement "{requirement}".',
)
return False

logger.info(
f'[{module_name}] Module satisfies all requirements.'
)
return True

class ModuleManager:
"""A manager for operating on a set of modules."""

Expand All @@ -381,7 +398,11 @@ def __init__(self, config: ApplicationConfig) -> None:

for section, options in config.items():
module_config = {section: options}
if Module.valid_class_section(module_config):
if Module.valid_class_section(
section=module_config,
requires_timeout=self.application_config['settings/astrality']['requires_timeout'],
requires_working_directory=self.application_config['_runtime']['config_directory'],
):
module = Module(
module_config=module_config,
config_directory=self.application_config['_runtime']['config_directory'],
Expand All @@ -393,7 +414,6 @@ def __init__(self, config: ApplicationConfig) -> None:
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'],
Expand Down
51 changes: 51 additions & 0 deletions astrality/tests/module/test_module_requires.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import logging
from pathlib import Path

from astrality.module import Module

def test_module_requires_option(caplog):
"""Test that modules are disabled when they don't satisfy `requires`."""

# Simple module that satisfies the requirements
does_satisfy_requiremnets = { 'module/satisfies': {
'enabled': True,
'requires': 'command -v cd',
}}
assert Module.valid_class_section(
section=does_satisfy_requiremnets,
requires_timeout=1,
requires_working_directory=Path('/'),
)


# Simple module that does not satisfy requirements
does_not_satisfy_requirements = { 'module/does_not_satisfy': {
'requires': 'command -v does_not_exist',
}}
assert not Module.valid_class_section(
section=does_not_satisfy_requirements,
requires_timeout=1,
requires_working_directory=Path('/'),
)
assert (
'astrality',
logging.WARNING,
'[module/does_not_satisfy] Module does not satisfy requirement "command -v does_not_exist".',
) in caplog.record_tuples


# Test failing one of many requirements
does_not_satisfy_one_requirement = { 'module/does_not_satisfy': {
'requires': ['command -v cd', 'command -v does_not_exist'],
}}
caplog.clear()
assert not Module.valid_class_section(
section=does_not_satisfy_requirements,
requires_timeout=1,
requires_working_directory=Path('/'),
)
assert (
'astrality',
logging.WARNING,
'[module/does_not_satisfy] Module does not satisfy requirement "command -v does_not_exist".',
) in caplog.record_tuples
42 changes: 34 additions & 8 deletions astrality/tests/test_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ def single_module_manager(simple_application_config):
class TestModuleClass:

def test_valid_class_section_method_with_valid_section(self, valid_module_section):
assert Module.valid_class_section(valid_module_section) == True
assert Module.valid_class_section(
section=valid_module_section,
requires_timeout=2,
requires_working_directory=Path('/'),
) == True

def test_valid_class_section_method_with_disabled_module_section(self):
disabled_module_section = {
Expand All @@ -86,22 +90,34 @@ def test_valid_class_section_method_with_disabled_module_section(self):
'on_exit': {'run': ['whatever']},
}
}
assert Module.valid_class_section(disabled_module_section) == False
assert Module.valid_class_section(
section=disabled_module_section,
requires_timeout=2,
requires_working_directory=Path('/'),
) == False

def test_valid_class_section_method_with_invalid_section(self):
invalid_module_section = {
'context/fonts': {
'some_key': 'some_value',
}
}
assert Module.valid_class_section(invalid_module_section) == False
assert Module.valid_class_section(
section=invalid_module_section,
requires_timeout=2,
requires_working_directory=Path('/'),
) == False

def test_valid_class_section_with_wrongly_sized_dict(self, valid_module_section):
invalid_module_section = valid_module_section
invalid_module_section.update({'module/valid2': {'enabled': True}})

with pytest.raises(RuntimeError):
Module.valid_class_section(invalid_module_section)
Module.valid_class_section(
section=invalid_module_section,
requires_timeout=2,
requires_working_directory=Path('/'),
)

def test_module_name(self, module):
assert module.name == 'test_module'
Expand Down Expand Up @@ -620,7 +636,7 @@ def test_import_sections_on_period_change(config_with_modules, freezer):
'week': Resolver({'day': 'monday'}),
}

def test_compiling_templates_on_cross_of_module_boundries():
def test_compiling_templates_on_cross_of_module_boundries(default_global_options):
module_A = {
'templates': {
'template_A': {
Expand All @@ -635,6 +651,7 @@ def test_compiling_templates_on_cross_of_module_boundries():
'temp_directory': Path('/tmp'),
},
}
modules_config.update(default_global_options)

module_manager = ModuleManager(modules_config)
module_manager.finish_tasks()
Expand Down Expand Up @@ -809,7 +826,10 @@ def test_detection_of_new_period_involving_several_modules(
# Now both timers should be considered period changed
assert module_manager.has_unfinished_tasks() == True

def test_that_shell_filter_is_run_from_config_directory(conf_path):
def test_that_shell_filter_is_run_from_config_directory(
conf_path,
default_global_options,
):
shell_filter_template = Path(__file__).parent / 'templates' / 'shell_filter_working_directory.template'
shell_filter_template_target = Path('/tmp/astrality/shell_filter_working_directory.template')
config = {
Expand All @@ -829,6 +849,7 @@ def test_that_shell_filter_is_run_from_config_directory(conf_path):
'temp_directory': Path('/tmp/astrality'),
},
}
config.update(default_global_options)
module_manager = ModuleManager(config)
module_manager.compile_templates('on_startup')

Expand Down Expand Up @@ -1102,7 +1123,7 @@ def test_that_only_startup_event_block_is_run_on_startup(
hour=12,
)

def test_trigger_event_module_action(conf_path):
def test_trigger_event_module_action(conf_path, default_global_options):
application_config = {
'module/A': {
'timer': {'type': 'weekday'},
Expand Down Expand Up @@ -1132,6 +1153,7 @@ def test_trigger_event_module_action(conf_path):
'temp_directory': Path('/tmp/astrality'),
},
}
application_config.update(default_global_options)
module_manager = ModuleManager(application_config)

# Check that all run commands have been imported into startup block
Expand Down Expand Up @@ -1171,7 +1193,10 @@ def test_trigger_event_module_action(conf_path):
),
)

def test_not_using_list_when_specifiying_trigger_action(conf_path):
def test_not_using_list_when_specifiying_trigger_action(
conf_path,
default_global_options,
):
application_config = {
'module/A': {
'on_startup': {
Expand All @@ -1186,6 +1211,7 @@ def test_not_using_list_when_specifiying_trigger_action(conf_path):
'temp_directory': Path('/tmp/astrality'),
},
}
application_config.update(default_global_options)
module_manager = ModuleManager(application_config)

# Check that all run commands have been imported into startup block
Expand Down
11 changes: 8 additions & 3 deletions astrality/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@
import logging
import subprocess
from pathlib import Path
from typing import Union
from typing import Any, Union

logger = logging.getLogger('astrality')


def run_shell(
command: str,
timeout: Union[int, float] = 2,
fallback: str = '',
fallback: Any = '',
working_directory: Path = Path.home(),
) -> str:
"""Return the standard output of a shell command."""
"""
Return the standard output of a shell command.
If the shell command has a non-zero exit code or times out, the function
returns the `fallback` argument instead of the standard output.
"""

process = subprocess.Popen(
command,
Expand Down
14 changes: 13 additions & 1 deletion docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Astrality makes two non-standard additions to the ``YAML`` syntax, so-called int

.. _parameter_expansion:

* **Parameter expansion**:
* **Parameter expansion**:
``${ENVIRONMENT_VARIABLE}`` is replaced with the value of the environment variable, i.e. the result of ``echo $ENVIRONMENT_VARIABLE``.

If the value of an environment variable contains other environment variables, then those environment variables will also be expanded.
Expand Down Expand Up @@ -82,6 +82,9 @@ Astrality makes two non-standard additions to the ``YAML`` syntax, so-called int

Interpolations in ``astrality.yaml`` occur on Astrality startup, and will not reflect changes to environment variables and shell commands after startup.


.. _configuration_options:

Astrality configuration options
===============================

Expand Down Expand Up @@ -122,6 +125,15 @@ Global Astrality configuration options are specified in ``astrality.yaml`` withi

*Useful when shell commands are dependent on earlier shell commands.*

.. _configuration_options_requires_timeout:

``requires_timeout``
*Default:* ``1``

Determines how long Astrality waits for :ref:`module requirements <module_requires>` to exit successfully, given in seconds. If the requirement times out, it will be considered failed.

*Useful when requirements are costly to determine, but you still do not want them to time out.*

Where to go from here
=====================

Expand Down

0 comments on commit 0ed7b58

Please sign in to comment.