1+ """System for preserving backwards-compatibility for user data.
2+
3+ We store quite a lot of user data, and there's always only one right way to
4+ write it to file. But when an update is released, we need to ensure that a new
5+ version can read data stored by any previous version. Hence, this module stores
6+ all prior loaders, binding them to their respective versions, so that we can
7+ always read user data saved sometime in the past.
8+
9+ The mechanism relies on 3 classes:
10+ * UserData defines the current representation of user data,
11+ * DataManager provides a high-level interface for loading (any past format) and
12+ saving (current format) user data,
13+ * Loaders stores all known loader functions together with version strings that
14+ indicate the earliest user data file version they are capable of reading.
15+
16+ The writing logic is simple: Main constructs the UserData object and puts there
17+ all the user data serialized to strings, then calls DataManager to save that
18+ object to a file. The save method writes a file in the current format.
19+
20+ Loading logic is a little more complicated, as it happens in two stages. First,
21+ all the loaders for all previous versions of the program are prepared in an
22+ OrderedDict. Second, Main requests the DataManager to load a given user data
23+ file. DataManager reads the file and attempts to find a version string in its
24+ data (on any failure it defaults to returning a default-constructed UserData
25+ instance). When a version string is found, it finds the first loader capable of
26+ reading it, and passes the file contents to that function, which constructs a
27+ UserData instance and fills it with data. This function is responsible for
28+ translation of the old data format (as seen in the file) to the current format
29+ (as required by UserData and thus Main).
30+
31+ If a new version changes the UserData layout, *ALL* previous loaders have to be
32+ updated to return this new layout. Therefore it is best not to modify the order
33+ and names of UserData arguments.
34+ """
35+
136import os
237from collections import OrderedDict
338from semantic_version import Version
439
40+
541class UserData (object ):
6- """ User data wrapper with simple semantics (simpler than a dict). """
42+ """User data wrapper with simple semantics (simpler than a dict)."""
743 def __init__ (
844 self ,
945 username = '' ,
@@ -24,19 +60,38 @@ def __init__(
2460 self .games_data = games_data
2561 self .is_empty = is_empty
2662
63+
2764class DataManager (object ):
28- """ Backwards-compatibility preserving interface for user data management. """
29- loaders = OrderedDict ()
65+ """Backwards-compatibility preserving interface for user data management.
66+
67+ Loaders should put themselves in the "loaders" list as tuples:
68+ (callable, version)
69+ so that we can construct an OrderedDict from them at init. The class method
70+ registerLoaderSince does it automatically and is designed to be used as a
71+ decorator around a loader.
72+ """
73+ all_loaders = []
3074
3175 def __init__ (self , userDataPath :str , version :str ):
3276 self .path = userDataPath
3377 self .version = version
78+ self .loaders = self .__orderLoaders ()
79+
80+ def __orderLoaders (self ):
81+ """Create an OrderedDict of loaders, ordered by version strings."""
82+ ordered_loaders = OrderedDict ()
83+ # Sort by version
84+ self .all_loaders .sort (key = lambda x : x [1 ])
85+ for loader , version in self .all_loaders :
86+ ordered_loaders [version ] = loader
87+ return ordered_loaders
3488
3589 def save (self , userData ):
36- # safety feature against failing to write new data and removing the old
90+ """Save the user data in the most recent format."""
91+ # Safety feature against failing to write new data and removing the old
3792 if os .path .exists (self .path ):
3893 os .rename (self .path , self .path + '.bak' )
39- # actually write data to disk
94+ # Now actually write data to disk
4095 with open (self .path , 'w' ) as user_file :
4196 user_file .write ('#VERSION\n ' )
4297 user_file .write (self .version + '\n ' )
@@ -51,33 +106,57 @@ def save(self, userData):
51106 user_file .write ('#GAMES\n ' )
52107 user_file .write (userData .games_conf + '\n ' )
53108 user_file .write (userData .games_data + '\n ' )
54- # if there were no errors at point, new data has been successfully written
109+ # If there were no errors at point, new data has been successfully written
55110 if os .path .exists (self .path + '.bak' ):
56111 os .remove (self .path + '.bak' )
57112
58113 def load (self ):
114+ """Load user data from a file, with backwards-compatibility.
115+
116+ Always returns a current format UserData, either with the content upgraded
117+ from a legacy format, or a default-constructed instance in case of failure.
118+ """
119+ # Check if the file exists
59120 if not os .path .exists (self .path ):
60- return UserData () # default constructor is empty
61- user_data = self .__readFile ()
62- data_version = self .__checkVersion (user_data )
63- loader = self .__selectLoader (data_version )
121+ return UserData ()
122+ # Read data and attempt to locate the version string
123+ user_data = self .readFile ()
124+ data_version = self .checkVersion (user_data )
125+ if not data_version :
126+ return UserData ()
127+ # Attempt to match loader to that string
128+ loader = self .selectLoader (data_version )
129+ if not loader :
130+ return UserData ()
131+ # Attempt to parse the user data using that loader
64132 try :
65133 parsed_data = loader (user_data )
66134 except :
67135 print ("User data parsing error." )
68136 return UserData ()
137+ # If we got this far, mark the UserData object as successfully loaded.
138+ # This flag is used by Main to determine whether the program is being ran
139+ # for the first time.
69140 parsed_data .is_empty = False
70141 return parsed_data
71- def __readFile (self ):
142+
143+ def readFile (self ):
144+ """Simply read lines from the user data file.
145+
146+ This always has to be done first (independent of the actual content), as
147+ the version string must be extracted before doing anything further.
148+ Lines starting with '#' are always ignored as comments.
149+ """
72150 with open (self .path , 'r' ) as user_file :
73151 user_data = [
74152 line .strip ('\n ' )
75153 for line in user_file .readlines ()
76154 if not line .startswith ('#' )
77155 ]
78156 return user_data
79- def __checkVersion (self , data ):
80- """ Tries to find a version string in the user data robustly. """
157+
158+ def checkVersion (self , data ):
159+ """Try to find a version string in the user data robustly."""
81160 for datum in data :
82161 # Don't expect version strings larger than that (most likely data)
83162 if len (datum ) > 20 :
@@ -89,44 +168,38 @@ def __checkVersion(self, data):
89168 continue
90169 else :
91170 return version
92- def __selectLoader (self , version ):
93- """ Iterate over registered loaders for as long as the data version is more
94- recent than the loader. This will stop when a loader version is too new
95- for the data. The previous (matching) lodaer will be returned.
171+ # If no version string is present
172+ return None
173+
174+ def selectLoader (self , data_version ):
175+ """Select the loader that matches the version of the given user data file.
176+
177+ Iterate over registered loaders for as long as the data version is more
178+ recent than the loader. This will stop when a loader version is too new
179+ for the data. The previous (matching) loader will be returned.
96180 """
97- loader = None
98- for v , p in self .loaders .items ():
99- if version >= v :
100- loader = p
181+ matching_loader = None
182+ for loader_version , loader in self .loaders .items ():
183+ if data_version >= loader_version :
184+ matching_loader = loader
101185 else :
102186 break
103- return loader
104-
105- @classmethod
106- def registerLoader (self , loader , version ):
107- """ Add a new loader to the ODict, resorting for easy version matching. """
108- # Get the existing loaders and their corresponding version numbers
109- versions = list (self .loaders .keys ())
110- loaders = list (self .loaders .values ())
111- # Add the new one
112- versions .append (version )
113- loaders .append (loader )
114- # Reconstruct the ODict with new version&loader in the correct order
115- self .loaders .clear ()
116- for v , p in sorted (zip (versions , loaders ), key = lambda x : x [0 ]):
117- self .loaders [v ] = p
187+ return matching_loader
188+
118189 def registerLoaderSince (version :str ):
190+ """Add the given loader to the loaders list."""
119191 version = Version (version )
120192 def decorator (loader ):
121- DataManager .registerLoader ( loader , version )
193+ DataManager .all_loaders . append (( loader , version ) )
122194 return loader
123195 return decorator
124196
197+
125198class Loaders (object ):
126- """ Just a holder for different data loading functions.
127-
128- It's friends with DataManager class, that is: updates its "loaders" ODict
129- with any loader defined here.
199+ """Just a holder for different data loading functions.
200+
201+ It's friends with DataManager class, that is: updates its "loaders" ODict
202+ with any loader defined here.
130203 """
131204 @DataManager .registerLoaderSince ('1.0.0-beta.1' )
132205 def loader100b (user_data ):
0 commit comments