diff --git a/mould/__main__.py b/mould/__main__.py index 693b59e..212a9cd 100644 --- a/mould/__main__.py +++ b/mould/__main__.py @@ -1,3 +1,3 @@ +from .cli import main -import mould.cli -mould.cli.main() +main(prog_name='mould') diff --git a/mould/cli.py b/mould/cli.py index ea8c7a5..f1e1e97 100644 --- a/mould/cli.py +++ b/mould/cli.py @@ -1,19 +1,88 @@ -import logging +import json import click -log = logging.getLogger(__name__) +from . import mould +from . import read_directory +from .transform import preview @click.command() -@click.option('--count', default=1, help='Number of greetings.') -@click.option('--name', prompt='Your name', help='The person to greet.') -@click.option('--debug', default=False, help='Debug mode.') -def main(count, name, debug): - """Simple program that greets NAME for a total of COUNT times.""" - logging.basicConfig(level=logging.DEBUG if debug else logging.INFO) +@click.argument('source', type=click.Path(exists=True)) +@click.argument('destination', type=click.Path()) +@click.option('--debug', is_flag=True, default=False, help='Debug mode.') +@click.option('--dry-run', is_flag=True, default=False, help='Dry run.') +def main(source, destination, debug, dry_run): - for x in range(count): - click.echo('Hello %s!' % name) + click.secho( + 'Reading from {} and writing to {}'.format(source, destination), + fg='green', + ) - log.debug('Goodbye %s!' % name) + source_files = read_directory(source) + + replacements = {} + + done = False + while not done: + try: + pattern = click.prompt( + click.style( + 'Enter a pattern to search for ' + '(enter when done)', + fg='green' + ), + default='', + show_default=False + ) + if pattern == '': + done = True + continue + + click.echo( + preview( + source_files, + { + pattern: '' + }, + ) + ) + + name = click.prompt( + click.style( + 'Give this pattern a name (or enter to discard)', + fg='green' + ), + default='', + show_default=False + ) + + if name: + replacements[pattern] = name + + if replacements: + click.secho( + 'Current replacements {}'.format(json.dumps(replacements)), + fg='yellow' + ) + except click.Abort: + done = True + + if replacements: + if not click.confirm(click.style( + 'Confirm moulding {} to {}, with replacements:\n{}\n'.format( + source, + destination, + # FIXME: print replacements better + json.dumps(replacements)), + fg='yellow' + )): + import sys + sys.exit(0) + + if not dry_run: + mould( + source, + replacements, + destination, + ) diff --git a/mould/gitignore.py b/mould/gitignore.py new file mode 100644 index 0000000..51c03ff --- /dev/null +++ b/mould/gitignore.py @@ -0,0 +1,26 @@ +def read_ignore(ignore_content): + return [ + ignore_line + for ignore_line in ignore_content.split() + if not ignore_line.startswith('#') + ] + + +def remove_ignores(file_paths, ignore_list): + """ + Remove files that match gitignore patterns + :param file_paths: + :param ignore_list: + :return: + """ + # https://stackoverflow.com/a/25230908/5549 + from fnmatch import fnmatch + matches = [] + for ignore in ignore_list: + file_paths = [ + n for n + in file_paths + if n.startswith('#') or not fnmatch(n, ignore) + ] + matches.extend(file_paths) + return matches diff --git a/mould/read.py b/mould/read.py index 5357014..6a6c8cb 100644 --- a/mould/read.py +++ b/mould/read.py @@ -2,30 +2,45 @@ import os from binaryornot import check +from .gitignore import read_ignore, remove_ignores + def read_directory(project_directory): directory_entries = [] - exclude_directories = ['.git'] project_parent_directory = os.path.normpath( os.path.join(project_directory, os.pardir) ) + ignore_path = os.path.join(project_directory, '.gitignore') + ignore_list = [] + if os.path.exists(ignore_path): + ignore_list = read_ignore(read_file(ignore_path)) + for root, dirs, files in os.walk(project_directory): + directory_path = os.path.relpath(root, project_parent_directory) + + if '.git' in directory_path: + dirs[:] = [] + continue + directory = { - 'path': os.path.relpath(root, project_parent_directory), + 'path': directory_path, 'files': [], } - for exclude_dir in exclude_directories: - if exclude_dir in dirs: - dirs.remove(exclude_dir) + if ignore_list: + files = remove_ignores(files, ignore_list) for file_path in files: + if file_path.startswith('.'): + continue + file_path = os.path.join(root, file_path) - is_binary, content = _read_file(file_path) + content = read_file(file_path) + is_binary = check.is_binary(file_path) directory['files'].append({ 'path': os.path.relpath(file_path, project_parent_directory), @@ -38,9 +53,7 @@ def read_directory(project_directory): return directory_entries -def _read_file(file_path): +def read_file(file_path): is_binary = check.is_binary(file_path) - mode = 'r{}'.format('b' if is_binary else 't') - - return is_binary, io.open(file_path, mode=mode).read() + return io.open(file_path, mode=mode).read() diff --git a/mould/transform.py b/mould/transform.py index bc7d4cf..366fe64 100644 --- a/mould/transform.py +++ b/mould/transform.py @@ -1,42 +1,95 @@ import difflib -import click +def replace_directory_entries(directory_entries, replacements): + """ + Perform `replacements` substitutions on `DirectoryEntry` instances. -def replace_directory_entries(directory_entries, replacements, preview=False): + Also mention the cookiecutter thing? + https://docs.python.org/3.3/library/stdtypes.html#str.replace + + Specifically the path elements + :param directory_entries: + :param replacements: A dict where the key is the [value of the needle] + and the value + :return: + """ replaced_entries = [] for directory_entry in directory_entries: - entry = dict(directory_entry) + # make a copy of the current entry + directory = dict(directory_entry) for search, replace in replacements.items(): + # transform the value into a cookiecutter variable replace = '{{cookiecutter.' + replace + '}}' - entry['path'] = entry['path'].replace(search, replace) - for file_record in entry['files']: - old_path = file_record['path'] + # replace directory path names + directory['path'] = directory['path'].replace(search, replace) + + for file_record in directory['files']: + # transform the file path file_record['path'] = file_record['path'].replace( search, replace ) - if not file_record['binary']: - before = file_record['content'] + # don't try to run replace on binary files + is_text_file = not file_record['binary'] + if is_text_file: + # transform the file content file_record['content'] = file_record['content'].replace( search, replace ) - if preview: - click.echo_via_pager('\n'.join( - difflib.unified_diff( - before.splitlines(), - file_record['content'].splitlines(), - fromfile=old_path, - tofile=file_record['path'], - lineterm='', - ) - )) - - replaced_entries.append(entry) + + replaced_entries.append(directory) return replaced_entries + + +def preview(directory_entries, replacements): + preview_content = [] + + for directory_entry in directory_entries: + # make a copy of the current entry + directory = dict(directory_entry) + + for search, replace in replacements.items(): + # transform the value into a cookiecutter variable + replace = '{{cookiecutter.' + replace + '}}' + + # replace directory path names + # directory['path'] = directory['path'].replace(search, replace) + + for file_record in directory['files']: + # save current path for diff + old_path = file_record['path'] + + # transform the file path + new_path = old_path.replace( + search, + replace + ) + + # don't try to run replace on binary files + is_text_file = not file_record['binary'] + if is_text_file: + # save current content for diff + old_content = file_record['content'] + + # transform the file content + new_content = old_content.replace( + search, + replace + ) + + preview_content.append('\n'.join(difflib.unified_diff( + old_content.splitlines(), + new_content.splitlines(), + fromfile=old_path, + tofile=new_path, + lineterm='', + ))) + + return '\n'.join(preview_content) diff --git a/mould/write.py b/mould/write.py index 539b9df..bd88f5c 100644 --- a/mould/write.py +++ b/mould/write.py @@ -1,18 +1,45 @@ import io import os +import click + def write_directory(directory_entries, target_directory): + """ + :param directory_entries: DirectoryEntry instances + :param target_directory: The directory to write the mould to + :return: + """ + + if not os.path.exists(target_directory): + os.mkdir(target_directory) + click.secho( + 'Creating {} directory'.format(target_directory), + fg='green' + ) + else: + # TODO: handle existing git target directory + click.secho( + '{} already exists, overwriting contents'.format(target_directory), + fg='red' + ) + for directory_entry in directory_entries: dir_to_create = os.path.join(target_directory, directory_entry['path']) - print('creating {}'.format(dir_to_create)) + click.secho( + 'creating {}'.format(dir_to_create), + fg='blue' + ) if not os.path.exists(dir_to_create): os.mkdir(dir_to_create) for file_entry in directory_entry['files']: file_to_create = os.path.join(target_directory, file_entry['path']) - print('writing {}'.format(file_to_create)) + click.secho( + 'writing {}'.format(file_to_create), + fg='blue' + ) mode = 'w{}'.format('b' if file_entry['binary'] else 't') with io.open(file_to_create, mode=mode) as fh: diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..3373f42 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,39 @@ +from click.testing import CliRunner +from mould.cli import main + + +def test_cli(tmpdir): + runner = CliRunner() + result = runner.invoke( + main, + ['tests/files/example-project', str(tmpdir)], + input=u'\n' + ) + + assert result.exit_code == 0 + + expected_output = [ + 'Reading from tests/files/example-project ' + 'and writing to {}'.format(str(tmpdir)), + + 'Enter a pattern to search for ' + '(enter when done): \n' + ] + + assert '\n'.join(expected_output) == result.output + assert not tmpdir.join('example-project').exists() + + +def test_cli_makes_one_replacement(tmpdir): + pattern = 'user@example.com' + name = 'email' + + result = CliRunner().invoke( + main, + ['tests/files/example-project', str(tmpdir)], + input=u'{}\n{}\n\ny\n'.format(pattern, name), + ) + + assert result.exit_code == 0 + + assert tmpdir.join('example-project/foo/bar/baz').exists() diff --git a/tests/test_gitignore.py b/tests/test_gitignore.py new file mode 100644 index 0000000..bd2d9ad --- /dev/null +++ b/tests/test_gitignore.py @@ -0,0 +1,12 @@ +from mould.gitignore import remove_ignores + + +def test_python_extension_glob_is_ignored(): + paths = [ + 'write.py', + 'write.pyc', + ] + ignore_list = [ + '*.py[cod]' + ] + assert ['write.py'] == remove_ignores(paths, ignore_list) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..19426ef --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,24 @@ +import subprocess +import sys + + +def test_should_invoke_main(monkeypatch, tmpdir): + monkeypatch.setenv('PYTHONPATH', '.') + + response = subprocess.check_output([ + sys.executable, + '-m', + 'mould', + 'tests/files/example-project', + str(tmpdir) + ], input=b'\n') + decoded_response = response.decode('utf-8') + + expected_output = [ + 'Reading from tests/files/example-project ' + 'and writing to {}'.format(str(tmpdir)), + + 'Enter a pattern to search for ' + '(enter when done): ' + ] + assert '\n'.join(expected_output) == decoded_response diff --git a/tests/test_read.py b/tests/test_read.py index 4aa0924..5edf5b1 100644 --- a/tests/test_read.py +++ b/tests/test_read.py @@ -50,7 +50,6 @@ def test_read_directory_files(): 'example-project/foo/bar/baz/sample.txt' ], 'example-project/proj': [ - 'example-project/proj/.gitkeep' ], } @@ -65,7 +64,6 @@ def test_read_directory_types(): 'example-project/README.md': False, 'example-project/file.bin': True, 'example-project/foo/bar/baz/sample.txt': False, - 'example-project/proj/.gitkeep': False, } directory_entries = read_directory('tests/files/example-project')