diff --git a/.github/workflows/tests-on-pr.yml b/.github/workflows/tests-on-pr.yml index e8ef0fc..08533f1 100644 --- a/.github/workflows/tests-on-pr.yml +++ b/.github/workflows/tests-on-pr.yml @@ -11,6 +11,7 @@ jobs: project: diffpy.cmi c_extension: false headless: false + python_version: 3.13 run: | set -Eeuo pipefail echo "Test cmds" diff --git a/news/copy-func-test.rst b/news/copy-func-test.rst new file mode 100644 index 0000000..22d461a --- /dev/null +++ b/news/copy-func-test.rst @@ -0,0 +1,23 @@ +**Added:** + +* Add test for ``copy_examples``. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/cmi/cli.py b/src/diffpy/cmi/cli.py index 5037a61..539b249 100644 --- a/src/diffpy/cmi/cli.py +++ b/src/diffpy/cmi/cli.py @@ -16,7 +16,7 @@ import argparse from pathlib import Path from shutil import copytree -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple from diffpy.cmi import __version__ from diffpy.cmi.conda import env_info @@ -25,22 +25,6 @@ from diffpy.cmi.profilesmanager import ProfilesManager -def copy_examples( - examples_dict: Dict[str, List[Tuple[str, Path]]], target_dir: Path = None -) -> None: - """Copy an example into the the target or current working directory. - - Parameters - ---------- - examples_dict : dict - Dictionary mapping pack name -> list of (example, path) tuples. - target_dir : pathlib.Path, optional - Target directory to copy examples into. Defaults to current - working directory. - """ - return - - # Examples def _get_examples_dir() -> Path: """Return the absolute path to the installed examples directory. diff --git a/src/diffpy/cmi/packsmanager.py b/src/diffpy/cmi/packsmanager.py index 9c25e9e..8c2f431 100644 --- a/src/diffpy/cmi/packsmanager.py +++ b/src/diffpy/cmi/packsmanager.py @@ -12,7 +12,7 @@ # See LICENSE.rst for license information. # ############################################################################## - +import shutil from importlib.resources import as_file from pathlib import Path from typing import List, Union @@ -133,6 +133,129 @@ def available_examples(self) -> dict[str, List[tuple[str, Path]]]: ) return examples_dict + def copy_examples( + self, + examples_to_copy: List[str], + target_dir: Path = None, + force: bool = False, + ) -> None: + """Copy examples or packs into the target or current working + directory. + + Parameters + ---------- + examples_to_copy : list of str + User-specified pack(s), example(s), or "all" to copy all. + target_dir : pathlib.Path, optional + Target directory to copy examples into. Defaults to current + working directory. + force : bool, optional + Defaults to ``False``. If ``True``, existing files are + overwritten and directories are merged + (extra files in the target are preserved). + """ + self._target_dir = target_dir.resolve() if target_dir else Path.cwd() + self._force = force + + if "all" in examples_to_copy: + self._copy_all() + return + + for item in examples_to_copy: + if item in self.available_examples(): + self._copy_pack(item) + elif self._is_example_name(item): + self._copy_example(item) + else: + raise FileNotFoundError( + f"No examples or packs found for input: '{item}'" + ) + del self._target_dir + del self._force + return + + def _copy_all(self): + """Copy all packs and examples.""" + for pack_name in self.available_examples(): + self._copy_pack(pack_name) + + def _copy_pack(self, pack_name): + """Copy all examples in a single pack.""" + examples = self.available_examples().get(pack_name, []) + for ex_name, ex_path in examples: + self._copy_tree_to_target(pack_name, ex_name, ex_path) + + def _copy_example(self, example_name): + """Copy a single example by its name.""" + example_found = False + for pack_name, examples in self.available_examples().items(): + for ex_name, ex_path in examples: + if ex_name == example_name: + self._copy_tree_to_target(pack_name, ex_name, ex_path) + example_found = True + if not example_found: + raise FileNotFoundError( + f"No examples or packs found for input: '{example_name}'" + ) + + def _is_example_name(self, name): + """Return True if the given name matches any known example.""" + for pack_name, examples in self.available_examples().items(): + for example_name, _ in examples: + if example_name == name: + return True + return False + + def _copy_tree_to_target(self, pack_name, example_name, example_origin): + """Copy an example folder from source to the user's target + directory.""" + target_dir = self._target_dir / pack_name / example_name + target_dir.parent.mkdir(parents=True, exist_ok=True) + if target_dir.exists() and self._force: + self._overwrite_example( + example_origin, target_dir, pack_name, example_name + ) + return + if target_dir.exists(): + self._copy_missing_files(example_origin, target_dir) + print( + f"WARNING: Example '{pack_name}/{example_name}'" + " already exists at the specified target directory. " + "Existing files were left unchanged; " + "new or missing files were copied. To overwrite everything, " + "rerun with --force." + ) + return + self._copy_new_example( + example_origin, target_dir, pack_name, example_name + ) + + def _overwrite_example( + self, example_origin, target, pack_name, example_name + ): + """Delete target and copy example.""" + shutil.rmtree(target) + shutil.copytree(example_origin, target) + print(f"Overwriting example '{pack_name}/{example_name}'.") + + def _copy_missing_files(self, example_origin, target): + """Copy only files and directories that are missing in the + target.""" + for example_item in example_origin.rglob("*"): + rel_path = example_item.relative_to(example_origin) + target_item = target / rel_path + if example_item.is_dir(): + target_item.mkdir(parents=True, exist_ok=True) + elif example_item.is_file() and not target_item.exists(): + target_item.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(example_item, target_item) + + def _copy_new_example( + self, example_origin, target, pack_name, example_name + ): + shutil.copytree(example_origin, target) + print(f"Copied example '{pack_name}/{example_name}'.") + def _resolve_pack_file(self, identifier: Union[str, Path]) -> Path: """Resolve a pack identifier to an absolute .txt path. diff --git a/tests/conftest.py b/tests/conftest.py index 5a4edca..c58b653 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,7 @@ def tmp_examples(tmp_path_factory): yield tmp_examples -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def example_cases(tmp_path_factory): """Copy the entire examples tree into a temp directory once per test session. @@ -30,6 +30,10 @@ def example_cases(tmp_path_factory): Returns the path to that copy. """ root_temp_dir = tmp_path_factory.mktemp("temp") + cwd = root_temp_dir / "cwd" + cwd.mkdir(parents=True, exist_ok=True) + existing_dir = cwd / "existing_target" + existing_dir.mkdir(parents=True, exist_ok=True) # case 1: pack with no examples case1ex_dir = root_temp_dir / "case1" / "docs" / "examples" @@ -44,13 +48,15 @@ def example_cases(tmp_path_factory): case2ex_dir / "full_pack" / "ex1" / "solution" / "diffpy-cmi" ) # full_pack, ex1 case2a.mkdir(parents=True, exist_ok=True) - (case2a / "script1.py").touch() + (case2a / "script1.py").write_text(f"# {case2a.name} script1\n") + case2b = ( case2ex_dir / "full_pack" / "ex2" / "random" / "path" ) # full_pack, ex2 case2b.mkdir(parents=True, exist_ok=True) - (case2b / "script1.py").touch() - (case2b / "script2.py").touch() + (case2b / "script1.py").write_text(f"# {case2b.name} script1\n") + (case2b / "script2.py").write_text(f"# {case2b.name} script2\n") + case2req_dir = root_temp_dir / "case2" / "requirements" / "packs" case2req_dir.mkdir(parents=True, exist_ok=True) @@ -58,16 +64,19 @@ def example_cases(tmp_path_factory): case3ex_dir = root_temp_dir / "case3" / "docs" / "examples" case3a = case3ex_dir / "packA" / "ex1" # packA, ex1 case3a.mkdir(parents=True, exist_ok=True) - (case3a / "script1.py").touch() + (case3a / "script1.py").write_text(f"# {case3a.name} script1\n") + case3b = case3ex_dir / "packA" / "ex2" / "solutions" # packA, ex2 case3b.mkdir(parents=True, exist_ok=True) - (case3b / "script2.py").touch() + (case3b / "script2.py").write_text(f"# {case3b.name} script2\n") + case3c = ( case3ex_dir / "packB" / "ex3" / "more" / "random" / "path" ) # packB, ex3 case3c.mkdir(parents=True, exist_ok=True) - (case3c / "script3.py").touch() - (case3c / "script4.py").touch() + (case3c / "script3.py").write_text(f"# {case3c.name} script3\n") + (case3c / "script4.py").write_text(f"# {case3c.name} script4\n") + case3req_dir = root_temp_dir / "case3" / "requirements" / "packs" case3req_dir.mkdir(parents=True, exist_ok=True) @@ -80,12 +89,27 @@ def example_cases(tmp_path_factory): # Case 5: multiple packs with the same example names case5ex_dir = root_temp_dir / "case5" / "docs" / "examples" + case5a = case5ex_dir / "packA" / "ex1" / "path1" # packA, ex1 case5a.mkdir(parents=True, exist_ok=True) - (case5a / "script1.py").touch() + (case5a / "script1.py").write_text(f"# {case5a.name} script1\n") + case5b = case5ex_dir / "packB" / "ex1" / "path2" # packB, ex1 case5b.mkdir(parents=True, exist_ok=True) - (case5b / "script2.py").touch() + (case5b / "script2.py").write_text(f"# {case5b.name} script2\n") + + case5c = case5ex_dir / "packA" / "ex2" # packA, ex2 + case5c.mkdir(parents=True, exist_ok=True) + (case5c / "script3.py").write_text(f"# {case5c.name} script3\n") + + case5d = case5ex_dir / "packB" / "ex3" + case5d.mkdir(parents=True, exist_ok=True) + (case5d / "script4.py").write_text(f"# {case5d.name} script4\n") + + case5e = case5ex_dir / "packB" / "ex4" + case5e.mkdir(parents=True, exist_ok=True) + (case5e / "script5.py").write_text(f"# {case5e.name} script5\n") + case5req_dir = root_temp_dir / "case5" / "requirements" / "packs" case5req_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9eb9712..e69de29 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,65 +0,0 @@ -import os -from pathlib import Path - -import pytest - -from diffpy.cmi import cli - - -def test_map_pack_to_examples_structure(): - """Test that map_pack_to_examples returns the right shape of - data.""" - actual = cli.map_pack_to_examples() - assert isinstance(actual, dict) - for pack, exdirs in actual.items(): - assert isinstance(pack, str) - assert isinstance(exdirs, list) - for ex in exdirs: - assert isinstance(ex, str) - # Check for known packs - assert "core" in actual.keys() - assert "pdf" in actual.keys() - # Check for known examples - assert ["linefit"] in actual.values() - - -@pytest.mark.parametrize( - "input_valid_str", - [ - "core/linefit", - "pdf/ch03NiModelling", - ], -) -def test_copy_example_success(tmp_path, input_valid_str): - """Given a valid example format (/), test that its copied - to the temp dir.""" - os.chdir(tmp_path) - actual = cli.copy_example(input_valid_str) - expected = tmp_path / Path(input_valid_str).name - assert expected.exists() and expected.is_dir() - assert actual == expected - - -def test_copy_example_fnferror(): - """Test that FileNotFoundError is raised when the example does not - exist.""" - with pytest.raises(FileNotFoundError): - cli.copy_example("pack/example1") - - -@pytest.mark.parametrize( - "input_bad_str", - [ - "", # empty string - "/", # missing pack and example - "corelinefit", # missing slash - "linefit", # missing pack and slash - "core/", # missing example - "/linefit", # missing pack - "core/linefit/extra", # too many slashes - ], -) -def test_copy_example_valueerror(input_bad_str): - """Test that ValueError is raised when the format is invalid.""" - with pytest.raises(ValueError): - cli.copy_example(input_bad_str) diff --git a/tests/test_packsmanager.py b/tests/test_packsmanager.py index 986b060..bc93275 100644 --- a/tests/test_packsmanager.py +++ b/tests/test_packsmanager.py @@ -1,3 +1,7 @@ +import os +import re +from pathlib import Path + import pytest from diffpy.cmi.packsmanager import PacksManager @@ -63,9 +67,12 @@ def paths_and_names_match(expected, actual, root): { "packA": [ ("ex1", "case5/docs/examples/packA/ex1"), + ("ex2", "case5/docs/examples/packA/ex2"), ], "packB": [ ("ex1", "case5/docs/examples/packB/ex1"), + ("ex3", "case5/docs/examples/packB/ex3"), + ("ex4", "case5/docs/examples/packB/ex4"), ], }, ), @@ -92,3 +99,245 @@ def test_tmp_file_structure(input, expected, example_cases): assert path.is_file() else: assert path.is_dir() + + +copy_params = [ + # Test various use cases to copy_examples on case5 + # 1) copy one example (ambiguous) + # 2) copy list of examples from same pack (ambiguous) + # 3) copy one example (unambiguous) + # 4) copy list of examples from same pack (unambiguous) + # 5) copy list of examples from different packs (unambiguous) + # 6) copy all examples from a pack + # 7) copy all examples from list of packs + # 8) copy all examples from all packs + ( # 1) copy one example, (ambiguous) + ["ex1"], + [ + Path("packA/ex1/path1/script1.py"), + Path("packB/ex1/path2/script2.py"), + ], + ), + ( # 2) copy list of examples from same pack (ambiguous) + ["ex1", "ex2"], + [ + Path("packA/ex1/path1/script1.py"), + Path("packB/ex1/path2/script2.py"), + Path("packA/ex2/script3.py"), + ], + ), + ( # 3) copy one example (unambiguous) + ["ex2"], + [ + Path("packA/ex2/script3.py"), + ], + ), + ( # 4) copy list of examples from same pack (unambiguous) + ["ex3", "ex4"], + [ + Path("packB/ex3/script4.py"), + Path("packB/ex4/script5.py"), + ], + ), + ( # 5) copy list of examples from different packs (unambiguous) + ["ex2", "ex3"], + [ + Path("packA/ex2/script3.py"), + Path("packB/ex3/script4.py"), + ], + ), + ( # 6) copy all examples from a pack + ["packA"], + [ + Path("packA/ex1/path1/script1.py"), + Path("packA/ex2/script3.py"), + ], + ), + ( # 7) copy all examples from list of packs + ["packA", "packB"], + [ + Path("packA/ex1/path1/script1.py"), + Path("packA/ex2/script3.py"), + Path("packB/ex1/path2/script2.py"), + Path("packB/ex3/script4.py"), + Path("packB/ex4/script5.py"), + ], + ), + ( # 8) copy all examples from all packs + ["all"], + [ + Path("packA/ex1/path1/script1.py"), + Path("packA/ex2/script3.py"), + Path("packB/ex1/path2/script2.py"), + Path("packB/ex3/script4.py"), + Path("packB/ex4/script5.py"), + ], + ), +] + + +# input: list of str - cli input(s) to copy_examples +# expected_paths: list of Path - expected relative paths to copied examples +@pytest.mark.parametrize("input,expected_paths", copy_params) +def test_copy_examples(input, expected_paths, example_cases): + examples_dir = example_cases / "case5" + pm = PacksManager(root_path=examples_dir) + target_dir = example_cases / "user_target" + pm.copy_examples(input, target_dir=target_dir) + actual = sorted(target_dir.rglob("*.py")) + expected = sorted([target_dir / path for path in expected_paths]) + assert actual == expected + for path in expected_paths: + copied_path = target_dir / path + original_path = examples_dir / path + if copied_path.is_file() and original_path.is_file(): + assert copied_path.read_text() == original_path.read_text() + + +# Test default and targeted copy_example location on case5 +# input: str or None - path arg to copy_examples +# expected: Path - expected relative path to copied example +@pytest.mark.parametrize( + "input,expected_paths", + [ + ( + None, + [ + Path("cwd/packA/ex1/path1/script1.py"), + Path("cwd/packA/ex2/script3.py"), + ], + ), + # input is a target dir that doesn't exist yet + # expected target dir to be created and examples copied there + ( + Path("user_target"), + [ + Path("cwd/user_target/packA/ex1/path1/script1.py"), + Path("cwd/user_target/packA/ex2/script3.py"), + ], + ), + # input is a target dir that already exists + # expected examples copied into existing dir + ( + Path("existing_target"), + [ + Path("cwd/existing_target/packA/ex1/path1/script1.py"), + Path("cwd/existing_target/packA/ex2/script3.py"), + ], + ), + ], +) +def test_copy_examples_location(input, expected_paths, example_cases): + examples_dir = example_cases / "case5" + os.chdir(example_cases / "cwd") + pm = PacksManager(root_path=examples_dir) + pm.copy_examples(["packA"], target_dir=input) + target_directory = ( + Path.cwd() if input is None else example_cases / "cwd" / input + ) + actual = sorted(target_directory.rglob("*.py")) + expected = sorted([example_cases / path for path in expected_paths]) + assert actual == expected + + +# Test bad inputs to copy_examples on case3 +# These include: +# 1) Input not found (example or pack) +# 2) Mixed good and bad inputs +# 3) Path to directory already exists +# 4) No input provided +@pytest.mark.parametrize( + "bad_inputs,expected,path,is_warning", + [ + ( + # 1) Input not found (example or pack). + # Expected: Raise an error with the message. + ["bad_example"], + "No examples or packs found for input: 'bad_example'", + None, + False, + ), + ( + # 2) Mixed good example and bad input. + # Expected: Raise an error with the message. + ["ex1", "bad_example"], + "No examples or packs found for input: 'bad_example'", + None, + False, + ), + ( + # 3) Mixed good pack and bad input. + # Expected: Raise an error with the message. + ["packA", "bad_example"], + "No examples or packs found for input: 'bad_example'", + None, + False, + ), + ( + # 4) Path to directory already exists. + # Expected: Raise a warning with the message. + ["ex1"], + ( + "WARNING: Example 'packA/ex1' already exists at" + " the specified target directory. " + "Existing files were left unchanged; new or missing " + "files were copied. " + "To overwrite everything, rerun with --force." + ), + Path("docs/examples/"), + True, + ), + ], +) +def test_copy_examples_bad( + bad_inputs, expected, path, is_warning, example_cases, capsys +): + examples_dir = example_cases / "case3" + pm = PacksManager(root_path=examples_dir) + target_dir = None if path is None else examples_dir / path + if is_warning: + pm.copy_examples(bad_inputs, target_dir=target_dir) + captured = capsys.readouterr() + actual = captured.out + assert re.search(re.escape(expected), actual) + else: + with pytest.raises(FileNotFoundError, match=re.escape(expected)): + pm.copy_examples(bad_inputs, target_dir=target_dir) + + +@pytest.mark.parametrize( + "expected_paths,force", + [ + ( + [ # UC1: copy examples to target dir with overwrite + # expected: Existing files are overwritten and new files copied + Path("packA/ex1/script1.py"), + Path("packA/ex2/solutions/script2.py"), + ], + True, + ), + ( + [ # UC2: copy examples to target dir without overwrite + # expected: Existing files are left unchanged; new files copied + Path("packA/ex1/path1/script1.py"), + Path("packA/ex1/script1.py"), + Path("packA/ex2/solutions/script2.py"), + Path("packA/ex2/script3.py"), + ], + False, + ), + ], +) +def test_copy_examples_force(example_cases, expected_paths, force): + examples_dir = example_cases / "case3" + pm = PacksManager(root_path=examples_dir) + case5dir = example_cases / "case5" / "docs" / "examples" + pm.copy_examples(["packA"], target_dir=case5dir, force=force) + actual = sorted((case5dir / "packA").rglob("*.py")) + expected = sorted([case5dir / path for path in expected_paths]) + assert actual == expected + for path in expected_paths: + copied_path = case5dir / path + original_path = examples_dir / path + if copied_path.is_file() and original_path.is_file(): + assert copied_path.read_text() == original_path.read_text()