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.
Files changed (64) hide show
  1. syncfm_core/__init__.py +76 -0
  2. syncfm_core/application.py +71 -0
  3. syncfm_core/cli/__init__.py +1 -0
  4. syncfm_core/cli/app.py +101 -0
  5. syncfm_core/cli/commands/__init__.py +1 -0
  6. syncfm_core/cli/commands/common.py +45 -0
  7. syncfm_core/cli/commands/discard.py +51 -0
  8. syncfm_core/cli/commands/profile.py +191 -0
  9. syncfm_core/cli/commands/resume.py +117 -0
  10. syncfm_core/cli/commands/status.py +26 -0
  11. syncfm_core/cli/commands/sync.py +108 -0
  12. syncfm_core/cli/configuration.py +16 -0
  13. syncfm_core/cli/context.py +52 -0
  14. syncfm_core/cli/dashboard.py +472 -0
  15. syncfm_core/cli/presenters.py +126 -0
  16. syncfm_core/exceptions.py +61 -0
  17. syncfm_core/models/__init__.py +28 -0
  18. syncfm_core/models/network_config.py +26 -0
  19. syncfm_core/models/retry_policy.py +18 -0
  20. syncfm_core/models/scrobble.py +22 -0
  21. syncfm_core/models/sync_progress.py +34 -0
  22. syncfm_core/models/sync_queue_item.py +27 -0
  23. syncfm_core/models/sync_request.py +19 -0
  24. syncfm_core/models/sync_result.py +15 -0
  25. syncfm_core/models/sync_session.py +30 -0
  26. syncfm_core/models/sync_status.py +18 -0
  27. syncfm_core/networks/__init__.py +13 -0
  28. syncfm_core/networks/factory.py +134 -0
  29. syncfm_core/networks/gnufm.py +93 -0
  30. syncfm_core/networks/pylast_adapter.py +286 -0
  31. syncfm_core/networks/pylast_mapper.py +76 -0
  32. syncfm_core/profiles/__init__.py +24 -0
  33. syncfm_core/profiles/factory.py +34 -0
  34. syncfm_core/profiles/keyring_store.py +141 -0
  35. syncfm_core/profiles/models.py +109 -0
  36. syncfm_core/profiles/paths.py +16 -0
  37. syncfm_core/profiles/protocols.py +45 -0
  38. syncfm_core/profiles/resolver.py +47 -0
  39. syncfm_core/profiles/service.py +183 -0
  40. syncfm_core/profiles/toml_store.py +396 -0
  41. syncfm_core/protocols/__init__.py +23 -0
  42. syncfm_core/protocols/network.py +65 -0
  43. syncfm_core/protocols/repositories.py +166 -0
  44. syncfm_core/py.typed +0 -0
  45. syncfm_core/repositories/__init__.py +19 -0
  46. syncfm_core/repositories/database.py +134 -0
  47. syncfm_core/repositories/queue_repository.py +361 -0
  48. syncfm_core/repositories/schema.py +107 -0
  49. syncfm_core/repositories/session_repository.py +177 -0
  50. syncfm_core/repositories/snapshot_repository.py +155 -0
  51. syncfm_core/services/__init__.py +21 -0
  52. syncfm_core/services/comparison_service.py +15 -0
  53. syncfm_core/services/metadata_service.py +123 -0
  54. syncfm_core/services/range_resolver.py +79 -0
  55. syncfm_core/services/recovery_service.py +84 -0
  56. syncfm_core/services/retry_service.py +41 -0
  57. syncfm_core/services/snapshot_service.py +64 -0
  58. syncfm_core/services/submission_service.py +121 -0
  59. syncfm_core/services/sync_service.py +390 -0
  60. syncfm_core-0.1.0.dist-info/METADATA +308 -0
  61. syncfm_core-0.1.0.dist-info/RECORD +64 -0
  62. syncfm_core-0.1.0.dist-info/WHEEL +4 -0
  63. syncfm_core-0.1.0.dist-info/entry_points.txt +3 -0
  64. syncfm_core-0.1.0.dist-info/licenses/LICENSE +232 -0
@@ -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)