syncfm-core 0.1.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.
- syncfm_core/__init__.py +76 -0
- syncfm_core/application.py +71 -0
- syncfm_core/cli/__init__.py +1 -0
- syncfm_core/cli/app.py +101 -0
- syncfm_core/cli/commands/__init__.py +1 -0
- syncfm_core/cli/commands/common.py +45 -0
- syncfm_core/cli/commands/discard.py +51 -0
- syncfm_core/cli/commands/profile.py +191 -0
- syncfm_core/cli/commands/resume.py +117 -0
- syncfm_core/cli/commands/status.py +26 -0
- syncfm_core/cli/commands/sync.py +108 -0
- syncfm_core/cli/configuration.py +16 -0
- syncfm_core/cli/context.py +52 -0
- syncfm_core/cli/dashboard.py +472 -0
- syncfm_core/cli/presenters.py +126 -0
- syncfm_core/exceptions.py +61 -0
- syncfm_core/models/__init__.py +28 -0
- syncfm_core/models/network_config.py +26 -0
- syncfm_core/models/retry_policy.py +18 -0
- syncfm_core/models/scrobble.py +22 -0
- syncfm_core/models/sync_progress.py +34 -0
- syncfm_core/models/sync_queue_item.py +27 -0
- syncfm_core/models/sync_request.py +19 -0
- syncfm_core/models/sync_result.py +15 -0
- syncfm_core/models/sync_session.py +30 -0
- syncfm_core/models/sync_status.py +18 -0
- syncfm_core/networks/__init__.py +13 -0
- syncfm_core/networks/factory.py +134 -0
- syncfm_core/networks/gnufm.py +93 -0
- syncfm_core/networks/pylast_adapter.py +286 -0
- syncfm_core/networks/pylast_mapper.py +76 -0
- syncfm_core/profiles/__init__.py +24 -0
- syncfm_core/profiles/factory.py +34 -0
- syncfm_core/profiles/keyring_store.py +141 -0
- syncfm_core/profiles/models.py +109 -0
- syncfm_core/profiles/paths.py +16 -0
- syncfm_core/profiles/protocols.py +45 -0
- syncfm_core/profiles/resolver.py +47 -0
- syncfm_core/profiles/service.py +183 -0
- syncfm_core/profiles/toml_store.py +396 -0
- syncfm_core/protocols/__init__.py +23 -0
- syncfm_core/protocols/network.py +65 -0
- syncfm_core/protocols/repositories.py +166 -0
- syncfm_core/py.typed +0 -0
- syncfm_core/repositories/__init__.py +19 -0
- syncfm_core/repositories/database.py +134 -0
- syncfm_core/repositories/queue_repository.py +361 -0
- syncfm_core/repositories/schema.py +107 -0
- syncfm_core/repositories/session_repository.py +177 -0
- syncfm_core/repositories/snapshot_repository.py +155 -0
- syncfm_core/services/__init__.py +21 -0
- syncfm_core/services/comparison_service.py +15 -0
- syncfm_core/services/metadata_service.py +123 -0
- syncfm_core/services/range_resolver.py +79 -0
- syncfm_core/services/recovery_service.py +84 -0
- syncfm_core/services/retry_service.py +41 -0
- syncfm_core/services/snapshot_service.py +64 -0
- syncfm_core/services/submission_service.py +121 -0
- syncfm_core/services/sync_service.py +390 -0
- syncfm_core-0.1.0.dist-info/METADATA +308 -0
- syncfm_core-0.1.0.dist-info/RECORD +64 -0
- syncfm_core-0.1.0.dist-info/WHEEL +4 -0
- syncfm_core-0.1.0.dist-info/entry_points.txt +3 -0
- syncfm_core-0.1.0.dist-info/licenses/LICENSE +232 -0
syncfm_core/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Reusable core package for synchronizing scrobbles."""
|
|
2
|
+
|
|
3
|
+
from syncfm_core.application import create_sync_service
|
|
4
|
+
from syncfm_core.exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
InvalidNetworkResponseError,
|
|
8
|
+
InvalidSyncRangeError,
|
|
9
|
+
MissingStartTimestampError,
|
|
10
|
+
NetworkConnectionError,
|
|
11
|
+
NetworkError,
|
|
12
|
+
PersistenceError,
|
|
13
|
+
ProfileAlreadyExistsError,
|
|
14
|
+
ProfileNotFoundError,
|
|
15
|
+
RetryExhaustedError,
|
|
16
|
+
SecretStorageError,
|
|
17
|
+
SessionNotFoundError,
|
|
18
|
+
SyncFMCoreError,
|
|
19
|
+
SynchronizationError,
|
|
20
|
+
)
|
|
21
|
+
from syncfm_core.models.network_config import NetworkConfig, NetworkType
|
|
22
|
+
from syncfm_core.models.retry_policy import RetryPolicy
|
|
23
|
+
from syncfm_core.models.scrobble import Scrobble, TrackMetadata
|
|
24
|
+
from syncfm_core.models.sync_progress import ProgressCallback, SyncProgress
|
|
25
|
+
from syncfm_core.models.sync_request import SyncRange, SyncRequest
|
|
26
|
+
from syncfm_core.models.sync_result import SyncResult
|
|
27
|
+
from syncfm_core.models.sync_session import SyncPhase, SyncSession
|
|
28
|
+
from syncfm_core.networks.factory import NetworkFactory
|
|
29
|
+
from syncfm_core.profiles import (
|
|
30
|
+
NetworkProfile,
|
|
31
|
+
ProfileInput,
|
|
32
|
+
ProfileService,
|
|
33
|
+
SecretStorage,
|
|
34
|
+
create_profile_service,
|
|
35
|
+
default_profile_path,
|
|
36
|
+
)
|
|
37
|
+
from syncfm_core.services.sync_service import SyncService
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"AuthenticationError",
|
|
41
|
+
"ConfigurationError",
|
|
42
|
+
"InvalidNetworkResponseError",
|
|
43
|
+
"InvalidSyncRangeError",
|
|
44
|
+
"MissingStartTimestampError",
|
|
45
|
+
"NetworkConfig",
|
|
46
|
+
"NetworkConnectionError",
|
|
47
|
+
"NetworkError",
|
|
48
|
+
"NetworkFactory",
|
|
49
|
+
"NetworkProfile",
|
|
50
|
+
"NetworkType",
|
|
51
|
+
"PersistenceError",
|
|
52
|
+
"ProfileAlreadyExistsError",
|
|
53
|
+
"ProfileInput",
|
|
54
|
+
"ProfileNotFoundError",
|
|
55
|
+
"ProfileService",
|
|
56
|
+
"ProgressCallback",
|
|
57
|
+
"RetryExhaustedError",
|
|
58
|
+
"RetryPolicy",
|
|
59
|
+
"Scrobble",
|
|
60
|
+
"SecretStorage",
|
|
61
|
+
"SecretStorageError",
|
|
62
|
+
"SessionNotFoundError",
|
|
63
|
+
"SyncFMCoreError",
|
|
64
|
+
"SyncPhase",
|
|
65
|
+
"SyncProgress",
|
|
66
|
+
"SyncRange",
|
|
67
|
+
"SyncRequest",
|
|
68
|
+
"SyncResult",
|
|
69
|
+
"SyncService",
|
|
70
|
+
"SyncSession",
|
|
71
|
+
"SynchronizationError",
|
|
72
|
+
"TrackMetadata",
|
|
73
|
+
"create_profile_service",
|
|
74
|
+
"create_sync_service",
|
|
75
|
+
"default_profile_path",
|
|
76
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Construct syncfm-core public application services."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from syncfm_core.models.retry_policy import RetryPolicy
|
|
6
|
+
from syncfm_core.models.sync_progress import ProgressCallback
|
|
7
|
+
from syncfm_core.repositories.database import Database
|
|
8
|
+
from syncfm_core.repositories.queue_repository import SQLiteSyncQueueRepository
|
|
9
|
+
from syncfm_core.repositories.session_repository import SQLiteSyncSessionRepository
|
|
10
|
+
from syncfm_core.repositories.snapshot_repository import (
|
|
11
|
+
SQLiteScrobbleSnapshotRepository,
|
|
12
|
+
)
|
|
13
|
+
from syncfm_core.services import SyncService
|
|
14
|
+
from syncfm_core.services.comparison_service import ComparisonService
|
|
15
|
+
from syncfm_core.services.metadata_service import MetadataService
|
|
16
|
+
from syncfm_core.services.range_resolver import SyncRangeResolver
|
|
17
|
+
from syncfm_core.services.recovery_service import RecoveryService
|
|
18
|
+
from syncfm_core.services.retry_service import RetryService
|
|
19
|
+
from syncfm_core.services.snapshot_service import SnapshotService
|
|
20
|
+
from syncfm_core.services.submission_service import SubmissionService
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_sync_service(
|
|
24
|
+
database_path: str | Path,
|
|
25
|
+
retry_policy: RetryPolicy | None = None,
|
|
26
|
+
progress_callback: ProgressCallback | None = None,
|
|
27
|
+
) -> SyncService:
|
|
28
|
+
"""Create a fully configured synchronization service.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
database_path: Filesystem path of the SQLite state database.
|
|
32
|
+
retry_policy: Optional transient-network retry configuration.
|
|
33
|
+
progress_callback: Optional callback receiving progress updates.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Configured central synchronization service.
|
|
37
|
+
"""
|
|
38
|
+
database = Database(database_path)
|
|
39
|
+
database.initialize()
|
|
40
|
+
|
|
41
|
+
session_repository = SQLiteSyncSessionRepository(database)
|
|
42
|
+
snapshot_repository = SQLiteScrobbleSnapshotRepository(database)
|
|
43
|
+
queue_repository = SQLiteSyncQueueRepository(database)
|
|
44
|
+
|
|
45
|
+
retry_service = RetryService(
|
|
46
|
+
retry_policy or RetryPolicy(),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return SyncService(
|
|
50
|
+
session_repository=session_repository,
|
|
51
|
+
snapshot_repository=snapshot_repository,
|
|
52
|
+
queue_repository=queue_repository,
|
|
53
|
+
range_resolver=SyncRangeResolver(),
|
|
54
|
+
snapshot_service=SnapshotService(
|
|
55
|
+
snapshot_repository,
|
|
56
|
+
progress_callback,
|
|
57
|
+
),
|
|
58
|
+
comparison_service=ComparisonService(queue_repository),
|
|
59
|
+
metadata_service=MetadataService(
|
|
60
|
+
queue_repository,
|
|
61
|
+
retry_service,
|
|
62
|
+
progress_callback,
|
|
63
|
+
),
|
|
64
|
+
submission_service=SubmissionService(
|
|
65
|
+
queue_repository,
|
|
66
|
+
retry_service,
|
|
67
|
+
progress_callback,
|
|
68
|
+
),
|
|
69
|
+
recovery_service=RecoveryService(queue_repository),
|
|
70
|
+
progress_callback=progress_callback,
|
|
71
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line frontend for syncfm-core."""
|
syncfm_core/cli/app.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Typer application providing the syncfm-core command-line interface."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from syncfm_core.cli.commands.discard import discard_command
|
|
10
|
+
from syncfm_core.cli.commands.profile import (
|
|
11
|
+
add_profile_command,
|
|
12
|
+
list_profiles_command,
|
|
13
|
+
remove_profile_command,
|
|
14
|
+
show_profile_command,
|
|
15
|
+
)
|
|
16
|
+
from syncfm_core.cli.commands.resume import resume_command
|
|
17
|
+
from syncfm_core.cli.commands.status import status_command
|
|
18
|
+
from syncfm_core.cli.commands.sync import sync_command
|
|
19
|
+
from syncfm_core.cli.configuration import default_database_path
|
|
20
|
+
from syncfm_core.cli.context import CLIContext, configure_logging
|
|
21
|
+
from syncfm_core.profiles import default_profile_path
|
|
22
|
+
|
|
23
|
+
DEFAULT_DATABASE_PATH = default_database_path()
|
|
24
|
+
DEFAULT_PROFILE_PATH = default_profile_path()
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="syncfm",
|
|
28
|
+
help="Synchronize scrobbles between Last.fm, Libre.fm and GNU.fm networks.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
rich_markup_mode="rich",
|
|
31
|
+
context_settings={
|
|
32
|
+
"help_option_names": ["-h", "--help"],
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
profile_app = typer.Typer(
|
|
37
|
+
name="profile",
|
|
38
|
+
help="Manage reusable network profiles.",
|
|
39
|
+
no_args_is_help=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
app.command("sync")(sync_command)
|
|
43
|
+
app.command("resume")(resume_command)
|
|
44
|
+
app.command("status")(status_command)
|
|
45
|
+
app.command("discard")(discard_command)
|
|
46
|
+
|
|
47
|
+
profile_app.command("add")(add_profile_command)
|
|
48
|
+
profile_app.command("list")(list_profiles_command)
|
|
49
|
+
profile_app.command("show")(show_profile_command)
|
|
50
|
+
profile_app.command("remove")(remove_profile_command)
|
|
51
|
+
|
|
52
|
+
app.add_typer(profile_app, name="profile")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.callback()
|
|
56
|
+
def application_callback(
|
|
57
|
+
context: typer.Context,
|
|
58
|
+
database_path: Annotated[
|
|
59
|
+
Path,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--database",
|
|
62
|
+
envvar="SYNCFM_DATABASE_PATH",
|
|
63
|
+
dir_okay=False,
|
|
64
|
+
help="Path to the SQLite state database",
|
|
65
|
+
),
|
|
66
|
+
] = DEFAULT_DATABASE_PATH,
|
|
67
|
+
config_path: Annotated[
|
|
68
|
+
Path,
|
|
69
|
+
typer.Option(
|
|
70
|
+
"--config",
|
|
71
|
+
envvar="SYNCFM_CONFIG_PATH",
|
|
72
|
+
dir_okay=False,
|
|
73
|
+
help="Path to the TOML profile configuration file.",
|
|
74
|
+
),
|
|
75
|
+
] = DEFAULT_PROFILE_PATH,
|
|
76
|
+
verbose: Annotated[
|
|
77
|
+
bool,
|
|
78
|
+
typer.Option(
|
|
79
|
+
"--verbose",
|
|
80
|
+
"-v",
|
|
81
|
+
help="Enable diagnostic logging.",
|
|
82
|
+
),
|
|
83
|
+
] = False,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Configure the syncfm command-line application."""
|
|
86
|
+
configure_logging(verbose)
|
|
87
|
+
context.obj = CLIContext(
|
|
88
|
+
database_path=database_path,
|
|
89
|
+
config_path=config_path,
|
|
90
|
+
console=Console(),
|
|
91
|
+
error_console=Console(stderr=True),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> None:
|
|
96
|
+
"""Run the syncfm command-line application."""
|
|
97
|
+
app()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Commands provided by the syncfm cli."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared helpers for network-based CLI commands."""
|
|
2
|
+
|
|
3
|
+
from functools import partial
|
|
4
|
+
|
|
5
|
+
from syncfm_core.cli.context import CLIContext, run_core_operation
|
|
6
|
+
from syncfm_core.networks.factory import NetworkFactory
|
|
7
|
+
from syncfm_core.profiles import create_profile_service
|
|
8
|
+
from syncfm_core.protocols.network import MusicNetwork
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_network_pair(
|
|
12
|
+
context: CLIContext,
|
|
13
|
+
source_profile: str,
|
|
14
|
+
target_profile: str,
|
|
15
|
+
) -> tuple[MusicNetwork, MusicNetwork]:
|
|
16
|
+
"""Create source and target networks from named profiles."""
|
|
17
|
+
profile_service = create_profile_service(context.config_path)
|
|
18
|
+
|
|
19
|
+
source_config = run_core_operation(
|
|
20
|
+
context,
|
|
21
|
+
partial(
|
|
22
|
+
profile_service.resolve,
|
|
23
|
+
source_profile,
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
target_config = run_core_operation(
|
|
27
|
+
context,
|
|
28
|
+
partial(
|
|
29
|
+
profile_service.resolve,
|
|
30
|
+
target_profile,
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
factory = NetworkFactory()
|
|
35
|
+
|
|
36
|
+
source_network = run_core_operation(
|
|
37
|
+
context,
|
|
38
|
+
partial(factory.create, source_config),
|
|
39
|
+
)
|
|
40
|
+
target_network = run_core_operation(
|
|
41
|
+
context,
|
|
42
|
+
partial(factory.create, target_config),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return source_network, target_network
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Discard an unfinished synchronization session."""
|
|
2
|
+
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from syncfm_core.application import create_sync_service
|
|
9
|
+
from syncfm_core.cli.context import get_cli_context, run_core_operation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def discard_command(
|
|
13
|
+
context: typer.Context,
|
|
14
|
+
session_id: Annotated[
|
|
15
|
+
str,
|
|
16
|
+
typer.Argument(
|
|
17
|
+
help="Identifier of the session to discard",
|
|
18
|
+
),
|
|
19
|
+
],
|
|
20
|
+
yes: Annotated[
|
|
21
|
+
bool,
|
|
22
|
+
typer.Option(
|
|
23
|
+
"--yes",
|
|
24
|
+
"-y",
|
|
25
|
+
help="Discard without asking for confirmation.",
|
|
26
|
+
),
|
|
27
|
+
] = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Discard an unfinished synchronization session."""
|
|
30
|
+
cli_context = get_cli_context(context)
|
|
31
|
+
|
|
32
|
+
if not yes:
|
|
33
|
+
confirmed = typer.confirm(f"Discard synchronization session {session_id!r}?")
|
|
34
|
+
if not confirmed:
|
|
35
|
+
raise typer.Abort
|
|
36
|
+
|
|
37
|
+
sync_service = run_core_operation(
|
|
38
|
+
cli_context,
|
|
39
|
+
partial(
|
|
40
|
+
create_sync_service,
|
|
41
|
+
cli_context.database_path,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
run_core_operation(
|
|
45
|
+
cli_context,
|
|
46
|
+
partial(sync_service.discard, session_id),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
cli_context.console.print(
|
|
50
|
+
f"[green]Discarded synchronization session {session_id}.[/green]"
|
|
51
|
+
)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Manage reusable network profiles."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from syncfm_core import NetworkType
|
|
10
|
+
from syncfm_core.cli.context import get_cli_context, run_core_operation
|
|
11
|
+
from syncfm_core.cli.presenters import present_profile, present_profiles
|
|
12
|
+
from syncfm_core.profiles import (
|
|
13
|
+
ProfileInput,
|
|
14
|
+
SecretStorage,
|
|
15
|
+
create_profile_service,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_profile_command(
|
|
20
|
+
context: typer.Context,
|
|
21
|
+
name: Annotated[
|
|
22
|
+
str,
|
|
23
|
+
typer.Argument(help="Unique lowercase profile name."),
|
|
24
|
+
],
|
|
25
|
+
type: Annotated[
|
|
26
|
+
NetworkType,
|
|
27
|
+
typer.Option("--type", help="Music network type."),
|
|
28
|
+
],
|
|
29
|
+
username: Annotated[
|
|
30
|
+
str,
|
|
31
|
+
typer.Option("--username", help="Network username."),
|
|
32
|
+
],
|
|
33
|
+
api_key: Annotated[
|
|
34
|
+
str,
|
|
35
|
+
typer.Option("--api-key", "--api_key", help="Network API key."),
|
|
36
|
+
],
|
|
37
|
+
hostname: Annotated[
|
|
38
|
+
str | None, typer.Option("--hostname", help="GNU.fm instance hostname.")
|
|
39
|
+
] = None,
|
|
40
|
+
proxy: Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--proxy", help="Proxy URL; scheme-less values default to socks5."
|
|
44
|
+
),
|
|
45
|
+
] = None,
|
|
46
|
+
secret_storage: Annotated[
|
|
47
|
+
SecretStorage,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--secret-storage",
|
|
50
|
+
"--secret_storage",
|
|
51
|
+
help="Where sensitive credentials should be stored.",
|
|
52
|
+
),
|
|
53
|
+
] = SecretStorage.KEYRING,
|
|
54
|
+
use_session_key: Annotated[
|
|
55
|
+
bool,
|
|
56
|
+
typer.Option(
|
|
57
|
+
"--use-session-key",
|
|
58
|
+
help="Prompt for a session key instead of a password.",
|
|
59
|
+
),
|
|
60
|
+
] = False,
|
|
61
|
+
force: Annotated[
|
|
62
|
+
bool,
|
|
63
|
+
typer.Option("--force", help="Replace an existing profile."),
|
|
64
|
+
] = False,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Create or replace a network profile."""
|
|
67
|
+
cli_context = get_cli_context(context)
|
|
68
|
+
profile_service = create_profile_service(cli_context.config_path)
|
|
69
|
+
|
|
70
|
+
api_secret = _secret_input(
|
|
71
|
+
"SYNCFM_PROFILE_API_SECRET",
|
|
72
|
+
"API secret",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
credential_name = (
|
|
76
|
+
"SYNCFM_PROFILE_SESSION_KEY" if use_session_key else "SYNCFM_PROFILE_PASSWORD"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
credential_prompt = "Session key" if use_session_key else "Password"
|
|
80
|
+
|
|
81
|
+
credential = _secret_input(
|
|
82
|
+
credential_name,
|
|
83
|
+
credential_prompt,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
profile_input = ProfileInput(
|
|
87
|
+
name=name,
|
|
88
|
+
type=type,
|
|
89
|
+
username=username,
|
|
90
|
+
api_key=api_key,
|
|
91
|
+
api_secret=api_secret,
|
|
92
|
+
password=None if use_session_key else credential,
|
|
93
|
+
session_key=credential if use_session_key else None,
|
|
94
|
+
hostname=hostname,
|
|
95
|
+
proxy=proxy,
|
|
96
|
+
secret_storage=secret_storage,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
run_core_operation(
|
|
100
|
+
cli_context,
|
|
101
|
+
partial(
|
|
102
|
+
profile_service.save,
|
|
103
|
+
profile_input,
|
|
104
|
+
replace=force,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
cli_context.console.print(f"[green]Saved network profile {name!r}.[/green]")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def list_profiles_command(context: typer.Context) -> None:
|
|
112
|
+
"""Display all network profiles."""
|
|
113
|
+
cli_context = get_cli_context(context)
|
|
114
|
+
profile_service = create_profile_service(cli_context.config_path)
|
|
115
|
+
|
|
116
|
+
profiles = run_core_operation(
|
|
117
|
+
cli_context,
|
|
118
|
+
profile_service.list,
|
|
119
|
+
)
|
|
120
|
+
present_profiles(cli_context.console, profiles)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def show_profile_command(
|
|
124
|
+
context: typer.Context,
|
|
125
|
+
name: Annotated[
|
|
126
|
+
str,
|
|
127
|
+
typer.Argument(help="Name of the profile to display."),
|
|
128
|
+
],
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Display one network profile without showing credentials."""
|
|
131
|
+
cli_context = get_cli_context(context)
|
|
132
|
+
profile_service = create_profile_service(cli_context.config_path)
|
|
133
|
+
|
|
134
|
+
profile = run_core_operation(
|
|
135
|
+
cli_context,
|
|
136
|
+
partial(profile_service.get, name),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
present_profile(cli_context.console, profile)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def remove_profile_command(
|
|
143
|
+
context: typer.Context,
|
|
144
|
+
name: Annotated[
|
|
145
|
+
str,
|
|
146
|
+
typer.Argument(help="Name of the profile to remove."),
|
|
147
|
+
],
|
|
148
|
+
yes: Annotated[
|
|
149
|
+
bool,
|
|
150
|
+
typer.Option(
|
|
151
|
+
"--yes",
|
|
152
|
+
"-y",
|
|
153
|
+
help="Remove without asking for confirmation.",
|
|
154
|
+
),
|
|
155
|
+
] = False,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Remove a network profile and its stored credentials."""
|
|
158
|
+
cli_context = get_cli_context(context)
|
|
159
|
+
profile_service = create_profile_service(cli_context.config_path)
|
|
160
|
+
|
|
161
|
+
run_core_operation(
|
|
162
|
+
cli_context,
|
|
163
|
+
partial(profile_service.get, name),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if not yes and not typer.confirm(f"Remove network profile {name!r}?"):
|
|
167
|
+
raise typer.Abort
|
|
168
|
+
|
|
169
|
+
run_core_operation(
|
|
170
|
+
cli_context,
|
|
171
|
+
partial(profile_service.remove, name),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
cli_context.console.print(f"[green]Removed network profile {name!r}.[/green]")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _secret_input(
|
|
178
|
+
environment_variable: str,
|
|
179
|
+
prompt: str,
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Read a secret from the environment or an interactive prompt."""
|
|
182
|
+
environment_value = os.environ.get(environment_variable)
|
|
183
|
+
|
|
184
|
+
if environment_value:
|
|
185
|
+
return environment_value
|
|
186
|
+
|
|
187
|
+
return typer.prompt(
|
|
188
|
+
prompt,
|
|
189
|
+
hide_input=True,
|
|
190
|
+
confirmation_prompt=False,
|
|
191
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Resume an unfinished synchronization operation."""
|
|
2
|
+
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from syncfm_core.application import create_sync_service
|
|
9
|
+
from syncfm_core.cli.commands.common import create_network_pair
|
|
10
|
+
from syncfm_core.cli.context import get_cli_context, run_core_operation
|
|
11
|
+
from syncfm_core.cli.presenters import RichProgressPresenter, present_result
|
|
12
|
+
from syncfm_core.exceptions import ConfigurationError
|
|
13
|
+
from syncfm_core.models.sync_session import SyncSession
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_profile_names(
|
|
17
|
+
session: SyncSession,
|
|
18
|
+
source_profile: str | None,
|
|
19
|
+
target_profile: str | None,
|
|
20
|
+
) -> tuple[str, str]:
|
|
21
|
+
"""Resolve stored profile names, with a fallback for legacy sessions."""
|
|
22
|
+
if session.source_profile is not None and session.target_profile is not None:
|
|
23
|
+
if source_profile is not None and source_profile != session.source_profile:
|
|
24
|
+
raise ConfigurationError(
|
|
25
|
+
"--source does not match the profile stored for this session."
|
|
26
|
+
)
|
|
27
|
+
if target_profile is not None and target_profile != session.target_profile:
|
|
28
|
+
raise ConfigurationError(
|
|
29
|
+
"--target does not match the profile stored for this session."
|
|
30
|
+
)
|
|
31
|
+
return session.source_profile, session.target_profile
|
|
32
|
+
|
|
33
|
+
if source_profile is None or target_profile is None:
|
|
34
|
+
raise ConfigurationError(
|
|
35
|
+
"This session predates stored profile references. Supply both "
|
|
36
|
+
"--source and --target once; later resumes will need only the "
|
|
37
|
+
"session ID."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return source_profile, target_profile
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resume_command(
|
|
44
|
+
context: typer.Context,
|
|
45
|
+
session_id: Annotated[
|
|
46
|
+
str,
|
|
47
|
+
typer.Argument(
|
|
48
|
+
help="Identifier of the synchronization session.",
|
|
49
|
+
),
|
|
50
|
+
],
|
|
51
|
+
source_profile: Annotated[
|
|
52
|
+
str | None,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--source",
|
|
55
|
+
envvar="SYNCFM_SOURCE_PROFILE",
|
|
56
|
+
help="Original source profile for a legacy session.",
|
|
57
|
+
),
|
|
58
|
+
] = None,
|
|
59
|
+
target_profile: Annotated[
|
|
60
|
+
str | None,
|
|
61
|
+
typer.Option(
|
|
62
|
+
"--target",
|
|
63
|
+
envvar="SYNCFM_TARGET_PROFILE",
|
|
64
|
+
help="Original target profile for a legacy session.",
|
|
65
|
+
),
|
|
66
|
+
] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Resume an unfinished synchronization session."""
|
|
69
|
+
cli_context = get_cli_context(context)
|
|
70
|
+
progress_presenter = RichProgressPresenter(cli_context.console)
|
|
71
|
+
|
|
72
|
+
sync_service = run_core_operation(
|
|
73
|
+
cli_context,
|
|
74
|
+
partial(
|
|
75
|
+
create_sync_service,
|
|
76
|
+
cli_context.database_path,
|
|
77
|
+
progress_callback=progress_presenter,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
session = run_core_operation(
|
|
82
|
+
cli_context,
|
|
83
|
+
partial(sync_service.get_session, session_id),
|
|
84
|
+
)
|
|
85
|
+
resolved_source_profile, resolved_target_profile = run_core_operation(
|
|
86
|
+
cli_context,
|
|
87
|
+
partial(
|
|
88
|
+
_resolve_profile_names,
|
|
89
|
+
session,
|
|
90
|
+
source_profile,
|
|
91
|
+
target_profile,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
progress_presenter.configure(
|
|
95
|
+
source_label=resolved_source_profile,
|
|
96
|
+
target_label=resolved_target_profile,
|
|
97
|
+
)
|
|
98
|
+
source_network, target_network = create_network_pair(
|
|
99
|
+
cli_context,
|
|
100
|
+
resolved_source_profile,
|
|
101
|
+
resolved_target_profile,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
with progress_presenter:
|
|
105
|
+
result = run_core_operation(
|
|
106
|
+
cli_context,
|
|
107
|
+
partial(
|
|
108
|
+
sync_service.resume,
|
|
109
|
+
session_id,
|
|
110
|
+
source_network,
|
|
111
|
+
target_network,
|
|
112
|
+
source_profile=resolved_source_profile,
|
|
113
|
+
target_profile=resolved_target_profile,
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
present_result(cli_context.console, result)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Display unfinished synchronization sessions."""
|
|
2
|
+
|
|
3
|
+
from functools import partial
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from syncfm_core.application import create_sync_service
|
|
8
|
+
from syncfm_core.cli.context import get_cli_context, run_core_operation
|
|
9
|
+
from syncfm_core.cli.presenters import present_sessions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def status_command(context: typer.Context) -> None:
|
|
13
|
+
"""Display unfinished synchronization sessions."""
|
|
14
|
+
cli_context = get_cli_context(context)
|
|
15
|
+
sync_service = run_core_operation(
|
|
16
|
+
cli_context,
|
|
17
|
+
partial(
|
|
18
|
+
create_sync_service,
|
|
19
|
+
cli_context.database_path,
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
sessions = run_core_operation(
|
|
23
|
+
cli_context,
|
|
24
|
+
sync_service.get_pending_sessions,
|
|
25
|
+
)
|
|
26
|
+
present_sessions(cli_context.console, sessions)
|