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.
- mopidy/__init__.py +14 -0
- mopidy/_app/__init__.py +0 -0
- mopidy/_app/cli.py +224 -0
- mopidy/_app/config.py +380 -0
- mopidy/_app/config_keyring.py +189 -0
- mopidy/_app/default.conf +25 -0
- mopidy/_app/deps.py +200 -0
- mopidy/_app/extensions.py +299 -0
- mopidy/_app/logs.py +141 -0
- mopidy/_app/process.py +66 -0
- mopidy/_app/server.py +261 -0
- mopidy/_exts/file/__init__.py +32 -0
- mopidy/_exts/file/backend.py +24 -0
- mopidy/_exts/file/ext.conf +19 -0
- mopidy/_exts/file/library.py +170 -0
- mopidy/_exts/file/types.py +11 -0
- mopidy/_exts/http/__init__.py +60 -0
- mopidy/_exts/http/actor.py +244 -0
- mopidy/_exts/http/data/clients.html +31 -0
- mopidy/_exts/http/data/favicon.ico +0 -0
- mopidy/_exts/http/data/mopidy.css +43 -0
- mopidy/_exts/http/ext.conf +8 -0
- mopidy/_exts/http/handlers.py +327 -0
- mopidy/_exts/http/jsonrpc.py +487 -0
- mopidy/_exts/http/network.py +31 -0
- mopidy/_exts/http/types.py +33 -0
- mopidy/_exts/m3u/__init__.py +30 -0
- mopidy/_exts/m3u/backend.py +23 -0
- mopidy/_exts/m3u/ext.conf +6 -0
- mopidy/_exts/m3u/playlists.py +202 -0
- mopidy/_exts/m3u/translator.py +106 -0
- mopidy/_exts/m3u/types.py +11 -0
- mopidy/_exts/softwaremixer/__init__.py +21 -0
- mopidy/_exts/softwaremixer/ext.conf +2 -0
- mopidy/_exts/softwaremixer/mixer.py +71 -0
- mopidy/_exts/stream/__init__.py +28 -0
- mopidy/_exts/stream/actor.py +200 -0
- mopidy/_exts/stream/ext.conf +11 -0
- mopidy/_exts/stream/http.py +68 -0
- mopidy/_exts/stream/parsers.py +145 -0
- mopidy/_lib/__init__.py +0 -0
- mopidy/_lib/gi.py +91 -0
- mopidy/_lib/logs.py +18 -0
- mopidy/_lib/paths.py +181 -0
- mopidy/_lib/process.py +12 -0
- mopidy/audio/__init__.py +12 -0
- mopidy/audio/_api.py +161 -0
- mopidy/audio/_gst.py +755 -0
- mopidy/audio/_listener.py +93 -0
- mopidy/audio/_utils.py +93 -0
- mopidy/audio/scan.py +412 -0
- mopidy/audio/tags.py +216 -0
- mopidy/backend/__init__.py +18 -0
- mopidy/backend/_backend.py +99 -0
- mopidy/backend/_library.py +112 -0
- mopidy/backend/_listener.py +29 -0
- mopidy/backend/_playback.py +184 -0
- mopidy/backend/_playlists.py +99 -0
- mopidy/config/__init__.py +58 -0
- mopidy/config/_types.py +125 -0
- mopidy/config/schemas.py +143 -0
- mopidy/config/types.py +506 -0
- mopidy/config/validators.py +61 -0
- mopidy/core/__init__.py +28 -0
- mopidy/core/_actor.py +341 -0
- mopidy/core/_history.py +81 -0
- mopidy/core/_library.py +388 -0
- mopidy/core/_listener.py +203 -0
- mopidy/core/_mixer.py +135 -0
- mopidy/core/_playback.py +597 -0
- mopidy/core/_playlists.py +287 -0
- mopidy/core/_state_storage.py +202 -0
- mopidy/core/_tracklist.py +664 -0
- mopidy/core/_validation.py +189 -0
- mopidy/exceptions.py +38 -0
- mopidy/ext/__init__.py +7 -0
- mopidy/ext/_extension.py +128 -0
- mopidy/ext/_registry.py +49 -0
- mopidy/httpclient.py +52 -0
- mopidy/listener.py +53 -0
- mopidy/mixer/__init__.py +9 -0
- mopidy/mixer/_listener.py +42 -0
- mopidy/mixer/_mixer.py +122 -0
- mopidy/models/__init__.py +16 -0
- mopidy/models/_base.py +25 -0
- mopidy/models/_collections.py +57 -0
- mopidy/models/_models.py +147 -0
- mopidy/models/_refs.py +85 -0
- mopidy/models/_tracklist.py +49 -0
- mopidy/py.typed +0 -0
- mopidy/types.py +105 -0
- mopidy/zeroconf.py +158 -0
- mopidy-4.0.0.dist-info/METADATA +70 -0
- mopidy-4.0.0.dist-info/RECORD +99 -0
- mopidy-4.0.0.dist-info/WHEEL +5 -0
- mopidy-4.0.0.dist-info/entry_points.txt +9 -0
- mopidy-4.0.0.dist-info/licenses/AUTHORS +151 -0
- mopidy-4.0.0.dist-info/licenses/LICENSE +202 -0
- 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")
|
mopidy/_app/__init__.py
ADDED
|
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 ""
|