Skip to content

Commit cdb25a6

Browse files
committed
refactor of userdata and better documentation
1 parent 497dadf commit cdb25a6

File tree

1 file changed

+114
-41
lines changed

1 file changed

+114
-41
lines changed

filmatyk/userdata.py

Lines changed: 114 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,45 @@
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+
136
import os
237
from collections import OrderedDict
338
from semantic_version import Version
439

40+
541
class 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+
2764
class 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+
125198
class 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

Comments
 (0)