def test_creation_of_backup(create_temp_files): """Existing external files should be backed up.""" target, template = create_temp_files(2) # This file is the original and should be backed up target.write_text('original') # This is the new content compiled to target template.write_text('new') compile_dict = { 'content': str(template.name), 'target': str(target), } compile_action = CompileAction( options=compile_dict, directory=template.parent, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) # We replace the content by executing the action compile_action.execute() assert target.read_text() == 'new' # And when cleaning up the module, the backup should be restored CreatedFiles().cleanup(module='test') assert target.read_text() == 'original'
def test_running_symlink_action_twice(create_temp_files): """Symlink action should be idempotent.""" content, target = create_temp_files(2) content.write_text('content') target.write_text('target') symlink_options = { 'content': str(content), 'target': str(target), } symlink_action = SymlinkAction( options=symlink_options, directory=content.parent, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) # Symlink first time symlink_action.execute() assert target.is_symlink() assert target.read_text() == 'content' # A backup shoud be created backup = CreatedFiles().creations['test'][str(target)]['backup'] assert Path(backup).read_text() == 'target' # Symlink one more time, and assert idempotency symlink_action.execute() assert target.is_symlink() assert target.read_text() == 'content' backup = CreatedFiles().creations['test'][str(target)]['backup'] assert Path(backup).read_text() == 'target'
def test_that_temporary_compile_targets_have_deterministic_paths(tmpdir): """Created compilation targets should be deterministic.""" template_source = Path(tmpdir, 'template.tmp') template_source.write_text('content') compile_dict = { 'content': str(template_source), } compile_action1 = CompileAction( options=compile_dict.copy(), directory=Path('/'), replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) compile_action2 = CompileAction( options=compile_dict.copy(), directory=Path('/'), replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) target1 = compile_action1.execute()[template_source] target2 = compile_action2.execute()[template_source] assert target1 == target2
def test_backup_of_symlink_target(create_temp_files): """Overwritten copy targets should be backed up.""" target, content = create_temp_files(2) # This file is the original and should be backed up target.write_text('original') # This is the new content which will be symlinked to content.write_text('new') symlink_options = { 'content': str(content.name), 'target': str(target), } symlink_action = SymlinkAction( options=symlink_options, directory=content.parent, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) # We replace the content by executing the action symlink_action.execute() assert target.resolve().read_text() == 'new' # And when cleaning up the module, the backup should be restored CreatedFiles().cleanup(module='test') assert target.read_text() == 'original'
def test_run_timeout_specified_in_action_block(tmpdir): """ Run actions can time out. The option `timeout` overrides any timeout providided to `execute()`. """ temp_dir = Path(tmpdir) run_action = RunAction( options={ 'shell': 'sleep 0.1 && echo hi', 'timeout': 0.05 }, directory=temp_dir, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) _, result = run_action.execute(default_timeout=10000) assert result == '' run_action = RunAction( options={ 'shell': 'sleep 0.1 && echo hi', 'timeout': 0.2 }, directory=temp_dir, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) _, result = run_action.execute(default_timeout=0) assert result == 'hi'
def test_run_timeout_specified_in_execute(tmpdir, caplog): """ Run actions can time out, and should log this. The the option `timeout` is not specified, use `default_timeout` argument instead. """ temp_dir = Path(tmpdir) run_action = RunAction( options={'shell': 'sleep 0.1 && echo hi'}, directory=temp_dir, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) caplog.clear() _, result = run_action.execute(default_timeout=0.05) assert 'used more than 0.05 seconds' in caplog.record_tuples[1][2] assert result == '' run_action = RunAction( options={'shell': 'sleep 0.1 && echo hi'}, directory=temp_dir, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) _, result = run_action.execute(default_timeout=0.2) assert result == 'hi'
def test_cleanup_of_created_directory(create_temp_files, tmpdir): """Created directories should be cleaned up.""" tmpdir = Path(tmpdir) [content] = create_temp_files(1) # The target requires a new directory to be created directory = tmpdir / 'dir' target = directory / 'target.tmp' # Execute the symlink action symlink_options = { 'content': str(content.name), 'target': str(target), } created_files = CreatedFiles().wrapper_for(module='test') symlink_action = SymlinkAction( options=symlink_options, directory=content.parent, replacer=lambda x: x, context_store={}, creation_store=created_files, ) symlink_action.execute() # The directory should now exist and be persisted assert directory.is_dir() assert directory in created_files.creation_store # But it should be deleted on cleanup CreatedFiles().cleanup(module='test') assert not directory.is_dir()
def test_that_cleanup_method_removes_files(tmpdir, create_temp_files): """The cleanup method should remove all files created by module.""" # Create some files which are made by modules ( content1, content2, content3, content4, compilation1, compilation2, compilation4, ) = create_temp_files(7) symlink3 = Path(tmpdir, 'symlink3.tmp') symlink3.symlink_to(content3) # Tell CreatedFiles object that these files have been created by two # different modules created_files = CreatedFiles() created_files.insert( module='name', creation_method=CreationMethod.COMPILE, contents=[content1, content2], targets=[compilation1, compilation2], ) created_files.insert( module='name', creation_method=CreationMethod.SYMLINK, contents=[content3], targets=[symlink3], ) created_files.insert( module='other_module', creation_method=CreationMethod.COMPILE, contents=[content4], targets=[compilation4], ) # Cleanup files only made by name created_files.cleanup(module='name') # Content files should be left alone for content in (content1, content2, content3, content4): assert content.exists() # But target files should be removed for cleaned_file in (compilation1, compilation2, symlink3): assert not cleaned_file.exists() # The other module should be left alone assert compilation4.exists() # No files should be persisted as created by the module afterwards assert created_files.by(module='name') == [] # This should be the case between object lifetimes too del created_files created_files = CreatedFiles() assert created_files.by(module='name') == []
def test_creation_and_cleanup_of_directory(create_temp_files): """Created directories should be tracked.""" # my_module is going to copy one file created_files = CreatedFiles().wrapper_for(module='my_module') # Specifically content -> target content, target = create_temp_files(2) # The target directory does not exist yet, so it creates it first created_files.insert_creation( content=None, target=target.parent, method=CreationMethod.MKDIR, ) # Then copies the file over created_files.insert_creation( content=content, target=target, method=CreationMethod.COPY, ) # These two creations should now be tracked global_created_files = CreatedFiles() creations = global_created_files.by('my_module') assert len(creations) == 2 assert target.parent in global_created_files assert target in global_created_files # And a small sanity check, the directory actually exists assert target.parent.is_dir() # Now we introduce a small complication; an file **not** created by # astrality is placed within this created directory. external_file = target.parent / 'external.tmp' external_file.touch() # If we now clean up the module, the copied file can be deleted, but not # the created directory, as that would delete an external file! global_created_files.cleanup(module='my_module') assert not target.exists() assert target.parent.is_dir() # And the directory is still tracked, even after the cleanup assert target.parent in CreatedFiles() # Now we delet this external file, # such that the directory can be cleaned up. external_file.unlink() global_created_files.cleanup(module='my_module') assert not target.parent.is_dir()
def test_setting_permissions_on_target_copy(tmpdir): """If permissions is provided, use it for the target.""" temp_dir = Path(tmpdir) / 'content' temp_dir.mkdir() target = Path(tmpdir) / 'target' target.mkdir() file1 = temp_dir / 'file1' file1.touch() file1.chmod(0o770) copy_options = { 'content': str(file1), 'target': str(target), 'include': r'file1', 'permissions': '777', } copy_action = CopyAction( options=copy_options, directory=temp_dir, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) copy_action.execute() assert ((target / 'file1').stat().st_mode & 0o000777) == 0o777
def test_filtering_stowed_templates(test_config_directory, tmpdir): """Users should be able to restrict compilable templates with ignore.""" temp_dir = Path(tmpdir) templates = \ test_config_directory / 'test_modules' / 'using_all_actions' stow_dict = { 'content': str(templates), 'target': str(temp_dir), 'templates': r'.+\.template', 'non_templates': 'ignore', } stow_action = StowAction( options=stow_dict, directory=test_config_directory, replacer=lambda x: x, context_store={'geography': { 'capitol': 'Berlin' }}, creation_store=CreatedFiles().wrapper_for(module='test'), ) # First testing if dry run is respected (too much work for a separate test) stow_action.execute(dry_run=True) assert len(list(temp_dir.iterdir())) == 0 # We should have a total of two stowed files stow_action.execute() assert len(list(temp_dir.iterdir())) == 2 assert len(list((temp_dir / 'recursive').iterdir())) == 1 assert (temp_dir / 'module.template').is_file() assert (temp_dir / 'recursive' / 'empty.template').is_file()
def test_symlinking_file_to_directory(tmpdir): """If symlinking from directory to file, place file in directory.""" temp_dir = Path(tmpdir) / 'content' temp_dir.mkdir() target = Path(tmpdir) / 'target' target.mkdir() file1 = temp_dir / 'file1' file1.touch() symlink_options = { 'content': str(file1), 'target': str(target), 'include': r'file1', } symlink_action = SymlinkAction( options=symlink_options, directory=temp_dir, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) symlink_action.execute() assert (target / 'file1').is_symlink() assert (target / 'file1').resolve() == file1 assert symlink_action.symlinked_files == { file1: {target / 'file1'}, }
def test_that_dry_run_is_respected(tmpdir, caplog): """If dry_run is True, no commands should be executed, only logged.""" temp_dir = Path(tmpdir) run_action = RunAction( options={ 'shell': 'touch touched.tmp', 'timeout': 1 }, directory=temp_dir, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) caplog.clear() result = run_action.execute(dry_run=True) # Command to be run and empty string should be returned assert result == ('touch touched.tmp', '') # Command to be run should be logged assert 'SKIPPED: ' in caplog.record_tuples[0][2] assert 'touch touched.tmp' in caplog.record_tuples[0][2] # Check that the command has *not* been run assert not (temp_dir / 'touched.tmp').is_file()
def test_filtering_compiled_templates(test_config_directory, tmpdir): """Users should be able to restrict compilable templates.""" temp_dir = Path(tmpdir) templates = \ test_config_directory / 'test_modules' / 'using_all_actions' compile_dict = { 'content': str(templates), 'target': str(temp_dir), 'include': r'.+\.template', } compile_action = CompileAction( options=compile_dict, directory=test_config_directory, replacer=lambda x: x, context_store={'geography': { 'capitol': 'Berlin' }}, creation_store=CreatedFiles().wrapper_for(module='test'), ) compile_action.execute() # We should have a total of two compiled files assert len(list(temp_dir.iterdir())) == 2 assert len(list((temp_dir / 'recursive').iterdir())) == 1 assert (temp_dir / 'module.template').is_file() assert (temp_dir / 'recursive' / 'empty.template').is_file()
def test_renaming_templates(test_config_directory, tmpdir): """Templates targets should be renameable with a capture group.""" temp_dir = Path(tmpdir) templates = \ test_config_directory / 'test_modules' / 'using_all_actions' # Multiple capture groups should be allowed compile_dict = { 'content': str(templates), 'target': str(temp_dir), 'include': r'(?:^template\.(.+)$|^(.+)\.template$)', } compile_action = CompileAction( options=compile_dict, directory=test_config_directory, replacer=lambda x: x, context_store={'geography': { 'capitol': 'Berlin' }}, creation_store=CreatedFiles().wrapper_for(module='test'), ) compile_action.execute() # We should have a total of two compiled files assert len(list(temp_dir.iterdir())) == 2 assert len(list((temp_dir / 'recursive').iterdir())) == 1 assert (temp_dir / 'module').is_file() assert (temp_dir / 'recursive' / 'empty').is_file()
def test_compiling_entire_directory(test_config_directory, tmpdir): """All directory contents should be recursively compiled.""" temp_dir = Path(tmpdir).resolve() templates = \ test_config_directory / 'test_modules' / 'using_all_actions' # TODO: Make this unecessary for file in templates.glob('**/*.tmp'): file.unlink() compile_dict = { 'content': str(templates), 'target': str(temp_dir), } compile_action = CompileAction( options=compile_dict, directory=test_config_directory, replacer=lambda x: x, context_store={'geography': { 'capitol': 'Berlin' }}, creation_store=CreatedFiles().wrapper_for(module='test'), ) results = compile_action.execute() # Check if return content is correct, showing performed compilations assert templates / 'module.template' in results assert results[templates / 'module.template'] == \ temp_dir / 'module.template' # Check if the templates actually have been compiled target_dir_content = list(temp_dir.iterdir()) assert len(target_dir_content) == 6 assert temp_dir / 'module.template' in target_dir_content assert (temp_dir / 'recursive' / 'empty.template').is_file()
def test_retrieving_all_compiled_templates(template_directory, tmpdir): """Compile actions should return all compiled templates.""" target1, target2 = Path(tmpdir) / 'target.tmp', Path(tmpdir) / 'target2' targets = [target1, target2] template = Path('no_context.template') compile_dict = { 'content': str(template), 'target': '{target}', } # First replace {target} with target1, then with target2, by doing some # trickery with the replacer function. compile_action = CompileAction( options=compile_dict, directory=template_directory, replacer=lambda x: x.format(target=targets.pop(), ) if x == '{target}' else x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) assert compile_action.performed_compilations() == {} compile_action.execute() assert compile_action.performed_compilations() == { template_directory / template: {target2}, } compile_action.execute() assert compile_action.performed_compilations() == { template_directory / template: {target1, target2}, }
def test_use_of_replacer(template_directory, tmpdir): """All options should be run through the replacer.""" compile_dict = { 'content': 'template', 'target': 'target', 'permissions': 'permissions', } template = template_directory / 'no_context.template' target = Path(tmpdir) / 'target' def replacer(string: str) -> str: """Trivial replacer.""" if string == 'template': return template.name elif string == 'target': return str(target) elif string == 'permissions': return '777' else: return string compile_action = CompileAction( options=compile_dict, directory=template_directory, replacer=replacer, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) target = list(compile_action.execute().values())[0] assert target.read_text() == 'one\ntwo\nthree' assert (target.stat().st_mode & 0o777) == 0o777
def test_compilation_with_context(template_directory): """ Templates should be compiled with the context store. It should compile differently after mutatinig the store. """ compile_dict = { 'content': 'test_template.conf', } context_store = {} compile_action = CompileAction( options=compile_dict, directory=template_directory, replacer=lambda x: x, context_store=context_store, creation_store=CreatedFiles().wrapper_for(module='test'), ) context_store['fonts'] = {2: 'ComicSans'} target = list(compile_action.execute().values())[0] username = os.environ.get('USER') assert target.read_text() == f'some text\n{username}\nComicSans' context_store['fonts'] = {2: 'TimesNewRoman'} target = list(compile_action.execute().values())[0] assert target.read_text() == f'some text\n{username}\nTimesNewRoman'
def test_symlinking_non_templates(test_config_directory, tmpdir): """Non-templates files should be implicitly symlinked.""" temp_dir = Path(tmpdir) templates = \ test_config_directory / 'test_modules' / 'using_all_actions' stow_dict = { 'content': str(templates), 'target': str(temp_dir), 'templates': r'.+\.template', } stow_action = StowAction( options=stow_dict, directory=test_config_directory, replacer=lambda x: x, context_store={'geography': { 'capitol': 'Berlin' }}, creation_store=CreatedFiles().wrapper_for(module='test'), ) stow_action.execute() # Templates should still stowed target_dir_content = list(temp_dir.iterdir()) assert len(target_dir_content) == 6 assert temp_dir / 'module.template' in target_dir_content assert not (temp_dir / 'module.template').is_symlink() assert (temp_dir / 'recursive' / 'empty.template').is_file() # The rest should be symlinked assert (temp_dir / 'modules.yml').is_symlink() assert (temp_dir / 'modules.yml').resolve() == templates / 'modules.yml' # Symlinked files should be not considered as a managed file, as it is # self-updating. assert templates / 'modules.yml' not in stow_action.managed_files()
def test_running_shell_command_with_environment_variable(caplog): """Shell commands should have access to the environment.""" run_action = RunAction( options={ 'shell': 'echo $USER', 'timeout': 2 }, directory=Path('/'), replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) caplog.clear() run_action.execute() assert caplog.record_tuples == [ ( 'astrality.actions', logging.INFO, f'Running command "echo {os.environ["USER"]}".', ), ( 'astrality.utils', logging.INFO, os.environ['USER'], ), ]
def test_that_dry_run_skips_compilation(template_directory, tmpdir, caplog): """If dry_run is True, skip compilation of template""" compilation_target = Path(tmpdir, 'target.tmp') template = template_directory / 'no_context.template' compile_dict = { 'content': 'no_context.template', 'target': str(compilation_target), } compile_action = CompileAction( options=compile_dict, directory=template_directory, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) caplog.clear() compilations = compile_action.execute(dry_run=True) # Check that the "compilation" is actually logged assert 'SKIPPED:' in caplog.record_tuples[0][2] assert str(template) in caplog.record_tuples[0][2] assert str(compilation_target) in caplog.record_tuples[0][2] # The template should still be returned assert template in compilations # And the compilation pair should be persisted assert compile_action.performed_compilations() == { template: {compilation_target}, } # But the file should not be compiled assert not compilations[template].exists()
def test_symlink_dry_run(create_temp_files, caplog): """If dry_run is True, only log and not symlink.""" content, target = create_temp_files(2) symlink_action = SymlinkAction( options={ 'content': str(content), 'target': str(target) }, directory=Path('/'), replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) caplog.clear() result = symlink_action.execute(dry_run=True) # We should log the symlink that had been performed assert 'SKIPPED:' in caplog.record_tuples[0][2] assert str(content) in caplog.record_tuples[0][2] assert str(target) in caplog.record_tuples[0][2] # We should also still return the intended result assert result == {content: target} # But the symlink should not be created in a dry run assert not target.is_symlink()
def test_that_replacer_is_run_every_time(context_directory): """ The replacer should be run a new every time self.execute() is invoked. """ context_import_dict = { 'from_path': 'several_sections.yml', 'from_section': 'section1', 'to_section': 'whatever', } context_store = Context() class Replacer: def __init__(self) -> None: self.invoke_number = 0 def __call__(self, option: str) -> str: self.invoke_number += 1 return option replacer = Replacer() import_context_action = ImportContextAction( options=context_import_dict, directory=context_directory, replacer=replacer, context_store=context_store, creation_store=CreatedFiles().wrapper_for(module='test'), ) import_context_action.execute() assert replacer.invoke_number == 3 import_context_action.execute() assert replacer.invoke_number == 6
def test_importing_entire_file(context_directory): """ Test importing all sections from context file. All context sections should be imported in the absence of `from_section`. """ context_import_dict = { 'from_path': 'several_sections.yml', } context_store = Context() import_context_action = ImportContextAction( options=context_import_dict, directory=context_directory, replacer=lambda x: x, context_store=context_store, creation_store=CreatedFiles().wrapper_for(module='test'), ) import_context_action.execute() expected_context = { 'section1': { 'k1_1': 'v1_1', 'k1_2': 'v1_2', }, 'section2': { 'k2_1': 'v2_1', 'k2_2': 'v2_2', }, } assert context_store == expected_context
def test_if_dry_run_is_respected(create_temp_files, caplog): """When dry_run is True, the copy action should only be logged.""" content, target = create_temp_files(2) content.write_text('content') target.write_text('target') copy_action = CopyAction( options={'content': str(content), 'target': str(target)}, directory=Path('/'), replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) caplog.clear() result = copy_action.execute(dry_run=True) # We should still return the copy pair assert result == {content: target} # We should log what would have been done assert 'SKIPPED:' in caplog.record_tuples[0][2] assert str(content) in caplog.record_tuples[0][2] assert str(target) in caplog.record_tuples[0][2] # But we should not copy the file under a dry run assert target.read_text() == 'target'
def test_copying_file_to_directory(tmpdir): """If copying from directory to file, place file in directory.""" temp_dir = Path(tmpdir) / 'content' temp_dir.mkdir() target = Path(tmpdir) / 'target' target.mkdir() file1 = temp_dir / 'file1' file1.touch() copy_options = { 'content': str(file1), 'target': str(target), 'include': r'file1', } copy_action = CopyAction( options=copy_options, directory=temp_dir, replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) copy_action.execute() assert (target / 'file1').read_text() == file1.read_text()
def test_creating_created_files_object_for_specific_module(create_temp_files): """You should be able to construct a CreatedFiles wrapper for a module.""" content, target = create_temp_files(2) created_files = CreatedFiles().wrapper_for(module='my_module') created_files.insert_creation( content=content, target=target, method=CreationMethod.SYMLINK, )
def test_null_object_pattern(): """Copy actions without options should do nothing.""" symlink_action = SymlinkAction( options={}, directory=Path('/'), replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) symlink_action.execute()
def test_null_object_pattern(): """Null objects should be executable.""" run_action = RunAction( options={}, directory=Path('/'), replacer=lambda x: x, context_store={}, creation_store=CreatedFiles().wrapper_for(module='test'), ) run_action.execute()