diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index a4abadd..107fb72 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -23,3 +23,6 @@ jobs: - name: Formatter run: | black src *.py --check + - name: Test + run: | + pytest diff --git a/.gitignore b/.gitignore index 784cd51..b282e51 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,6 @@ __pycache__/ *$py.class *.sw[a-z] -#Temporarily -tests/ # C extensions *.so diff --git a/script/bootstrap b/script/bootstrap index 73f2c65..c4315e3 100644 --- a/script/bootstrap +++ b/script/bootstrap @@ -6,4 +6,5 @@ pyenv install -s pyenv virtualenv $VENV_NAME pyenv activate $VENV_NAME +pip install wheel twine pip install -r $REQ_FILE diff --git a/src/carboncopy/constants.py b/src/carboncopy/constants.py index 80c1dfd..952532f 100644 --- a/src/carboncopy/constants.py +++ b/src/carboncopy/constants.py @@ -1,6 +1,6 @@ RCFILE_PATH = ".carboncopyrc" -FORCED_IGNORE_PATTERNS = r"^.git/*" +FORCED_IGNORE_PATTERNS = r".*\.git/.*" FETCH_URL_PATTERN = r"Fetch URL" GIT_LINK_PATTERN = r"(?<=git@github.com:)[A-Za-z0-9_\-\.]+/[A-Za-z0-9_\-\.]+" GIT_EXT_PATTERN = r"\.git$" diff --git a/src/carboncopy/fs_utils.py b/src/carboncopy/fs_utils.py index 5aee153..bd3a43e 100644 --- a/src/carboncopy/fs_utils.py +++ b/src/carboncopy/fs_utils.py @@ -38,7 +38,7 @@ def get_template_transforms(path: Path) -> List[Transform]: while stack: current_path = stack.pop() - if not current_path.is_dir(): + if not Path(current_path).is_dir(): file_paths.append(current_path) continue @@ -47,7 +47,10 @@ def get_template_transforms(path: Path) -> List[Transform]: stack.append(child_path) return [ - Transform(source=filename, destination=filename.relative_to(path)) + Transform( + source=filename, + destination=path.parent.joinpath(filename.relative_to(path)), + ) for filename in file_paths ] @@ -69,9 +72,8 @@ def squash(transform: Transform) -> None: except IsADirectoryError: pretty_print( "Failed to copy {source} -> {destination}".format( - source=source, destination=destination + source=source, destination=destination, ) ) except Exception as e: - pretty_print(e.__class__) - pretty_print(e) + pretty_print(str(e.__class__)) diff --git a/src/carboncopy/use_cases.py b/src/carboncopy/use_cases.py index 191ca22..2bedb57 100644 --- a/src/carboncopy/use_cases.py +++ b/src/carboncopy/use_cases.py @@ -46,19 +46,24 @@ def get_local_config(root_path: Path = Path(".")) -> Dict[str, Any]: class UseCases: - def __init__(self, config: Dict[str, Any]) -> None: + def __init__( + self, config: Dict[str, Any], non_interactive=False, root_path=Path(".") + ) -> None: self.config = config self.template_repo: Dict[str, str] = {} self.org = None self.repo = None + self.non_interactive = non_interactive + self.root_path = root_path def fetch_template_repository_details(self) -> None: org, repo = get_local_repository_meta() - template_repo_data = get_repo_metadata(org, repo) if not (org and repo): raise NotInAGitRepositoryError() + template_repo_data = get_repo_metadata(org, repo) + if not template_repo_data: raise NoTemplateError() @@ -72,13 +77,16 @@ class UseCases: ) def stage_changes(self) -> List[Transform]: - path = Path(self.config["temp_directory"]) + path = self.root_path.joinpath(Path(self.config["temp_directory"])) available_transforms = get_template_transforms(path) def can_stage(path_str: str) -> bool: - return not re.match(FORCED_IGNORE_PATTERNS, path_str) and all( - [re.match(patt, path_str) for patt in self.config["ignore"]] + is_forced_ignore = re.search(FORCED_IGNORE_PATTERNS, path_str) + is_custom_ignore = any( + [re.search(patt, path_str) for patt in self.config["ignore"]] ) + is_dir = Path(path_str).is_dir() + return not is_dir and not is_custom_ignore and not is_forced_ignore allowed_transforms = [ transform @@ -86,12 +94,17 @@ class UseCases: if can_stage(transform.get_destination(as_str=True)) ] + if self.non_interactive: + return allowed_transforms + destinations = [ transform.get_destination(as_str=True) for transform in allowed_transforms ] + chosen_files = prompt_staging_files_confirmation( destinations, "{}/{}".format(self.org, self.repo) ) + chosen_transforms = [ transform for transform in allowed_transforms @@ -106,4 +119,4 @@ class UseCases: squash(path) def clean_up(self) -> None: - clean_temp_files(Path(self.config["temp_directory"])) + clean_temp_files(self.root_path.joinpath(Path(self.config["temp_directory"]))) diff --git a/tasks.py b/tasks.py index 6dca06b..25083e8 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,7 @@ from invoke import Collection, task +TMP_PATH = "/tmp/carboncopy_pytest/" + @task def format_all(ctx): @@ -26,8 +28,14 @@ def publish(ctx, test=False): ctx.run("twine upload dist/*") +@task +def test(ctx): + ctx.run("pytest") + + ns = Collection() ns.add_task(format_all, name="format") ns.add_task(typecheck) ns.add_task(package) ns.add_task(publish) +ns.add_task(test) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__snapshots__/test_use_cases.ambr b/tests/__snapshots__/test_use_cases.ambr new file mode 100644 index 0000000..4fbf5bf --- /dev/null +++ b/tests/__snapshots__/test_use_cases.ambr @@ -0,0 +1,54 @@ +# name: test_fetch_template_repository_details_throws_NoTemplateError_if_no_template_repo + '' +--- +# name: test_fetch_template_repository_details_throws_NoTemplateError_if_no_template_repo.1 + '' +--- +# name: test_fetch_template_repository_details_throws_NotInGitRepositoryError_if_not_in_repo + '' +--- +# name: test_fetch_template_repository_details_throws_NotInGitRepositoryError_if_not_in_repo.1 + '' +--- +# name: test_get_local_config_merges_rcfile_with_default_config + '' +--- +# name: test_get_local_config_merges_rcfile_with_default_config.1 + '' +--- +# name: test_get_local_config_returns_default_config_if_invalid_config_file_present + ' + Invalid RC file! + + ' +--- +# name: test_get_local_config_returns_default_config_if_invalid_config_file_present.1 + '' +--- +# name: test_get_local_config_returns_default_config_if_no_config_file_present + ' + No config file found in current directory! Proceeding with defaults. + + ' +--- +# name: test_get_local_config_returns_default_config_if_no_config_file_present.1 + '' +--- +# name: test_stage_changes_creates_transforms_for_all_valid_changes + '' +--- +# name: test_stage_changes_creates_transforms_for_all_valid_changes.1 + '' +--- +# name: test_stage_changes_ignores_all_configured_ignore_patterns + '' +--- +# name: test_stage_changes_ignores_all_configured_ignore_patterns.1 + '' +--- +# name: test_stage_changes_ignores_all_forced_ignore_patterns + '' +--- +# name: test_stage_changes_ignores_all_forced_ignore_patterns.1 + '' +--- diff --git a/tests/test_use_cases.py b/tests/test_use_cases.py new file mode 100644 index 0000000..b576382 --- /dev/null +++ b/tests/test_use_cases.py @@ -0,0 +1,170 @@ +import json + +import pytest + +from src.carboncopy.use_cases import get_local_config, UseCases +from src.carboncopy.constants import RCFILE_PATH +from src.carboncopy.config_defaults import CONFIG_DEFAULTS +from src.carboncopy.git_utils import NotInAGitRepositoryError, NoTemplateError + +from .test_utils import assert_captured_output_matches_snapshot + +def test_get_local_config_returns_default_config_if_no_config_file_present(tmp_path, snapshot, capsys): + assert len(list(tmp_path.iterdir())) == 0 + + fetched_config = get_local_config(tmp_path) + + assert fetched_config == CONFIG_DEFAULTS + + assert_captured_output_matches_snapshot(capsys, snapshot) + +def test_get_local_config_returns_default_config_if_invalid_config_file_present(tmp_path, snapshot, capsys): + invalid_config_file = tmp_path / RCFILE_PATH + + invalid_config_file.write_text('') + + assert len(list(tmp_path.iterdir())) == 1 + + fetched_config = get_local_config(tmp_path) + + assert fetched_config == CONFIG_DEFAULTS + + assert_captured_output_matches_snapshot(capsys, snapshot) + +def test_get_local_config_merges_rcfile_with_default_config(tmp_path, snapshot, capsys): + valid_config = { "ignore": ["some-file.md"] } + + config_file = tmp_path / RCFILE_PATH + config_file.write_text(json.dumps(valid_config)) + + assert len(list(tmp_path.iterdir())) == 1 + + fetched_config = get_local_config(tmp_path) + + expected_config = {**CONFIG_DEFAULTS, **valid_config} + + assert fetched_config == expected_config + + assert_captured_output_matches_snapshot(capsys, snapshot) + +def test_fetch_template_repository_details_throws_NotInGitRepositoryError_if_not_in_repo(capsys, snapshot, monkeypatch): + # This simulates the repository meta not finding a repository. + def _mock(): + return (None, None) + + monkeypatch.setattr("src.carboncopy.use_cases.get_local_repository_meta", _mock) + + use_cases = UseCases(config=CONFIG_DEFAULTS) + + with pytest.raises(NotInAGitRepositoryError): + use_cases.fetch_template_repository_details() + + assert_captured_output_matches_snapshot(capsys, snapshot) + +def test_fetch_template_repository_details_throws_NoTemplateError_if_no_template_repo(capsys, monkeypatch, snapshot): + # This simulates the repository data not containing a template repo reference + def _mock(a, b): + return None + + def _mock_local_meta(): + return 'org', 'repo' + + monkeypatch.setattr('src.carboncopy.use_cases.get_local_repository_meta', _mock_local_meta) + monkeypatch.setattr("src.carboncopy.use_cases.get_repo_metadata", _mock) + + use_cases = UseCases(config=CONFIG_DEFAULTS, non_interactive=True) + + with pytest.raises(NoTemplateError): + use_cases.fetch_template_repository_details() + + assert_captured_output_matches_snapshot(capsys, snapshot) + +def test_stage_changes_ignores_all_forced_ignore_patterns(capsys, tmp_path, snapshot): + # Set up a mock template_directory + temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"] + temp_dir.mkdir() + # .git is a notoriously ignored directory + forced_ignored_dir = temp_dir / ".git" + forced_ignored_dir.mkdir() + some_file = forced_ignored_dir / "some_file.txt" + some_file.write_text("smol file") + + use_cases = UseCases(config=CONFIG_DEFAULTS, non_interactive=True, root_path=tmp_path) + + staged = use_cases.stage_changes() + assert len(staged) == 0 + + assert_captured_output_matches_snapshot(capsys, snapshot) + + +def test_stage_changes_ignores_all_configured_ignore_patterns(capsys, tmp_path, snapshot): + # Set up a mock template_directory + temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"] + temp_dir.mkdir() + forced_ignored_dir = temp_dir / "ignore_folder" + forced_ignored_dir.mkdir() + some_file = forced_ignored_dir / "some_file.txt" + some_file.write_text("smol file") + + config = { "ignore": ["ignore_folder"] } + merged_config = {**CONFIG_DEFAULTS, **config} + use_cases = UseCases(config=merged_config , non_interactive=True, root_path=tmp_path) + + staged = use_cases.stage_changes() + assert len(staged) == 0 + + assert_captured_output_matches_snapshot(capsys, snapshot) + +def test_stage_changes_creates_transforms_for_all_valid_changes(capsys, snapshot, tmp_path): + # Set up a mock template_directory + temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"] + temp_dir.mkdir() + forced_ignored_dir = temp_dir / "coolio" + forced_ignored_dir.mkdir() + some_file = forced_ignored_dir / "some_file.txt" + some_file.write_text("smol file") + + use_cases = UseCases(config=CONFIG_DEFAULTS , non_interactive=True, root_path=tmp_path) + + staged = use_cases.stage_changes() + assert len(staged) == 1 + + assert_captured_output_matches_snapshot(capsys, snapshot) + + + +def test_apply_changes_squashes_all_files(tmp_path, capsys, snapshot): + temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"] + temp_dir.mkdir() + cloned_repo = temp_dir / "attack_of_the_clone_repos" + cloned_repo.mkdir() + some_file = cloned_repo / "some_file.txt" + some_file.write_text("smol file") + + use_cases = UseCases(config=CONFIG_DEFAULTS, non_interactive=True, root_path=tmp_path) + + staged = use_cases.stage_changes() + use_cases.apply_changes(staged) + + resulting_files = list(tmp_path.iterdir()) + + resulting_copy = tmp_path / "attack_of_the_clone_repos" + assert len(list(resulting_copy.iterdir())) == 1 + + copied_file = resulting_copy / "some_file.txt" + + assert copied_file.read_text() == "smol file" + +def test_clean_up_cleans_up_temporary_directory_files(tmp_path): + temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"] + temp_dir.mkdir() + cloned_repo = temp_dir / "attack_of_the_clone_repos" + cloned_repo.mkdir() + some_file = cloned_repo / "some_file.txt" + some_file.write_text("smol file") + + use_cases = UseCases(config=CONFIG_DEFAULTS, non_interactive=True, root_path=tmp_path) + + use_cases.clean_up() + + assert len(list(tmp_path.iterdir())) == 0 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..7591a87 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,7 @@ +import pytest + +def assert_captured_output_matches_snapshot(capsys, snapshot): + captured_out = capsys.readouterr() + + assert captured_out.out == snapshot + assert captured_out.err == snapshot