Coverage for /home/pi/Software/model-railway-signalling/model_railway_signals/library/file_interface.py: 41%
133 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-08 18:20 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-08 18:20 +0100
1# ----------------------------------------------------------------------------------------------
2# This library module enables layout schematics to be saved and loaded to/from file
3# (this includes all schematic editor settings, schematic objects and object state)
4# ----------------------------------------------------------------------------------------------
5#
6# External API - classes and functions (used by the Schematic Editor):
7#
8# load_schematic - Opens a file picker dialog to select a 'sig' file to load and then loads the file,
9# populating the global layout_state dictionary (for library object state) and returning
10# the loaded file as a dictionary (containing the editor configuration and schematic objects)
11# Returns the filename that was used to load the file (or None if the file was not loaded)
12# Optional Parameters:
13# requested_filename:str - A file load will be attempted without opening the file picker dialog
14#
15# purge_loaded_state_information() - called by the editor after the layout has been successfully created
16# within the editor to stop any subsequently created objects erroneously inheriting state
17#
18# save_schematic - Saves the supplied 'settings' and 'objects' to file together with the current state of
19# The library objects (which is queried directly from the library objects themselves
20# Returns the filename that was used to save the file (or None if the file was not saved)
21# Mandatory Parameters:
22# settings:dict - dictionary holding the editor settings
23# objects:dict - dictionary holding the editor objects
24# requested_filename:str - to load/save - default = None (will default to 'main-python-script.sig')
25# Optional Parameters:
26# save_as:bool - Specify true to open the file picker 'save as' dialog(default=False)
27#
28# External API - classes and functions (used by the other library modules):
29#
30# get_initial_item_state(layout_element:str,item_id:int) - Called by other library modules on creation of
31# library objects to query the initial state (e.g. layout_element="signal", item_id=1.
32# Returns an object-specific dictionary containing key-value pairs of object state
33#
34#------------------------------------------------------------------------------------------------
36import os
37import json
38import logging
39import tkinter.messagebox
40import tkinter.filedialog
41from . import signals_common
42from . import track_sections
43from . import block_instruments
44from . import points
45from . import common
47#-------------------------------------------------------------------------------------------------
48# Global variable to hold the last filename used for save/load
49#-------------------------------------------------------------------------------------------------
51last_fully_qualified_file_name = None
53#-------------------------------------------------------------------------------------------------
54# Global dictionary to hold the loaded layout state
55#-------------------------------------------------------------------------------------------------
57layout_state ={}
59#-------------------------------------------------------------------------------------------------
60# Define all the layout state elements that needs to be saved/loaded for each module. This
61# makes the remainder of the code generic. If we ever need to add another "layout_element" or
62# another "item_element" (to an existing layout element) then we just define that here
63# Note that we also define the type for each element to enable a level of validation on load
64# This is also important for "enum" types where we have to save the VALUE of the Enum
65#-------------------------------------------------------------------------------------------------
67def get_sig_file_config(get_sig_file_data:bool = False):
69 signal_elements = ( ("sigclear","bool"),("subclear","bool"),("override","bool"),
70 ("siglocked","bool") ,("sublocked","bool"),("routeset","enum"),
71 ("releaseonred","bool"),("releaseonyel","bool"),("theatretext","str") )
72 point_elements = ( ("switched","bool"),("fpllock","bool"),("locked","bool") )
73 section_elements = ( ("occupied","bool"),("labeltext","str") )
74 instrument_elements = ( ("sectionstate","bool"),("repeaterstate","bool") )
76 layout_elements = { "signals" : {"elements" : signal_elements},
77 "points" : {"elements" : point_elements},
78 "sections" : {"elements" : section_elements},
79 "instruments": {"elements" : instrument_elements} }
81 if get_sig_file_data: 81 ↛ 82line 81 didn't jump to line 82, because the condition on line 81 was never true
82 layout_elements["points"]["source"] = points.points
83 layout_elements["signals"]["source"] = signals_common.signals
84 layout_elements["sections"]["source"] = track_sections.sections
85 layout_elements["instruments"]["source"] = block_instruments.instruments
87 return(layout_elements)
89#-------------------------------------------------------------------------------------------------
90# API function purge the loaded layout_state information - this is called by the editor after the
91# layout has been successfully created within the editor to stop any subsequently created objects
92# (with the same Item ID) erroneously inheriting state.
93#-------------------------------------------------------------------------------------------------
95def purge_loaded_state_information():
96 global layout_state
97 layout_state ={}
98 return()
100#-------------------------------------------------------------------------------------------------
101# API function to handle the loading of a schematic file by the the schematic editor.
102# Returns the name of the loaded file if successful (otherwise None) and the loaded
103# 'layout_state' (dict containing the schematic settings, objects and object state).
104# Also populates the global 'layout_state' dictonary with the loaded data as this is
105# queried when library objects are created in order to set the initial state.
106#-------------------------------------------------------------------------------------------------
108def load_schematic(requested_filename:str=None):
109 global last_fully_qualified_file_name ## Set by 'load_state' and 'save_state' ##
110 global layout_state ## populated on successful file load ##
111 # If the requested filename is 'None' then we always open a file picker dialog. This
112 # is pre-populated with the 'last_fully_qualified_file_name' if it exists as a file
113 # Otherwise, we will attempt to load the requested filename (without a dialog)
114 if requested_filename is None: 114 ↛ 115line 114 didn't jump to line 115, because the condition on line 114 was never true
115 if last_fully_qualified_file_name is not None and os.path.isfile(last_fully_qualified_file_name):
116 path, name = os.path.split(last_fully_qualified_file_name)
117 else:
118 path, name = ".", ""
119 filename_to_load = tkinter.filedialog.askopenfilename(title='Load Layout State',
120 filetypes=(('sig files','*.sig'),('all files','*.*')),
121 initialdir=path, initialfile=name)
122 # If dialogue is cancelled then Filename will remain as 'None' as nothing will be loaded
123 if filename_to_load == () or filename_to_load == "":
124 filename_to_load = None
125 elif not os.path.isfile(requested_filename): 125 ↛ 126line 125 didn't jump to line 126, because the condition on line 125 was never true
126 filename_to_load = None
127 else:
128 filename_to_load = requested_filename
129 # We should now have a valid filename (and the file exists) unless the user has cancelled
130 # the file load dialog or the specified file does not exist (in this case it will be None)
131 if filename_to_load is None: 131 ↛ 132line 131 didn't jump to line 132, because the condition on line 131 was never true
132 logging.info("Load File - No file selected - Layout will remain in its default state")
133 else:
134 logging.info("Load File - Loading layout configuration from '"+filename_to_load+"'")
135 try:
136 with open (filename_to_load,'r') as file:
137 file_contents=file.read()
138 file.close
139 except Exception as exception:
140 logging.error("Load File - Error opening file - Layout will remain in its default state")
141 logging.error("Load File - Reported Exception: "+str(exception))
142 tkinter.messagebox.showerror(parent=common.root_window,
143 title="File Load Error", message=str(exception))
144 filename_to_load = None
145 else:
146 # The file has been successfuly opened and loaded - Now convert it from the json format back
147 # into the dictionary of signals, points and sections - with exception handling in case it fails
148 try:
149 loaded_state = json.loads(file_contents)
150 except Exception as exception:
151 logging.error("Load File - Couldn't read file - Layout will be created in its default state")
152 logging.error("Load File - Reported exception: "+str(exception))
153 tkinter.messagebox.showerror(parent=common.root_window,
154 title="File Parse Error", message=str(exception))
155 filename_to_load = None
156 else:
157 # File parsing was successful - we can populate the global dictionary and
158 # update the global 'last_fully_qualified_file_name' for the next save/load
159 layout_state = loaded_state
160 last_fully_qualified_file_name = filename_to_load
161 # Return the filename that was actually loaded (which will be None if the load failed)
162 # And the dictionary containing the layout state (configuration, objects, state etc)
163 return(filename_to_load, layout_state)
165#-------------------------------------------------------------------------------------------------
166# API function to handle the saving of a schematic file by the the schematic editor.
167# (the schematic settings, objects - passed in to the function by the editor and and
168# the current state of all libray objects - which are populated by this function))
169# Returns the name of the saved file if successful (otherwise None)
170#-------------------------------------------------------------------------------------------------
172def save_schematic(settings:dict, objects:dict, requested_filename:str, save_as:bool=False):
173 global last_fully_qualified_file_name
174 dictionary_to_save={}
175 dictionary_to_save["settings"] = settings
176 dictionary_to_save["objects"] = objects
177 # If the 'save_as' option has been specified then we want to provide a default file
178 # to the user in the dialog. This will be the requested_filename (if this is a valid file)
179 # or the last loaded / saved file (if the last_fully_qualified_file_name is valid)
180 # If the 'save_as' option has not been specified ('save' rather than 'save-as') then
181 # we will just try to save the specified requested_filename (if it fails, it fails)
182 if save_as:
183 if requested_filename is not None and os.path.isfile(requested_filename):
184 path, name = os.path.split(requested_filename)
185 elif last_fully_qualified_file_name is not None and os.path.isfile(last_fully_qualified_file_name):
186 path, name = os.path.split(last_fully_qualified_file_name)
187 else:
188 path, name = ".", ""
189 filename_to_save = tkinter.filedialog.asksaveasfilename(title='Save Layout State',
190 filetypes=(('sig files','*.sig'),('all files','*.*')),
191 initialfile=name, initialdir=path)
192 # If dialogue is cancelled then Filename will remain as 'None' as nothing will be saved
193 if filename_to_save == () or filename_to_save == "": filename_to_save = None
194 elif not os.path.isfile(requested_filename):
195 filename_to_save = None
196 else:
197 filename_to_save = requested_filename
198 # We should now have a valid filename (and the file exists) unless the user has cancelled
199 # the file save dialog or the specified file does not exist (in this case it will be None)
200 if filename_to_save is None:
201 logging.info("Save File - No file selected")
202 else:
203 # We have a valid filename - Force the ".sig" extension
204 if not filename_to_save.endswith(".sig"): filename_to_save = filename_to_save+".sig"
205 # Note that the asksaveasfilename dialog returns the fully qualified filename
206 # (including the path) - we only need the name so strip out the path element
207 logging.info("Save File - Saving Layout Configuration as '"+filename_to_save+"'")
208 dictionary_to_save["information"] = "Model Railway Signalling Configuration File"
209 # Retrieve the DEFINITION of all the data items we need to save to maintain state
210 # These are defined in a single function at the top of this source file. We also
211 # retrieve the source DATA we need to save from the various source dictionaries
212 layout_elements = get_sig_file_config(get_sig_file_data=True)
213 # Iterate through the main LAYOUT-ELEMENTS (e.g. signals, points, sections etc)
214 for layout_element in layout_elements:
215 # For each LAYOUT ELEMENT create a sub-dictionary to hold the individual ITEMS
216 # The individual ITEMS will be the individual points, signals, sections etc
217 dictionary_to_save[layout_element] = {}
218 # Get the dictionary containing the source data for this LAYOUT ELEMENT
219 source_data_dictionary = layout_elements[layout_element]["source"]
220 # Get the list of the ITEM ELEMENTS we need to save for this LAYOUT ELEMENT
221 item_elements_to_save = layout_elements[layout_element]["elements"]
222 # Iterate through the ITEMS that exist for this LAYOUT ELEMENT
223 # Each ITEM represents a specific signal, point, section etc
224 for item in source_data_dictionary:
225 # For each ITEM, create a sub-dictionary to hold the individual ITEM ELEMENTS
226 # Each ITEM ELEMENT represents a specific parameter for an ITEM (e.g."sigclear")
227 dictionary_to_save[layout_element][item] = {}
228 # Iterate through the ITEM ELEMENTS to save for the specific ITEM
229 for item_element in item_elements_to_save:
230 # Value [0] is the element name, Value [1] is the element type
231 if item_element[0] not in source_data_dictionary[item].keys():
232 # if the element isn't present in the source dict then we save a NULL value
233 dictionary_to_save[layout_element][item][item_element[0]] = None
234 elif item_element[1]=="enum":
235 # Enumeration values cannot be converted to json as is - we need to use the value
236 parameter = source_data_dictionary[item][item_element[0]]
237 dictionary_to_save[layout_element][item][item_element[0]] = parameter.value
238 else:
239 # The Json conversion should support all standard python types
240 parameter = source_data_dictionary[item][item_element[0]]
241 dictionary_to_save[layout_element][item][item_element[0]] = parameter
242 # convert the file to a human readable json format and save the file
243 file_contents = json.dumps(dictionary_to_save,indent=4,sort_keys=True)
244 try:
245 with open (filename_to_save,'w') as file:
246 file.write(file_contents)
247 file.close
248 except Exception as exception:
249 logging.error("Save File - Error saving file - Reported exception: "+str(exception))
250 tkinter.messagebox.showerror(parent=common.root_window,
251 title="File Save Error",message=str(exception))
252 filename_to_save = None
253 else:
254 # File parsing was successful - update the global 'last_fully_qualified_file_name'
255 last_fully_qualified_file_name = filename_to_save
256 return(filename_to_save)
258#-------------------------------------------------------------------------------------------------
259# Library Function called on creation of a Library Object to return the initial state of the object
260# from the loaded data. If no layout state has been loaded or the loaded data doesn't include an
261# entry for the Object then we return 'None' and the Object will retain its "as created" default state
262#-------------------------------------------------------------------------------------------------
264def get_initial_item_state(layout_element:str,item_id:int):
265 # Retrieve the DEFINITION of all the data items that are available
266 sig_file_config = get_sig_file_config()
267 # Check if the requested LAYOUT ELEMENT is a supported
268 if layout_element not in sig_file_config.keys(): 268 ↛ 269line 268 didn't jump to line 269, because the condition on line 268 was never true
269 logging.error("File Interface - Item type not supported : "+layout_element)
270 state_to_return = None
271 else:
272 # Create a dictionary to hold the state information we want to return
273 state_to_return = {}
274 # Iterate through the ITEM ELEMENTS we are interested in for the LAYOUT ELEMENT and
275 # set an initial value of NONE (to be returned if we fail to validate the loaded data)
276 for item_element in sig_file_config[layout_element]["elements"]:
277 # retrieve the required ITEM ELEMENT Name
278 item_element_name = item_element[0]
279 state_to_return[item_element_name] = None
280 # See if the specified LAYOUT ELEMENT exists in the loaded file
281 if not layout_element in layout_state.keys():
282 # This could be a valid condition if no file has been loaded - fail silently
283 pass
284 # See if the specified ITEM (for the LAYOUT ELEMENT) exists in the loaded file
285 elif str(item_id) not in layout_state[layout_element].keys(): 285 ↛ 287line 285 didn't jump to line 287, because the condition on line 285 was never true
286 # We know a file is loaded - therefore this is a valid error to report
287 logging.warning("File Interface - Data missing for '"+layout_element+"-"
288 +str(item_id)+"' - Default values will be set")
289 else:
290 # Iterate through the ITEM ELEMENTS we are interested in for the LAYOUT ELEMENT
291 for item_element in sig_file_config[layout_element]["elements"]:
292 # retrieve the required ITEM ELEMENT Name and expected ITEM ELEMENT Type
293 element_name = item_element[0]
294 element_type = item_element[1]
295 # Test to see if the required ITEM ELEMENT is present for the ITEM
296 if element_name not in layout_state[layout_element][str(item_id)]: 296 ↛ 297line 296 didn't jump to line 297, because the condition on line 296 was never true
297 logging.warning("File Interface - Data missing for '"+layout_element +"-"
298 +str(item_id)+"-"+element_name+"' - Default value will be set")
299 else:
300 # Retrieve the ITEM ELEMENT Value from the loaded data
301 element_value = layout_state[layout_element][str(item_id)][element_name]
302 # We can do some basic validation on the loaded data to check the expected type
303 if element_type == "bool" and not isinstance(element_value,bool) and element_value is not None: 303 ↛ 304line 303 didn't jump to line 304, because the condition on line 303 was never true
304 logging.warning("File Interface - Data corrupted for '"+layout_element
305 +"-"+str(item_id)+"-"+element_name+"' - Default value will be set")
306 elif element_type == "str" and not isinstance(element_value,str) and element_value is not None: 306 ↛ 307line 306 didn't jump to line 307, because the condition on line 306 was never true
307 logging.warning("File Interface - Data corrupted for '"+layout_element
308 +"-"+str(item_id)+"-"+element_name+"' - Default value will be set")
309 elif element_type == "enum" and not isinstance(element_value,int) and element_value is not None: 309 ↛ 310line 309 didn't jump to line 310, because the condition on line 309 was never true
310 logging.warning("File Interface - Data corrupted for '"+layout_element
311 +"-"+str(item_id)+"-"+element_name+"' - Default value will be set")
312 else:
313 # Add the ITEM ELEMENT (and the loaded ITEM VALUE) to the dictionary
314 state_to_return[element_name] = element_value
315 return(state_to_return)
317############################################################################################################