config.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import argparse
  2. import ast
  3. import json
  4. import os
  5. import pathlib
  6. import sys
  7. from bandcamp_dl import __version__
  8. TEMPLATE = '%{artist}/%{album}/%{track} - %{title}'
  9. OK_CHARS = '-_~'
  10. SPACE_CHAR = '-'
  11. CASE_LOWER = 'lower'
  12. CASE_UPPER = 'upper'
  13. CASE_CAMEL = 'camel'
  14. CASE_NONE = 'none'
  15. USER_HOME = pathlib.Path.home()
  16. # For Linux/BSD https://www.freedesktop.org/wiki/Software/xdg-user-dirs/
  17. # For Windows ans MacOS .appname is fine
  18. CONFIG_PATH = USER_HOME / (".config" if os.name == "posix" else ".bandcamp-dl") / "bandcamp-dl.json"
  19. OPTION_MIGRATION_FORWARD = "forward"
  20. OPTION_MIGRATION_REVERSE = "reverse"
  21. class Config(dict):
  22. # TODO: change this to dataclass when support for Python < 3.7 is dropped
  23. _defaults = {"base_dir": str(USER_HOME), # TODO: pass the Path object instead?
  24. "template": TEMPLATE,
  25. "overwrite": False,
  26. "no_art": False,
  27. "embed_art": False,
  28. "embed_lyrics": False,
  29. "group": False,
  30. "no_slugify": False,
  31. "ok_chars": OK_CHARS,
  32. "space_char": SPACE_CHAR,
  33. "ascii_only": False,
  34. "keep_spaces": False,
  35. "case_mode": CASE_LOWER,
  36. "no_confirm": False,
  37. "debug": False,
  38. "embed_genres": False,
  39. "cover_quality": 0,
  40. "untitled_path_from_slug": False,
  41. "truncate_album": 0,
  42. "truncate_track": 0}
  43. def __init__(self, dict_=None):
  44. if dict_ is None:
  45. super().__init__(**Config._defaults)
  46. else:
  47. super().__init__(**dict_)
  48. self.__dict__ = self
  49. self._read_write_config()
  50. def _read_write_config(self):
  51. if CONFIG_PATH.exists():
  52. with pathlib.Path.open(CONFIG_PATH, 'r+') as fobj:
  53. try:
  54. user_config = json.load(fobj)
  55. # change hyphen with underscore
  56. user_config = {k.replace('-', '_'): v for k, v in user_config.items()}
  57. # overwrite defaults with user provided config
  58. if self._update_with_dict(user_config) or \
  59. set(user_config.keys()).difference(set(self.keys())) :
  60. # persist migrated options, removal of unsupported options, or missing
  61. # options with their defaults
  62. sys.stderr.write(f"Modified configuration has been written to "
  63. f"`{CONFIG_PATH}'.\n")
  64. fobj.seek(0) # r/w mode
  65. fobj.truncate() # r/w mode
  66. json.dump({k: v for k, v in self.items()}, fobj)
  67. except json.JSONDecodeError:
  68. # NOTE: we don't have logger yet
  69. sys.stderr.write(f"Malformed configuration file `{CONFIG_PATH}'. Check json syntax.\n")
  70. else:
  71. # No config found - populate it with the defaults
  72. os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
  73. with pathlib.Path.open(CONFIG_PATH, mode="w") as fobj:
  74. conf = {k.replace('_', '-'): v for k, v in self.items()}
  75. json.dump(conf, fobj)
  76. sys.stderr.write(f"Configuration has been written to `{CONFIG_PATH}'.\n")
  77. def _update_with_dict(self, dict_):
  78. """update this config instance with the persisted key-value
  79. set, migrating or dropping any unknown options and returning
  80. true when the underlying config needs updating"""
  81. modified = False
  82. for key, val in dict_.items():
  83. if key not in self:
  84. modified = True
  85. if not self._migrate_option(key, val):
  86. sys.stderr.write(f"Dropping unknown config option '{key}={val}'\n")
  87. continue
  88. self[key] = val
  89. def _migrate_option(self, key, val):
  90. """where supported, migrate legacy options and their values
  91. to update this config instance's new option, returning
  92. true / false to indicate whether or not this key was
  93. supported"""
  94. migration_type = migration_key = migration_val = None
  95. if key == "keep_upper":
  96. # forward migration
  97. migration_type = OPTION_MIGRATION_FORWARD
  98. migration_key = "case_mode"
  99. migration_val = self.case_mode = CASE_NONE if val else CASE_LOWER
  100. elif key == "case_mode":
  101. # reverse migration
  102. migration_type = OPTION_MIGRATION_REVERSE
  103. migration_key = "keep_upper"
  104. migration_val = self.keep_upper = False if val == CASE_LOWER else True
  105. if val in [CASE_UPPER, CASE_CAMEL]:
  106. sys.stderr.write(f"Warning, lossy reverse migration, new value '{val}' is not backwards compatible\n")
  107. if migration_type:
  108. sys.stderr.write(f"{migration_type.capitalize()} migration of config option: '{key}={val}' -> " \
  109. f"'{migration_key}={migration_val}'\n")
  110. return True
  111. else:
  112. return False