diff --git a/.gitignore b/.gitignore index 093e835f..c1b6a85d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ __pycache__/ .idea .vscode +# direnv +.envrc + # Unit test / coverage reports htmlcov/ .coverage diff --git a/RATapi/project.py b/RATapi/project.py index 9b38f88e..da36b5ed 100644 --- a/RATapi/project.py +++ b/RATapi/project.py @@ -4,6 +4,7 @@ import copy import functools import json +import warnings from enum import Enum from pathlib import Path from textwrap import indent @@ -835,17 +836,17 @@ def classlist_script(name, classlist): + "\n)" ) - def save(self, path: Union[str, Path], filename: str = "project"): + def save(self, filepath: Union[str, Path] = "./project.json"): """Save a project to a JSON file. Parameters ---------- - path : str or Path - The path in which the project will be written. - filename : str - The name of the generated project file. + filepath : str or Path + The path to where the project file will be written. """ + filepath = Path(filepath).with_suffix(".json") + json_dict = {} for field in self.model_fields: attr = getattr(self, field) @@ -869,7 +870,7 @@ def make_custom_file_dict(item): "name": item.name, "filename": item.filename, "language": item.language, - "path": str(item.path), + "path": try_relative_to(item.path, filepath), } json_dict["custom_files"] = [make_custom_file_dict(file) for file in attr] @@ -879,8 +880,7 @@ def make_custom_file_dict(item): else: json_dict[field] = attr - file = Path(path, f"{filename.removesuffix('.json')}.json") - file.write_text(json.dumps(json_dict)) + filepath.write_text(json.dumps(json_dict)) @classmethod def load(cls, path: Union[str, Path]) -> "Project": @@ -892,15 +892,21 @@ def load(cls, path: Union[str, Path]) -> "Project": The path to the project file. """ - input = Path(path).read_text() - model_dict = json.loads(input) - for i in range(0, len(model_dict["data"])): - if model_dict["data"][i]["name"] == "Simulation": - model_dict["data"][i]["data"] = np.empty([0, 3]) - del model_dict["data"][i]["data_range"] + path = Path(path) + input_data = path.read_text() + model_dict = json.loads(input_data) + for dataset in model_dict["data"]: + if dataset["name"] == "Simulation": + dataset["data"] = np.empty([0, 3]) + del dataset["data_range"] else: - data = model_dict["data"][i]["data"] - model_dict["data"][i]["data"] = np.array(data) + data = dataset["data"] + dataset["data"] = np.array(data) + + # file paths are saved as relative to the project directory + for file in model_dict["custom_files"]: + if not Path(file["path"]).is_absolute(): + file["path"] = Path(path, file["path"]) return cls.model_validate(model_dict) @@ -943,3 +949,34 @@ def wrapped_func(*args, **kwargs): return return_value return wrapped_func + + +def try_relative_to(path: Path, relative_to: Path) -> str: + """Attempt to create a relative path and warn the user if it isn't possible. + + Parameters + ---------- + path : Path + The path to try to find a relative path for. + relative_to: Path + The path to which we find a relative path for ``path``. + + Returns + ------- + str + The relative path if successful, else the absolute path. + + """ + path = Path(path) + relative_to = Path(relative_to) + if path.is_relative_to(relative_to): + return str(path.relative_to(relative_to)) + else: + warnings.warn( + "Could not save custom file path as relative to the project directory, " + "which means that it may not work on other devices." + "If you would like to share your project, make sure your custom files " + "are in a subfolder of the project save location.", + stacklevel=2, + ) + return str(path.resolve()) diff --git a/tests/test_project.py b/tests/test_project.py index ee2136ba..eb2b2d8f 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -2,6 +2,7 @@ import copy import tempfile +import warnings from pathlib import Path from typing import Callable @@ -1556,8 +1557,43 @@ def test_save_load(project, request): original_project = request.getfixturevalue(project) with tempfile.TemporaryDirectory() as tmp: - original_project.save(tmp) - converted_project = RATapi.Project.load(Path(tmp, "project.json")) + # ignore relative path warnings + path = Path(tmp, "project.json") + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + original_project.save(path) + converted_project = RATapi.Project.load(path) + + # resolve custom files in case the original project had unresolvable relative paths + for file in original_project.custom_files: + file.path = file.path.resolve() for field in RATapi.Project.model_fields: assert getattr(converted_project, field) == getattr(original_project, field) + + +def test_relative_paths(): + """Test that ``try_relative_to`` correctly creates relative paths to subfolders.""" + + with tempfile.TemporaryDirectory() as tmp: + data_path = Path(tmp, "data/myfile.dat") + + assert Path(RATapi.project.try_relative_to(data_path, tmp)) == Path("data/myfile.dat") + + +def test_relative_paths_warning(): + """Test that we get a warning for trying to walk up paths.""" + + data_path = "/tmp/project/data/mydata.dat" + relative_path = "/tmp/project/project_path/myproj.dat" + + with pytest.warns( + match="Could not save custom file path as relative to the project directory, " + "which means that it may not work on other devices." + "If you would like to share your project, make sure your custom files " + "are in a subfolder of the project save location.", + ): + assert ( + Path(RATapi.project.try_relative_to(data_path, relative_path)) + == Path("/tmp/project/data/mydata.dat").resolve() + )