mopidy 4.0.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.
Files changed (99) hide show
  1. mopidy/__init__.py +14 -0
  2. mopidy/_app/__init__.py +0 -0
  3. mopidy/_app/cli.py +224 -0
  4. mopidy/_app/config.py +380 -0
  5. mopidy/_app/config_keyring.py +189 -0
  6. mopidy/_app/default.conf +25 -0
  7. mopidy/_app/deps.py +200 -0
  8. mopidy/_app/extensions.py +299 -0
  9. mopidy/_app/logs.py +141 -0
  10. mopidy/_app/process.py +66 -0
  11. mopidy/_app/server.py +261 -0
  12. mopidy/_exts/file/__init__.py +32 -0
  13. mopidy/_exts/file/backend.py +24 -0
  14. mopidy/_exts/file/ext.conf +19 -0
  15. mopidy/_exts/file/library.py +170 -0
  16. mopidy/_exts/file/types.py +11 -0
  17. mopidy/_exts/http/__init__.py +60 -0
  18. mopidy/_exts/http/actor.py +244 -0
  19. mopidy/_exts/http/data/clients.html +31 -0
  20. mopidy/_exts/http/data/favicon.ico +0 -0
  21. mopidy/_exts/http/data/mopidy.css +43 -0
  22. mopidy/_exts/http/ext.conf +8 -0
  23. mopidy/_exts/http/handlers.py +327 -0
  24. mopidy/_exts/http/jsonrpc.py +487 -0
  25. mopidy/_exts/http/network.py +31 -0
  26. mopidy/_exts/http/types.py +33 -0
  27. mopidy/_exts/m3u/__init__.py +30 -0
  28. mopidy/_exts/m3u/backend.py +23 -0
  29. mopidy/_exts/m3u/ext.conf +6 -0
  30. mopidy/_exts/m3u/playlists.py +202 -0
  31. mopidy/_exts/m3u/translator.py +106 -0
  32. mopidy/_exts/m3u/types.py +11 -0
  33. mopidy/_exts/softwaremixer/__init__.py +21 -0
  34. mopidy/_exts/softwaremixer/ext.conf +2 -0
  35. mopidy/_exts/softwaremixer/mixer.py +71 -0
  36. mopidy/_exts/stream/__init__.py +28 -0
  37. mopidy/_exts/stream/actor.py +200 -0
  38. mopidy/_exts/stream/ext.conf +11 -0
  39. mopidy/_exts/stream/http.py +68 -0
  40. mopidy/_exts/stream/parsers.py +145 -0
  41. mopidy/_lib/__init__.py +0 -0
  42. mopidy/_lib/gi.py +91 -0
  43. mopidy/_lib/logs.py +18 -0
  44. mopidy/_lib/paths.py +181 -0
  45. mopidy/_lib/process.py +12 -0
  46. mopidy/audio/__init__.py +12 -0
  47. mopidy/audio/_api.py +161 -0
  48. mopidy/audio/_gst.py +755 -0
  49. mopidy/audio/_listener.py +93 -0
  50. mopidy/audio/_utils.py +93 -0
  51. mopidy/audio/scan.py +412 -0
  52. mopidy/audio/tags.py +216 -0
  53. mopidy/backend/__init__.py +18 -0
  54. mopidy/backend/_backend.py +99 -0
  55. mopidy/backend/_library.py +112 -0
  56. mopidy/backend/_listener.py +29 -0
  57. mopidy/backend/_playback.py +184 -0
  58. mopidy/backend/_playlists.py +99 -0
  59. mopidy/config/__init__.py +58 -0
  60. mopidy/config/_types.py +125 -0
  61. mopidy/config/schemas.py +143 -0
  62. mopidy/config/types.py +506 -0
  63. mopidy/config/validators.py +61 -0
  64. mopidy/core/__init__.py +28 -0
  65. mopidy/core/_actor.py +341 -0
  66. mopidy/core/_history.py +81 -0
  67. mopidy/core/_library.py +388 -0
  68. mopidy/core/_listener.py +203 -0
  69. mopidy/core/_mixer.py +135 -0
  70. mopidy/core/_playback.py +597 -0
  71. mopidy/core/_playlists.py +287 -0
  72. mopidy/core/_state_storage.py +202 -0
  73. mopidy/core/_tracklist.py +664 -0
  74. mopidy/core/_validation.py +189 -0
  75. mopidy/exceptions.py +38 -0
  76. mopidy/ext/__init__.py +7 -0
  77. mopidy/ext/_extension.py +128 -0
  78. mopidy/ext/_registry.py +49 -0
  79. mopidy/httpclient.py +52 -0
  80. mopidy/listener.py +53 -0
  81. mopidy/mixer/__init__.py +9 -0
  82. mopidy/mixer/_listener.py +42 -0
  83. mopidy/mixer/_mixer.py +122 -0
  84. mopidy/models/__init__.py +16 -0
  85. mopidy/models/_base.py +25 -0
  86. mopidy/models/_collections.py +57 -0
  87. mopidy/models/_models.py +147 -0
  88. mopidy/models/_refs.py +85 -0
  89. mopidy/models/_tracklist.py +49 -0
  90. mopidy/py.typed +0 -0
  91. mopidy/types.py +105 -0
  92. mopidy/zeroconf.py +158 -0
  93. mopidy-4.0.0.dist-info/METADATA +70 -0
  94. mopidy-4.0.0.dist-info/RECORD +99 -0
  95. mopidy-4.0.0.dist-info/WHEEL +5 -0
  96. mopidy-4.0.0.dist-info/entry_points.txt +9 -0
  97. mopidy-4.0.0.dist-info/licenses/AUTHORS +151 -0
  98. mopidy-4.0.0.dist-info/licenses/LICENSE +202 -0
  99. mopidy-4.0.0.dist-info/top_level.txt +1 -0
mopidy/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ import platform
2
+ import sys
3
+ import warnings
4
+ from importlib.metadata import version
5
+
6
+ if not sys.version_info >= (3, 13):
7
+ sys.exit(
8
+ f"ERROR: Mopidy requires Python >= 3.13, "
9
+ f"but found {platform.python_version()}.",
10
+ )
11
+
12
+ warnings.filterwarnings("ignore", "could not open display")
13
+
14
+ __version__ = version("mopidy")
File without changes
mopidy/_app/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from collections import defaultdict
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ from cyclopts import App, Group, Parameter, Token
12
+ from platformdirs import PlatformDirs
13
+
14
+ import mopidy
15
+ from mopidy._app import config, deps, logs, process, server
16
+ from mopidy._app.config import ConfigLoader, ConfigManager, ConfigOverrides
17
+ from mopidy._app.extensions import ExtensionManager, ExtensionStatus
18
+ from mopidy.config import Config
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def early_setup() -> ExtensionManager | None:
24
+ try:
25
+ logs.bootstrap_delayed_logging()
26
+ logger.info(f"Starting Mopidy {mopidy.__version__}")
27
+
28
+ # Setup signal handlers so we can always shut down cleanly
29
+ process.setup_signal_handlers()
30
+
31
+ # Load extensions
32
+ extensions = ExtensionManager.discover()
33
+ ExtensionManager.set_global(extensions)
34
+ except KeyboardInterrupt:
35
+ return None
36
+ except Exception:
37
+ logger.exception("Unhandled exception")
38
+ raise
39
+ else:
40
+ return extensions
41
+
42
+
43
+ def config_paths_default() -> list[Path]:
44
+ # Use /etc instead of /etc/xdg unless XDG_CONFIG_DIRS is set.
45
+ os.environ.setdefault("XDG_CONFIG_DIRS", "/etc")
46
+ dirs = PlatformDirs(appname="mopidy", appauthor="mopidy")
47
+ return [
48
+ dirs.site_config_path / "mopidy.conf",
49
+ dirs.user_config_path / "mopidy.conf",
50
+ ]
51
+
52
+
53
+ def config_paths_display(value: list[Path]) -> str:
54
+ return ", ".join(str(path) for path in value)
55
+
56
+
57
+ @Parameter(
58
+ name="--config",
59
+ help=(
60
+ "Config files to use. "
61
+ "Repeat parameter or separate values with colon to use multiple files. "
62
+ "Later files have higher precedence."
63
+ ),
64
+ show_default=config_paths_display,
65
+ negative="",
66
+ n_tokens=1,
67
+ )
68
+ def config_paths_converter(_: type, tokens: Sequence[Token]) -> list[Path]:
69
+ return [
70
+ Path(path).expanduser() for token in tokens for path in token.value.split(":")
71
+ ]
72
+
73
+
74
+ @Parameter(
75
+ name=("--option", "-o"),
76
+ help=(
77
+ "Override config values. "
78
+ "Repeat parameter to override multiple values. "
79
+ "Format: SECTION/KEY=VALUE."
80
+ ),
81
+ negative="",
82
+ n_tokens=1,
83
+ )
84
+ def config_overrides_converter(
85
+ _: type, tokens: Sequence[Token]
86
+ ) -> list[dict[str, dict[str, str]]]:
87
+ result = defaultdict(dict)
88
+ for token in tokens:
89
+ if "=" not in token.value:
90
+ msg = f"Invalid config override: {token.value!r}"
91
+ raise ValueError(msg)
92
+ key, value = token.value.split("=", 1)
93
+ if "/" not in key:
94
+ msg = f"Invalid config override key: {key!r}"
95
+ raise ValueError(msg)
96
+ section, key = key.split("/", 1)
97
+ result[section][key] = value
98
+ return [result]
99
+
100
+
101
+ app = App(name="mopidy")
102
+ app.meta.group_parameters = Group("Global parameters", sort_key=0)
103
+
104
+
105
+ @app.meta.default
106
+ def launcher(
107
+ *tokens: Annotated[
108
+ str,
109
+ Parameter(show=False, allow_leading_hyphen=True),
110
+ ],
111
+ config_paths: Annotated[
112
+ list[Path],
113
+ Parameter(converter=config_paths_converter),
114
+ ] = config_paths_default(), # noqa: B008
115
+ config_overrides: Annotated[
116
+ list[ConfigOverrides] | None,
117
+ Parameter(converter=config_overrides_converter),
118
+ ] = None,
119
+ quiet: Annotated[
120
+ bool,
121
+ Parameter(
122
+ name=("--quiet", "-q"),
123
+ help="Decrease amount of output to a minimum.",
124
+ negative="",
125
+ ),
126
+ ] = False,
127
+ verbosity_level: Annotated[
128
+ int,
129
+ Parameter(
130
+ name=("--verbose", "-v"),
131
+ help="Increase amount of output. Repeat up to four times for more.",
132
+ count=True,
133
+ ),
134
+ ] = 0,
135
+ ) -> None:
136
+ """Common setup for all Mopidy commands.
137
+
138
+ This function runs before the command specified on the command line.
139
+ """
140
+ try:
141
+ # Get the extension manager that was created by early_setup()
142
+ extensions = ExtensionManager.get_global()
143
+
144
+ # Create default config file
145
+ primary_config_path = config_paths[-1]
146
+ if not primary_config_path.exists():
147
+ default_config = ConfigLoader.only_defaults(extensions).validate()
148
+ if default_config.write(
149
+ path=primary_config_path,
150
+ with_header=True,
151
+ hide_secrets=False,
152
+ comment_out_defaults=True,
153
+ ):
154
+ logger.info(
155
+ f"Initialized {primary_config_path.as_uri()} with default config"
156
+ )
157
+
158
+ # Resolve current config
159
+ config_manager = ConfigLoader(
160
+ paths=config_paths,
161
+ overrides=config_overrides[0] if config_overrides else None,
162
+ extensions=extensions,
163
+ ).validate()
164
+
165
+ # Make the config manager available to the config command
166
+ ConfigManager.set_global(config_manager)
167
+
168
+ # Create application directories
169
+ process.create_app_dirs(config_manager.config)
170
+
171
+ # Start regular logging
172
+ logs.setup_logging(
173
+ config=config_manager.config,
174
+ verbosity_level=-1 if quiet else verbosity_level,
175
+ )
176
+
177
+ # Check extensions
178
+ for ext_name, error in extensions.check_config_and_env(
179
+ config=config_manager.config,
180
+ config_errors=config_manager.errors,
181
+ ).items():
182
+ if error is not None:
183
+ config_manager.disable_extension(ext_name, comment=error)
184
+ extensions.log_summary()
185
+ if not extensions.with_status(ExtensionStatus.ENABLED):
186
+ logger.error("No extensions enabled. Exiting...")
187
+ sys.exit(1)
188
+
189
+ # Check config
190
+ config_manager.log_errors()
191
+ if config_manager.app_errors:
192
+ logger.error("Please fix fatal configuration errors. Exiting...")
193
+ sys.exit(1)
194
+
195
+ # Share the validated config globally
196
+ Config.set_global(config_manager.config)
197
+
198
+ # Run current command
199
+ # Anything that wants to exit after this point muse use the exit_process()
200
+ # helper as actors can have been started.
201
+ app(tokens)
202
+ except KeyboardInterrupt:
203
+ return
204
+ except Exception:
205
+ logger.exception("Unhandled exception")
206
+ raise
207
+
208
+
209
+ # Register all built-in, non-extension commands
210
+ app.default(server.command)
211
+ app.command(
212
+ config.command,
213
+ name="config",
214
+ help="Display currently active configuration.",
215
+ )
216
+ app.command(
217
+ deps.command,
218
+ name="deps",
219
+ help="Display installed extensions and their dependencies.",
220
+ )
221
+
222
+ # Register extension commands
223
+ if extensions := early_setup():
224
+ extensions.init_commands(app)
mopidy/_app/config.py ADDED
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import configparser
4
+ import logging
5
+ import os
6
+ import re
7
+ import textwrap
8
+ from collections.abc import Generator
9
+ from contextvars import ContextVar
10
+ from functools import cached_property
11
+ from pathlib import Path
12
+ from typing import Any, ClassVar
13
+
14
+ import mopidy
15
+ from mopidy._app.extensions import ExtensionManager, ExtensionStatus
16
+ from mopidy._lib import paths
17
+ from mopidy.config import Config, read, types
18
+ from mopidy.config.schemas import ConfigSchema, MapConfigSchema
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def command() -> None:
24
+ config_manager = ConfigManager.get_global()
25
+ print( # noqa: T201
26
+ config_manager.format(
27
+ with_header=False,
28
+ hide_secrets=True,
29
+ comment_out_defaults=False,
30
+ )
31
+ )
32
+
33
+
34
+ app_schemas: list[ConfigSchema | MapConfigSchema] = [
35
+ ConfigSchema(
36
+ "core",
37
+ {
38
+ "cache_dir": types.Path(),
39
+ "config_dir": types.Path(),
40
+ "data_dir": types.Path(),
41
+ #
42
+ # MPD supports at most 10k tracks, some clients segfault when this
43
+ # is exceeded.
44
+ "max_tracklist_length": types.Integer(minimum=1),
45
+ "restore_state": types.Boolean(optional=True),
46
+ },
47
+ ),
48
+ ConfigSchema(
49
+ "logging",
50
+ {
51
+ "verbosity": types.Integer(minimum=-1, maximum=4),
52
+ "format": types.String(),
53
+ "color": types.Boolean(),
54
+ "config_file": types.Path(optional=True),
55
+ },
56
+ ),
57
+ MapConfigSchema(
58
+ "loglevels",
59
+ types.LogLevel(),
60
+ ),
61
+ ConfigSchema(
62
+ "audio",
63
+ {
64
+ "mixer": types.String(),
65
+ "mixer_volume": types.Integer(optional=True, minimum=0, maximum=100),
66
+ "output": types.String(),
67
+ "buffer_time": types.Integer(optional=True, minimum=1),
68
+ },
69
+ ),
70
+ ConfigSchema(
71
+ "proxy",
72
+ {
73
+ "scheme": types.String(
74
+ optional=True,
75
+ choices=("http", "https", "socks4", "socks5"),
76
+ ),
77
+ "hostname": types.Hostname(optional=True),
78
+ "port": types.Port(optional=True),
79
+ "username": types.String(optional=True),
80
+ "password": types.Secret(optional=True),
81
+ },
82
+ ),
83
+ ]
84
+
85
+
86
+ type ConfigDict = dict[str, dict[str, Any]]
87
+ type ConfigErrors = dict[str, dict[str, str]]
88
+ type ConfigOverrides = dict[str, dict[str, str]]
89
+
90
+
91
+ class ConfigLoader:
92
+ def __init__(
93
+ self,
94
+ *,
95
+ paths: list[Path],
96
+ overrides: ConfigOverrides | None = None,
97
+ extensions: ExtensionManager,
98
+ ) -> None:
99
+ self._paths = paths
100
+ self._overrides: ConfigOverrides = overrides or {}
101
+ self._extensions = extensions
102
+
103
+ @classmethod
104
+ def only_defaults(cls, extensions: ExtensionManager | None) -> ConfigLoader:
105
+ if extensions is None:
106
+ extensions = ExtensionManager() # Empty extension manager
107
+ return cls(
108
+ paths=[], # Ignore existing config files
109
+ overrides=None, # Ignore all overrides
110
+ extensions=extensions,
111
+ )
112
+
113
+ @cached_property
114
+ def _config_defaults(self) -> list[str]:
115
+ return [
116
+ read(Path(__file__).parent / "default.conf"),
117
+ *self._extensions.config_defaults,
118
+ ]
119
+
120
+ @cached_property
121
+ def _config_schemas(self) -> list[ConfigSchema | MapConfigSchema]:
122
+ return [
123
+ *app_schemas,
124
+ *self._extensions.config_schemas,
125
+ ]
126
+
127
+ @cached_property
128
+ def raw_config(self) -> ConfigDict:
129
+ parser = configparser.RawConfigParser(inline_comment_prefixes=(";",))
130
+
131
+ # TODO: simply return path to config file for defaults so we can load it
132
+ # all in the same way?
133
+ logger.debug("Loading config from builtin defaults")
134
+ for default in self._config_defaults:
135
+ if isinstance(default, bytes):
136
+ default = default.decode()
137
+ parser.read_string(default)
138
+
139
+ # Load config from a series of config files
140
+ for path in self._paths:
141
+ path = paths.expand_path(path)
142
+ # TODO: Drop support for directories?
143
+ if path.is_dir():
144
+ for entry in path.iterdir():
145
+ if entry.is_file() and entry.suffix == ".conf":
146
+ self._read_config_file(parser, entry)
147
+ else:
148
+ self._read_config_file(parser, path)
149
+
150
+ if self._overrides:
151
+ logger.info("Loading config from command line options")
152
+ parser.read_dict(self._overrides)
153
+
154
+ return {section: dict(parser.items(section)) for section in parser.sections()}
155
+
156
+ def _read_config_file(
157
+ self,
158
+ parser: configparser.RawConfigParser,
159
+ file_path: Path,
160
+ ) -> None:
161
+ if not file_path.exists():
162
+ logger.debug(
163
+ f"Loading config from {file_path.as_uri()} failed; it does not exist"
164
+ )
165
+ return
166
+ if not os.access(str(file_path), os.R_OK):
167
+ logger.info(
168
+ f"Loading config from {file_path.as_uri()} failed; "
169
+ "read permission missing"
170
+ )
171
+ return
172
+
173
+ try:
174
+ logger.info(f"Loading config from {file_path.as_uri()}")
175
+ with file_path.open("r") as fh:
176
+ parser.read_file(fh)
177
+ except configparser.MissingSectionHeaderError:
178
+ logger.warning(
179
+ f"Loading config from {file_path.as_uri()} failed; "
180
+ f"it does not have a config section",
181
+ )
182
+ except configparser.ParsingError as e:
183
+ linenos = ", ".join(str(lineno) for lineno, line in e.errors)
184
+ logger.warning(
185
+ f"Config file {file_path.as_uri()} has errors; "
186
+ f"line {linenos} has been ignored",
187
+ )
188
+ except OSError:
189
+ # TODO: if this is the initial load of logging config we might not
190
+ # have a logger at this point, we might want to handle this better.
191
+ logger.debug(f"Config file {file_path.as_uri()} not found; skipping")
192
+
193
+ def validate(self) -> ConfigManager:
194
+ validated_config: ConfigDict = {}
195
+ errors: ConfigErrors = {}
196
+
197
+ for schema in self._config_schemas:
198
+ values = self.raw_config.get(schema.name, {})
199
+ result, error = schema.deserialize(values)
200
+ if error:
201
+ errors[schema.name] = error
202
+ if result:
203
+ validated_config[schema.name] = result
204
+
205
+ schemaless_sections = set(self.raw_config) - {
206
+ schema.name for schema in self._config_schemas
207
+ }
208
+ for section in schemaless_sections:
209
+ logger.debug(
210
+ f"Skipping validation of config section {section!r} "
211
+ f"because no matching extension is loaded"
212
+ )
213
+
214
+ return ConfigManager(
215
+ extensions=self._extensions,
216
+ config_schemas=self._config_schemas,
217
+ config=validated_config,
218
+ errors=errors,
219
+ )
220
+
221
+
222
+ class ConfigManager:
223
+ _instance: ClassVar[ContextVar[ConfigManager | None]] = ContextVar(
224
+ "ConfigManager", default=None
225
+ )
226
+
227
+ @classmethod
228
+ def get_global(cls) -> ConfigManager:
229
+ if (instance := cls._instance.get()) is None:
230
+ msg = f"{cls} not set in context"
231
+ raise RuntimeError(msg)
232
+ return instance
233
+
234
+ @classmethod
235
+ def set_global(cls, instance: ConfigManager) -> None:
236
+ if cls._instance.get() is not None:
237
+ msg = f"{cls} already set in context"
238
+ raise RuntimeError(msg)
239
+ cls._instance.set(instance)
240
+
241
+ def __init__(
242
+ self,
243
+ *,
244
+ extensions: ExtensionManager,
245
+ config_schemas: list[ConfigSchema | MapConfigSchema],
246
+ config: ConfigDict,
247
+ errors: ConfigErrors,
248
+ ) -> None:
249
+ self._extensions = extensions
250
+ self._config_schemas = config_schemas
251
+ self._config = config
252
+ self.errors = errors
253
+
254
+ def disable_extension(self, ext_name: str, comment: str) -> None:
255
+ if ext_name not in self._config:
256
+ self._config[ext_name] = {}
257
+ self._config[ext_name]["enabled"] = False
258
+ if ext_name not in self.errors:
259
+ self.errors[ext_name] = {}
260
+ self.errors[ext_name]["enabled"] = comment
261
+
262
+ @property
263
+ def config(self) -> Config:
264
+ return Config(self._config)
265
+
266
+ @property
267
+ def app_errors(self) -> ConfigErrors:
268
+ return {k: v for k, v in self.errors.items() if k not in self._extensions and v}
269
+
270
+ @property
271
+ def extension_errors(self) -> ConfigErrors:
272
+ return {k: v for k, v in self.errors.items() if k in self._extensions and v}
273
+
274
+ def log_errors(self) -> None:
275
+ for section in sorted(self.app_errors):
276
+ logger.warning(f"Found fatal {section!r} configuration errors:")
277
+ for field, msg in self.errors[section].items():
278
+ logger.warning(f" {section}/{field}: {msg}")
279
+
280
+ for section in sorted(self.extension_errors):
281
+ status = self._extensions[section].status
282
+ if status == ExtensionStatus.DISABLED:
283
+ continue
284
+ logger.warning(
285
+ f"Found {section!r} configuration errors. "
286
+ f"The extension has been automatically disabled:",
287
+ )
288
+ for field, msg in self.errors[section].items():
289
+ logger.warning(f" {section}/{field}: {msg}")
290
+
291
+ def write(
292
+ self,
293
+ *,
294
+ path: Path,
295
+ with_header: bool = False,
296
+ hide_secrets: bool = True,
297
+ comment_out_defaults: bool = False,
298
+ ) -> bool:
299
+ try:
300
+ paths.get_or_create_file(
301
+ path,
302
+ mkdir=True,
303
+ content=self.format(
304
+ with_header=with_header,
305
+ hide_secrets=hide_secrets,
306
+ comment_out_defaults=comment_out_defaults,
307
+ ),
308
+ )
309
+ except OSError as exc:
310
+ logger.warning(f"Unable to write config to {path.as_uri()}: {exc}")
311
+ return False
312
+ else:
313
+ return True
314
+
315
+ def format(
316
+ self,
317
+ *,
318
+ with_header: bool = False,
319
+ hide_secrets: bool = True,
320
+ comment_out_defaults: bool = False,
321
+ ) -> str:
322
+ result = "\n".join(
323
+ self._format_generator(
324
+ with_header=with_header,
325
+ hide_secrets=hide_secrets,
326
+ comment_out_defaults=comment_out_defaults,
327
+ )
328
+ )
329
+
330
+ # Throw away all bytes that are not valid UTF-8
331
+ return result.encode(errors="surrogateescape").decode(errors="replace")
332
+
333
+ def _format_generator(
334
+ self,
335
+ *,
336
+ with_header: bool,
337
+ hide_secrets: bool,
338
+ comment_out_defaults: bool,
339
+ ) -> Generator[str]:
340
+ if with_header:
341
+ versions = [
342
+ f"mopidy {mopidy.__version__}",
343
+ *[
344
+ f"{r.extension.dist_name} {r.extension.version}"
345
+ for r in self._extensions.values()
346
+ if r.extension is not None
347
+ ],
348
+ ]
349
+ yield textwrap.dedent(f"""\
350
+ # For further information about options in this file see:
351
+ # https://docs.mopidy.com/
352
+ #
353
+ # The initial commented out values reflect the defaults as of:
354
+ # {"\n# ".join(versions)}
355
+ #
356
+ # Available options and defaults might have changed since then,
357
+ # run `mopidy config` to see the current effective config and
358
+ # `mopidy --version` to check the current version.
359
+ """)
360
+
361
+ for schema in self._config_schemas:
362
+ serialized = schema.serialize(
363
+ self.config.get(schema.name, {}),
364
+ display=hide_secrets,
365
+ )
366
+ if not serialized:
367
+ continue
368
+ yield f"[{schema.name}]"
369
+ for key, value in serialized.items():
370
+ if isinstance(value, types.DeprecatedValue):
371
+ continue
372
+ line = f"{key} ="
373
+ if value is not None:
374
+ line += " " + value
375
+ if error := self.errors.get(schema.name, {}).get(key):
376
+ line += " ; " + error.capitalize()
377
+ if comment_out_defaults:
378
+ line = re.sub(r"^", "#", line, flags=re.MULTILINE)
379
+ yield line
380
+ yield ""