pgzero-plus 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pgzero-plus might be problematic. Click here for more details.
- pgzero/__init__.py +41 -0
- pgzero/__main__.py +3 -0
- pgzero/actor.py +597 -0
- pgzero/animation.py +244 -0
- pgzero/builtins.py +45 -0
- pgzero/clock.py +200 -0
- pgzero/constants.py +33 -0
- pgzero/data/icon.png +0 -0
- pgzero/data/joypad.png +0 -0
- pgzero/game.py +573 -0
- pgzero/keyboard.py +67 -0
- pgzero/loaders.py +308 -0
- pgzero/music.py +180 -0
- pgzero/ptext.py +500 -0
- pgzero/rect.py +523 -0
- pgzero/runner.py +233 -0
- pgzero/screen.py +210 -0
- pgzero/soundfmt.py +105 -0
- pgzero/spellcheck.py +173 -0
- pgzero/sprites.py +182 -0
- pgzero/storage.py +191 -0
- pgzero/tilemaps.py +307 -0
- pgzero/tone.py +132 -0
- pgzero_plus-0.1.0.dist-info/METADATA +259 -0
- pgzero_plus-0.1.0.dist-info/RECORD +30 -0
- pgzero_plus-0.1.0.dist-info/WHEEL +5 -0
- pgzero_plus-0.1.0.dist-info/entry_points.txt +2 -0
- pgzero_plus-0.1.0.dist-info/licenses/COPYING +165 -0
- pgzero_plus-0.1.0.dist-info/top_level.txt +2 -0
- pgzrun.py +34 -0
pgzero/spellcheck.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from operator import itemgetter
|
|
4
|
+
|
|
5
|
+
from .game import PGZeroGame, positional_parameters
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def distance(a, b):
|
|
9
|
+
"""Compute the distance between a and b.
|
|
10
|
+
|
|
11
|
+
This is based on Damerau-Levenshtein distance, but we modify the cost
|
|
12
|
+
of some edits, like insertion or removal of '_', or capitalisation changes.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
d = {}
|
|
16
|
+
la = len(a)
|
|
17
|
+
lb = len(b)
|
|
18
|
+
for i in range(la + 1):
|
|
19
|
+
d[i, 0] = i
|
|
20
|
+
for j in range(1, lb + 1):
|
|
21
|
+
d[0, j] = j
|
|
22
|
+
|
|
23
|
+
for i, ca in enumerate(a, start=1):
|
|
24
|
+
for j, cb in enumerate(b, start=1):
|
|
25
|
+
cost = int(ca != cb)
|
|
26
|
+
if ca.lower() == cb.lower():
|
|
27
|
+
subst_cost = 0
|
|
28
|
+
else:
|
|
29
|
+
subst_cost = 1.25 * cost
|
|
30
|
+
insertion_cost = 0.7 if cb == "_" else 1.0
|
|
31
|
+
deletion_cost = 0.7 if ca == "_" else 1.0
|
|
32
|
+
d[i, j] = min(
|
|
33
|
+
d[i - 1, j] + deletion_cost, # deletion
|
|
34
|
+
d[i, j - 1] + insertion_cost, # insertion
|
|
35
|
+
d[i - 1, j - 1] + subst_cost, # substitution
|
|
36
|
+
)
|
|
37
|
+
if i > 1 and j > 1 and ca == b[j - 2] and a[i - 2] == cb:
|
|
38
|
+
d[i, j] = min(d[i, j], d[i - 2, j - 2] + cost) # transposition
|
|
39
|
+
|
|
40
|
+
return d[la, lb]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def suggest(word, candidates):
|
|
44
|
+
"""Suggest good candidates as corrections for the given word.
|
|
45
|
+
|
|
46
|
+
Suggestions will be ordered from best to worst.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
candidates_with_score = [(c, distance(word, c)) for c in candidates]
|
|
50
|
+
good_candidates = [(c, d) for c, d in candidates_with_score if d < 2.6]
|
|
51
|
+
good_candidates.sort(key=itemgetter(1))
|
|
52
|
+
# print(word, good_candidates)
|
|
53
|
+
return [c for c, d in good_candidates]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compare(have, want):
|
|
57
|
+
"""Compare a set of names we have (from user input) to those we want.
|
|
58
|
+
|
|
59
|
+
This is a greedy algorithm that will take the best answer for each word
|
|
60
|
+
in have in turn.
|
|
61
|
+
|
|
62
|
+
Returns (typos with suggestions, unrecognised functions)
|
|
63
|
+
"""
|
|
64
|
+
want = set(want)
|
|
65
|
+
have = set(have)
|
|
66
|
+
matched = want & have
|
|
67
|
+
want -= matched
|
|
68
|
+
have -= matched
|
|
69
|
+
typos = []
|
|
70
|
+
for w in have:
|
|
71
|
+
suggestions = suggest(w, want)
|
|
72
|
+
if suggestions:
|
|
73
|
+
s = suggestions[0]
|
|
74
|
+
typos.append((w, s))
|
|
75
|
+
want.discard(s)
|
|
76
|
+
have -= set(w for w, s in typos)
|
|
77
|
+
return typos, have
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# The list of hooks we support
|
|
81
|
+
|
|
82
|
+
EVENT_HOOKS = list(PGZeroGame.EVENT_HANDLERS.values())
|
|
83
|
+
|
|
84
|
+
HOOKS = ["draw", "update", "start"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# The list of magic module-level constants
|
|
88
|
+
CONSTS = ["TITLE", "WIDTH", "HEIGHT", "ICON"]
|
|
89
|
+
|
|
90
|
+
# Available parameters for each hook
|
|
91
|
+
# NB. update() takes one or zero positional parameter but we don't constrain
|
|
92
|
+
# the name.
|
|
93
|
+
#
|
|
94
|
+
# FIXME: These are from the documentation; there could be some missing here
|
|
95
|
+
VALID_PARAMS = {
|
|
96
|
+
"on_mouse_down": ["pos", "button"],
|
|
97
|
+
"on_mouse_up": ["pos", "button"],
|
|
98
|
+
"on_mouse_move": ["pos", "buttons", "rel"],
|
|
99
|
+
"on_key_up": ["key", "mod"],
|
|
100
|
+
"on_key_down": ["unicode", "key", "mod"],
|
|
101
|
+
"draw": [],
|
|
102
|
+
"on_music_end": [],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InvalidParameter(Exception):
|
|
107
|
+
"""A parameter to a hook was invalid."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SpellCheckResult:
|
|
111
|
+
def warn(self, msg, found, suggestion):
|
|
112
|
+
print(msg.format(found=found, suggestion=suggestion))
|
|
113
|
+
|
|
114
|
+
def warn_event_handlers(self, typos, missing):
|
|
115
|
+
if not typos or missing:
|
|
116
|
+
return
|
|
117
|
+
print("Warning: Found some 'on' functions that are not " "valid event handlers.")
|
|
118
|
+
for found, suggestion in typos:
|
|
119
|
+
print(" {found} (did you mean {suggestion}?)".format(found=found, suggestion=suggestion))
|
|
120
|
+
for f in missing:
|
|
121
|
+
print(" ", f)
|
|
122
|
+
|
|
123
|
+
def error(self, msg, found, suggestion):
|
|
124
|
+
raise InvalidParameter(msg.format(found=found, suggestion=suggestion))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def spellcheck(namespace, result=SpellCheckResult()):
|
|
128
|
+
"""Spell check the names in the given module.
|
|
129
|
+
|
|
130
|
+
Where hooks are found, validate their positional parameters and offer
|
|
131
|
+
suggestions where mispelled.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
funcs = {}
|
|
135
|
+
consts = []
|
|
136
|
+
for name, val in namespace.items():
|
|
137
|
+
if callable(val) and not isinstance(val, type):
|
|
138
|
+
funcs[name] = val
|
|
139
|
+
elif isinstance(val, (str, int)):
|
|
140
|
+
consts.append(name)
|
|
141
|
+
|
|
142
|
+
typos, _ = compare(funcs, HOOKS)
|
|
143
|
+
for found, suggestion in typos:
|
|
144
|
+
result.warn("Warning: found function named {found}: " "did you mean {suggestion}?", found, suggestion)
|
|
145
|
+
|
|
146
|
+
typos, missing = compare(funcs, EVENT_HOOKS)
|
|
147
|
+
maybe_hook = re.compile("on[A-Z_]")
|
|
148
|
+
missing_hooks = [f for f in missing if re.match(maybe_hook, f)]
|
|
149
|
+
result.warn_event_handlers(typos, missing_hooks)
|
|
150
|
+
|
|
151
|
+
typos, _ = compare(consts, CONSTS)
|
|
152
|
+
for found, suggestion in typos:
|
|
153
|
+
result.warn("Warning: found constant named {found}: " "did you mean {suggestion}?", found, suggestion)
|
|
154
|
+
|
|
155
|
+
for name, handler in funcs.items():
|
|
156
|
+
try:
|
|
157
|
+
valid = VALID_PARAMS[name]
|
|
158
|
+
except KeyError:
|
|
159
|
+
continue
|
|
160
|
+
else:
|
|
161
|
+
param_names = positional_parameters(handler)
|
|
162
|
+
for param in param_names:
|
|
163
|
+
if param in valid:
|
|
164
|
+
continue
|
|
165
|
+
suggestions = suggest(param, valid)
|
|
166
|
+
if suggestions:
|
|
167
|
+
result.error(
|
|
168
|
+
"%s() hook accepts no parameter {found}; " "did you mean {suggestion}?" % name,
|
|
169
|
+
param,
|
|
170
|
+
suggestions[0],
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
result.error("%s() hook accepts no parameter {found}" % name, param, None)
|
pgzero/sprites.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import pygame
|
|
2
|
+
|
|
3
|
+
from . import loaders
|
|
4
|
+
from .actor import Actor, POS_TOPLEFT, ANCHOR_CENTER
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SpriteSheet:
|
|
8
|
+
def __init__(self, name):
|
|
9
|
+
self.name = name
|
|
10
|
+
self.sheet = loaders.sprites.load(name)
|
|
11
|
+
|
|
12
|
+
def image_at(self, rectangle, color_key=None):
|
|
13
|
+
rect = pygame.Rect(rectangle)
|
|
14
|
+
image = pygame.Surface(rect.size, pygame.SRCALPHA).convert_alpha()
|
|
15
|
+
image.blit(self.sheet, (0, 0), rect)
|
|
16
|
+
|
|
17
|
+
if color_key is not None:
|
|
18
|
+
if color_key == -1:
|
|
19
|
+
color_key = image.get_at((0, 0))
|
|
20
|
+
image.set_colorkey(color_key, pygame.RLEACCEL)
|
|
21
|
+
|
|
22
|
+
return image
|
|
23
|
+
|
|
24
|
+
def images_at(self, rects, color_key=None):
|
|
25
|
+
return [self.image_at(rect, color_key) for rect in rects]
|
|
26
|
+
|
|
27
|
+
def load_strip(self, rect, image_count, color_key=None):
|
|
28
|
+
rects = [(rect[0] + rect[2] * i, rect[1], rect[2], rect[3]) for i in range(image_count)]
|
|
29
|
+
return self.images_at(rects, color_key)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Sprite:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
image_name,
|
|
36
|
+
frame_width,
|
|
37
|
+
frame_height,
|
|
38
|
+
row_number,
|
|
39
|
+
frame_count,
|
|
40
|
+
fps=10,
|
|
41
|
+
transparent_color=None,
|
|
42
|
+
loop=True,
|
|
43
|
+
):
|
|
44
|
+
self.image_name = image_name
|
|
45
|
+
self.frame_width = frame_width
|
|
46
|
+
self.frame_height = frame_height
|
|
47
|
+
self.row_number = row_number
|
|
48
|
+
self.frame_count = frame_count
|
|
49
|
+
self.fps = fps
|
|
50
|
+
self.transparent_color = transparent_color
|
|
51
|
+
self.loop = loop
|
|
52
|
+
self.finished = False
|
|
53
|
+
self.frame_index = 0
|
|
54
|
+
self.last_update_time = pygame.time.get_ticks()
|
|
55
|
+
|
|
56
|
+
ss = SpriteSheet(image_name)
|
|
57
|
+
sheet_width, sheet_height = ss.sheet.get_size()
|
|
58
|
+
if frame_height * (row_number + 1) > sheet_height:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Row {row_number} is outside the sprite sheet. "
|
|
61
|
+
f"The sheet is {sheet_width}x{sheet_height}, "
|
|
62
|
+
f"but row {row_number} with frame height {frame_height} "
|
|
63
|
+
f"would end at y={frame_height * (row_number + 1)}."
|
|
64
|
+
)
|
|
65
|
+
if frame_width * frame_count > sheet_width:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"The requested frames go past the edge of the sprite sheet. "
|
|
68
|
+
f"The sheet is {sheet_width} pixels wide, but "
|
|
69
|
+
f"{frame_count} frames with width {frame_width} need "
|
|
70
|
+
f"{frame_width * frame_count} pixels."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
first_rect = (
|
|
74
|
+
0,
|
|
75
|
+
frame_height * row_number,
|
|
76
|
+
frame_width,
|
|
77
|
+
frame_height,
|
|
78
|
+
)
|
|
79
|
+
self.images = ss.load_strip(first_rect, frame_count, transparent_color)
|
|
80
|
+
|
|
81
|
+
if not self.images:
|
|
82
|
+
raise ValueError("Sprite must contain at least one frame")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def image(self):
|
|
86
|
+
return self.images[self.frame_index]
|
|
87
|
+
|
|
88
|
+
def copy(self):
|
|
89
|
+
return Sprite(
|
|
90
|
+
self.image_name,
|
|
91
|
+
self.frame_width,
|
|
92
|
+
self.frame_height,
|
|
93
|
+
self.row_number,
|
|
94
|
+
self.frame_count,
|
|
95
|
+
self.fps,
|
|
96
|
+
self.transparent_color,
|
|
97
|
+
self.loop,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def reset(self):
|
|
101
|
+
self.set_frame(0)
|
|
102
|
+
|
|
103
|
+
def set_frame(self, frame_index):
|
|
104
|
+
if frame_index < 0 or frame_index >= len(self.images):
|
|
105
|
+
raise ValueError(f"Frame index {frame_index} is out of range")
|
|
106
|
+
|
|
107
|
+
self.frame_index = frame_index
|
|
108
|
+
self.finished = False
|
|
109
|
+
self.last_update_time = pygame.time.get_ticks()
|
|
110
|
+
return self.image
|
|
111
|
+
|
|
112
|
+
def next_frame(self):
|
|
113
|
+
if self.finished:
|
|
114
|
+
return self.image
|
|
115
|
+
|
|
116
|
+
current_time = pygame.time.get_ticks()
|
|
117
|
+
frame_duration_ms = 1000 / self.fps
|
|
118
|
+
|
|
119
|
+
if current_time - self.last_update_time >= frame_duration_ms:
|
|
120
|
+
if self.frame_index < len(self.images) - 1:
|
|
121
|
+
self.frame_index += 1
|
|
122
|
+
elif self.loop:
|
|
123
|
+
self.frame_index = 0
|
|
124
|
+
else:
|
|
125
|
+
self.finished = True
|
|
126
|
+
|
|
127
|
+
self.last_update_time = current_time
|
|
128
|
+
|
|
129
|
+
return self.image
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SpriteActor(Actor):
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
sprite,
|
|
136
|
+
pos=POS_TOPLEFT,
|
|
137
|
+
anchor=ANCHOR_CENTER,
|
|
138
|
+
collision_rect=None,
|
|
139
|
+
**kwargs,
|
|
140
|
+
):
|
|
141
|
+
self._sprite = sprite
|
|
142
|
+
self._paused = False
|
|
143
|
+
|
|
144
|
+
super().__init__(
|
|
145
|
+
sprite.image,
|
|
146
|
+
pos=pos,
|
|
147
|
+
anchor=anchor,
|
|
148
|
+
collision_rect=collision_rect,
|
|
149
|
+
**kwargs,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def sprite(self):
|
|
154
|
+
return self._sprite
|
|
155
|
+
|
|
156
|
+
@sprite.setter
|
|
157
|
+
def sprite(self, sprite):
|
|
158
|
+
old_sprite = self._sprite
|
|
159
|
+
self._sprite = sprite
|
|
160
|
+
if old_sprite is not sprite:
|
|
161
|
+
self._sprite.reset()
|
|
162
|
+
self.image = self._sprite.image
|
|
163
|
+
|
|
164
|
+
def pause(self):
|
|
165
|
+
self._paused = True
|
|
166
|
+
|
|
167
|
+
def resume(self):
|
|
168
|
+
self._paused = False
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def paused(self):
|
|
172
|
+
return self._paused
|
|
173
|
+
|
|
174
|
+
def update_animation(self):
|
|
175
|
+
if not self._paused:
|
|
176
|
+
frame = self._sprite.next_frame()
|
|
177
|
+
if frame is not self._orig_surf:
|
|
178
|
+
self.image = frame
|
|
179
|
+
|
|
180
|
+
def draw(self):
|
|
181
|
+
self.update_animation()
|
|
182
|
+
super().draw()
|
pgzero/storage.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
from hashlib import sha1
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = ['StorageCorruptionException', 'JSONEncodingException', 'Storage']
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StorageCorruptionException(Exception):
|
|
11
|
+
"""The data in the storage is corrupted."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JSONEncodingException(Exception):
|
|
15
|
+
"""The data in the storage is corrupted."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_platform_pgzero_path():
|
|
19
|
+
r"""Get the storage directory for pgzero save data.
|
|
20
|
+
|
|
21
|
+
Under Windows, return %APPDATA%\pgzero. Under Linux/MacOS, return
|
|
22
|
+
~/.config/pgzero/saves.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
if platform.system() == 'Windows':
|
|
26
|
+
try:
|
|
27
|
+
appdata = os.environ['APPDATA']
|
|
28
|
+
except KeyError:
|
|
29
|
+
raise KeyError(
|
|
30
|
+
"Couldn't find the AppData directory for Pygame Zero save "
|
|
31
|
+
"data. Please set the %APPDATA% environment variable."
|
|
32
|
+
)
|
|
33
|
+
return os.path.join(appdata, 'pgzero')
|
|
34
|
+
return os.path.expanduser(os.path.join('~', '.config/pgzero/saves'))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Storage(dict):
|
|
38
|
+
"""Behaves like a dictionary that serialises itself to disk.
|
|
39
|
+
|
|
40
|
+
The name of the file will be based on the basename of the script plus
|
|
41
|
+
a hash of the script's path, ensuring that each script on the filesystem
|
|
42
|
+
has a unique save file.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
STORAGE_DIR = _get_platform_pgzero_path()
|
|
46
|
+
|
|
47
|
+
# Keep a reference to all defined storages
|
|
48
|
+
storages = []
|
|
49
|
+
|
|
50
|
+
def __init__(self, filename=None):
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.loaded = False
|
|
53
|
+
self._save_file = filename
|
|
54
|
+
self.storages.append(self)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def save_all(cls):
|
|
58
|
+
"""Save all instances of Storage."""
|
|
59
|
+
cls._ensure_save_path()
|
|
60
|
+
for storage in cls.storages:
|
|
61
|
+
storage.save()
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def _ensure_save_path(cls):
|
|
65
|
+
"""Ensure that the directory for all save game data exists."""
|
|
66
|
+
try:
|
|
67
|
+
os.makedirs(cls.STORAGE_DIR)
|
|
68
|
+
except (IsADirectoryError, PermissionError):
|
|
69
|
+
pass
|
|
70
|
+
except FileExistsError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def _set_filename_from_path(self, file_path):
|
|
74
|
+
"""Set the path to save to from the given filename.
|
|
75
|
+
|
|
76
|
+
We include the basename of the game script, to help with identifying
|
|
77
|
+
the save data, as well as the sha1 hash of the full path of the file on
|
|
78
|
+
disk, to ensure that every separate game has its own storage.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
if not os.path.isabs(file_path):
|
|
82
|
+
file_path = os.path.abspath(file_path)
|
|
83
|
+
|
|
84
|
+
fn_hash = sha1(file_path.encode('utf-8')).hexdigest()
|
|
85
|
+
base, _ = os.path.splitext(os.path.basename(file_path))
|
|
86
|
+
self._save_file = '{}-{}.json'.format(base, fn_hash)
|
|
87
|
+
self.load()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def path(self):
|
|
91
|
+
"""Get the path for data data."""
|
|
92
|
+
if self._save_file is None:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"Cannot save/load storage as no filename is set."
|
|
95
|
+
)
|
|
96
|
+
return os.path.join(self.STORAGE_DIR, self._save_file)
|
|
97
|
+
|
|
98
|
+
def load(self):
|
|
99
|
+
"""Load data into the storage from disk.
|
|
100
|
+
|
|
101
|
+
This replaces all previous contents of the storage. If there is no save
|
|
102
|
+
file found then the storage will be empty.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
self.clear()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
with open(self.path, 'r') as f:
|
|
109
|
+
data = json.load(f)
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
# If no file exists, it's fine
|
|
112
|
+
pass
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
raise StorageCorruptionException(
|
|
115
|
+
"Storage is corrupted. Couldn't load the data."
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
self.update(data)
|
|
119
|
+
self.loaded = True
|
|
120
|
+
|
|
121
|
+
def save(self):
|
|
122
|
+
"""Save data to disk."""
|
|
123
|
+
if not self and not self.loaded:
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
data = json.dumps(self)
|
|
127
|
+
except TypeError:
|
|
128
|
+
msgs = [
|
|
129
|
+
"{} - type {}".format(*item)
|
|
130
|
+
for item in self._get_json_error_keys(self)
|
|
131
|
+
]
|
|
132
|
+
if not msgs:
|
|
133
|
+
# Didn't find an explanation, so let original error propagate
|
|
134
|
+
raise
|
|
135
|
+
raise JSONEncodingException(
|
|
136
|
+
"The following storage entries cannot be stored, because "
|
|
137
|
+
"they are not JSON serializable types:\n{}\n\n"
|
|
138
|
+
"Only the following types of objects are JSON-serialisable: "
|
|
139
|
+
"dict, list/tuple, str, float/int, bool, and None".format(
|
|
140
|
+
"\n".join(msgs)
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
path = self.path
|
|
145
|
+
with open(path, 'w+') as f:
|
|
146
|
+
f.write(data)
|
|
147
|
+
print("Saved storage to", path)
|
|
148
|
+
|
|
149
|
+
# Constants for use with isinstance in _get_json_error_keys()
|
|
150
|
+
JSON_PRIMITIVES = (float, int, str, bool, type(None))
|
|
151
|
+
JSON_SEQ = (list, tuple)
|
|
152
|
+
JSON_MAPPING = dict
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def _get_json_error_keys(cls, obj, json_path='storage'):
|
|
156
|
+
"""Identify the paths with the storage which failed to be JSON encoded.
|
|
157
|
+
|
|
158
|
+
Return an iterable of message strings.
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
if isinstance(obj, dict):
|
|
162
|
+
# TODO: also check/report that k is a string, as only strings are
|
|
163
|
+
# valid as object keys in JSON.
|
|
164
|
+
for k, v in obj.items():
|
|
165
|
+
if isinstance(v, cls.JSON_PRIMITIVES):
|
|
166
|
+
continue
|
|
167
|
+
subpath = '{}[{!r}]'.format(json_path, k)
|
|
168
|
+
yield from cls._get_json_error_keys(v, subpath)
|
|
169
|
+
elif isinstance(obj, (list, tuple)):
|
|
170
|
+
for i, value in enumerate(obj):
|
|
171
|
+
if isinstance(value, cls.JSON_PRIMITIVES):
|
|
172
|
+
continue
|
|
173
|
+
subpath = '{}[{}]'.format(json_path, i)
|
|
174
|
+
yield from cls._get_json_error_keys(value, subpath)
|
|
175
|
+
elif isinstance(obj, cls.JSON_PRIMITIVES):
|
|
176
|
+
return
|
|
177
|
+
else:
|
|
178
|
+
# TODO: Could have a custom JSONEncoder in the future, in which
|
|
179
|
+
# case we would have to actually run json.dumps on it to get the
|
|
180
|
+
# result and possibly even drill down on the parameters we got
|
|
181
|
+
# back, if that object happens to have a list as an attribute, for
|
|
182
|
+
# example.
|
|
183
|
+
t = type(obj)
|
|
184
|
+
if t.__module__ != 'builtins':
|
|
185
|
+
typename = '{t.__module__}.{t.__qualname__}'.format(t=t)
|
|
186
|
+
else:
|
|
187
|
+
typename = t.__qualname__
|
|
188
|
+
yield json_path, typename
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
storage = Storage()
|