qplotter 1.6.0b3__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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2330 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import tempfile
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from os import makedirs, path
|
|
9
|
+
from shutil import copyfileobj
|
|
10
|
+
|
|
11
|
+
import jsonschema
|
|
12
|
+
|
|
13
|
+
from qplot._auxiliary_paths import ensure_safe_auxiliary_path
|
|
14
|
+
from qplot.diagnostics import log_exception
|
|
15
|
+
|
|
16
|
+
from .themes import dark, light, pyqt
|
|
17
|
+
|
|
18
|
+
THEME_CLASSES = {
|
|
19
|
+
"light": light,
|
|
20
|
+
"dark": dark,
|
|
21
|
+
"pyqt": pyqt,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _strict_integer(_checker, instance):
|
|
26
|
+
return isinstance(instance, int) and not isinstance(instance, bool)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _finite_number(_checker, instance):
|
|
30
|
+
if isinstance(instance, bool) or not isinstance(instance, (int, float)):
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
return math.isfinite(instance)
|
|
34
|
+
except (OverflowError, TypeError, ValueError):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_STRICT_TYPE_CHECKER = (
|
|
39
|
+
jsonschema.Draft202012Validator.TYPE_CHECKER
|
|
40
|
+
.redefine("integer", _strict_integer)
|
|
41
|
+
.redefine("number", _finite_number)
|
|
42
|
+
)
|
|
43
|
+
StrictConfigValidator = jsonschema.validators.extend(
|
|
44
|
+
jsonschema.Draft202012Validator,
|
|
45
|
+
type_checker=_STRICT_TYPE_CHECKER,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _reject_json_constant(value):
|
|
50
|
+
raise json.JSONDecodeError(
|
|
51
|
+
f"Non-standard JSON numeric constant {value!r}",
|
|
52
|
+
value,
|
|
53
|
+
0,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
class config:
|
|
57
|
+
"""
|
|
58
|
+
Class for reading and writing to the config.json file.
|
|
59
|
+
|
|
60
|
+
**NOTE, config search functions are only set up for 1 level of nested
|
|
61
|
+
dictionaries and will require edits or work arounds after that.
|
|
62
|
+
|
|
63
|
+
see self.default_file for config location
|
|
64
|
+
|
|
65
|
+
.config_schema.json control defaults and restrictions on what can be changed
|
|
66
|
+
"""
|
|
67
|
+
config_file_name = "config.json"
|
|
68
|
+
schema_file_name = "config_schema.json"
|
|
69
|
+
|
|
70
|
+
default_path = path.expanduser(
|
|
71
|
+
path.join("~", ".qplot")
|
|
72
|
+
)
|
|
73
|
+
default_file = path.join(default_path, config_file_name)
|
|
74
|
+
default__schema_file = str(files("qplot.configuration") / schema_file_name)
|
|
75
|
+
|
|
76
|
+
def __init__(self):
|
|
77
|
+
self.schema = self.load_config(self.default__schema_file)
|
|
78
|
+
self.startup_warning = None
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
loaded_config = self.load_config(self.default_file)
|
|
82
|
+
except FileNotFoundError as error:
|
|
83
|
+
# A read can fail with ENOENT even when the settings path still
|
|
84
|
+
# exists (for example, a dangling link). Keep that original.
|
|
85
|
+
try:
|
|
86
|
+
os.lstat(self.default_file)
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
pass
|
|
89
|
+
except OSError as stat_error:
|
|
90
|
+
self._recover_unreadable_config(stat_error)
|
|
91
|
+
return
|
|
92
|
+
else:
|
|
93
|
+
self._recover_unreadable_config(error)
|
|
94
|
+
return
|
|
95
|
+
self.config = self.build_default_config()
|
|
96
|
+
try:
|
|
97
|
+
self.save_config(self.default_file)
|
|
98
|
+
except OSError as error:
|
|
99
|
+
self._record_startup_persistence_failure(
|
|
100
|
+
"Could not create default configuration at "
|
|
101
|
+
f"{self.default_file}",
|
|
102
|
+
error,
|
|
103
|
+
)
|
|
104
|
+
except (ValueError, UnicodeDecodeError, RecursionError) as error:
|
|
105
|
+
# JSONDecodeError subclasses ValueError. The decoder also raises
|
|
106
|
+
# ValueError for integer conversion limits and RecursionError for
|
|
107
|
+
# excessive nesting; these concern only the user settings read.
|
|
108
|
+
self._recover_invalid_config(error)
|
|
109
|
+
except OSError as error:
|
|
110
|
+
self._recover_unreadable_config(error)
|
|
111
|
+
else:
|
|
112
|
+
try:
|
|
113
|
+
if not isinstance(loaded_config, dict):
|
|
114
|
+
raise jsonschema.ValidationError(
|
|
115
|
+
"config.json root must be a JSON object"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
migrated = self._migrate_config(loaded_config)
|
|
119
|
+
self.validate(loaded_config)
|
|
120
|
+
self.config = loaded_config
|
|
121
|
+
if migrated:
|
|
122
|
+
try:
|
|
123
|
+
self.save_config(self.default_file)
|
|
124
|
+
except OSError as error:
|
|
125
|
+
self._record_startup_persistence_failure(
|
|
126
|
+
"Could not persist migrated configuration at "
|
|
127
|
+
f"{self.default_file}",
|
|
128
|
+
error,
|
|
129
|
+
)
|
|
130
|
+
except jsonschema.ValidationError as error:
|
|
131
|
+
self._recover_invalid_config(error)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def __str__(self) -> str:
|
|
135
|
+
"""
|
|
136
|
+
Produces a display similar to how the config.json file looks
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
str
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
return json.dumps(self.config, indent=4)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def __repr__(self):
|
|
147
|
+
return str(self)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def dump(self):
|
|
151
|
+
"""
|
|
152
|
+
Prints out Information about config.json, including location and all data
|
|
153
|
+
contained inside the file
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
print(f"config.json at: {self.default_file} \ncontents:")
|
|
157
|
+
print(str(self))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get(self, key):
|
|
161
|
+
"""
|
|
162
|
+
Returns data of at specified key.
|
|
163
|
+
Key must be laid out as a dot (.) seperated path, i.e.
|
|
164
|
+
'GUI.main_frame_size'
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
key : str
|
|
169
|
+
Key of value.
|
|
170
|
+
|
|
171
|
+
Raises
|
|
172
|
+
------
|
|
173
|
+
KeyError
|
|
174
|
+
Key passed was invalid, other not in correct for or key not found.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
out : any
|
|
179
|
+
Value at specified key.
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
out = None
|
|
183
|
+
# Get number of nests to look though
|
|
184
|
+
keys = key.split(".")
|
|
185
|
+
if len(keys) == 1:
|
|
186
|
+
out = self.config.get(key)
|
|
187
|
+
elif len(keys) == 2:
|
|
188
|
+
section = self.config.get(keys[0])
|
|
189
|
+
if isinstance(section, dict):
|
|
190
|
+
out = section.get(keys[1])
|
|
191
|
+
else:
|
|
192
|
+
raise KeyError(f"Key length too long, {key}. Please ensure you use a dot (.) seperated key")
|
|
193
|
+
|
|
194
|
+
# Return value if found
|
|
195
|
+
if out != None:
|
|
196
|
+
return out
|
|
197
|
+
else:
|
|
198
|
+
raise KeyError(f"Key: {key}, not found. Please ensure you use a dot (.) seperated key")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def update(self, key, value):
|
|
202
|
+
"""
|
|
203
|
+
Updates value at key location
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
key : str
|
|
208
|
+
Lookup Key of value.
|
|
209
|
+
value : any
|
|
210
|
+
Value to be changed to.
|
|
211
|
+
|
|
212
|
+
Raises
|
|
213
|
+
------
|
|
214
|
+
jsonschema.exceptions.ValidationError
|
|
215
|
+
Value updated is not allowed under conditions set by schema.
|
|
216
|
+
Either due to incorrect typing or trying to add a new value.
|
|
217
|
+
|
|
218
|
+
"""
|
|
219
|
+
self.update_many({key: value})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def update_many(self, values):
|
|
223
|
+
"""Validate and persist several values as one configuration update."""
|
|
224
|
+
|
|
225
|
+
updated_config = deepcopy(self.config)
|
|
226
|
+
for key, value in values.items():
|
|
227
|
+
keys = key.split(".")
|
|
228
|
+
target = updated_config
|
|
229
|
+
for part in keys[:-1]:
|
|
230
|
+
try:
|
|
231
|
+
target = target[part]
|
|
232
|
+
except KeyError as err:
|
|
233
|
+
raise KeyError(
|
|
234
|
+
f"Key: {key}, not found. Please ensure you use a dot (.) seperated key"
|
|
235
|
+
) from err
|
|
236
|
+
|
|
237
|
+
if not isinstance(target, dict):
|
|
238
|
+
raise KeyError(
|
|
239
|
+
f"Key: {key}, cannot be updated because {part} is not a section"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if keys[-1] not in target:
|
|
243
|
+
raise KeyError(
|
|
244
|
+
f"Key: {key}, not found. Please ensure you use a dot (.) seperated key"
|
|
245
|
+
)
|
|
246
|
+
target[keys[-1]] = value
|
|
247
|
+
|
|
248
|
+
self.validate(updated_config)
|
|
249
|
+
previous_config = self.config
|
|
250
|
+
self.config = updated_config
|
|
251
|
+
try:
|
|
252
|
+
self.save_config(self.default_file)
|
|
253
|
+
except Exception:
|
|
254
|
+
self.config = previous_config
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def load_config(self, path: str):
|
|
259
|
+
"""
|
|
260
|
+
Load a config JSON file
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
path: str
|
|
265
|
+
path to the config file
|
|
266
|
+
|
|
267
|
+
Return
|
|
268
|
+
------
|
|
269
|
+
config: dict
|
|
270
|
+
Returns config file in form of a dictionary
|
|
271
|
+
|
|
272
|
+
Raises
|
|
273
|
+
------
|
|
274
|
+
FileNotFoundError:
|
|
275
|
+
if config is missing
|
|
276
|
+
|
|
277
|
+
"""
|
|
278
|
+
with open(path, encoding="utf-8") as fp:
|
|
279
|
+
config = json.load(fp, parse_constant=_reject_json_constant)
|
|
280
|
+
return config
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def save_config(self, path: str) -> None:
|
|
284
|
+
"""
|
|
285
|
+
Save current config to file at given path.
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
path: str
|
|
290
|
+
path of file
|
|
291
|
+
|
|
292
|
+
"""
|
|
293
|
+
ensure_safe_auxiliary_path(path)
|
|
294
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
295
|
+
makedirs(directory, exist_ok=True)
|
|
296
|
+
file_mode = None
|
|
297
|
+
try:
|
|
298
|
+
file_mode = stat.S_IMODE(os.stat(path).st_mode)
|
|
299
|
+
except FileNotFoundError:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
file_descriptor, temporary_path = tempfile.mkstemp(
|
|
303
|
+
prefix=f".{os.path.basename(path)}.",
|
|
304
|
+
suffix=".tmp",
|
|
305
|
+
dir=directory,
|
|
306
|
+
text=True,
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
with os.fdopen(file_descriptor, "w", encoding="utf-8") as fp:
|
|
310
|
+
json.dump(self.config, fp, indent=4, allow_nan=False)
|
|
311
|
+
fp.write("\n")
|
|
312
|
+
fp.flush()
|
|
313
|
+
os.fsync(fp.fileno())
|
|
314
|
+
if file_mode is not None:
|
|
315
|
+
os.chmod(temporary_path, file_mode)
|
|
316
|
+
ensure_safe_auxiliary_path(path)
|
|
317
|
+
os.replace(temporary_path, path)
|
|
318
|
+
self._sync_config_directory(directory)
|
|
319
|
+
except Exception:
|
|
320
|
+
try:
|
|
321
|
+
os.unlink(temporary_path)
|
|
322
|
+
except FileNotFoundError:
|
|
323
|
+
pass
|
|
324
|
+
raise
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _sync_config_directory(directory: str) -> None:
|
|
329
|
+
"""Persist the rename on platforms that support directory fsync."""
|
|
330
|
+
|
|
331
|
+
if os.name == "nt":
|
|
332
|
+
return
|
|
333
|
+
try:
|
|
334
|
+
descriptor = os.open(directory, os.O_RDONLY)
|
|
335
|
+
except OSError:
|
|
336
|
+
return
|
|
337
|
+
try:
|
|
338
|
+
os.fsync(descriptor)
|
|
339
|
+
except OSError:
|
|
340
|
+
pass
|
|
341
|
+
finally:
|
|
342
|
+
os.close(descriptor)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def reset_to_defaults(self):
|
|
346
|
+
"""
|
|
347
|
+
Resets the config.json file to default determined by the config_schema
|
|
348
|
+
|
|
349
|
+
Needs adjusting if nesting of config increases
|
|
350
|
+
"""
|
|
351
|
+
config = self.build_default_config()
|
|
352
|
+
|
|
353
|
+
# Save reset to file. Keep the last persisted configuration
|
|
354
|
+
# authoritative if any part of the atomic write fails.
|
|
355
|
+
had_previous_config = hasattr(self, "config")
|
|
356
|
+
previous_config = getattr(self, "config", {})
|
|
357
|
+
self.config = config
|
|
358
|
+
try:
|
|
359
|
+
self.save_config(self.default_file)
|
|
360
|
+
except Exception:
|
|
361
|
+
if had_previous_config:
|
|
362
|
+
self.config = previous_config
|
|
363
|
+
else:
|
|
364
|
+
del self.config
|
|
365
|
+
raise
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def build_default_config(self):
|
|
369
|
+
"""Construct and validate a fresh default configuration in memory."""
|
|
370
|
+
|
|
371
|
+
config = {}
|
|
372
|
+
for key, val in self.schema["properties"].items():
|
|
373
|
+
config[key] = {
|
|
374
|
+
setting: deepcopy(setting_schema["default"])
|
|
375
|
+
for setting, setting_schema in val["properties"].items()
|
|
376
|
+
}
|
|
377
|
+
self.validate(config)
|
|
378
|
+
return config
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _recover_invalid_config(self, original_error):
|
|
382
|
+
"""Use defaults after an invalid config without risking the original."""
|
|
383
|
+
|
|
384
|
+
diagnostic = original_error
|
|
385
|
+
if isinstance(original_error, jsonschema.ValidationError):
|
|
386
|
+
# ValidationError.__str__ pretty-prints the rejected instance.
|
|
387
|
+
# Deep, invalid settings must not prevent recovery while logging.
|
|
388
|
+
diagnostic = f"ValidationError: {original_error.message}"
|
|
389
|
+
log_exception(
|
|
390
|
+
f"Invalid configuration at {self.default_file}",
|
|
391
|
+
diagnostic,
|
|
392
|
+
__name__,
|
|
393
|
+
)
|
|
394
|
+
self.config = self.build_default_config()
|
|
395
|
+
self.startup_warning = (
|
|
396
|
+
"Invalid configuration was reset to defaults; the original "
|
|
397
|
+
"settings were backed up."
|
|
398
|
+
)
|
|
399
|
+
try:
|
|
400
|
+
self.invalid_config_backup_file = self.backup_invalid_config()
|
|
401
|
+
except OSError as error:
|
|
402
|
+
self._record_startup_persistence_failure(
|
|
403
|
+
"Could not back up invalid configuration at "
|
|
404
|
+
f"{self.default_file}",
|
|
405
|
+
error,
|
|
406
|
+
)
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
self.save_config(self.default_file)
|
|
411
|
+
except OSError as error:
|
|
412
|
+
self._record_startup_persistence_failure(
|
|
413
|
+
"Could not replace invalid configuration at "
|
|
414
|
+
f"{self.default_file}",
|
|
415
|
+
error,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _recover_unreadable_config(self, original_error):
|
|
420
|
+
"""Use defaults without changing a settings file that could not be read."""
|
|
421
|
+
|
|
422
|
+
log_exception(
|
|
423
|
+
f"Could not read configuration at {self.default_file}",
|
|
424
|
+
original_error,
|
|
425
|
+
__name__,
|
|
426
|
+
)
|
|
427
|
+
self.config = self.build_default_config()
|
|
428
|
+
self.startup_warning = (
|
|
429
|
+
"Could not read configuration; using defaults for this session. "
|
|
430
|
+
"The original settings were left untouched."
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _record_startup_persistence_failure(self, context, error):
|
|
435
|
+
"""Log a failed startup write and retain a non-blocking UI warning."""
|
|
436
|
+
|
|
437
|
+
log_exception(context, error, __name__)
|
|
438
|
+
self.startup_warning = (
|
|
439
|
+
"Configuration recovery could not be saved; using defaults for "
|
|
440
|
+
"this session."
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def validate(self, candidate):
|
|
445
|
+
"""Validate a config with strict Python integer and finite-number types."""
|
|
446
|
+
|
|
447
|
+
StrictConfigValidator(self.schema).validate(candidate)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _migrate_config(self, candidate):
|
|
451
|
+
"""Apply non-destructive migrations for newly introduced settings."""
|
|
452
|
+
|
|
453
|
+
preferences = candidate.get("user_preference")
|
|
454
|
+
migrated = False
|
|
455
|
+
|
|
456
|
+
if isinstance(preferences, dict):
|
|
457
|
+
preference_schema = self.schema["properties"]["user_preference"]["properties"]
|
|
458
|
+
# ``axis_tick_density`` was superseded by an explicit target because
|
|
459
|
+
# PyQtGraph clamps density values and cannot produce sparse axes.
|
|
460
|
+
if preferences.pop("axis_tick_density", None) is not None:
|
|
461
|
+
migrated = True
|
|
462
|
+
|
|
463
|
+
for key in ("colorbar_width", "axis_tick_width", "axis_major_tick_count"):
|
|
464
|
+
if key not in preferences:
|
|
465
|
+
preferences[key] = deepcopy(preference_schema[key]["default"])
|
|
466
|
+
migrated = True
|
|
467
|
+
|
|
468
|
+
runtime_settings = candidate.get("runtime_settings")
|
|
469
|
+
if isinstance(runtime_settings, dict):
|
|
470
|
+
runtime_schema = self.schema["properties"]["runtime_settings"]["properties"]
|
|
471
|
+
for key in ("max_heatmap_grid_cells", "max_heatmap_grid_side"):
|
|
472
|
+
if key not in runtime_settings:
|
|
473
|
+
runtime_settings[key] = deepcopy(runtime_schema[key]["default"])
|
|
474
|
+
migrated = True
|
|
475
|
+
|
|
476
|
+
return migrated
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def schema_for(self, key):
|
|
480
|
+
"""Return the JSON-schema node for a supported dotted config key."""
|
|
481
|
+
|
|
482
|
+
keys = key.split(".")
|
|
483
|
+
if len(keys) != 2:
|
|
484
|
+
raise KeyError(
|
|
485
|
+
f"Key: {key}, not found. Please ensure you use a dot (.) seperated key"
|
|
486
|
+
)
|
|
487
|
+
try:
|
|
488
|
+
return self.schema["properties"][keys[0]]["properties"][keys[1]]
|
|
489
|
+
except KeyError as err:
|
|
490
|
+
raise KeyError(
|
|
491
|
+
f"Key: {key}, not found. Please ensure you use a dot (.) seperated key"
|
|
492
|
+
) from err
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def backup_invalid_config(self):
|
|
496
|
+
"""
|
|
497
|
+
Copies an invalid config file aside before resetting to defaults.
|
|
498
|
+
|
|
499
|
+
"""
|
|
500
|
+
ensure_safe_auxiliary_path(self.default_file)
|
|
501
|
+
directory = path.dirname(self.default_file)
|
|
502
|
+
makedirs(directory, exist_ok=True)
|
|
503
|
+
source_stat = os.stat(self.default_file)
|
|
504
|
+
|
|
505
|
+
while True:
|
|
506
|
+
backup_file = self.next_invalid_config_backup_file()
|
|
507
|
+
try:
|
|
508
|
+
descriptor = os.open(
|
|
509
|
+
backup_file,
|
|
510
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
511
|
+
stat.S_IMODE(source_stat.st_mode),
|
|
512
|
+
)
|
|
513
|
+
except FileExistsError:
|
|
514
|
+
continue
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
with (
|
|
519
|
+
open(self.default_file, "rb") as source,
|
|
520
|
+
os.fdopen(descriptor, "wb") as destination,
|
|
521
|
+
):
|
|
522
|
+
copyfileobj(source, destination)
|
|
523
|
+
destination.flush()
|
|
524
|
+
os.fsync(destination.fileno())
|
|
525
|
+
os.chmod(backup_file, stat.S_IMODE(source_stat.st_mode))
|
|
526
|
+
os.utime(
|
|
527
|
+
backup_file,
|
|
528
|
+
ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns),
|
|
529
|
+
)
|
|
530
|
+
except OSError:
|
|
531
|
+
try:
|
|
532
|
+
os.unlink(backup_file)
|
|
533
|
+
except FileNotFoundError:
|
|
534
|
+
pass
|
|
535
|
+
raise
|
|
536
|
+
return backup_file
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def next_invalid_config_backup_file(self):
|
|
540
|
+
"""
|
|
541
|
+
Returns a non-existing path for an invalid config backup.
|
|
542
|
+
|
|
543
|
+
"""
|
|
544
|
+
directory = path.dirname(self.default_file)
|
|
545
|
+
stem, suffix = path.splitext(path.basename(self.default_file))
|
|
546
|
+
candidate = path.join(directory, f"{stem}.invalid{suffix}")
|
|
547
|
+
if not path.exists(candidate):
|
|
548
|
+
return candidate
|
|
549
|
+
|
|
550
|
+
index = 1
|
|
551
|
+
while True:
|
|
552
|
+
candidate = path.join(directory, f"{stem}.invalid.{index}{suffix}")
|
|
553
|
+
if not path.exists(candidate):
|
|
554
|
+
return candidate
|
|
555
|
+
index += 1
|
|
556
|
+
|
|
557
|
+
###############################################################################
|
|
558
|
+
#handled functions
|
|
559
|
+
|
|
560
|
+
@property
|
|
561
|
+
def theme(self):
|
|
562
|
+
"""
|
|
563
|
+
Fetches theme data from .themes
|
|
564
|
+
|
|
565
|
+
Returns
|
|
566
|
+
-------
|
|
567
|
+
callable
|
|
568
|
+
Returns the class in qplt.configuration.themes coresponding to the
|
|
569
|
+
set config value
|
|
570
|
+
|
|
571
|
+
"""
|
|
572
|
+
config_theme = self.get("user_preference.theme")
|
|
573
|
+
return THEME_CLASSES[config_theme]
|