Configure new server flow works
This commit is contained in:
@@ -14,7 +14,7 @@ from typing import (
|
||||
Tuple,
|
||||
)
|
||||
|
||||
from dataclasses_json import dataclass_json
|
||||
from dataclasses_json import DataClassJsonMixin
|
||||
from gi.repository import Gtk
|
||||
|
||||
from .api_objects import (
|
||||
@@ -153,33 +153,18 @@ class CacheMissError(Exception):
|
||||
super().__init__(*args)
|
||||
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class ConfigurationStore:
|
||||
class ConfigurationStore(dict):
|
||||
"""
|
||||
This defines an abstract store for all configuration parameters for a given Adapter.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._store: Dict[str, Any] = {}
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
values = ", ".join(f"{k}={v!r}" for k, v in self._store.items())
|
||||
values = ", ".join(f"{k}={v!r}" for k, v in sorted(self.items()))
|
||||
return f"ConfigurationStore({values})"
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get the configuration value in the store with the given key. If the key doesn't
|
||||
exist in the store, return the default.
|
||||
"""
|
||||
return self._store.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
"""
|
||||
Set the value of the given key in the store.
|
||||
"""
|
||||
self._store[key] = value
|
||||
|
||||
def get_secret(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get the secret value in the store with the given key. If the key doesn't exist
|
||||
@@ -188,7 +173,7 @@ class ConfigurationStore:
|
||||
with secret storage yourself.
|
||||
"""
|
||||
# TODO make secret storage less stupid.
|
||||
return self._store.get(key, default)
|
||||
return self.get(key, default)
|
||||
|
||||
def set_secret(self, key: str, value: Any = None) -> Any:
|
||||
"""
|
||||
@@ -197,10 +182,7 @@ class ConfigurationStore:
|
||||
is configured as the underlying secret storage mechanism so you don't have to
|
||||
deal with secret storage yourself.
|
||||
"""
|
||||
self._store[key] = value
|
||||
|
||||
def keys(self) -> Iterable[str]:
|
||||
return self._store.keys()
|
||||
self[key] = value
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@@ -154,7 +154,7 @@ class ConfigureServerForm(Gtk.Box):
|
||||
if cpd.required:
|
||||
self.required_config_parameter_keys.add(key)
|
||||
if cpd.default is not None:
|
||||
config_store.set(key, config_store.get(key, cpd.default))
|
||||
config_store[key] = config_store.get(key, cpd.default)
|
||||
|
||||
label = Gtk.Label(cpd.description + ":", halign=Gtk.Align.END)
|
||||
|
||||
@@ -265,7 +265,7 @@ class ConfigureServerForm(Gtk.Box):
|
||||
|
||||
def set_icon_and_label(icon_name: str, label_text: str):
|
||||
self.config_verification_box.add(
|
||||
Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.BUTTON)
|
||||
Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DND)
|
||||
)
|
||||
label = Gtk.Label(
|
||||
label=label_text,
|
||||
@@ -276,18 +276,19 @@ class ConfigureServerForm(Gtk.Box):
|
||||
self.config_verification_box.add(label)
|
||||
|
||||
if is_valid:
|
||||
set_icon_and_label("config-ok-symbolic", "Configuration is valid")
|
||||
set_icon_and_label(
|
||||
"config-ok-symbolic", "<b>Configuration is valid</b>"
|
||||
)
|
||||
elif escaped := util.esc(error_text):
|
||||
set_icon_and_label("config-error-symbolic", escaped)
|
||||
|
||||
self.config_verification_box.show_all()
|
||||
|
||||
def _on_config_change(self, key: str, value: Any, secret: bool = False):
|
||||
set_fn = cast(
|
||||
Callable[[str, Any], None],
|
||||
(self.config_store.set_secret if secret else self.config_store.set),
|
||||
)
|
||||
set_fn(key, value)
|
||||
if secret:
|
||||
self.config_store.set_secret(key, value)
|
||||
else:
|
||||
self.config_store[key] = value
|
||||
self._verification_status_ratchet += 1
|
||||
self._verify_config(self._verification_status_ratchet)
|
||||
|
||||
|
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
||||
<path class="error" fill="#ad2b05" d="M12 1.42A10.58 10.58 0 001.42 12 10.58 10.58 0 0012 22.58 10.58 10.58 0 0022.58 12 10.58 10.58 0 0012 1.42zM7.45 5.33L12 9.88l4.55-4.55 2.12 2.12L14.12 12l4.55 4.55-2.12 2.12L12 14.12l-4.55 4.55-2.12-2.12L9.88 12 5.33 7.45l2.12-2.12z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 348 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
||||
<path class="success" fill="#19ad05" d="M12 1.42A10.58 10.58 0 001.42 12 10.58 10.58 0 0012 22.58 10.58 10.58 0 0022.58 12 10.58 10.58 0 0012 1.42zm4.9 5.1L19 8.68l-8.63 8.49a1.5 1.5 0 01-2.1 0l-3.55-3.5 2.1-2.14 2.5 2.46z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 299 B |
@@ -300,10 +300,7 @@ class AdapterManager:
|
||||
caching_adapter_type := config.provider.caching_adapter_type
|
||||
) and config.provider.ground_truth_adapter_type.can_be_cached:
|
||||
caching_adapter = caching_adapter_type(
|
||||
{
|
||||
key: getattr(config.provider, key)
|
||||
for key in caching_adapter_type.get_config_parameters()
|
||||
},
|
||||
config.provider.caching_adapter_config,
|
||||
source_data_dir.joinpath("c"),
|
||||
is_cache=True,
|
||||
)
|
||||
|
@@ -172,7 +172,7 @@ class SubsonicAdapter(Adapter):
|
||||
"ignored_articles.pickle"
|
||||
)
|
||||
|
||||
self.hostname = config.get("server_address")
|
||||
self.hostname = config["server_address"]
|
||||
if (
|
||||
(ssid := config.get("local_network_ssid"))
|
||||
and (lan_address := config.get("local_network_address"))
|
||||
@@ -200,9 +200,9 @@ class SubsonicAdapter(Adapter):
|
||||
if not parsed_hostname.scheme:
|
||||
self.hostname = "https://" + self.hostname
|
||||
|
||||
self.username = config.get("username")
|
||||
self.username = config["username"]
|
||||
self.password = config.get_secret("password")
|
||||
self.verify_cert = config.get("verify_cert")
|
||||
self.verify_cert = config["verify_cert"]
|
||||
|
||||
self.is_shutting_down = False
|
||||
|
||||
@@ -651,7 +651,9 @@ class SubsonicAdapter(Adapter):
|
||||
self._get(
|
||||
self._make_url("savePlayQueue"),
|
||||
id=song_ids,
|
||||
current=song_ids[current_song_index] if current_song_index else None,
|
||||
current=song_ids[current_song_index]
|
||||
if current_song_index is not None
|
||||
else None,
|
||||
position=math.floor(position.total_seconds() * 1000) if position else None,
|
||||
)
|
||||
|
||||
|
@@ -156,16 +156,18 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# configured, and if none are configured, then show the dialog to create a new
|
||||
# one.
|
||||
if self.app_config.provider is None:
|
||||
if len(self.app_config.providers) > 0:
|
||||
self.app_config._current_provider_id = 0
|
||||
else:
|
||||
if len(self.app_config.providers) == 0:
|
||||
self.show_configure_servers_dialog()
|
||||
|
||||
# If they didn't add one with the dialog, close the window.
|
||||
if self.app_config.provider is None:
|
||||
if len(self.app_config.providers) == 0:
|
||||
self.window.close()
|
||||
return
|
||||
|
||||
self.app_config.current_provider_id = list(
|
||||
self.app_config.providers.keys()
|
||||
)[0]
|
||||
|
||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||
|
||||
# Connect after we know there's a server configured.
|
||||
@@ -1298,6 +1300,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
)
|
||||
|
||||
def save_play_queue(self, song_playing_order_token: int = None):
|
||||
print('SAVE PLAY QUEUE')
|
||||
if (
|
||||
len(self.app_config.state.play_queue) == 0
|
||||
or self.app_config.provider is None
|
||||
|
@@ -1,34 +1,30 @@
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from abc import ABCMeta
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from typing import Any, cast, Dict, Optional, Type
|
||||
|
||||
import dataclasses_json
|
||||
from dataclasses_json import dataclass_json, DataClassJsonMixin
|
||||
from dataclasses_json import config, DataClassJsonMixin
|
||||
|
||||
from sublime.adapters import ConfigurationStore
|
||||
from sublime.ui.state import UIState
|
||||
|
||||
|
||||
# JSON decoder and encoder translations
|
||||
decoder_functions = {
|
||||
Path: (lambda p: Path(p) if p else None),
|
||||
}
|
||||
encoder_functions = {
|
||||
Path: (lambda p: str(p.resolve()) if p else None),
|
||||
}
|
||||
def encode_path(path: Path) -> str:
|
||||
return str(path.resolve())
|
||||
|
||||
for type_, translation_function in decoder_functions.items():
|
||||
dataclasses_json.cfg.global_config.decoders[type_] = translation_function
|
||||
dataclasses_json.cfg.global_config.decoders[Optional[type_]] = translation_function
|
||||
|
||||
for type_, translation_function in encoder_functions.items():
|
||||
dataclasses_json.cfg.global_config.encoders[type_] = translation_function
|
||||
dataclasses_json.cfg.global_config.encoders[Optional[type_]] = translation_function
|
||||
dataclasses_json.cfg.global_config.decoders[Path] = Path
|
||||
dataclasses_json.cfg.global_config.decoders[Optional[Path]] = (
|
||||
lambda p: Path(p) if p else None
|
||||
)
|
||||
|
||||
dataclasses_json.cfg.global_config.encoders[Path] = encode_path
|
||||
dataclasses_json.cfg.global_config.encoders[Optional[Path]] = encode_path
|
||||
|
||||
|
||||
class ReplayGainType(Enum):
|
||||
@@ -66,6 +62,57 @@ class ProviderConfiguration:
|
||||
self.caching_adapter_type.migrate_configuration(self.caching_adapter_config)
|
||||
|
||||
|
||||
def encode_providers(
|
||||
providers_dict: Dict[str, Dict[str, Any]]
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
return {
|
||||
id_: {
|
||||
**config,
|
||||
"ground_truth_adapter_type": config["ground_truth_adapter_type"].__name__,
|
||||
"caching_adapter_type": (
|
||||
cast(type, config.get("caching_adapter_type")).__name__
|
||||
if config.get("caching_adapter_type")
|
||||
else None
|
||||
),
|
||||
}
|
||||
for id_, config in providers_dict.items()
|
||||
}
|
||||
|
||||
|
||||
def decode_providers(
|
||||
providers_dict: Dict[str, Dict[str, Any]]
|
||||
) -> Dict[str, ProviderConfiguration]:
|
||||
from sublime.adapters import AdapterManager
|
||||
|
||||
def find_adapter_type(type_name: str) -> Type:
|
||||
for available_adapter in AdapterManager.available_adapters:
|
||||
if available_adapter.__name__ == type_name:
|
||||
return available_adapter
|
||||
raise Exception(f"Couldn't find adapter of type {type_name}")
|
||||
|
||||
return {
|
||||
id_: ProviderConfiguration(
|
||||
config["id"],
|
||||
config["name"],
|
||||
ground_truth_adapter_type=find_adapter_type(
|
||||
config["ground_truth_adapter_type"]
|
||||
),
|
||||
ground_truth_adapter_config=ConfigurationStore(
|
||||
**config["ground_truth_adapter_config"]
|
||||
),
|
||||
caching_adapter_type=(
|
||||
find_adapter_type(cat)
|
||||
if (cat := config.get("caching_adapter_type"))
|
||||
else None
|
||||
),
|
||||
caching_adapter_config=(
|
||||
ConfigurationStore(**config.get("caching_adapter_config", {}))
|
||||
),
|
||||
)
|
||||
for id_, config in providers_dict.items()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfiguration(DataClassJsonMixin):
|
||||
version: int = 5
|
||||
@@ -73,8 +120,12 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
filename: Optional[Path] = None
|
||||
|
||||
# Providers
|
||||
providers: Dict[str, ProviderConfiguration] = field(default_factory=dict)
|
||||
providers: Dict[str, ProviderConfiguration] = field(
|
||||
default_factory=dict,
|
||||
metadata=config(encoder=encode_providers, decoder=decode_providers),
|
||||
)
|
||||
current_provider_id: Optional[str] = None
|
||||
_loaded_provider_id: Optional[str] = field(default=None, init=False)
|
||||
|
||||
# Global Settings
|
||||
song_play_notification: bool = True
|
||||
@@ -112,7 +163,7 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
self.cache_location = path
|
||||
|
||||
self._state = None
|
||||
self._current_provider_id = None
|
||||
self._loaded_provider_id = None
|
||||
self.migrate()
|
||||
|
||||
def migrate(self):
|
||||
@@ -124,7 +175,7 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
|
||||
@property
|
||||
def provider(self) -> Optional[ProviderConfiguration]:
|
||||
return self.providers.get(self._current_provider_id or "")
|
||||
return self.providers.get(self.current_provider_id or "")
|
||||
|
||||
@property
|
||||
def state(self) -> UIState:
|
||||
@@ -132,7 +183,7 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
return UIState()
|
||||
|
||||
# If the provider has changed, then retrieve the new provider's state.
|
||||
if self._current_provider_id != provider.id:
|
||||
if self._loaded_provider_id != provider.id:
|
||||
self.load_state()
|
||||
|
||||
return self._state
|
||||
@@ -142,7 +193,7 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
if not (provider := self.provider):
|
||||
return
|
||||
|
||||
self._current_provider_id = provider.id
|
||||
self._loaded_provider_id = provider.id
|
||||
if (state_filename := self._state_file_location) and state_filename.exists():
|
||||
try:
|
||||
with open(state_filename, "rb") as f:
|
||||
|
@@ -3,17 +3,28 @@ from time import sleep
|
||||
|
||||
import pytest
|
||||
|
||||
from sublime.adapters import AdapterManager, Result, SearchResult
|
||||
from sublime.adapters.subsonic import api_objects as SubsonicAPI
|
||||
from sublime.adapters import AdapterManager, ConfigurationStore, Result, SearchResult
|
||||
from sublime.adapters.filesystem import FilesystemAdapter
|
||||
from sublime.adapters.subsonic import api_objects as SubsonicAPI, SubsonicAdapter
|
||||
from sublime.config import AppConfiguration, ProviderConfiguration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter_manager(tmp_path: Path):
|
||||
subsonic_config_store = ConfigurationStore(
|
||||
server_address="https://subsonic.example.com", username="test",
|
||||
)
|
||||
subsonic_config_store.set_secret("password", "testpass")
|
||||
|
||||
config = AppConfiguration(
|
||||
providers={
|
||||
"1": ProviderConfiguration(
|
||||
name="foo", server_address="bar", username="baz", password="ohea",
|
||||
id="1",
|
||||
name="foo",
|
||||
ground_truth_adapter_type=SubsonicAdapter,
|
||||
ground_truth_adapter_config=subsonic_config_store,
|
||||
caching_adapter_type=FilesystemAdapter,
|
||||
caching_adapter_config=ConfigurationStore(),
|
||||
)
|
||||
},
|
||||
current_provider_id="1",
|
||||
|
@@ -8,6 +8,7 @@ from typing import Any, Generator, List, Tuple
|
||||
import pytest
|
||||
from dateutil.tz import tzutc
|
||||
|
||||
from sublime.adapters import ConfigurationStore
|
||||
from sublime.adapters.subsonic import (
|
||||
api_objects as SubsonicAPI,
|
||||
SubsonicAdapter,
|
||||
@@ -18,14 +19,12 @@ MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(tmp_path: Path):
|
||||
adapter = SubsonicAdapter(
|
||||
{
|
||||
"server_address": "http://subsonic.example.com",
|
||||
"username": "test",
|
||||
"password": "testpass",
|
||||
},
|
||||
tmp_path,
|
||||
config = ConfigurationStore(
|
||||
server_address="https://subsonic.example.com", username="test",
|
||||
)
|
||||
config.set_secret("password", "testpass")
|
||||
|
||||
adapter = SubsonicAdapter(config, tmp_path)
|
||||
adapter._is_mock = True
|
||||
yield adapter
|
||||
adapter.shutdown()
|
||||
@@ -84,7 +83,7 @@ def test_request_making_methods(adapter: SubsonicAdapter):
|
||||
}
|
||||
assert sorted(expected.items()) == sorted(adapter._get_params().items())
|
||||
|
||||
assert adapter._make_url("foo") == "http://subsonic.example.com/rest/foo.view"
|
||||
assert adapter._make_url("foo") == "https://subsonic.example.com/rest/foo.view"
|
||||
|
||||
|
||||
def test_ping_status(adapter: SubsonicAdapter):
|
||||
|
@@ -1,65 +1,86 @@
|
||||
import os
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
from sublime.adapters import ConfigurationStore
|
||||
from sublime.adapters.filesystem import FilesystemAdapter
|
||||
from sublime.adapters.subsonic import SubsonicAdapter
|
||||
from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_filename(tmp_path: Path):
|
||||
yield tmp_path.joinpath("config.json")
|
||||
|
||||
|
||||
def test_config_default_cache_location():
|
||||
config = AppConfiguration()
|
||||
assert config.cache_location == os.path.expanduser("~/.local/share/sublime-music")
|
||||
assert config.cache_location == Path("~/.local/share/sublime-music").expanduser()
|
||||
|
||||
|
||||
def test_server_property():
|
||||
config = AppConfiguration()
|
||||
server = ServerConfiguration(name="foo", server_address="bar", username="baz")
|
||||
config.servers.append(server)
|
||||
assert config.server is None
|
||||
config.current_server_index = 0
|
||||
assert asdict(config.server) == asdict(server)
|
||||
provider = ProviderConfiguration(
|
||||
id="1",
|
||||
name="foo",
|
||||
ground_truth_adapter_type=SubsonicAdapter,
|
||||
ground_truth_adapter_config=ConfigurationStore(),
|
||||
)
|
||||
config.providers["1"] = provider
|
||||
assert config.provider is None
|
||||
config.current_provider_id = "1"
|
||||
assert config.provider == provider
|
||||
|
||||
expected_state_file_location = Path("~/.local/share").expanduser()
|
||||
expected_state_file_location = expected_state_file_location.joinpath(
|
||||
"sublime-music", "6df23dc03f9b54cc38a0fc1483df6e21", "state.pickle",
|
||||
"sublime-music", "1", "state.pickle",
|
||||
)
|
||||
assert config._state_file_location == expected_state_file_location
|
||||
|
||||
|
||||
def test_yaml_load_unload():
|
||||
config = AppConfiguration()
|
||||
server = ServerConfiguration(name="foo", server_address="bar", username="baz")
|
||||
config.servers.append(server)
|
||||
config.current_server_index = 0
|
||||
|
||||
yamlified = yaml.dump(asdict(config))
|
||||
unyamlified = yaml.load(yamlified, Loader=yaml.CLoader)
|
||||
deserialized = AppConfiguration(**unyamlified)
|
||||
|
||||
return
|
||||
|
||||
# TODO (#197) reinstate these tests with the new config system.
|
||||
# Make sure that the config and each of the servers gets loaded in properly
|
||||
# into the dataclass objects.
|
||||
assert asdict(config) == asdict(deserialized)
|
||||
assert type(deserialized.replay_gain) == ReplayGainType
|
||||
for i, server in enumerate(deserialized.servers):
|
||||
assert asdict(config.servers[i]) == asdict(server)
|
||||
|
||||
|
||||
def test_config_migrate():
|
||||
config = AppConfiguration(always_stream=True)
|
||||
server = ServerConfiguration(
|
||||
name="Test", server_address="https://test.host", username="test"
|
||||
def test_json_load_unload(config_filename: Path):
|
||||
subsonic_config_store = ConfigurationStore(username="test")
|
||||
subsonic_config_store.set_secret("password", "testpass")
|
||||
original_config = AppConfiguration(
|
||||
providers={
|
||||
"1": ProviderConfiguration(
|
||||
id="1",
|
||||
name="foo",
|
||||
ground_truth_adapter_type=SubsonicAdapter,
|
||||
ground_truth_adapter_config=subsonic_config_store,
|
||||
caching_adapter_type=FilesystemAdapter,
|
||||
caching_adapter_config=ConfigurationStore(),
|
||||
)
|
||||
},
|
||||
current_provider_id="1",
|
||||
filename=config_filename,
|
||||
)
|
||||
|
||||
original_config.save()
|
||||
|
||||
loaded_config = AppConfiguration.load_from_file(config_filename)
|
||||
|
||||
assert original_config.version == loaded_config.version
|
||||
assert original_config.providers == loaded_config.providers
|
||||
assert original_config.provider == loaded_config.provider
|
||||
|
||||
|
||||
def test_config_migrate(config_filename: Path):
|
||||
config = AppConfiguration(
|
||||
providers={
|
||||
"1": ProviderConfiguration(
|
||||
id="1",
|
||||
name="foo",
|
||||
ground_truth_adapter_type=SubsonicAdapter,
|
||||
ground_truth_adapter_config=ConfigurationStore(),
|
||||
)
|
||||
},
|
||||
current_provider_id="1",
|
||||
filename=config_filename,
|
||||
)
|
||||
config.servers.append(server)
|
||||
config.migrate()
|
||||
|
||||
assert config.version == 4
|
||||
assert config.allow_song_downloads is False
|
||||
for server in config.servers:
|
||||
server.version == 0
|
||||
assert config.version == 5
|
||||
|
||||
|
||||
def test_replay_gain_enum():
|
||||
|
Reference in New Issue
Block a user