q2google 0.0.1__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.
q2google/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Public package API for q2google.
2
+
3
+ Exports the main orchestrator (``GoProToPhotosSync``), Google Photos façade types, session
4
+ persistence protocols, and settings helpers.
5
+ """
6
+
7
+ from q2google.config import Q2GoogleSettings, get_settings
8
+ from q2google.gphotos.auth import GooglePhotosOAuth
9
+ from q2google.photos import GooglePhotosClient
10
+ from q2google.state.base import SessionState, SyncStateBackend
11
+ from q2google.state.local import JsonFileBackend
12
+ from q2google.sync import GoProToPhotosSync
13
+
14
+ __all__ = [
15
+ "GoProToPhotosSync",
16
+ "GooglePhotosClient",
17
+ "GooglePhotosOAuth",
18
+ "JsonFileBackend",
19
+ "Q2GoogleSettings",
20
+ "SessionState",
21
+ "SyncStateBackend",
22
+ "get_settings",
23
+ ]
q2google/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Entry point for ``python -m q2google``.
2
+
3
+ Delegates to the Typer application defined in :mod:`q2google.cli`.
4
+ """
5
+
6
+ from q2google.cli import app
7
+
8
+ if __name__ == "__main__":
9
+ app()
@@ -0,0 +1,5 @@
1
+ """CLI package for q2google; exposes the Typer application entry point."""
2
+
3
+ from q2google.cli._app import app
4
+
5
+ __all__ = ["app"]
q2google/cli/_app.py ADDED
@@ -0,0 +1,215 @@
1
+ """Typer application and CLI command definitions for q2google."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib.metadata
7
+ import time
8
+ import uuid
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from q2google.cli._logging import _configure_cli_logging
16
+ from q2google.cli._printer import OutputFormat, SyncPrinter
17
+ from q2google.cli._runner import _run_sync
18
+ from q2google.config import get_settings
19
+ from q2google.state.local import JsonFileBackend
20
+
21
+ app = typer.Typer(
22
+ help="q2google — sync GoPro cloud media to Google Photos.",
23
+ rich_markup_mode="rich",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+ _err_console = Console(stderr=True, soft_wrap=True)
28
+
29
+
30
+ def _version_callback(value: bool) -> None:
31
+ if value:
32
+ version = importlib.metadata.version("q2google")
33
+ typer.echo(version)
34
+ raise typer.Exit()
35
+
36
+
37
+ @app.callback()
38
+ def _main(
39
+ version: bool = typer.Option(
40
+ False,
41
+ "--version",
42
+ "-V",
43
+ callback=_version_callback,
44
+ is_eager=True,
45
+ help="Show the version and exit.",
46
+ ),
47
+ ) -> None:
48
+ """q2google — sync GoPro cloud media to Google Photos."""
49
+
50
+
51
+ def _parse_iso_datetime(value: str) -> datetime:
52
+ """Parse an ISO 8601 calendar date or datetime string.
53
+
54
+ Args:
55
+ value: String accepted by :meth:`datetime.datetime.fromisoformat`.
56
+
57
+ Returns:
58
+ Parsed timezone-naive or aware datetime.
59
+
60
+ Raises:
61
+ typer.Exit: Printed to stderr and exits with code 1 when ``value`` is invalid.
62
+ """
63
+ try:
64
+ return datetime.fromisoformat(value)
65
+ except ValueError:
66
+ _err_console.print(f"[bold red]Error:[/bold red] Invalid ISO date/datetime: {value!r}")
67
+ raise typer.Exit(code=1)
68
+
69
+
70
+ @app.command("sync")
71
+ def sync_command(
72
+ start_date: str = typer.Option(
73
+ ...,
74
+ "--start",
75
+ help="Capture window start (ISO date or datetime, e.g. 2026-01-08).",
76
+ ),
77
+ end_date: str = typer.Option(
78
+ ...,
79
+ "--end",
80
+ help="Capture window end (ISO date or datetime, e.g. 2026-01-09).",
81
+ ),
82
+ credentials: Path | None = typer.Option(
83
+ None,
84
+ "--credentials",
85
+ help="Google OAuth client secrets JSON; default from [cyan]Q2GOOGLE_CREDENTIALS_PATH[/cyan] / settings.",
86
+ ),
87
+ token: Path | None = typer.Option(
88
+ None,
89
+ "--token",
90
+ help="Authorized user token path; default from [cyan]Q2GOOGLE_TOKEN_PATH[/cyan] / settings.",
91
+ ),
92
+ state_dir: Path | None = typer.Option(
93
+ None,
94
+ "--state-dir",
95
+ help="JSON session root; default from [cyan]Q2GOOGLE_STATE_DIR[/cyan] / settings.",
96
+ ),
97
+ session_id: str | None = typer.Option(
98
+ None,
99
+ "--session-id",
100
+ help="Resume id; default [cyan]Q2GOOGLE_SESSION_ID[/cyan] / settings, else a new UUID.",
101
+ ),
102
+ chunk_multiplier: int | None = typer.Option(
103
+ None,
104
+ "--chunk-multiplier",
105
+ help="Resumable upload chunk multiplier; default from settings.",
106
+ ),
107
+ max_items: int | None = typer.Option(
108
+ None,
109
+ "--max-items",
110
+ help="GoPro list_media_items cap; default from settings.",
111
+ ),
112
+ prefer_height: int | None = typer.Option(
113
+ None,
114
+ "--prefer-height",
115
+ help="Preferred GoPro download height; default from settings.",
116
+ ),
117
+ batch_size: int | None = typer.Option(
118
+ None,
119
+ "--batch-size",
120
+ help="Transfer batch size for new sessions; default from settings.",
121
+ ),
122
+ fail_fast: bool | None = typer.Option(
123
+ None,
124
+ "--fail-fast/--no-fail-fast",
125
+ help="Fail-fast mode; default from [cyan]Q2GOOGLE_FAIL_FAST[/cyan] / settings.",
126
+ ),
127
+ log_level: str | None = typer.Option(
128
+ None,
129
+ "--log-level",
130
+ help="Logging level (overrides quiet default); e.g. [bold]DEBUG[/bold], [bold]INFO[/bold].",
131
+ ),
132
+ verbose: bool = typer.Option(
133
+ False,
134
+ "--verbose",
135
+ "-v",
136
+ help="Log q2google at INFO (libraries stay quieter unless [bold]--log-level DEBUG[/bold]).",
137
+ ),
138
+ output: OutputFormat = typer.Option(
139
+ OutputFormat.rich,
140
+ "--output",
141
+ "-o",
142
+ help="Output format: [bold]rich[/bold] (default), [bold]tsv[/bold], or [bold]json[/bold].",
143
+ ),
144
+ ) -> None:
145
+ """Run discovery, transfer, and create stages for the given capture window.
146
+
147
+ Merges Typer options with :func:`~q2google.config.get_settings`, starts logging, resolves the
148
+ session id, and executes :func:`~q2google.cli._runner._run_sync` via :func:`asyncio.run`.
149
+
150
+ Args:
151
+ start_date: ISO start of the GoPro capture window (required).
152
+ end_date: ISO end of the GoPro capture window (required).
153
+ credentials: OAuth secrets path; defaults from settings when omitted.
154
+ token: User token path; defaults from settings when omitted.
155
+ state_dir: Session JSON directory; defaults from settings when omitted.
156
+ session_id: Explicit resume id; otherwise settings or a new UUID.
157
+ chunk_multiplier: Upload chunk multiplier; defaults from settings when omitted.
158
+ max_items: GoPro listing cap; defaults from settings when omitted.
159
+ prefer_height: Preferred asset height; defaults from settings when omitted.
160
+ batch_size: New-session transfer batch size; defaults from settings when omitted.
161
+ fail_fast: Overrides settings when ``True`` or ``False``; ``None`` uses settings.
162
+ log_level: Explicit logging level; when omitted the CLI defaults to quiet (WARNING).
163
+ verbose: When True and ``log_level`` is omitted, sets INFO for ``q2google`` loggers.
164
+ output: Output format selector; ``rich`` (default), ``tsv``, or ``json``.
165
+ """
166
+ try:
167
+ cfg = get_settings()
168
+ _configure_cli_logging(explicit_level=log_level, verbose=verbose)
169
+
170
+ start = _parse_iso_datetime(start_date)
171
+ end = _parse_iso_datetime(end_date)
172
+
173
+ sid = session_id or cfg.session_id or str(uuid.uuid4())
174
+ resolved_state_dir = state_dir if state_dir is not None else cfg.state_dir
175
+
176
+ state_backend = JsonFileBackend(resolved_state_dir)
177
+ existing_session = state_backend.load(sid)
178
+
179
+ printer = SyncPrinter(fmt=output)
180
+ printer.print_session_start(sid, existing_session, start=start, end=end)
181
+
182
+ t0 = time.perf_counter()
183
+ responses, transfer_metrics = asyncio.run(
184
+ _run_sync(
185
+ cfg=cfg,
186
+ start=start,
187
+ end=end,
188
+ credentials=credentials if credentials is not None else cfg.credentials_path,
189
+ token=token if token is not None else cfg.token_path,
190
+ state_dir=resolved_state_dir,
191
+ session_id=sid,
192
+ chunk_multiplier=(
193
+ chunk_multiplier if chunk_multiplier is not None else cfg.chunk_granularity_multiplier
194
+ ),
195
+ max_items=max_items if max_items is not None else cfg.gopro_max_items,
196
+ prefer_height=prefer_height if prefer_height is not None else cfg.gopro_prefer_height,
197
+ batch_size=batch_size,
198
+ fail_fast=fail_fast,
199
+ printer=printer,
200
+ )
201
+ )
202
+ elapsed = time.perf_counter() - t0
203
+
204
+ final_state = state_backend.load(sid)
205
+ if final_state is None:
206
+ printer.print_state_missing_warning(transfer_metrics, elapsed)
207
+ else:
208
+ printer.print_sync_summary(
209
+ sid, final_state, responses, elapsed_seconds=elapsed, transfer_metrics=transfer_metrics
210
+ )
211
+ except (typer.Exit, KeyboardInterrupt):
212
+ raise
213
+ except Exception as exc:
214
+ _err_console.print(f"[bold red]Error:[/bold red] {exc}")
215
+ raise typer.Exit(code=1)
@@ -0,0 +1,101 @@
1
+ """String formatters for durations, byte counts, and transfer rates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from q2google.metrics import SyncTransferMetrics
6
+
7
+
8
+ class MetricsFormatter:
9
+ """Format measurements and transfer metrics into human-readable strings."""
10
+
11
+ @staticmethod
12
+ def duration_compact(seconds: float) -> str:
13
+ """Format a duration in seconds as a compact human-readable string.
14
+
15
+ Args:
16
+ seconds: Duration in seconds.
17
+
18
+ Returns:
19
+ Formatted string like ``"1.23s"``, ``"5m 30.00s"``, or ``"2h 15m 30.00s"``.
20
+ """
21
+ if seconds < 60:
22
+ return f"{seconds:.2f}s"
23
+ minutes, sec = divmod(seconds, 60.0)
24
+ if minutes < 60:
25
+ return f"{int(minutes)}m {sec:.2f}s"
26
+ hours, mins = divmod(int(minutes), 60)
27
+ return f"{hours}h {mins}m {sec:.2f}s"
28
+
29
+ @classmethod
30
+ def execution_time(cls, seconds: float) -> str:
31
+ """Format wall-clock duration with an ``Execution time:`` prefix.
32
+
33
+ Args:
34
+ seconds: Elapsed seconds.
35
+
36
+ Returns:
37
+ String like ``"Execution time: 1m 23.45s"``.
38
+ """
39
+ return f"Execution time: {cls.duration_compact(seconds)}"
40
+
41
+ @staticmethod
42
+ def bytes_human(n: int) -> str:
43
+ """Format a byte count using binary IEC labels (KiB, MiB, GiB).
44
+
45
+ Args:
46
+ n: Number of bytes.
47
+
48
+ Returns:
49
+ Human-readable size string like ``"12.34 MiB"``.
50
+ """
51
+ if n < 1024:
52
+ return f"{n} B"
53
+ kib = n / 1024
54
+ if kib < 1024:
55
+ return f"{kib:.2f} KiB"
56
+ mib = n / (1024**2)
57
+ if mib < 1024:
58
+ return f"{mib:.2f} MiB"
59
+ gib = n / (1024**3)
60
+ return f"{gib:.2f} GiB"
61
+
62
+ @staticmethod
63
+ def rate_mib_s(bytes_count: int, seconds: float) -> str:
64
+ """Format average throughput as MiB/s.
65
+
66
+ Args:
67
+ bytes_count: Total bytes transferred.
68
+ seconds: Duration of the transfer.
69
+
70
+ Returns:
71
+ Throughput string like ``"12.34 MiB/s"`` or ``"n/a"`` when undefined.
72
+ """
73
+ if seconds <= 0 or bytes_count <= 0:
74
+ return "n/a"
75
+ mib_per_s = bytes_count / (1024 * 1024) / seconds
76
+ return f"{mib_per_s:.2f} MiB/s"
77
+
78
+ @classmethod
79
+ def throughput_rows(cls, m: SyncTransferMetrics) -> list[tuple[str, str]]:
80
+ """Build label/value pairs for transfer throughput display.
81
+
82
+ Args:
83
+ m: Transfer metrics from the sync run.
84
+
85
+ Returns:
86
+ List of ``(label, value)`` pairs; empty list when no transfer activity occurred.
87
+ """
88
+ if m.bytes_downloaded == 0 and m.bytes_uploaded == 0 and m.seconds_transfer_wall <= 0:
89
+ return []
90
+ return [
91
+ (
92
+ "CDN downloaded",
93
+ f"{cls.bytes_human(m.bytes_downloaded)}"
94
+ f" (avg {cls.rate_mib_s(m.bytes_downloaded, m.seconds_downloading)})",
95
+ ),
96
+ (
97
+ "Uploaded to Google",
98
+ f"{cls.bytes_human(m.bytes_uploaded)} (avg {cls.rate_mib_s(m.bytes_uploaded, m.seconds_uploading)})",
99
+ ),
100
+ ("Transfer stage wall time", cls.duration_compact(m.seconds_transfer_wall)),
101
+ ]
@@ -0,0 +1,43 @@
1
+ """Logging configuration helpers for the q2google CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ _NOISY_LOGGER_NAMES: tuple[str, ...] = (
8
+ "aiohttp",
9
+ "aiohttp.client",
10
+ "google",
11
+ "google_auth_oauthlib",
12
+ "urllib3",
13
+ "gopro_api",
14
+ )
15
+
16
+
17
+ def _configure_cli_logging(*, explicit_level: str | None, verbose: bool) -> None:
18
+ """Configure root logging for Typer; quiet by default, optional verbose or explicit level.
19
+
20
+ Third-party libraries stay at WARNING unless root level is DEBUG.
21
+
22
+ Args:
23
+ explicit_level: ``--log-level`` value when provided.
24
+ verbose: ``True`` when ``-v`` / ``--verbose`` is set.
25
+ """
26
+ if explicit_level is not None:
27
+ root_level = getattr(logging, explicit_level.upper(), logging.WARNING)
28
+ elif verbose:
29
+ root_level = logging.INFO
30
+ else:
31
+ root_level = logging.WARNING
32
+
33
+ logging.basicConfig(
34
+ level=root_level,
35
+ format="%(levelname)s %(name)s: %(message)s",
36
+ )
37
+
38
+ if root_level < logging.DEBUG:
39
+ for name in _NOISY_LOGGER_NAMES:
40
+ logging.getLogger(name).setLevel(logging.WARNING)
41
+
42
+ if explicit_level is None:
43
+ logging.getLogger("q2google").setLevel(logging.INFO if verbose else logging.WARNING)