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 +23 -0
- q2google/__main__.py +9 -0
- q2google/cli/__init__.py +5 -0
- q2google/cli/_app.py +215 -0
- q2google/cli/_formatters.py +101 -0
- q2google/cli/_logging.py +43 -0
- q2google/cli/_printer.py +533 -0
- q2google/cli/_runner.py +116 -0
- q2google/config.py +142 -0
- q2google/gphotos/__init__.py +10 -0
- q2google/gphotos/api.py +219 -0
- q2google/gphotos/auth.py +98 -0
- q2google/gphotos/models.py +219 -0
- q2google/metrics.py +30 -0
- q2google/photos.py +196 -0
- q2google/stages/__init__.py +17 -0
- q2google/stages/common.py +71 -0
- q2google/stages/create.py +130 -0
- q2google/stages/discovery.py +67 -0
- q2google/stages/transfer.py +227 -0
- q2google/state/__init__.py +31 -0
- q2google/state/base.py +299 -0
- q2google/state/local.py +81 -0
- q2google/sync.py +178 -0
- q2google-0.0.1.dist-info/METADATA +388 -0
- q2google-0.0.1.dist-info/RECORD +29 -0
- q2google-0.0.1.dist-info/WHEEL +4 -0
- q2google-0.0.1.dist-info/entry_points.txt +2 -0
- q2google-0.0.1.dist-info/licenses/LICENSE +21 -0
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
q2google/cli/__init__.py
ADDED
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
|
+
]
|
q2google/cli/_logging.py
ADDED
|
@@ -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)
|