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.
Files changed (45) hide show
  1. dvr_scan/LICENSE +47 -0
  2. dvr_scan/__init__.py +55 -0
  3. dvr_scan/__main__.py +80 -0
  4. dvr_scan/app/__init__.py +10 -0
  5. dvr_scan/app/__main__.py +218 -0
  6. dvr_scan/app/about_window.py +137 -0
  7. dvr_scan/app/application.py +1700 -0
  8. dvr_scan/app/common.py +31 -0
  9. dvr_scan/app/region_editor.py +1416 -0
  10. dvr_scan/app/scan_window.py +258 -0
  11. dvr_scan/app/widgets.py +213 -0
  12. dvr_scan/cli.py +511 -0
  13. dvr_scan/config.py +607 -0
  14. dvr_scan/controller.py +215 -0
  15. dvr_scan/detector.py +124 -0
  16. dvr_scan/docs/404.html +1 -0
  17. dvr_scan/docs/assets/javascripts/bundle.88dd0f4e.min.js +15 -0
  18. dvr_scan/docs/assets/stylesheets/main.6f8fc17f.min.css +1 -0
  19. dvr_scan/docs/assets/stylesheets/palette.06af60db.min.css +1 -0
  20. dvr_scan/docs/changelog.html +1 -0
  21. dvr_scan/docs/docs.html +73 -0
  22. dvr_scan/docs/faq.html +13 -0
  23. dvr_scan/docs/guide.html +1 -0
  24. dvr_scan/docs/index.html +5 -0
  25. dvr_scan/docs/resources.html +30 -0
  26. dvr_scan/docs/style.min.css +1 -0
  27. dvr_scan/dvr-scan-logo.png +0 -0
  28. dvr_scan/dvr-scan.ico +0 -0
  29. dvr_scan/dvr-scan.png +0 -0
  30. dvr_scan/opencv_loader.py +37 -0
  31. dvr_scan/overlays.py +251 -0
  32. dvr_scan/platform.py +236 -0
  33. dvr_scan/region.py +65 -0
  34. dvr_scan/scanner.py +1303 -0
  35. dvr_scan/shared/__init__.py +287 -0
  36. dvr_scan/shared/cli.py +291 -0
  37. dvr_scan/shared/settings.py +72 -0
  38. dvr_scan/subtractor.py +163 -0
  39. dvr_scan/video_joiner.py +188 -0
  40. dvr_scan_headless-1.8.dist-info/METADATA +119 -0
  41. dvr_scan_headless-1.8.dist-info/RECORD +45 -0
  42. dvr_scan_headless-1.8.dist-info/WHEEL +5 -0
  43. dvr_scan_headless-1.8.dist-info/entry_points.txt +2 -0
  44. dvr_scan_headless-1.8.dist-info/licenses/LICENSE +47 -0
  45. 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]))