DVR-Scan-Headless 1.8__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.
- dvr_scan/LICENSE +47 -0
- dvr_scan/__init__.py +55 -0
- dvr_scan/__main__.py +80 -0
- dvr_scan/app/__init__.py +10 -0
- dvr_scan/app/__main__.py +218 -0
- dvr_scan/app/about_window.py +137 -0
- dvr_scan/app/application.py +1700 -0
- dvr_scan/app/common.py +31 -0
- dvr_scan/app/region_editor.py +1416 -0
- dvr_scan/app/scan_window.py +258 -0
- dvr_scan/app/widgets.py +213 -0
- dvr_scan/cli.py +511 -0
- dvr_scan/config.py +607 -0
- dvr_scan/controller.py +215 -0
- dvr_scan/detector.py +124 -0
- dvr_scan/docs/404.html +1 -0
- dvr_scan/docs/assets/javascripts/bundle.88dd0f4e.min.js +15 -0
- dvr_scan/docs/assets/stylesheets/main.6f8fc17f.min.css +1 -0
- dvr_scan/docs/assets/stylesheets/palette.06af60db.min.css +1 -0
- dvr_scan/docs/changelog.html +1 -0
- dvr_scan/docs/docs.html +73 -0
- dvr_scan/docs/faq.html +13 -0
- dvr_scan/docs/guide.html +1 -0
- dvr_scan/docs/index.html +5 -0
- dvr_scan/docs/resources.html +30 -0
- dvr_scan/docs/style.min.css +1 -0
- dvr_scan/dvr-scan-logo.png +0 -0
- dvr_scan/dvr-scan.ico +0 -0
- dvr_scan/dvr-scan.png +0 -0
- dvr_scan/opencv_loader.py +37 -0
- dvr_scan/overlays.py +251 -0
- dvr_scan/platform.py +236 -0
- dvr_scan/region.py +65 -0
- dvr_scan/scanner.py +1303 -0
- dvr_scan/shared/__init__.py +287 -0
- dvr_scan/shared/cli.py +291 -0
- dvr_scan/shared/settings.py +72 -0
- dvr_scan/subtractor.py +163 -0
- dvr_scan/video_joiner.py +188 -0
- dvr_scan_headless-1.8.dist-info/METADATA +119 -0
- dvr_scan_headless-1.8.dist-info/RECORD +45 -0
- dvr_scan_headless-1.8.dist-info/WHEEL +5 -0
- dvr_scan_headless-1.8.dist-info/entry_points.txt +2 -0
- dvr_scan_headless-1.8.dist-info/licenses/LICENSE +47 -0
- dvr_scan_headless-1.8.dist-info/top_level.txt +1 -0
dvr_scan/config.py
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
#
|
|
2
|
+
# PySceneDetect: Python-Based Video Scene Detector
|
|
3
|
+
# ---------------------------------------------------------------
|
|
4
|
+
# [ Site: http://www.scenedetect.scenedetect.com/ ]
|
|
5
|
+
# [ Docs: http://manual.scenedetect.scenedetect.com/ ]
|
|
6
|
+
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
|
7
|
+
#
|
|
8
|
+
# Copyright (C) 2016 Brandon Castellano <http://www.bcastell.com>.
|
|
9
|
+
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
|
10
|
+
# included LICENSE file, or visit one of the above pages for details.
|
|
11
|
+
#
|
|
12
|
+
"""``dvr_scan.config`` Module
|
|
13
|
+
|
|
14
|
+
Handles loading configuration files from disk and validating each setting. Only validation
|
|
15
|
+
of the config file schema and data types are performed. The constants and default values
|
|
16
|
+
defined here are used by the CLI as well to ensure there is one source of truth. Note that
|
|
17
|
+
the constants defined here for use by the CLI/config file may differ from their equivalents
|
|
18
|
+
in the `dvr_scan` module.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import typing as ty
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from configparser import DEFAULTSECT, ConfigParser, ParsingError
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from platformdirs import user_config_dir
|
|
28
|
+
from scenedetect.frame_timecode import FrameTimecode
|
|
29
|
+
|
|
30
|
+
from dvr_scan.scanner import DEFAULT_FFMPEG_INPUT_ARGS, DEFAULT_FFMPEG_OUTPUT_ARGS
|
|
31
|
+
|
|
32
|
+
# Backwards compatibility for config options that were renamed/replaced.
|
|
33
|
+
MIGRATED_CONFIG_OPTION: ty.Dict[str, str] = {
|
|
34
|
+
"timecode": "time-code",
|
|
35
|
+
"timecode-margin": "text-margin",
|
|
36
|
+
"timecode-font-scale": "text-font-scale",
|
|
37
|
+
"timecode-font-thickness": "text-font-thickness",
|
|
38
|
+
"timecode-font-color": "text-font-color",
|
|
39
|
+
"timecode-bg-color": "text-bg-color",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
DEPRECATED_CONFIG_OPTION: ty.Dict[str, str] = {
|
|
43
|
+
"region-of-interest": "The region-of-interest config option is deprecated and may be removed. "
|
|
44
|
+
"Use the load-region option instead, or specify -R/--load-region."
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ValidatedValue(ABC):
|
|
49
|
+
"""Used to represent configuration values that must be validated against constraints."""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def value(self) -> ty.Any:
|
|
54
|
+
"""Get the value after validation."""
|
|
55
|
+
raise NotImplementedError()
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue":
|
|
60
|
+
"""Validate and get the user-specified configuration option.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
OptionParseFailure: Value from config file did not meet validation constraints.
|
|
64
|
+
"""
|
|
65
|
+
raise NotImplementedError()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class OptionParseFailure(Exception):
|
|
69
|
+
"""Raised when a value provided in a user config file fails validation."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, error):
|
|
72
|
+
super().__init__()
|
|
73
|
+
self.error = error
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TimecodeValue(ValidatedValue):
|
|
77
|
+
"""Validator for timecode values in frames (1234), seconds (123.4s), or HH:MM:SS.
|
|
78
|
+
|
|
79
|
+
Stores value in original representation."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, value: ty.Union[int, float, str]):
|
|
82
|
+
# Ensure value is a valid timecode.
|
|
83
|
+
FrameTimecode(timecode=value, fps=100.0)
|
|
84
|
+
self._value = value
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def value(self) -> ty.Union[int, float, str]:
|
|
88
|
+
return self._value
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return str(self.value)
|
|
92
|
+
|
|
93
|
+
def __str__(self) -> str:
|
|
94
|
+
return str(self.value)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue":
|
|
98
|
+
try:
|
|
99
|
+
return TimecodeValue(config_value)
|
|
100
|
+
except ValueError as ex:
|
|
101
|
+
raise OptionParseFailure(
|
|
102
|
+
"Timecodes must be in frames (1234), seconds (123.4s), or HH:MM:SS (00:02:03.400)."
|
|
103
|
+
) from ex
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RangeValue(ValidatedValue):
|
|
107
|
+
"""Validator for int/float ranges. `min_val` and `max_val` are inclusive."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
value: ty.Union[int, float],
|
|
112
|
+
min_val: ty.Union[int, float],
|
|
113
|
+
max_val: ty.Union[int, float],
|
|
114
|
+
):
|
|
115
|
+
if value < min_val or value > max_val:
|
|
116
|
+
# min and max are inclusive.
|
|
117
|
+
raise ValueError()
|
|
118
|
+
self._value = value
|
|
119
|
+
self._min_val = min_val
|
|
120
|
+
self._max_val = max_val
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def value(self) -> ty.Union[int, float]:
|
|
124
|
+
return self._value
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def min_val(self) -> ty.Union[int, float]:
|
|
128
|
+
"""Minimum value of the range."""
|
|
129
|
+
return self._min_val
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def max_val(self) -> ty.Union[int, float]:
|
|
133
|
+
"""Maximum value of the range."""
|
|
134
|
+
return self._max_val
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return str(self.value)
|
|
138
|
+
|
|
139
|
+
def __str__(self) -> str:
|
|
140
|
+
return str(self.value)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def from_config(config_value: str, default: "RangeValue") -> "RangeValue":
|
|
144
|
+
try:
|
|
145
|
+
return RangeValue(
|
|
146
|
+
value=int(config_value) if isinstance(default.value, int) else float(config_value),
|
|
147
|
+
min_val=default.min_val,
|
|
148
|
+
max_val=default.max_val,
|
|
149
|
+
)
|
|
150
|
+
except ValueError as ex:
|
|
151
|
+
raise OptionParseFailure(
|
|
152
|
+
"Value must be between %s and %s." % (default.min_val, default.max_val)
|
|
153
|
+
) from ex
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class KernelSizeValue(ValidatedValue):
|
|
157
|
+
"""Validator for kernel sizes (odd integer > 1, 0 for off, or -1 for auto size)."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, value: int = -1):
|
|
160
|
+
value = int(value)
|
|
161
|
+
if value not in (-1, 0) and (value < 3 or value % 2 == 0):
|
|
162
|
+
raise ValueError()
|
|
163
|
+
self._value = value
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def value(self) -> int:
|
|
167
|
+
return self._value
|
|
168
|
+
|
|
169
|
+
def __repr__(self) -> str:
|
|
170
|
+
return str(self.value)
|
|
171
|
+
|
|
172
|
+
def __str__(self) -> str:
|
|
173
|
+
if self.value == -1:
|
|
174
|
+
return "-1 (auto)"
|
|
175
|
+
elif self.value == 0:
|
|
176
|
+
return "0 (off)"
|
|
177
|
+
return str(self.value)
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeValue":
|
|
181
|
+
try:
|
|
182
|
+
return KernelSizeValue(int(config_value))
|
|
183
|
+
except ValueError as ex:
|
|
184
|
+
raise OptionParseFailure(
|
|
185
|
+
"Size must be odd number starting from 3, 0 to disable, or -1 for auto."
|
|
186
|
+
) from ex
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class RegionValueDeprecated(ValidatedValue):
|
|
190
|
+
"""Validator for deprecated region-of-interest values."""
|
|
191
|
+
|
|
192
|
+
_IGNORE_CHARS = [",", "/", "(", ")"]
|
|
193
|
+
"""Characters to ignore."""
|
|
194
|
+
|
|
195
|
+
def __init__(self, value: ty.Optional[str] = None, allow_size: bool = False):
|
|
196
|
+
if value is not None:
|
|
197
|
+
translation_table = str.maketrans(
|
|
198
|
+
{char: " " for char in RegionValueDeprecated._IGNORE_CHARS}
|
|
199
|
+
)
|
|
200
|
+
values = value.translate(translation_table).split()
|
|
201
|
+
valid_lengths = (2, 4) if allow_size else (4,)
|
|
202
|
+
if not (
|
|
203
|
+
len(values) in valid_lengths
|
|
204
|
+
and all([val.isdigit() for val in values])
|
|
205
|
+
and all([int(val) >= 0 for val in values])
|
|
206
|
+
):
|
|
207
|
+
raise ValueError()
|
|
208
|
+
self._value = [int(val) for val in values]
|
|
209
|
+
else:
|
|
210
|
+
self._value = None
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def value(self) -> ty.Optional[ty.List[int]]:
|
|
214
|
+
return self._value
|
|
215
|
+
|
|
216
|
+
def __repr__(self) -> str:
|
|
217
|
+
return str(self.value)
|
|
218
|
+
|
|
219
|
+
def __str__(self) -> str:
|
|
220
|
+
if self.value is not None:
|
|
221
|
+
return "(%d,%d)/(%d,%d)" % (
|
|
222
|
+
self.value[0],
|
|
223
|
+
self.value[1],
|
|
224
|
+
self.value[2],
|
|
225
|
+
self.value[3],
|
|
226
|
+
)
|
|
227
|
+
return str(self.value)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def from_config(config_value: str, default: "RegionValueDeprecated") -> "RegionValueDeprecated":
|
|
231
|
+
try:
|
|
232
|
+
return RegionValueDeprecated(config_value)
|
|
233
|
+
except ValueError as ex:
|
|
234
|
+
raise OptionParseFailure(
|
|
235
|
+
"ROI must be four positive integers of the form (x,y)/(w,h)."
|
|
236
|
+
" Brackets, commas, slashes, and spaces are optional."
|
|
237
|
+
) from ex
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class RGBValue(ValidatedValue):
|
|
241
|
+
"""Validator for RGB values of the form (255, 255, 255) or 0xFFFFFF."""
|
|
242
|
+
|
|
243
|
+
_IGNORE_CHARS = [",", "/", "(", ")"]
|
|
244
|
+
"""Characters to ignore."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, value: ty.Union[int, str, "RGBValue"]):
|
|
247
|
+
if isinstance(value, RGBValue):
|
|
248
|
+
return value
|
|
249
|
+
# If not an int, convert to one.
|
|
250
|
+
# First try to convert values of the form 'ffffff'.
|
|
251
|
+
if not isinstance(value, int) and len(value) == 6:
|
|
252
|
+
try:
|
|
253
|
+
new_value = int(value, base=16)
|
|
254
|
+
value = new_value
|
|
255
|
+
except ValueError:
|
|
256
|
+
# Continue trying to process it like the remaining types.
|
|
257
|
+
pass
|
|
258
|
+
# Next try to convert values of the form '0xffffff'.
|
|
259
|
+
if not isinstance(value, int) and value[0:2] in ("0x", "0X"):
|
|
260
|
+
value = int(value, base=16)
|
|
261
|
+
# Finally try to process in the form '(255, 255, 255)'.
|
|
262
|
+
if not isinstance(value, int):
|
|
263
|
+
translation_table = str.maketrans({char: " " for char in RGBValue._IGNORE_CHARS})
|
|
264
|
+
values = value.translate(translation_table).split()
|
|
265
|
+
if not (
|
|
266
|
+
len(values) == 3
|
|
267
|
+
and all([val.isdigit() for val in values])
|
|
268
|
+
and all([int(val) >= 0 for val in values])
|
|
269
|
+
):
|
|
270
|
+
raise ValueError()
|
|
271
|
+
value = int(values[0]) << 16 | int(values[1]) << 8 | int(values[2])
|
|
272
|
+
assert isinstance(value, int)
|
|
273
|
+
if value < 0x000000 or value > 0xFFFFFF:
|
|
274
|
+
raise ValueError("RGB value must be between 0x000000 and 0xFFFFFF.")
|
|
275
|
+
# Convert into tuple of (R, G, B)
|
|
276
|
+
self._value = (
|
|
277
|
+
(value & 0xFF0000) >> 16,
|
|
278
|
+
(value & 0x00FF00) >> 8,
|
|
279
|
+
(value & 0x0000FF),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def value(self) -> ty.Tuple[int, int, int]:
|
|
284
|
+
return self._value
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def value_as_int(self) -> int:
|
|
288
|
+
"""Return value in integral (binary) form as opposed to tuple of R,G,B."""
|
|
289
|
+
return int(self.value[0]) << 16 | int(self.value[1]) << 8 | int(self.value[2])
|
|
290
|
+
|
|
291
|
+
def __repr__(self) -> str:
|
|
292
|
+
return str(self.value)
|
|
293
|
+
|
|
294
|
+
def __str__(self) -> str:
|
|
295
|
+
return "0x%06x" % self.value_as_int
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def from_config(config_value: str, default: "RGBValue") -> "RGBValue":
|
|
299
|
+
try:
|
|
300
|
+
return RGBValue(config_value)
|
|
301
|
+
except ValueError as ex:
|
|
302
|
+
raise OptionParseFailure(
|
|
303
|
+
"Color values must be in hex (0xFFFFFF) or R,G,B (255,255,255)."
|
|
304
|
+
) from ex
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
ConfigValue = ty.Union[bool, int, float, str, Path]
|
|
308
|
+
ConfigDict = ty.Dict[str, ConfigValue]
|
|
309
|
+
|
|
310
|
+
_CONFIG_FILE_NAME: str = "dvr-scan.cfg"
|
|
311
|
+
_CONFIG_FILE_DIR: Path = Path(user_config_dir("DVR-Scan", False))
|
|
312
|
+
|
|
313
|
+
USER_CONFIG_FILE_PATH: Path = _CONFIG_FILE_DIR / _CONFIG_FILE_NAME
|
|
314
|
+
|
|
315
|
+
# TODO: Investigate if centralizing user help strings here would be useful.
|
|
316
|
+
# It might make CLI help and documentation are easier to create, as well as
|
|
317
|
+
# for generating a default config file template.
|
|
318
|
+
|
|
319
|
+
# TODO: Replace these default values with those set in dvr_scan.context.
|
|
320
|
+
CONFIG_MAP: ConfigDict = {
|
|
321
|
+
# General Options
|
|
322
|
+
"quiet-mode": False,
|
|
323
|
+
# Input/Output
|
|
324
|
+
"ffmpeg-input-args": DEFAULT_FFMPEG_INPUT_ARGS,
|
|
325
|
+
"ffmpeg-output-args": DEFAULT_FFMPEG_OUTPUT_ARGS,
|
|
326
|
+
"input-mode": "opencv",
|
|
327
|
+
"opencv-codec": "XVID",
|
|
328
|
+
"output-dir": "",
|
|
329
|
+
"open-output-dir": True,
|
|
330
|
+
"output-mode": "opencv",
|
|
331
|
+
"region-editor": False,
|
|
332
|
+
# Motion Events
|
|
333
|
+
"min-event-length": TimecodeValue("0.1s"),
|
|
334
|
+
"time-before-event": TimecodeValue("1.5s"),
|
|
335
|
+
"time-post-event": TimecodeValue("2.0s"),
|
|
336
|
+
"use-pts": False,
|
|
337
|
+
# Detection Parameters
|
|
338
|
+
"bg-subtractor": "MOG2",
|
|
339
|
+
"threshold": 0.15,
|
|
340
|
+
"max-threshold": 255.0,
|
|
341
|
+
"max-area": 1.0,
|
|
342
|
+
"max-width": 1.0,
|
|
343
|
+
"max-height": 1.0,
|
|
344
|
+
"variance-threshold": 16.0,
|
|
345
|
+
"kernel-size": KernelSizeValue(),
|
|
346
|
+
"downscale-factor": 0,
|
|
347
|
+
"learning-rate": float(-1),
|
|
348
|
+
# TODO(1.9): Remove, has been replaced with region files.
|
|
349
|
+
"region-of-interest": RegionValueDeprecated(),
|
|
350
|
+
"load-region": "",
|
|
351
|
+
"frame-skip": 0,
|
|
352
|
+
# Overlays
|
|
353
|
+
# Text Overlays
|
|
354
|
+
"time-code": False,
|
|
355
|
+
"frame-metrics": False,
|
|
356
|
+
"text-border": 4,
|
|
357
|
+
"text-margin": 4,
|
|
358
|
+
"text-font-scale": 1.0,
|
|
359
|
+
"text-font-thickness": 2,
|
|
360
|
+
"text-font-color": RGBValue(0xFFFFFF),
|
|
361
|
+
"text-bg-color": RGBValue(0x000000),
|
|
362
|
+
# Bounding Box
|
|
363
|
+
"bounding-box": False,
|
|
364
|
+
"bounding-box-smooth-time": TimecodeValue("0.1s"),
|
|
365
|
+
"bounding-box-color": RGBValue(0xFF0000),
|
|
366
|
+
"bounding-box-thickness": 0.0032,
|
|
367
|
+
"bounding-box-min-size": 0.032,
|
|
368
|
+
"thumbnails": None,
|
|
369
|
+
# Logging
|
|
370
|
+
"verbosity": "info",
|
|
371
|
+
"save-log": True,
|
|
372
|
+
# max-log-size is not implemented, but is kept for backwards compatibility with older
|
|
373
|
+
# config files. Previously, logs would be appended to the same file until they reached this size
|
|
374
|
+
# after which a new file would be created.
|
|
375
|
+
"max-log-size": 20000,
|
|
376
|
+
"max-log-files": 15,
|
|
377
|
+
# Development
|
|
378
|
+
"debug": False,
|
|
379
|
+
}
|
|
380
|
+
"""Mapping of valid configuration file parameters and their default values or placeholders.
|
|
381
|
+
The types of these values are used when decoding the configuration file. Valid choices for
|
|
382
|
+
certain string options are stored in `CHOICE_MAP`."""
|
|
383
|
+
|
|
384
|
+
CHOICE_MAP: ty.Dict[str, ty.List[str]] = {
|
|
385
|
+
"input-mode": ["opencv", "pyav", "moviepy"],
|
|
386
|
+
"opencv-codec": ["XVID", "MP4V", "MP42", "H264"],
|
|
387
|
+
"output-mode": ["scan_only", "opencv", "copy", "ffmpeg"],
|
|
388
|
+
"verbosity": ["debug", "info", "warning", "error"],
|
|
389
|
+
"bg-subtractor": ["MOG2", "CNT", "MOG2_CUDA"],
|
|
390
|
+
"thumbnails": ["highscore"],
|
|
391
|
+
}
|
|
392
|
+
"""Mapping of string options which can only be of a particular set of values. We use a list instead
|
|
393
|
+
of a set to preserve order when generating error contexts. Values are case-insensitive, and must be
|
|
394
|
+
in lowercase in this map."""
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class ConfigLoadFailure(Exception):
|
|
398
|
+
"""Raised when a user-specified configuration file fails to be loaded or validated."""
|
|
399
|
+
|
|
400
|
+
def __init__(self, init_log: ty.Tuple[int, str], reason: ty.Optional[Exception] = None):
|
|
401
|
+
super().__init__()
|
|
402
|
+
self.init_log = init_log
|
|
403
|
+
self.reason = reason
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class ConfigRegistry:
|
|
407
|
+
"""Provides application option values based on either user-specified configuration, or
|
|
408
|
+
default values specified in the global CONFIG_MAP."""
|
|
409
|
+
|
|
410
|
+
def __init__(self):
|
|
411
|
+
self._init_log: ty.List[ty.Tuple[int, str]] = []
|
|
412
|
+
self._config: ConfigDict = {}
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def config_dict(self) -> ConfigDict:
|
|
416
|
+
"""Current configuration options that are set for each setting."""
|
|
417
|
+
return self._config
|
|
418
|
+
|
|
419
|
+
def consume_init_log(self):
|
|
420
|
+
"""Consumes initialization log."""
|
|
421
|
+
init_log = self._init_log
|
|
422
|
+
self._init_log = []
|
|
423
|
+
return init_log
|
|
424
|
+
|
|
425
|
+
def _log(self, level: int, message: str):
|
|
426
|
+
self._init_log.append((level, message))
|
|
427
|
+
|
|
428
|
+
def load(self, path: ty.Optional[Path] = None):
|
|
429
|
+
"""Loads configuration file from given `path`. If `path` is not specified, tries
|
|
430
|
+
to load from the default location (USER_CONFIG_FILE_PATH).
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
ConfigLoadFailure: The config file being loaded is corrupt or invalid,
|
|
434
|
+
or `path` was specified but does not exist.
|
|
435
|
+
"""
|
|
436
|
+
# Validate `path`, or if not provided, use USER_CONFIG_FILE_PATH if it exists.
|
|
437
|
+
if path:
|
|
438
|
+
self._log(logging.INFO, "Loading config from file: %s" % path)
|
|
439
|
+
if not path.exists():
|
|
440
|
+
self._log(logging.ERROR, "File not found: %s" % path)
|
|
441
|
+
raise ConfigLoadFailure(self._init_log)
|
|
442
|
+
else:
|
|
443
|
+
# Gracefully handle the case where there isn't a user config file.
|
|
444
|
+
if not USER_CONFIG_FILE_PATH.exists():
|
|
445
|
+
self._log(logging.DEBUG, "User config file not found.")
|
|
446
|
+
return
|
|
447
|
+
path = USER_CONFIG_FILE_PATH
|
|
448
|
+
self._log(logging.INFO, "Loading user config file:\n %s" % path)
|
|
449
|
+
# Try to load and parse the config file at `path`.
|
|
450
|
+
config = ConfigParser()
|
|
451
|
+
try:
|
|
452
|
+
config_file_contents = "[%s]\n%s" % (DEFAULTSECT, open(path).read())
|
|
453
|
+
config.read_string(config_file_contents, source=str(path))
|
|
454
|
+
except ParsingError as ex:
|
|
455
|
+
raise ConfigLoadFailure(self._init_log, reason=ex) from ex
|
|
456
|
+
except OSError as ex:
|
|
457
|
+
raise ConfigLoadFailure(self._init_log, reason=ex) from ex
|
|
458
|
+
self._parse_config(config)
|
|
459
|
+
if any(level >= logging.ERROR for level, _ in self._init_log):
|
|
460
|
+
raise ConfigLoadFailure(self._init_log)
|
|
461
|
+
|
|
462
|
+
def _migrate_deprecated(self, config: ConfigParser):
|
|
463
|
+
migrated_options = [opt for opt in config[DEFAULTSECT] if opt in MIGRATED_CONFIG_OPTION]
|
|
464
|
+
for migrated in migrated_options:
|
|
465
|
+
replacement = MIGRATED_CONFIG_OPTION[migrated]
|
|
466
|
+
if replacement in config[DEFAULTSECT]:
|
|
467
|
+
self._log(
|
|
468
|
+
logging.WARNING,
|
|
469
|
+
f"WARNING: deprecated config option {migrated} was overriden by {replacement}.",
|
|
470
|
+
)
|
|
471
|
+
else:
|
|
472
|
+
self._log(
|
|
473
|
+
logging.WARNING,
|
|
474
|
+
f"WARNING: config option {migrated} is deprecated, use {replacement} instead.",
|
|
475
|
+
)
|
|
476
|
+
config[DEFAULTSECT][replacement] = config[DEFAULTSECT][migrated]
|
|
477
|
+
del config[DEFAULTSECT][migrated]
|
|
478
|
+
deprecated_options = [opt for opt in config[DEFAULTSECT] if opt in DEPRECATED_CONFIG_OPTION]
|
|
479
|
+
for deprecated in deprecated_options:
|
|
480
|
+
self._log(logging.WARNING, f"WARNING: {DEPRECATED_CONFIG_OPTION[deprecated]}")
|
|
481
|
+
|
|
482
|
+
def _parse_config(
|
|
483
|
+
self, config: ConfigParser
|
|
484
|
+
) -> ty.Tuple[ty.Optional[ConfigDict], ty.List[ty.Tuple[int, str]]]:
|
|
485
|
+
"""Process the given configuration into a key-value mapping.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Configuration mapping and list of any processing errors in human readable form.
|
|
489
|
+
"""
|
|
490
|
+
if config.sections():
|
|
491
|
+
self._log(
|
|
492
|
+
logging.ERROR,
|
|
493
|
+
"Invalid config file: must not contain any sections, found:\n %s"
|
|
494
|
+
% (", ".join(["[%s]" % section for section in config.sections()])),
|
|
495
|
+
)
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
self._migrate_deprecated(config)
|
|
499
|
+
|
|
500
|
+
for option in config[DEFAULTSECT]:
|
|
501
|
+
if option not in CONFIG_MAP:
|
|
502
|
+
self._log(logging.ERROR, "Unsupported config option: %s" % (option))
|
|
503
|
+
continue
|
|
504
|
+
try:
|
|
505
|
+
value_type = None
|
|
506
|
+
if isinstance(CONFIG_MAP[option], bool):
|
|
507
|
+
value_type = "yes/no value"
|
|
508
|
+
self._config[option] = config.getboolean(DEFAULTSECT, option)
|
|
509
|
+
continue
|
|
510
|
+
elif isinstance(CONFIG_MAP[option], int):
|
|
511
|
+
value_type = "integer"
|
|
512
|
+
self._config[option] = config.getint(DEFAULTSECT, option)
|
|
513
|
+
continue
|
|
514
|
+
elif isinstance(CONFIG_MAP[option], float):
|
|
515
|
+
value_type = "number"
|
|
516
|
+
self._config[option] = config.getfloat(DEFAULTSECT, option)
|
|
517
|
+
continue
|
|
518
|
+
except ValueError as _:
|
|
519
|
+
self._log(
|
|
520
|
+
logging.ERROR,
|
|
521
|
+
"Invalid setting for %s. Value is not a valid %s." % (option, value_type),
|
|
522
|
+
)
|
|
523
|
+
self._log(logging.DEBUG, "%s = %s" % (option, config.get(DEFAULTSECT, option)))
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
# Handle custom validation types.
|
|
527
|
+
config_value = config.get(DEFAULTSECT, option)
|
|
528
|
+
default = CONFIG_MAP[option]
|
|
529
|
+
option_type = type(default)
|
|
530
|
+
if issubclass(option_type, ValidatedValue):
|
|
531
|
+
try:
|
|
532
|
+
self._config[option] = option_type.from_config(
|
|
533
|
+
config_value=config_value, default=default
|
|
534
|
+
)
|
|
535
|
+
except OptionParseFailure as ex:
|
|
536
|
+
self._log(
|
|
537
|
+
logging.ERROR,
|
|
538
|
+
"Invalid setting for %s: %s\n%s" % (option, config_value, ex.error),
|
|
539
|
+
)
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
# If we didn't process the value as a given type, handle it as a string. We also
|
|
543
|
+
# replace newlines with spaces, and strip any remaining leading/trailing whitespace.
|
|
544
|
+
if value_type is None:
|
|
545
|
+
config_value = config.get(DEFAULTSECT, option).replace("\n", " ").strip()
|
|
546
|
+
if option in CHOICE_MAP:
|
|
547
|
+
if config_value.lower() not in [
|
|
548
|
+
choice.lower() for choice in CHOICE_MAP[option]
|
|
549
|
+
]:
|
|
550
|
+
self._log(
|
|
551
|
+
logging.ERROR,
|
|
552
|
+
"Invalid setting for %s:\n %s\nMust be one of: %s."
|
|
553
|
+
% (
|
|
554
|
+
option,
|
|
555
|
+
config.get(DEFAULTSECT, option),
|
|
556
|
+
", ".join(choice for choice in CHOICE_MAP[option]),
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
continue
|
|
560
|
+
self._config[option] = config_value
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
def is_default(self, option: str) -> bool:
|
|
564
|
+
"""True if the option is default, i.e. is NOT set by the user."""
|
|
565
|
+
return option not in self._config
|
|
566
|
+
|
|
567
|
+
def get(
|
|
568
|
+
self,
|
|
569
|
+
option: str,
|
|
570
|
+
override: ty.Optional[ConfigValue] = None,
|
|
571
|
+
ignore_default: bool = False,
|
|
572
|
+
) -> ConfigValue:
|
|
573
|
+
"""Get the current setting or default value of the specified option."""
|
|
574
|
+
assert option in CONFIG_MAP
|
|
575
|
+
if override is not None:
|
|
576
|
+
return override
|
|
577
|
+
if option in self._config:
|
|
578
|
+
value = self._config[option]
|
|
579
|
+
else:
|
|
580
|
+
value = CONFIG_MAP[option]
|
|
581
|
+
if ignore_default:
|
|
582
|
+
return None
|
|
583
|
+
if issubclass(type(value), ValidatedValue):
|
|
584
|
+
return value.value # Extract validated value.
|
|
585
|
+
return value
|
|
586
|
+
|
|
587
|
+
def get_help_string(self, option: str, show_default: ty.Optional[bool] = None) -> str:
|
|
588
|
+
"""Get string for help text including the option's value, if set, otherwise the default.
|
|
589
|
+
|
|
590
|
+
Arguments:
|
|
591
|
+
option: Command-line option whose name matches an entry in CONFIG_MAP.
|
|
592
|
+
show_default: Always show default value. If None, only shows default value
|
|
593
|
+
if the type is not a flag (boolean). It will still be displayed if set.
|
|
594
|
+
"""
|
|
595
|
+
assert option in CONFIG_MAP
|
|
596
|
+
is_flag = isinstance(CONFIG_MAP[option], bool)
|
|
597
|
+
if option in self._config:
|
|
598
|
+
if is_flag:
|
|
599
|
+
value_str = "on" if self._config[option] else "off"
|
|
600
|
+
else:
|
|
601
|
+
value_str = str(self._config[option])
|
|
602
|
+
return " [setting: %s]" % (value_str)
|
|
603
|
+
if show_default is False or (
|
|
604
|
+
show_default is None and is_flag and CONFIG_MAP[option] is False
|
|
605
|
+
):
|
|
606
|
+
return ""
|
|
607
|
+
return " [default: %s]" % (str(CONFIG_MAP[option]))
|