44import copy
55import functools
66import json
7+ import warnings
78from enum import Enum
89from pathlib import Path
10+ from sys import version_info
911from textwrap import indent
1012from typing import Annotated , Any , Callable , Union
1113
@@ -835,17 +837,18 @@ def classlist_script(name, classlist):
835837 + "\n )"
836838 )
837839
838- def save (self , path : Union [str , Path ], filename : str = "project" ):
840+ def save (self , filepath : Union [str , Path ], filename : str = "project" ):
839841 """Save a project to a JSON file.
840842
841843 Parameters
842844 ----------
843- path : str or Path
845+ filepath : str or Path
844846 The path in which the project will be written.
845847 filename : str
846848 The name of the generated project file.
847849
848850 """
851+ filepath = Path (filepath )
849852 json_dict = {}
850853 for field in self .model_fields :
851854 attr = getattr (self , field )
@@ -869,7 +872,7 @@ def make_custom_file_dict(item):
869872 "name" : item .name ,
870873 "filename" : item .filename ,
871874 "language" : item .language ,
872- "path" : str (item .path ),
875+ "path" : str (try_relative_to ( item .path , filepath ) ),
873876 }
874877
875878 json_dict ["custom_files" ] = [make_custom_file_dict (file ) for file in attr ]
@@ -879,7 +882,7 @@ def make_custom_file_dict(item):
879882 else :
880883 json_dict [field ] = attr
881884
882- file = Path (path , f"{ filename .removesuffix ('.json' )} .json" )
885+ file = Path (filepath , f"{ filename .removesuffix ('.json' )} .json" )
883886 file .write_text (json .dumps (json_dict ))
884887
885888 @classmethod
@@ -892,15 +895,21 @@ def load(cls, path: Union[str, Path]) -> "Project":
892895 The path to the project file.
893896
894897 """
895- input = Path (path ).read_text ()
896- model_dict = json .loads (input )
897- for i in range (0 , len (model_dict ["data" ])):
898- if model_dict ["data" ][i ]["name" ] == "Simulation" :
899- model_dict ["data" ][i ]["data" ] = np .empty ([0 , 3 ])
900- del model_dict ["data" ][i ]["data_range" ]
898+ path = Path (path )
899+ input_data = path .read_text ()
900+ model_dict = json .loads (input_data )
901+ for dataset in model_dict ["data" ]:
902+ if dataset ["name" ] == "Simulation" :
903+ dataset ["data" ] = np .empty ([0 , 3 ])
904+ del dataset ["data_range" ]
901905 else :
902- data = model_dict ["data" ][i ]["data" ]
903- model_dict ["data" ][i ]["data" ] = np .array (data )
906+ data = dataset ["data" ]
907+ dataset ["data" ] = np .array (data )
908+
909+ # file paths are saved as relative to the project directory
910+ for file in model_dict ["custom_files" ]:
911+ if not Path (file ["path" ]).is_absolute ():
912+ file ["path" ] = Path (path , file ["path" ])
904913
905914 return cls .model_validate (model_dict )
906915
@@ -943,3 +952,44 @@ def wrapped_func(*args, **kwargs):
943952 return return_value
944953
945954 return wrapped_func
955+
956+
957+ def try_relative_to (path : Path , relative_to : Path ) -> Path :
958+ """Attempt to create a relative path and warn the user if it isn't possible.
959+
960+ Parameters
961+ ----------
962+ path : Path
963+ The path to try to find a relative path for.
964+ relative_to: Path
965+ The path to which we find a relative path for ``path``.
966+
967+ Returns
968+ -------
969+ Path
970+ The relative path if successful, else the absolute path.
971+
972+ """
973+ # we use the absolute paths to resolve symlinks and so on
974+ abs_path = Path (path ).resolve ()
975+ abs_base = Path (relative_to ).resolve ()
976+
977+ # 'walking up' paths is only added in Python 3.12
978+ if version_info .minor < 12 :
979+ try :
980+ relative_path = abs_path .relative_to (abs_base )
981+ except ValueError as err :
982+ warnings .warn ("Could not save a custom file path as relative to the project directory. "
983+ "This may mean the project may not open on other devices. "
984+ f"Error message: { err } " , stacklevel = 2 )
985+ return abs_path
986+ else :
987+ try :
988+ relative_path = abs_path .relative_to (abs_base , walk_up = True )
989+ except ValueError as err :
990+ warnings .warn ("Could not save a custom file path as relative to the project directory. "
991+ "This may mean the project may not open on other devices. "
992+ f"Error message: { err } " , stacklevel = 2 )
993+ return abs_path
994+
995+ return relative_path
0 commit comments