q2google 0.0.1__tar.gz
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-0.0.1/.github/workflows/ci.yml +52 -0
- q2google-0.0.1/.github/workflows/release.yml +91 -0
- q2google-0.0.1/.gitignore +9 -0
- q2google-0.0.1/LICENSE +21 -0
- q2google-0.0.1/PKG-INFO +388 -0
- q2google-0.0.1/README.md +372 -0
- q2google-0.0.1/docs/ARCHITECTURE.md +73 -0
- q2google-0.0.1/pyproject.toml +62 -0
- q2google-0.0.1/q2google/__init__.py +23 -0
- q2google-0.0.1/q2google/__main__.py +9 -0
- q2google-0.0.1/q2google/cli/__init__.py +5 -0
- q2google-0.0.1/q2google/cli/_app.py +215 -0
- q2google-0.0.1/q2google/cli/_formatters.py +101 -0
- q2google-0.0.1/q2google/cli/_logging.py +43 -0
- q2google-0.0.1/q2google/cli/_printer.py +533 -0
- q2google-0.0.1/q2google/cli/_runner.py +116 -0
- q2google-0.0.1/q2google/config.py +142 -0
- q2google-0.0.1/q2google/gphotos/__init__.py +10 -0
- q2google-0.0.1/q2google/gphotos/api.py +219 -0
- q2google-0.0.1/q2google/gphotos/auth.py +98 -0
- q2google-0.0.1/q2google/gphotos/models.py +219 -0
- q2google-0.0.1/q2google/metrics.py +30 -0
- q2google-0.0.1/q2google/photos.py +196 -0
- q2google-0.0.1/q2google/stages/__init__.py +17 -0
- q2google-0.0.1/q2google/stages/common.py +71 -0
- q2google-0.0.1/q2google/stages/create.py +130 -0
- q2google-0.0.1/q2google/stages/discovery.py +67 -0
- q2google-0.0.1/q2google/stages/transfer.py +227 -0
- q2google-0.0.1/q2google/state/__init__.py +31 -0
- q2google-0.0.1/q2google/state/base.py +299 -0
- q2google-0.0.1/q2google/state/local.py +81 -0
- q2google-0.0.1/q2google/sync.py +178 -0
- q2google-0.0.1/tests/test_sync.py +75 -0
- q2google-0.0.1/uv.lock +1049 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
concurrency:
|
|
8
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
9
|
+
cancel-in-progress: true
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
lint-and-build:
|
|
16
|
+
name: Lint and build
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
strategy:
|
|
19
|
+
fail-fast: false
|
|
20
|
+
matrix:
|
|
21
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: astral-sh/setup-uv@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python-version }}
|
|
28
|
+
|
|
29
|
+
- name: Install package and dev tools
|
|
30
|
+
run: uv pip install -e ".[dev]"
|
|
31
|
+
|
|
32
|
+
- name: Lint (pyproject.toml [tool.ruff])
|
|
33
|
+
run: uv run task lint
|
|
34
|
+
|
|
35
|
+
- name: Build wheel and sdist (pyproject.toml [build-system])
|
|
36
|
+
run: uv build
|
|
37
|
+
|
|
38
|
+
# docs:
|
|
39
|
+
# name: Build documentation
|
|
40
|
+
# runs-on: ubuntu-latest
|
|
41
|
+
# steps:
|
|
42
|
+
# - uses: actions/checkout@v4
|
|
43
|
+
|
|
44
|
+
# - uses: astral-sh/setup-uv@v5
|
|
45
|
+
# with:
|
|
46
|
+
# python-version: "3.12"
|
|
47
|
+
|
|
48
|
+
# - name: Install docs dependencies
|
|
49
|
+
# run: uv pip install -e ".[docs]"
|
|
50
|
+
|
|
51
|
+
# - name: Build MkDocs site (--strict)
|
|
52
|
+
# run: uv run mkdocs build --strict
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
name: Release artifacts
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
tags:
|
|
8
|
+
- "v*"
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: write
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
name: Build Python distributions
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- uses: astral-sh/setup-uv@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
|
|
24
|
+
- name: Build distributions
|
|
25
|
+
run: uv build
|
|
26
|
+
|
|
27
|
+
- name: Upload workflow artifact (main or v* tag)
|
|
28
|
+
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
|
29
|
+
uses: actions/upload-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: python-dist
|
|
32
|
+
path: dist/*
|
|
33
|
+
|
|
34
|
+
- name: Publish GitHub Release (v* tags only)
|
|
35
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
36
|
+
uses: softprops/action-gh-release@v2
|
|
37
|
+
with:
|
|
38
|
+
files: |
|
|
39
|
+
dist/*.whl
|
|
40
|
+
dist/*.tar.gz
|
|
41
|
+
env:
|
|
42
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
43
|
+
|
|
44
|
+
publish-pypi:
|
|
45
|
+
name: Publish to PyPI
|
|
46
|
+
needs: build
|
|
47
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
environment: pypi
|
|
50
|
+
permissions:
|
|
51
|
+
contents: read
|
|
52
|
+
id-token: write
|
|
53
|
+
steps:
|
|
54
|
+
- name: Download built distributions
|
|
55
|
+
uses: actions/download-artifact@v4
|
|
56
|
+
with:
|
|
57
|
+
name: python-dist
|
|
58
|
+
path: dist
|
|
59
|
+
|
|
60
|
+
- name: Publish package to PyPI
|
|
61
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
62
|
+
|
|
63
|
+
# deploy-docs:
|
|
64
|
+
# name: Deploy docs to GitHub Pages
|
|
65
|
+
# needs: build
|
|
66
|
+
# if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
|
67
|
+
# runs-on: ubuntu-latest
|
|
68
|
+
# steps:
|
|
69
|
+
# - uses: actions/checkout@v4
|
|
70
|
+
|
|
71
|
+
# - uses: astral-sh/setup-uv@v5
|
|
72
|
+
# with:
|
|
73
|
+
# python-version: "3.12"
|
|
74
|
+
|
|
75
|
+
# - name: Install docs dependencies
|
|
76
|
+
# run: uv pip install -e ".[docs]"
|
|
77
|
+
|
|
78
|
+
# - name: Fetch gh-pages history for mike
|
|
79
|
+
# run: git fetch origin gh-pages --depth=1 || true
|
|
80
|
+
|
|
81
|
+
# - name: Deploy versioned docs
|
|
82
|
+
# run: |
|
|
83
|
+
# git config user.name github-actions
|
|
84
|
+
# git config user.email github-actions@github.com
|
|
85
|
+
# if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
|
86
|
+
# mike deploy --push "$GITHUB_REF_NAME"
|
|
87
|
+
# else
|
|
88
|
+
# # "latest" is an alias in versions.json; deploy version "main" and move the alias (needs --update-aliases).
|
|
89
|
+
# mike deploy --push --update-aliases main latest
|
|
90
|
+
# mike set-default --push latest
|
|
91
|
+
# fi
|
q2google-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 himewel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
q2google-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: q2google
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Sync GoPro cloud media to Google Photos Library.
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: <3.14,>=3.10
|
|
7
|
+
Requires-Dist: aiofiles~=25.1.0
|
|
8
|
+
Requires-Dist: aiohttp~=3.11.11
|
|
9
|
+
Requires-Dist: google-auth-oauthlib~=1.3.0
|
|
10
|
+
Requires-Dist: gopro-api~=0.0.7
|
|
11
|
+
Requires-Dist: pydantic-settings>=2
|
|
12
|
+
Requires-Dist: pydantic>=2
|
|
13
|
+
Requires-Dist: rich>=13
|
|
14
|
+
Requires-Dist: typer>=0.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# q2google
|
|
18
|
+
|
|
19
|
+
Sync media from **GoPro cloud** into **Google Photos** for a capture date range with **resumable session state**.
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Python **3.12 or 3.13** (3.14 is excluded until dependent wheels catch up)
|
|
24
|
+
- **`GP_ACCESS_TOKEN`** environment variable — GoPro cloud access token (required by `AsyncGoProClient`)
|
|
25
|
+
- Google OAuth **installed app** credentials (`client_secret.json` from Google Cloud Console)
|
|
26
|
+
- A writable path for the user token (`token.json` by default)
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install q2google
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv add q2google
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv add q2google
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Library usage
|
|
47
|
+
|
|
48
|
+
### Minimal example
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import asyncio
|
|
52
|
+
from datetime import datetime
|
|
53
|
+
|
|
54
|
+
from gopro_api import AsyncGoProClient
|
|
55
|
+
|
|
56
|
+
from q2google import (
|
|
57
|
+
GoProToPhotosSync,
|
|
58
|
+
GooglePhotosClient,
|
|
59
|
+
GooglePhotosOAuth,
|
|
60
|
+
JsonFileBackend,
|
|
61
|
+
)
|
|
62
|
+
from q2google.gphotos.api import GooglePhotosAPI
|
|
63
|
+
from q2google.gphotos.models import PhotosScopes
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def main() -> None:
|
|
67
|
+
oauth = GooglePhotosOAuth(
|
|
68
|
+
client_secrets_file="client_secret.json",
|
|
69
|
+
scopes=[PhotosScopes.READ_AND_APPEND],
|
|
70
|
+
token_file="token.json",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async with (
|
|
74
|
+
AsyncGoProClient() as gopro,
|
|
75
|
+
GooglePhotosAPI(credentials=oauth) as api,
|
|
76
|
+
):
|
|
77
|
+
photos = GooglePhotosClient(api=api)
|
|
78
|
+
backend = JsonFileBackend(root_dir=".q2google_sessions")
|
|
79
|
+
|
|
80
|
+
syncer = GoProToPhotosSync(
|
|
81
|
+
gopro=gopro,
|
|
82
|
+
photos=photos,
|
|
83
|
+
state_backend=backend,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
responses = await syncer.sync_date_range(
|
|
87
|
+
start_date=datetime(2026, 1, 8),
|
|
88
|
+
end_date=datetime(2026, 1, 9),
|
|
89
|
+
session_id="my-session",
|
|
90
|
+
)
|
|
91
|
+
print(f"Created {len(responses)} batch(es).")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Resuming a session
|
|
98
|
+
|
|
99
|
+
Pass the same `session_id` on subsequent runs. `GoProToPhotosSync` loads the persisted `SessionState` and skips already-completed items:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
responses = await syncer.sync_date_range(
|
|
103
|
+
start_date=datetime(2026, 1, 8), # ignored when resuming
|
|
104
|
+
end_date=datetime(2026, 1, 9), # ignored when resuming
|
|
105
|
+
session_id="my-session", # same key → resumes from checkpoint
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Custom state backend
|
|
110
|
+
|
|
111
|
+
Implement `SyncStateBackend` to persist sessions in any storage layer (database, object store, etc.):
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from q2google import SessionState, SyncStateBackend
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RedisBackend:
|
|
118
|
+
def load(self, session_id: str) -> SessionState | None:
|
|
119
|
+
raw = redis_client.get(session_id)
|
|
120
|
+
return SessionState.from_dict(json.loads(raw)) if raw else None
|
|
121
|
+
|
|
122
|
+
def save(self, state: SessionState) -> None:
|
|
123
|
+
redis_client.set(state.session_id, json.dumps(state.to_dict()))
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Pass it directly to `GoProToPhotosSync(state_backend=RedisBackend())`. No other changes required.
|
|
127
|
+
|
|
128
|
+
### Stage completion hook
|
|
129
|
+
|
|
130
|
+
`on_stage_complete` is called after each of the three pipeline stages (discovery, transfer, create). Use it to report progress, emit metrics, or trigger side-effects:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from q2google.state.base import SessionState, StageKey
|
|
134
|
+
from q2google.photos import MediaItemBatchCreateResponse
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def report(
|
|
138
|
+
stage: StageKey,
|
|
139
|
+
state: SessionState,
|
|
140
|
+
responses: list[MediaItemBatchCreateResponse] | None,
|
|
141
|
+
) -> None:
|
|
142
|
+
print(f"[{stage}] items={len(state.items)} stage_states={state.stages}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
responses = await syncer.sync_date_range(
|
|
146
|
+
start_date=datetime(2026, 1, 8),
|
|
147
|
+
end_date=datetime(2026, 1, 9),
|
|
148
|
+
session_id="my-session",
|
|
149
|
+
on_stage_complete=report,
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Public API
|
|
154
|
+
|
|
155
|
+
All public symbols are importable directly from `q2google`:
|
|
156
|
+
|
|
157
|
+
| Symbol | Description |
|
|
158
|
+
|--------|-------------|
|
|
159
|
+
| `GoProToPhotosSync` | Main orchestrator; runs discovery → transfer → create. |
|
|
160
|
+
| `GooglePhotosClient` | Resumable upload facade (`upload_file_path`, `create_media_items`). |
|
|
161
|
+
| `GooglePhotosOAuth` | Load, refresh, or obtain Google OAuth credentials. |
|
|
162
|
+
| `JsonFileBackend` | File-based `SyncStateBackend`; one JSON per session under a root directory. |
|
|
163
|
+
| `SessionState` | Full persisted session document (`to_dict` / `from_dict` for custom stores). |
|
|
164
|
+
| `SyncStateBackend` | Protocol — implement `load` / `save` to plug in any storage layer. |
|
|
165
|
+
| `Q2GoogleSettings` | Pydantic settings; batch sizes, timeouts, and paths with env-var overrides. |
|
|
166
|
+
| `get_settings` | Return a singleton `Q2GoogleSettings` from environment / `.env`. |
|
|
167
|
+
|
|
168
|
+
Lower-level symbols in `q2google.gphotos`:
|
|
169
|
+
|
|
170
|
+
| Symbol | Description |
|
|
171
|
+
|--------|-------------|
|
|
172
|
+
| `GooglePhotosAPI` | Thin `aiohttp` wrapper for Library v1 — use as `async with GooglePhotosAPI(...) as api`. |
|
|
173
|
+
| `GooglePhotoLibraryPort` | Protocol matching `GooglePhotosAPI`; implement for testing or alternative HTTP clients. |
|
|
174
|
+
| `PhotosScopes` | Enum of OAuth scopes (`READ_AND_APPEND`, `READ_ONLY`, `APPEND_ONLY`). |
|
|
175
|
+
|
|
176
|
+
## CLI
|
|
177
|
+
|
|
178
|
+
The package ships a CLI for one-off or scripted use:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
q2google sync \
|
|
182
|
+
--start-date 2026-01-08 \
|
|
183
|
+
--end-date 2026-01-09 \
|
|
184
|
+
--credentials client_secret.json \
|
|
185
|
+
--token token.json
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Useful options:
|
|
189
|
+
|
|
190
|
+
| Option | Description |
|
|
191
|
+
|--------|-------------|
|
|
192
|
+
| `--state-dir` | JSON session root (default: `.q2google_sessions` or `Q2GOOGLE_STATE_DIR`) |
|
|
193
|
+
| `--session-id` | Stable id to resume a run (`Q2GOOGLE_SESSION_ID` if unset) |
|
|
194
|
+
| `--batch-size` | Files per cycle for **new** sessions; ignored when resuming (persisted session wins) |
|
|
195
|
+
| `--fail-fast` | Stop on first error after persisting state |
|
|
196
|
+
| `--log-level DEBUG` | Verbose logging |
|
|
197
|
+
|
|
198
|
+
## Library usage
|
|
199
|
+
|
|
200
|
+
### Minimal example
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
import asyncio
|
|
204
|
+
from datetime import datetime
|
|
205
|
+
|
|
206
|
+
from gopro_api import AsyncGoProClient
|
|
207
|
+
|
|
208
|
+
from q2google import (
|
|
209
|
+
GoProToPhotosSync,
|
|
210
|
+
GooglePhotosClient,
|
|
211
|
+
GooglePhotosOAuth,
|
|
212
|
+
JsonFileBackend,
|
|
213
|
+
)
|
|
214
|
+
from q2google.gphotos.api import GooglePhotosAPI
|
|
215
|
+
from q2google.gphotos.models import PhotosScopes
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def main() -> None:
|
|
219
|
+
oauth = GooglePhotosOAuth(
|
|
220
|
+
client_secrets_file="client_secret.json",
|
|
221
|
+
scopes=[PhotosScopes.READ_AND_APPEND],
|
|
222
|
+
token_file="token.json",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async with (
|
|
226
|
+
AsyncGoProClient() as gopro,
|
|
227
|
+
GooglePhotosAPI(credentials=oauth) as api,
|
|
228
|
+
):
|
|
229
|
+
photos = GooglePhotosClient(api=api)
|
|
230
|
+
backend = JsonFileBackend(root_dir=".q2google_sessions")
|
|
231
|
+
|
|
232
|
+
syncer = GoProToPhotosSync(
|
|
233
|
+
gopro=gopro,
|
|
234
|
+
photos=photos,
|
|
235
|
+
state_backend=backend,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
responses = await syncer.sync_date_range(
|
|
239
|
+
start_date=datetime(2026, 1, 8),
|
|
240
|
+
end_date=datetime(2026, 1, 9),
|
|
241
|
+
session_id="my-session",
|
|
242
|
+
)
|
|
243
|
+
print(f"Created {len(responses)} batch(es).")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
asyncio.run(main())
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Resuming a session
|
|
250
|
+
|
|
251
|
+
Pass the same `session_id` on subsequent runs. `GoProToPhotosSync` loads the persisted `SessionState` and skips already-completed items:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
responses = await syncer.sync_date_range(
|
|
255
|
+
start_date=datetime(2026, 1, 8), # ignored when resuming
|
|
256
|
+
end_date=datetime(2026, 1, 9), # ignored when resuming
|
|
257
|
+
session_id="my-session", # same key → resumes from checkpoint
|
|
258
|
+
)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Custom state backend
|
|
262
|
+
|
|
263
|
+
Implement `SyncStateBackend` to persist sessions in any storage layer (database, object store, etc.):
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from q2google import SessionState, SyncStateBackend
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class RedisBackend:
|
|
270
|
+
def load(self, session_id: str) -> SessionState | None:
|
|
271
|
+
raw = redis_client.get(session_id)
|
|
272
|
+
return SessionState.from_dict(json.loads(raw)) if raw else None
|
|
273
|
+
|
|
274
|
+
def save(self, state: SessionState) -> None:
|
|
275
|
+
redis_client.set(state.session_id, json.dumps(state.to_dict()))
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Pass it directly to `GoProToPhotosSync(state_backend=RedisBackend())`. No other changes required.
|
|
279
|
+
|
|
280
|
+
### Stage completion hook
|
|
281
|
+
|
|
282
|
+
`on_stage_complete` is called after each of the three pipeline stages (discovery, transfer, create). Use it to report progress, emit metrics, or trigger side-effects:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
from q2google.state.base import SessionState, StageKey
|
|
286
|
+
from q2google.photos import MediaItemBatchCreateResponse
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def report(
|
|
290
|
+
stage: StageKey,
|
|
291
|
+
state: SessionState,
|
|
292
|
+
responses: list[MediaItemBatchCreateResponse] | None,
|
|
293
|
+
) -> None:
|
|
294
|
+
print(f"[{stage}] items={len(state.items)} stage_states={state.stages}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
responses = await syncer.sync_date_range(
|
|
298
|
+
start_date=datetime(2026, 1, 8),
|
|
299
|
+
end_date=datetime(2026, 1, 9),
|
|
300
|
+
session_id="my-session",
|
|
301
|
+
on_stage_complete=report,
|
|
302
|
+
)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Public API
|
|
306
|
+
|
|
307
|
+
All public symbols are importable directly from `q2google`:
|
|
308
|
+
|
|
309
|
+
| Symbol | Description |
|
|
310
|
+
|--------|-------------|
|
|
311
|
+
| `GoProToPhotosSync` | Main orchestrator; runs discovery → transfer → create. |
|
|
312
|
+
| `GooglePhotosClient` | Resumable upload facade (`upload_file_path`, `create_media_items`). |
|
|
313
|
+
| `GooglePhotosOAuth` | Load, refresh, or obtain Google OAuth credentials. |
|
|
314
|
+
| `JsonFileBackend` | File-based `SyncStateBackend`; one JSON per session under a root directory. |
|
|
315
|
+
| `SessionState` | Full persisted session document (`to_dict` / `from_dict` for custom stores). |
|
|
316
|
+
| `SyncStateBackend` | Protocol — implement `load` / `save` to plug in any storage layer. |
|
|
317
|
+
| `Q2GoogleSettings` | Pydantic settings; batch sizes, timeouts, and paths with env-var overrides. |
|
|
318
|
+
| `get_settings` | Return a singleton `Q2GoogleSettings` from environment / `.env`. |
|
|
319
|
+
|
|
320
|
+
Lower-level symbols in `q2google.gphotos`:
|
|
321
|
+
|
|
322
|
+
| Symbol | Description |
|
|
323
|
+
|--------|-------------|
|
|
324
|
+
| `GooglePhotosAPI` | Thin `aiohttp` wrapper for Library v1 — use as `async with GooglePhotosAPI(...) as api`. |
|
|
325
|
+
| `GooglePhotoLibraryPort` | Protocol matching `GooglePhotosAPI`; implement for testing or alternative HTTP clients. |
|
|
326
|
+
| `PhotosScopes` | Enum of OAuth scopes (`READ_AND_APPEND`, `READ_ONLY`, `APPEND_ONLY`). |
|
|
327
|
+
|
|
328
|
+
## Configuration
|
|
329
|
+
|
|
330
|
+
All CLI options have environment-variable equivalents. `Q2GoogleSettings` (Pydantic `BaseSettings`) loads them with the `Q2GOOGLE_` prefix and also reads a `.env` file in the working directory.
|
|
331
|
+
|
|
332
|
+
| Variable | Purpose |
|
|
333
|
+
|----------|---------|
|
|
334
|
+
| `GP_ACCESS_TOKEN` | **GoPro cloud access token** — read by `AsyncGoProClient`; required for discovery |
|
|
335
|
+
| `Q2GOOGLE_CREDENTIALS_PATH` | Google OAuth client secrets JSON path |
|
|
336
|
+
| `Q2GOOGLE_TOKEN_PATH` | Authorized user token path |
|
|
337
|
+
| `Q2GOOGLE_STATE_DIR` | JSON session state directory |
|
|
338
|
+
| `Q2GOOGLE_SESSION_ID` | Default session id when `--session-id` is omitted |
|
|
339
|
+
| `Q2GOOGLE_SYNC_BATCH_SIZE` | Transfer batch size for **new** sessions |
|
|
340
|
+
| `Q2GOOGLE_PHOTOS_LIBRARY_BATCH_SIZE` | Items per `batchCreate` (1–50) |
|
|
341
|
+
| `Q2GOOGLE_FAIL_FAST` | `true` / `false` |
|
|
342
|
+
| `Q2GOOGLE_LOG_LEVEL` | e.g. `INFO`, `DEBUG` |
|
|
343
|
+
| `Q2GOOGLE_GOOGLE_PHOTOS_TIMEOUT_SECONDS` | Library API request timeout |
|
|
344
|
+
| `Q2GOOGLE_DOWNLOAD_CHUNK_SIZE_BYTES` | CDN stream chunk size |
|
|
345
|
+
|
|
346
|
+
See `q2google.config.Q2GoogleSettings` for the full list and defaults.
|
|
347
|
+
|
|
348
|
+
## Architecture
|
|
349
|
+
|
|
350
|
+
`sync_date_range` splits every run into three sequential stages. State is persisted through `SyncStateBackend` after each stage, so interrupted runs can resume from the last checkpoint.
|
|
351
|
+
|
|
352
|
+
```mermaid
|
|
353
|
+
sequenceDiagram
|
|
354
|
+
participant Caller
|
|
355
|
+
participant Sync as GoProToPhotosSync
|
|
356
|
+
participant GoPro as AsyncGoProClient
|
|
357
|
+
participant Photos as GooglePhotosClient
|
|
358
|
+
participant Store as SyncStateBackend
|
|
359
|
+
|
|
360
|
+
Caller->>Sync: sync_date_range(start, end, session_id)
|
|
361
|
+
Sync->>Store: load(session_id)
|
|
362
|
+
Store-->>Sync: SessionState or new
|
|
363
|
+
Note over Sync: discovery
|
|
364
|
+
Sync->>GoPro: list_media_items, get_download_url
|
|
365
|
+
Sync->>Store: save(state)
|
|
366
|
+
Note over Sync: transfer
|
|
367
|
+
Sync->>Photos: upload_file_path per item
|
|
368
|
+
Sync->>Store: save(state)
|
|
369
|
+
Note over Sync: create
|
|
370
|
+
Sync->>Photos: create_media_items_from_upload_sessions
|
|
371
|
+
Sync->>Store: save(state)
|
|
372
|
+
Sync-->>Caller: list of batch create responses
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for module layout and extension points.
|
|
376
|
+
|
|
377
|
+
## Development
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
uv sync
|
|
381
|
+
task format # Ruff import fix + format
|
|
382
|
+
task lint # Ruff check + format check (no writes)
|
|
383
|
+
task test # Pytest with coverage on `q2google`
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## License
|
|
387
|
+
|
|
388
|
+
See repository metadata (add a `LICENSE` file if needed).
|