Configure new server flow works

This commit is contained in:
Sumner Evans
2020-06-06 16:57:21 -06:00
parent 3f128604de
commit 98cf892de7
11 changed files with 189 additions and 116 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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",

View File

@@ -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):

View File

@@ -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():