pushframe 5.0.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.
- pushframe/__init__.py +4 -0
- pushframe/api/__init__.py +0 -0
- pushframe/api/accountApi.py +72 -0
- pushframe/api/activityApi.py +87 -0
- pushframe/api/assetApi.py +194 -0
- pushframe/api/baseApi.py +6 -0
- pushframe/api/frameApi.py +271 -0
- pushframe/api/notificationApi.py +15 -0
- pushframe/api/peopleApi.py +25 -0
- pushframe/api/playlistApi.py +9 -0
- pushframe/aura.py +182 -0
- pushframe/aws/__init__.py +0 -0
- pushframe/aws/awsclient.py +23 -0
- pushframe/aws/s3client.py +40 -0
- pushframe/aws/sqsclient.py +33 -0
- pushframe/cache.py +50 -0
- pushframe/cli.py +1134 -0
- pushframe/client.py +267 -0
- pushframe/exif.py +147 -0
- pushframe/export.py +53 -0
- pushframe/google/__init__.py +43 -0
- pushframe/google/bootstrap.py +134 -0
- pushframe/google/cache.py +167 -0
- pushframe/google/client.py +140 -0
- pushframe/google/enumerate.py +270 -0
- pushframe/google/manifest.py +111 -0
- pushframe/google/parsers.py +345 -0
- pushframe/google/redaction.py +33 -0
- pushframe/google/vault.py +126 -0
- pushframe/gsync.py +463 -0
- pushframe/migration.py +86 -0
- pushframe/models/__init__.py +0 -0
- pushframe/models/activity.py +79 -0
- pushframe/models/asset.py +159 -0
- pushframe/models/frame.py +105 -0
- pushframe/models/meta.py +11 -0
- pushframe/models/person.py +24 -0
- pushframe/models/user.py +22 -0
- pushframe/ratelimit.py +222 -0
- pushframe/reconcile.py +384 -0
- pushframe/sync.py +1105 -0
- pushframe/utils/dt.py +15 -0
- pushframe/utils/io.py +23 -0
- pushframe/utils/settings.py +59 -0
- pushframe-5.0.0.dist-info/METADATA +53 -0
- pushframe-5.0.0.dist-info/RECORD +49 -0
- pushframe-5.0.0.dist-info/WHEEL +4 -0
- pushframe-5.0.0.dist-info/entry_points.txt +2 -0
- pushframe-5.0.0.dist-info/licenses/LICENSE +31 -0
pushframe/cli.py
ADDED
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
from pushframe.aura import Aura
|
|
13
|
+
from pushframe.aws.s3client import S3Client
|
|
14
|
+
from pushframe.aws.sqsclient import SQSClient
|
|
15
|
+
from pushframe.client import RateLimitError
|
|
16
|
+
from pushframe.google.bootstrap import BootstrapError, PROFILE_ENV_VAR, default_bootstrap
|
|
17
|
+
from pushframe.google.client import GoogleSession
|
|
18
|
+
from pushframe.google.enumerate import (
|
|
19
|
+
EnumerateError,
|
|
20
|
+
enumerate_album,
|
|
21
|
+
list_shared_albums,
|
|
22
|
+
measure_disk_weight,
|
|
23
|
+
)
|
|
24
|
+
from pushframe.google.parsers import AlbumSummary
|
|
25
|
+
from pushframe.google.redaction import redact_link, redact_tokens
|
|
26
|
+
from pushframe.google.vault import CookieVaultError
|
|
27
|
+
from pushframe.models.frame import Frame
|
|
28
|
+
from pushframe.ratelimit import WriteBudget, check_geo, _default_resolver, GeoMismatchError, BudgetExhausted
|
|
29
|
+
from pushframe.reconcile import apply_reconciliation, find_placeholders
|
|
30
|
+
from pushframe.sync import scan_directory, compute_plan, execute_plan, ConsecutiveWriteFailureError
|
|
31
|
+
from pushframe.utils.settings import (
|
|
32
|
+
AURA_WRITE_BUDGET_CAPACITY,
|
|
33
|
+
AURA_WRITE_BUDGET_REFILL_PER_MIN,
|
|
34
|
+
AURA_WRITE_BUDGET_WAIT,
|
|
35
|
+
AURA_WRITE_BUDGET_MAX_WAIT,
|
|
36
|
+
AURA_COUNTRY,
|
|
37
|
+
AURA_GEO_FAIL_OPEN,
|
|
38
|
+
AURA_STATE_DIR,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# execute_plan's own defaults for the two budget-wait knobs (pushframe/sync.py:
|
|
42
|
+
# wait_on_budget=True, max_wait_seconds=3600.0). Used below to forward the
|
|
43
|
+
# AURA_WRITE_BUDGET_WAIT / AURA_WRITE_BUDGET_MAX_WAIT env values only when they
|
|
44
|
+
# would actually change execute_plan's behavior, preserving the Phase 08
|
|
45
|
+
# "defaults don't override execute_plan defaults" contract.
|
|
46
|
+
_EXECUTE_PLAN_DEFAULT_WAIT = True
|
|
47
|
+
_EXECUTE_PLAN_DEFAULT_MAX_WAIT = 3600.0
|
|
48
|
+
|
|
49
|
+
# First-N photos printed by default before truncating with a "+K more"
|
|
50
|
+
# summary line (D-06). Claude's discretion per 06-CONTEXT.md; real frames
|
|
51
|
+
# can hold 77+ assets (Phase 2 finding) so dumping everything by default
|
|
52
|
+
# isn't useful in a terminal.
|
|
53
|
+
INSPECT_PHOTO_LIMIT = 10
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
57
|
+
"""Construct the root `pushframe` parser. Subparsers are structured so
|
|
58
|
+
`inspect`/`sync` siblings can be added in later phases (D-02).
|
|
59
|
+
|
|
60
|
+
`--debug` lives on the root parser (folded todo, promoted from the
|
|
61
|
+
`status`-only subparser) so it is parsed once regardless of which
|
|
62
|
+
subcommand runs, and every future subcommand inherits the same
|
|
63
|
+
quiet-by-default logging convention for free.
|
|
64
|
+
"""
|
|
65
|
+
parser = argparse.ArgumentParser(prog='pushframe')
|
|
66
|
+
# REL-02 (phase 22): version reporting from the single __init__ constant
|
|
67
|
+
# (pyproject is asserted equal by tests/test_version.py + the release
|
|
68
|
+
# workflow against the git tag).
|
|
69
|
+
from pushframe import __version__
|
|
70
|
+
parser.add_argument('--version', action='version',
|
|
71
|
+
version=f'pushframe {__version__}')
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
'--debug',
|
|
74
|
+
action='store_true',
|
|
75
|
+
default=False,
|
|
76
|
+
help='Show verbose loguru request/response logging on stderr',
|
|
77
|
+
)
|
|
78
|
+
subparsers = parser.add_subparsers(dest='command', required=True)
|
|
79
|
+
subparsers.add_parser('status', help='Check config/auth health and list account frames')
|
|
80
|
+
inspect_parser = subparsers.add_parser('inspect', help="Inspect a frame's photos and metadata")
|
|
81
|
+
inspect_parser.add_argument('--frame', required=True, help='Frame name (substring) or id')
|
|
82
|
+
sync_parser = subparsers.add_parser('sync', help='Dry-run diff a local directory against a frame')
|
|
83
|
+
sync_parser.add_argument('dir', help='Local directory to scan for photos')
|
|
84
|
+
sync_parser.add_argument('--frame', required=True, help='Frame name (substring) or id')
|
|
85
|
+
sync_parser.add_argument('--apply', action='store_true', default=False, help='Execute the plan (upload + delete) instead of only printing it')
|
|
86
|
+
sync_parser.add_argument('--yes', action='store_true', default=False, help='Skip the confirmation prompt (required for --apply when running non-interactively)')
|
|
87
|
+
# The three tiers of "no longer in the local directory" (D-02/D-03).
|
|
88
|
+
# Hiding is the default because the frame has no photo-count limit, so a
|
|
89
|
+
# mistaken sync should cost visibility, never photos. argparse enforces
|
|
90
|
+
# the mutual exclusion at parse time (V5).
|
|
91
|
+
removal_group = sync_parser.add_mutually_exclusive_group()
|
|
92
|
+
removal_group.add_argument('--delete', action='store_true', default=False,
|
|
93
|
+
help='Remove gone-local photos from the frame instead of hiding them (frame-scoped; the photo leaves this frame)')
|
|
94
|
+
removal_group.add_argument('--hard-delete', action='store_true', default=False, dest='hard_delete',
|
|
95
|
+
help='IRREVERSIBLY destroy gone-local photos instead of hiding them (account-wide; requires typing the exact count to confirm)')
|
|
96
|
+
|
|
97
|
+
# `push` = purely additive upload from a supply ("buffet") directory. Unlike
|
|
98
|
+
# `sync`, the frame is NOT diffed-to-match the directory: nothing is ever
|
|
99
|
+
# deleted (structurally -- to_delete is forced empty). Photos already on the
|
|
100
|
+
# frame are still skipped via the md5 diff, so only new files upload. The
|
|
101
|
+
# probe flags (--limit/--batch-size/--chunk-delay) make it the safe tool for
|
|
102
|
+
# empirically measuring the anti-abuse write budget without touching
|
|
103
|
+
# existing frame photos.
|
|
104
|
+
push_parser = subparsers.add_parser('push', help='Upload photos from a directory to a frame (additive -- never deletes)')
|
|
105
|
+
push_parser.add_argument('dir', help='Local directory of photos to upload (a supply/"buffet"; the frame is NOT synced to match it)')
|
|
106
|
+
push_parser.add_argument('--frame', required=True, help='Frame name (substring) or id')
|
|
107
|
+
push_parser.add_argument('--apply', action='store_true', default=False, help='Execute the upload instead of only printing the plan')
|
|
108
|
+
push_parser.add_argument('--yes', action='store_true', default=False, help='Skip the confirmation prompt (required for --apply when running non-interactively)')
|
|
109
|
+
push_parser.add_argument('--limit', type=int, default=None, help='Upload at most N photos this run (for controlled anti-abuse budget probing)')
|
|
110
|
+
push_parser.add_argument('--batch-size', type=int, default=None, dest='batch_size', help='Assets per select_asset/batch_update call (default 50)')
|
|
111
|
+
push_parser.add_argument('--chunk-delay', type=float, default=None, dest='chunk_delay', help='Seconds to pause between write chunks (default 5)')
|
|
112
|
+
# Proactive write-rate-budget + geo pre-flight guard overrides (Phase 09,
|
|
113
|
+
# ANTI-06). `sync` deliberately does NOT expose these -- `sync --apply`
|
|
114
|
+
# still gets the budget/geo guard by default (built from AURA_* env vars
|
|
115
|
+
# in run_sync), just without per-run override flags this phase.
|
|
116
|
+
push_parser.add_argument('--max-wait', type=float, default=None, dest='max_wait', help='Max seconds to wait for write budget before stopping (default 3600)')
|
|
117
|
+
push_parser.add_argument('--no-wait', action='store_true', default=False, help='Stop immediately instead of waiting when the write budget is exhausted')
|
|
118
|
+
push_parser.add_argument('--country', default=None, help='Override the expected account country for the geo pre-flight guard (default from PUSHFRAME_COUNTRY, legacy AURA_COUNTRY)')
|
|
119
|
+
push_parser.add_argument('--ignore-budget', action='store_true', default=False, dest='ignore_budget', help='Escape hatch: bypass the write budget entirely for this run')
|
|
120
|
+
|
|
121
|
+
# `reconcile` = data hygiene on EXISTING stuck placeholder rows (REL-05,
|
|
122
|
+
# D-13) -- deliberately outside the sync/push loop. Report-only by
|
|
123
|
+
# default; `--remove` is required to attempt any write, mirroring
|
|
124
|
+
# `--apply`'s report-vs-mutate split.
|
|
125
|
+
reconcile_parser = subparsers.add_parser(
|
|
126
|
+
'reconcile', help='Report (and optionally remove) stuck placeholder rows on a frame')
|
|
127
|
+
reconcile_parser.add_argument('--frame', required=True, help='Frame name (substring) or id')
|
|
128
|
+
reconcile_parser.add_argument(
|
|
129
|
+
'--remove', action='store_true', default=False,
|
|
130
|
+
help='Attempt removal of stuck placeholder rows instead of only reporting them')
|
|
131
|
+
reconcile_parser.add_argument(
|
|
132
|
+
'--yes', action='store_true', default=False,
|
|
133
|
+
help='Skip the confirmation prompt (required for --remove when running non-interactively)')
|
|
134
|
+
reconcile_parser.add_argument(
|
|
135
|
+
'--mechanism', choices=['remove', 'hard-delete', 'complete'], default='remove',
|
|
136
|
+
help="Which removal mechanism to attempt. 'remove' (the default) is confirmed working "
|
|
137
|
+
"live as of 2026-09-03 (plan 11-06); 'hard-delete' is unconfirmed; "
|
|
138
|
+
"'complete' is not yet implemented")
|
|
139
|
+
reconcile_parser.add_argument(
|
|
140
|
+
'--max-age-hours', type=float, default=24.0, dest='max_age_hours',
|
|
141
|
+
help='Minimum age in hours for a placeholder row to be reported as stuck rather than '
|
|
142
|
+
'recently created (default 24)')
|
|
143
|
+
# Plan 11-06, Task 1: explicit opt-in on `find_placeholders`'
|
|
144
|
+
# `unknown_age_policy` -- corrects D-15's unconditional form now that
|
|
145
|
+
# plan 11-05 established live that `created_at` is never sent by this
|
|
146
|
+
# API, which made the unconditional form permanently inert rather than
|
|
147
|
+
# conservative (see pushframe/reconcile.py's find_placeholders
|
|
148
|
+
# docstring). Bare `--remove` (this flag omitted) is BYTE-FOR-BYTE
|
|
149
|
+
# unchanged: an unresolvable creation time still lands in unknown_age
|
|
150
|
+
# and is never a removal candidate. Keyword-only on the Python side and
|
|
151
|
+
# its own explicitly named flag here -- nothing promotes a row by
|
|
152
|
+
# accident.
|
|
153
|
+
reconcile_parser.add_argument(
|
|
154
|
+
'--include-unknown-age', action='store_true', default=False, dest='include_unknown_age',
|
|
155
|
+
help='Explicit opt-in: treat placeholder rows whose creation time this API never sends '
|
|
156
|
+
'(unknown_age) as eligible for removal too, not just rows old enough per '
|
|
157
|
+
'--max-age-hours. Without this flag, --remove cannot touch unknown-age rows.')
|
|
158
|
+
|
|
159
|
+
# Phase 17 (LGS-02..05): Google link + album selection. google-link is
|
|
160
|
+
# BOTH the link and the re-link command (one documented surface); the
|
|
161
|
+
# dedicated-profile env prerequisite is enforced in run_google_link.
|
|
162
|
+
link_parser = subparsers.add_parser(
|
|
163
|
+
'google-link', help='Link (or re-link) Google Photos via the dedicated-profile browser bootstrap')
|
|
164
|
+
album_parser = subparsers.add_parser(
|
|
165
|
+
'google-album', help='Select a Google Photos album and enumerate every item with exact disk weight')
|
|
166
|
+
album_parser.add_argument(
|
|
167
|
+
'target', nargs='?', default=None,
|
|
168
|
+
help='Album share URL, AF1Qip… id, or album-name substring (ambiguity -> numbered list, exit 2)')
|
|
169
|
+
album_parser.add_argument(
|
|
170
|
+
'--list', action='store_true', default=False,
|
|
171
|
+
help='List the account\'s shared albums and exit (discovery aid)')
|
|
172
|
+
# Phase 18 (CSE-01..08, SAFE-01..04): album → frame mirror sync. Dry-run
|
|
173
|
+
# is the structural default; --apply is gated (SAFE-02 mass-hide threshold
|
|
174
|
+
# + y/N) and removal is hide-only (CSE-06) — no delete tier on this verb.
|
|
175
|
+
gsync_parser = subparsers.add_parser(
|
|
176
|
+
'google-sync', help='Mirror a Google Photos album onto one frame (dry-run by default; hide-by-default removals)')
|
|
177
|
+
gsync_parser.add_argument(
|
|
178
|
+
'album', help='Album share URL, AF1Qip… id, or album-name substring')
|
|
179
|
+
gsync_parser.add_argument(
|
|
180
|
+
'--frame', required=True,
|
|
181
|
+
help='Target frame name substring or id (single album→frame pair)')
|
|
182
|
+
gsync_parser.add_argument(
|
|
183
|
+
'--apply', action='store_true', default=False,
|
|
184
|
+
help='Execute the plan (uploads + hides). Without it, only print the plan')
|
|
185
|
+
gsync_parser.add_argument(
|
|
186
|
+
'--yes', action='store_true', default=False,
|
|
187
|
+
help='Skip the confirmation prompt (required for --apply when running non-interactively)')
|
|
188
|
+
gsync_parser.add_argument(
|
|
189
|
+
'--debug', action='store_true', default=False,
|
|
190
|
+
help='Verbose logging')
|
|
191
|
+
return parser
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _configure_cli_logging(debug: bool) -> None:
|
|
195
|
+
"""Neutralize (or leave alone) loguru's stderr handlers for the CLI.
|
|
196
|
+
|
|
197
|
+
`Aura.__init__` -> `Aura._init_logger()` (frozen per D-04) adds a
|
|
198
|
+
level=INFO stderr sink on every construction but never removes loguru's
|
|
199
|
+
auto-registered default stderr handler (its `logger.remove()` is
|
|
200
|
+
commented out), so two stderr handlers fire on every HTTP call. Because
|
|
201
|
+
`aura.py` cannot be edited, this CLI-side helper re-initializes loguru's
|
|
202
|
+
sinks *after* `Aura()` construction to compensate for the frozen file's
|
|
203
|
+
missing cleanup.
|
|
204
|
+
|
|
205
|
+
- debug=True: no-op — every sink `_init_logger()` registered stays
|
|
206
|
+
active, reproducing today's full verbose output (opt-in per the
|
|
207
|
+
user's UAT suggestion).
|
|
208
|
+
- debug=False (default): drop every accumulated handler, then restore
|
|
209
|
+
on-disk logging (the same `logs/file_{time}.log` sink target
|
|
210
|
+
`_init_logger()` uses) plus a stricter `sys.stderr` sink at level
|
|
211
|
+
WARNING so genuine warnings/errors still surface without the
|
|
212
|
+
INFO/DEBUG request/response spam.
|
|
213
|
+
"""
|
|
214
|
+
if debug:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
logger.remove()
|
|
218
|
+
os.makedirs('logs/', exist_ok=True)
|
|
219
|
+
logger.add('logs/file_{time}.log')
|
|
220
|
+
logger.add(sys.stderr, level='WARNING')
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def run_status(aura=None, debug: bool = False, google_session=None) -> int:
|
|
224
|
+
"""Status command handler. Returns a process exit code (0 success, 1
|
|
225
|
+
failure) — never calls sys.exit directly. Accepts an optional injected
|
|
226
|
+
`Aura` (dependency-injection seam) so this is testable offline.
|
|
227
|
+
|
|
228
|
+
`google_session` (phase 17) is the same DI pattern for the Google
|
|
229
|
+
section: inject a GoogleSession (real one over a MockTransport in
|
|
230
|
+
tests) to control its output hermetically. When None, the section is
|
|
231
|
+
built from the cookie vault — vault absent means `linked: no` with no
|
|
232
|
+
network call.
|
|
233
|
+
"""
|
|
234
|
+
# Config health check (D-07) — must run first; never print the password
|
|
235
|
+
# value, only the literal set/NOT SET. IDN-04: PUSHFRAME_* primary,
|
|
236
|
+
# AURA_* legacy fallback.
|
|
237
|
+
email_set = bool(os.getenv('PUSHFRAME_EMAIL') or os.getenv('AURA_EMAIL'))
|
|
238
|
+
password_set = bool(os.getenv('PUSHFRAME_PASSWORD') or os.getenv('AURA_PASSWORD'))
|
|
239
|
+
print(f"PUSHFRAME_EMAIL: {'set' if email_set else 'NOT SET'}")
|
|
240
|
+
print(f"PUSHFRAME_PASSWORD: {'set' if password_set else 'NOT SET'}")
|
|
241
|
+
|
|
242
|
+
if not (email_set and password_set):
|
|
243
|
+
# D-09: stop immediately after the config check — no Aura, no
|
|
244
|
+
# network call, when either credential is missing.
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
aura = aura or Aura()
|
|
248
|
+
# Must run after Aura() construction (which registers the noisy sinks)
|
|
249
|
+
# and before login/get_frames (the HTTP calls that trigger them).
|
|
250
|
+
_configure_cli_logging(debug)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
aura.login()
|
|
254
|
+
except Exception as e:
|
|
255
|
+
# D-08: bad credentials, network error, or API drift all surface
|
|
256
|
+
# here — a broad catch at the CLI boundary is correct.
|
|
257
|
+
print(f'Login failed: {e}')
|
|
258
|
+
return 1
|
|
259
|
+
|
|
260
|
+
frames = aura.frame_api.get_frames()
|
|
261
|
+
print(f'Logged in as {os.getenv("PUSHFRAME_EMAIL") or os.getenv("AURA_EMAIL")}')
|
|
262
|
+
print(f'{len(frames)} frames:')
|
|
263
|
+
for frame in frames:
|
|
264
|
+
print(f' - {frame.name} (id: {frame.id})')
|
|
265
|
+
|
|
266
|
+
# Phase 17 (LGS-03): the Google link section — email + session state
|
|
267
|
+
# only, NEVER cookie values or vault contents (D-02).
|
|
268
|
+
for line in _google_status_section(google_session):
|
|
269
|
+
print(line)
|
|
270
|
+
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _google_status_section(google_session=None) -> list[str]:
|
|
275
|
+
"""Build the `Google:` section lines for `status` (LGS-03/D-02).
|
|
276
|
+
|
|
277
|
+
linked/account/session only — never a cookie value, a token, or the
|
|
278
|
+
vault contents. With no injected session: a missing vault prints
|
|
279
|
+
`linked: no` with zero network calls; a present vault gets ONE home
|
|
280
|
+
GET (short timeout) for the usable/expired signal, and any failure
|
|
281
|
+
degrades to an honest `unreachable` instead of a guess.
|
|
282
|
+
"""
|
|
283
|
+
lines = ['Google:']
|
|
284
|
+
if google_session is not None:
|
|
285
|
+
session = google_session
|
|
286
|
+
else:
|
|
287
|
+
try:
|
|
288
|
+
# Denylist-safe route: vault.load() fires inside from_vault,
|
|
289
|
+
# whose frame is pushframe.google.client (sanctioned).
|
|
290
|
+
session = GoogleSession.from_vault(timeout=10.0)
|
|
291
|
+
except CookieVaultError:
|
|
292
|
+
lines.append(' linked: no')
|
|
293
|
+
lines.append(' (no Google session vault — run `pushframe google-link`)')
|
|
294
|
+
return lines
|
|
295
|
+
try:
|
|
296
|
+
linked = session.is_linked()
|
|
297
|
+
except Exception as e:
|
|
298
|
+
lines.append(' linked: unknown (session check failed)')
|
|
299
|
+
lines.append(f' session: unreachable ({type(e).__name__})')
|
|
300
|
+
return lines
|
|
301
|
+
lines.append(f' linked: {"yes" if linked else "no"}')
|
|
302
|
+
if linked:
|
|
303
|
+
email = None
|
|
304
|
+
try:
|
|
305
|
+
email = session.account_email()
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
lines.append(f' account: {email or "(not resolvable)"}')
|
|
309
|
+
lines.append(' session: usable')
|
|
310
|
+
else:
|
|
311
|
+
lines.append(' account: (not resolvable)')
|
|
312
|
+
lines.append(' session: expired')
|
|
313
|
+
return lines
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def run_google_link(*, debug: bool = False, bootstrap_fn=None) -> int:
|
|
317
|
+
"""google-link command handler (LGS-02): link AND re-link are the same
|
|
318
|
+
command. Requires the dedicated-profile env (T-16-06 posture) and runs
|
|
319
|
+
the interactive bootstrap through the `bootstrap_fn` seam — tests inject
|
|
320
|
+
a fake and never launch a browser (TEST-02).
|
|
321
|
+
|
|
322
|
+
Prints the vault path (path only), the 0600 confirmation and the
|
|
323
|
+
auth-marker cookie NAMES — never values (T-16-07/D-02).
|
|
324
|
+
"""
|
|
325
|
+
_configure_cli_logging(debug)
|
|
326
|
+
|
|
327
|
+
if not os.environ.get(PROFILE_ENV_VAR, '').strip():
|
|
328
|
+
print(f'google-link failed: {PROFILE_ENV_VAR} is unset — the daily-driver '
|
|
329
|
+
f'profile is structurally unreachable; point the env var at a '
|
|
330
|
+
f'dedicated Chrome profile directory (e.g. ~/.config/pushframe/'
|
|
331
|
+
f'chrome-profile) and re-run (T-16-06)')
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
# Re-link notice (LGS-02): same command refreshes an existing session.
|
|
335
|
+
# Vault read routes through from_vault (denylist-sanctioned); a dead or
|
|
336
|
+
# absent vault both fall through to the bootstrap.
|
|
337
|
+
try:
|
|
338
|
+
GoogleSession.from_vault()
|
|
339
|
+
print('existing Google session found — refreshing it (re-link is this same command)')
|
|
340
|
+
except Exception:
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
bootstrap = bootstrap_fn or default_bootstrap
|
|
344
|
+
try:
|
|
345
|
+
summary = bootstrap()
|
|
346
|
+
except BootstrapError as e:
|
|
347
|
+
print(f'google-link failed: {e}')
|
|
348
|
+
return 1
|
|
349
|
+
|
|
350
|
+
print(f"vault saved: {summary['vault_path']} (0600, outside the repo)")
|
|
351
|
+
print(f"session cookies present: {summary['cookie_count']} total; "
|
|
352
|
+
f"auth markers: {summary['auth_markers']}")
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass
|
|
357
|
+
class FrameResolution:
|
|
358
|
+
"""Result of resolving a `--frame` CLI argument against the account's
|
|
359
|
+
frame list (CLI-04). `status` is a discriminator — 'resolved',
|
|
360
|
+
'ambiguous', or 'not_found' — deliberately not a raised exception
|
|
361
|
+
(MOD-03 typed exceptions stays deferred; see 06-CONTEXT.md)."""
|
|
362
|
+
frame: Frame | None
|
|
363
|
+
status: str
|
|
364
|
+
candidates: list[Frame] = field(default_factory=list)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def resolve_frame(target: str, frames: list[Frame]) -> FrameResolution:
|
|
368
|
+
"""Resolve a `--frame` value to a single Frame (CLI-04, D-01..D-04).
|
|
369
|
+
|
|
370
|
+
Pure function — no I/O, no side effects. Resolution order:
|
|
371
|
+
1. Case-insensitive substring match on `Frame.name` (D-01).
|
|
372
|
+
- Exactly one match -> resolved (D-02).
|
|
373
|
+
- More than one match -> ambiguous; the id fallback is NOT
|
|
374
|
+
attempted (D-03).
|
|
375
|
+
2. Zero name matches -> fall back to an exact (case-sensitive)
|
|
376
|
+
match on `Frame.id`.
|
|
377
|
+
- Exactly one match -> resolved (D-02).
|
|
378
|
+
- Otherwise -> not_found, candidates is every frame on the
|
|
379
|
+
account so the caller can list available names (D-04).
|
|
380
|
+
"""
|
|
381
|
+
target_lower = target.lower()
|
|
382
|
+
name_matches = [f for f in frames if target_lower in f.name.lower()]
|
|
383
|
+
|
|
384
|
+
if len(name_matches) == 1:
|
|
385
|
+
return FrameResolution(frame=name_matches[0], status='resolved', candidates=[])
|
|
386
|
+
if len(name_matches) > 1:
|
|
387
|
+
return FrameResolution(frame=None, status='ambiguous', candidates=name_matches)
|
|
388
|
+
|
|
389
|
+
id_matches = [f for f in frames if f.id == target]
|
|
390
|
+
if len(id_matches) == 1:
|
|
391
|
+
return FrameResolution(frame=id_matches[0], status='resolved', candidates=[])
|
|
392
|
+
|
|
393
|
+
return FrameResolution(frame=None, status='not_found', candidates=frames)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@dataclass
|
|
397
|
+
class AlbumResolution:
|
|
398
|
+
"""Result of resolving a `google-album <target>` argument (phase 17,
|
|
399
|
+
D-05) — mirrors resolve_frame's FrameResolution contract: a 'resolved'/
|
|
400
|
+
'ambiguous'/'not_found' discriminator plus candidates, deliberately not
|
|
401
|
+
a raised exception. `album` is an AlbumSummary when resolved.
|
|
402
|
+
|
|
403
|
+
Direct targets (share URL or AF1Qip id) bypass name resolution entirely
|
|
404
|
+
(D-05: "Lien/ID direct accepté tel quel") — they resolve by construction
|
|
405
|
+
with zero candidates, and the enumeration validates them."""
|
|
406
|
+
|
|
407
|
+
album: 'AlbumSummary | None'
|
|
408
|
+
status: str
|
|
409
|
+
candidates: list = field(default_factory=list)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def resolve_album(target: str, albums: list) -> AlbumResolution:
|
|
413
|
+
"""Resolve a `google-album` target (D-05, resolve_frame-style).
|
|
414
|
+
|
|
415
|
+
Pure function — no I/O. Order:
|
|
416
|
+
1. A target that LOOKS like a share URL (photos.google.com/share/…,
|
|
417
|
+
photos.app.goo.gl/…) or a full AF1Qip… id resolves directly — no
|
|
418
|
+
name matching (D-05's link/id path).
|
|
419
|
+
2. Otherwise: case-insensitive substring match on album titles —
|
|
420
|
+
exactly one -> resolved; several -> ambiguous (numbered choice is
|
|
421
|
+
the caller's print; no silent pick, T-17-06).
|
|
422
|
+
3. Zero matches -> not_found with every album as candidates.
|
|
423
|
+
"""
|
|
424
|
+
import re as _re
|
|
425
|
+
|
|
426
|
+
share_id_m = _re.search(r"/share/([A-Za-z0-9_-]+)", target)
|
|
427
|
+
if ('photos.google.com/share/' in target or 'photos.app.goo.gl/' in target
|
|
428
|
+
or _re.fullmatch(r"AF1Qip[A-Za-z0-9_-]{20,}", target)):
|
|
429
|
+
album_id = share_id_m.group(1) if share_id_m else target
|
|
430
|
+
return AlbumResolution(album=AlbumSummary(album_id=album_id, title=None,
|
|
431
|
+
share_url=target),
|
|
432
|
+
status='resolved', candidates=[])
|
|
433
|
+
|
|
434
|
+
target_lower = target.lower()
|
|
435
|
+
name_matches = [a for a in albums
|
|
436
|
+
if a.title and target_lower in a.title.lower()]
|
|
437
|
+
if len(name_matches) == 1:
|
|
438
|
+
return AlbumResolution(album=name_matches[0], status='resolved', candidates=[])
|
|
439
|
+
if len(name_matches) > 1:
|
|
440
|
+
return AlbumResolution(album=None, status='ambiguous', candidates=name_matches)
|
|
441
|
+
return AlbumResolution(album=None, status='not_found', candidates=albums)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _print_album_candidates(candidates: list, numbered: bool = False,
|
|
445
|
+
show_count: bool = False) -> None:
|
|
446
|
+
"""Print an album candidate list (redacted ids, D-05/T-17-08); the
|
|
447
|
+
metadata item count is shown when available (item_count is metadata —
|
|
448
|
+
videos included; the authoritative photo count is the enumeration's)."""
|
|
449
|
+
for i, candidate in enumerate(candidates, 1):
|
|
450
|
+
label = f' {i}. ' if numbered else ' - '
|
|
451
|
+
title = candidate.title or '(untitled)'
|
|
452
|
+
id_shape = redact_link(candidate.album_id) if candidate.album_id else '(no id)'
|
|
453
|
+
count = ''
|
|
454
|
+
if show_count and candidate.item_count is not None:
|
|
455
|
+
count = f' — {candidate.item_count} items (metadata)'
|
|
456
|
+
print(f'{label}{title} (id shape: {id_shape}){count}')
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def run_google_album(target: str, *, debug: bool = False, session=None,
|
|
460
|
+
list_all: bool = False) -> int:
|
|
461
|
+
"""google-album command handler (LGS-04/LGS-05, D-05/D-06): resolve an
|
|
462
|
+
album by share URL, id, or title substring — ambiguity prints a numbered
|
|
463
|
+
list and exits 2 (no silent pick, no per-photo picking anywhere) — then
|
|
464
|
+
enumerate EVERY item (continuation until exhaustion) and print the exact
|
|
465
|
+
disk weight (1-byte Range GETs).
|
|
466
|
+
|
|
467
|
+
`session` is the DI seam (TEST-02): tests inject a GoogleSession over a
|
|
468
|
+
MockTransport; without injection the session comes from the vault.
|
|
469
|
+
"""
|
|
470
|
+
_configure_cli_logging(debug)
|
|
471
|
+
|
|
472
|
+
if target is None and not list_all:
|
|
473
|
+
print("google-album: provide an album name, share URL, or id "
|
|
474
|
+
"(or pass --list to discover the account's shared albums)")
|
|
475
|
+
return 2
|
|
476
|
+
|
|
477
|
+
if session is None:
|
|
478
|
+
try:
|
|
479
|
+
session = GoogleSession.from_vault()
|
|
480
|
+
except CookieVaultError as e:
|
|
481
|
+
print(f'google-album failed: {e}')
|
|
482
|
+
return 1
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
albums = list_shared_albums(session)
|
|
486
|
+
except EnumerateError as e:
|
|
487
|
+
print(f'google-album failed: {redact_tokens(str(e))}')
|
|
488
|
+
return 1
|
|
489
|
+
|
|
490
|
+
if list_all:
|
|
491
|
+
if not albums:
|
|
492
|
+
print('No shared albums found on this account.')
|
|
493
|
+
return 0
|
|
494
|
+
print(f'{len(albums)} shared albums:')
|
|
495
|
+
_print_album_candidates(albums, numbered=True, show_count=True)
|
|
496
|
+
return 0
|
|
497
|
+
|
|
498
|
+
resolved = resolve_album(target, albums)
|
|
499
|
+
|
|
500
|
+
if resolved.status == 'ambiguous':
|
|
501
|
+
print(f"'{target}' matches more than one album — re-run with a link/id "
|
|
502
|
+
f"or a fuller name:")
|
|
503
|
+
_print_album_candidates(resolved.candidates, numbered=True, show_count=True)
|
|
504
|
+
return 2
|
|
505
|
+
if resolved.status == 'not_found':
|
|
506
|
+
print(f"No album matches '{target}'. Available shared albums:")
|
|
507
|
+
_print_album_candidates(albums, numbered=True, show_count=True)
|
|
508
|
+
return 2
|
|
509
|
+
|
|
510
|
+
album = resolved.album
|
|
511
|
+
# The page_key (share URL's ?key=) is optional but the /albums listing
|
|
512
|
+
# carries it — use it when the resolved album matches a listed one.
|
|
513
|
+
page_key = None
|
|
514
|
+
if album.share_url and 'key=' in album.share_url:
|
|
515
|
+
page_key = album.share_url.split('key=', 1)[1].split('&', 1)[0] or None
|
|
516
|
+
elif album.album_id:
|
|
517
|
+
listed = next((a for a in albums if a.album_id == album.album_id
|
|
518
|
+
and a.share_url and 'key=' in a.share_url), None)
|
|
519
|
+
if listed:
|
|
520
|
+
page_key = listed.share_url.split('key=', 1)[1].split('&', 1)[0] or None
|
|
521
|
+
|
|
522
|
+
print(f'Album: {album.title or "(untitled)"} '
|
|
523
|
+
f'(id shape: {redact_link(album.album_id)})')
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
listing = enumerate_album(session, album.album_id, page_key=page_key)
|
|
527
|
+
except EnumerateError as e:
|
|
528
|
+
print(f'google-album failed: {redact_tokens(str(e))}')
|
|
529
|
+
return 1
|
|
530
|
+
|
|
531
|
+
base_urls = [i['base_url'] for i in listing.items]
|
|
532
|
+
sizes = measure_disk_weight(session, base_urls)
|
|
533
|
+
for item, size in zip(listing.items, sizes):
|
|
534
|
+
item['bytes'] = size
|
|
535
|
+
total = sum(sizes)
|
|
536
|
+
|
|
537
|
+
print(f'Items: {len(listing.items)} '
|
|
538
|
+
f"(pages: {listing.page_count}, "
|
|
539
|
+
f"exhausted: {'cleanly' if listing.exhausted_cleanly else 'NO — INCOMPLETE'})")
|
|
540
|
+
print(f'Disk weight: {total:,} bytes = {total / 1024 / 1024:.1f} MiB '
|
|
541
|
+
f'(min {min(sizes):,}, max {max(sizes):,}, '
|
|
542
|
+
f'avg {total // max(len(sizes), 1):,})')
|
|
543
|
+
print('Per-item (index | id shape | WxH | bytes):')
|
|
544
|
+
for idx, (item, size) in enumerate(zip(listing.items, sizes), 1):
|
|
545
|
+
w = item.get('width') if item.get('width') is not None else '?'
|
|
546
|
+
h = item.get('height') if item.get('height') is not None else '?'
|
|
547
|
+
id_shape = redact_link(item['id'])
|
|
548
|
+
print(f' {idx:4d} | {id_shape} | {w}x{h} | {size:,}')
|
|
549
|
+
|
|
550
|
+
return 0
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def run_inspect(frame_arg: str, aura=None, debug: bool = False) -> int:
|
|
554
|
+
"""Inspect command handler. Returns a process exit code (0 success, 1
|
|
555
|
+
failure) — never calls sys.exit directly. Accepts an optional injected
|
|
556
|
+
`Aura` (dependency-injection seam) so this is testable offline.
|
|
557
|
+
|
|
558
|
+
`frame_arg` is argparse-required, not an env var, so (unlike
|
|
559
|
+
`run_status`) there's no config-precheck step before constructing
|
|
560
|
+
`Aura()`.
|
|
561
|
+
"""
|
|
562
|
+
aura = aura or Aura()
|
|
563
|
+
# Must run after Aura() construction (which registers the noisy sinks)
|
|
564
|
+
# and before login/get_frames (the HTTP calls that trigger them).
|
|
565
|
+
_configure_cli_logging(debug)
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
aura.login()
|
|
569
|
+
except Exception as e:
|
|
570
|
+
# D-05: bad credentials, network error, or API drift all surface
|
|
571
|
+
# here — a broad catch at the CLI boundary is correct.
|
|
572
|
+
print(f'Login failed: {e}')
|
|
573
|
+
return 1
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
frames = aura.frame_api.get_frames()
|
|
577
|
+
resolved = resolve_frame(frame_arg, frames)
|
|
578
|
+
|
|
579
|
+
if resolved.status == 'ambiguous':
|
|
580
|
+
print(f"'{frame_arg}' matches more than one frame name — re-run with --frame <id>:")
|
|
581
|
+
for candidate in resolved.candidates:
|
|
582
|
+
print(f' - {candidate.name} (id: {candidate.id})')
|
|
583
|
+
return 1
|
|
584
|
+
|
|
585
|
+
if resolved.status == 'not_found':
|
|
586
|
+
print(f"No frame matches name or id '{frame_arg}'. Available frames:")
|
|
587
|
+
for candidate in resolved.candidates:
|
|
588
|
+
print(f' - {candidate.name} (id: {candidate.id})')
|
|
589
|
+
return 1
|
|
590
|
+
|
|
591
|
+
frame, total_asset_count = aura.frame_api.get_frame(resolved.frame.id)
|
|
592
|
+
contributors = frame.contributors or []
|
|
593
|
+
|
|
594
|
+
print(f'Frame: {frame.name} (id: {frame.id})')
|
|
595
|
+
print(f'Owner: {frame.user.name} <{frame.user.email}>')
|
|
596
|
+
print(f'Contributors ({len(contributors)}):')
|
|
597
|
+
for contributor in contributors:
|
|
598
|
+
print(f' - {contributor.name} <{contributor.email}>')
|
|
599
|
+
print(f'Assets: {total_asset_count}')
|
|
600
|
+
|
|
601
|
+
assets = aura.get_all_assets(resolved.frame.id)
|
|
602
|
+
shown = assets[:INSPECT_PHOTO_LIMIT]
|
|
603
|
+
print(f'Photos (showing {len(shown)} of {len(assets)}, API order):')
|
|
604
|
+
for asset in shown:
|
|
605
|
+
print(f' - {asset.id} | {asset.file_name} | {asset.taken_at_dt}')
|
|
606
|
+
remaining = len(assets) - len(shown)
|
|
607
|
+
if remaining > 0:
|
|
608
|
+
print(f' ... +{remaining} more')
|
|
609
|
+
# REL-05, D-13: computed via the exact same pure function
|
|
610
|
+
# `run_reconcile` calls, so the two counts can never disagree.
|
|
611
|
+
print(f'Placeholder rows: {find_placeholders(assets).placeholder_count} '
|
|
612
|
+
f'(run `pushframe reconcile --frame ...` for detail)')
|
|
613
|
+
except Exception as e:
|
|
614
|
+
# WR-01 fail-loud (D-05): surface post-login API drift instead of a
|
|
615
|
+
# raw traceback.
|
|
616
|
+
print(f'Failed to inspect frame: {e}')
|
|
617
|
+
return 1
|
|
618
|
+
|
|
619
|
+
return 0
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def run_reconcile(frame_arg: str, *, remove: bool = False, yes: bool = False, mechanism: str = 'remove',
|
|
623
|
+
max_age_hours: float = 24.0, include_unknown_age: bool = False,
|
|
624
|
+
aura=None, debug: bool = False) -> int:
|
|
625
|
+
"""Reconcile command handler (REL-05, D-13) -- data hygiene on EXISTING
|
|
626
|
+
stuck placeholder rows, deliberately outside the sync/push loop. Reports
|
|
627
|
+
how many placeholder rows a frame carries unconditionally, whether or
|
|
628
|
+
not any removal mechanism works, and (only when `remove=True`) attempts
|
|
629
|
+
a bounded, gated removal. Returns a process exit code (0 success, 1
|
|
630
|
+
failure) -- never calls sys.exit directly. Accepts an optional injected
|
|
631
|
+
`Aura` (dependency-injection seam), mirroring `run_inspect`.
|
|
632
|
+
|
|
633
|
+
`include_unknown_age` (plan 11-06, Task 1, CLI-side `--include-unknown-age`)
|
|
634
|
+
is the explicit opt-in on `find_placeholders`' `unknown_age_policy` --
|
|
635
|
+
default `False` reproduces today's behaviour exactly (an unresolvable
|
|
636
|
+
creation time stays in `unknown_age`, never a removal candidate).
|
|
637
|
+
"""
|
|
638
|
+
aura = aura or Aura()
|
|
639
|
+
# Must run after Aura() construction (which registers the noisy sinks)
|
|
640
|
+
# and before login/get_frames (the HTTP calls that trigger them).
|
|
641
|
+
_configure_cli_logging(debug)
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
aura.login()
|
|
645
|
+
except Exception as e:
|
|
646
|
+
print(f'Login failed: {e}')
|
|
647
|
+
return 1
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
frames = aura.frame_api.get_frames()
|
|
651
|
+
resolved = resolve_frame(frame_arg, frames)
|
|
652
|
+
|
|
653
|
+
if resolved.status == 'ambiguous':
|
|
654
|
+
print(f"'{frame_arg}' matches more than one frame name — re-run with --frame <id>:")
|
|
655
|
+
for candidate in resolved.candidates:
|
|
656
|
+
print(f' - {candidate.name} (id: {candidate.id})')
|
|
657
|
+
return 1
|
|
658
|
+
|
|
659
|
+
if resolved.status == 'not_found':
|
|
660
|
+
print(f"No frame matches name or id '{frame_arg}'. Available frames:")
|
|
661
|
+
for candidate in resolved.candidates:
|
|
662
|
+
print(f' - {candidate.name} (id: {candidate.id})')
|
|
663
|
+
return 1
|
|
664
|
+
|
|
665
|
+
frame = resolved.frame
|
|
666
|
+
assets = aura.get_all_assets(frame.id)
|
|
667
|
+
|
|
668
|
+
if not assets:
|
|
669
|
+
# T-11-11: an empty listing cannot be distinguished from a
|
|
670
|
+
# frame with genuinely no placeholders -- refuse to report a
|
|
671
|
+
# reassuring zero on no data.
|
|
672
|
+
print(f'No assets returned for "{frame.name}" (id: {frame.id}) -- an empty asset '
|
|
673
|
+
f'listing cannot be distinguished from a frame with no placeholders. Refusing '
|
|
674
|
+
f'to report zero placeholders on no data.')
|
|
675
|
+
return 1
|
|
676
|
+
|
|
677
|
+
# REL-05, D-13: the exact same pure function `run_inspect` calls, so
|
|
678
|
+
# the two counts can never disagree. `unknown_age_policy` defaults
|
|
679
|
+
# to 'unknown_age' -- 'stuck' only when --include-unknown-age was
|
|
680
|
+
# explicitly passed (plan 11-06, Task 1).
|
|
681
|
+
unknown_age_policy = 'stuck' if include_unknown_age else 'unknown_age'
|
|
682
|
+
result = find_placeholders(assets, age_threshold_seconds=max_age_hours * 3600,
|
|
683
|
+
unknown_age_policy=unknown_age_policy)
|
|
684
|
+
|
|
685
|
+
print(f'Frame: {frame.name} (id: {frame.id})')
|
|
686
|
+
print(f'Assets scanned: {result.total_scanned}')
|
|
687
|
+
print(f'Placeholder rows: {result.placeholder_count}')
|
|
688
|
+
print(f' stuck (older than {max_age_hours}h): {len(result.stuck)}')
|
|
689
|
+
print(f' recently created (may still be processing): {len(result.recently_created)}')
|
|
690
|
+
print(f' creation time unknown: {len(result.unknown_age)}')
|
|
691
|
+
for asset in result.stuck:
|
|
692
|
+
print(f' - {asset.id}')
|
|
693
|
+
|
|
694
|
+
if not remove:
|
|
695
|
+
# D-13: reconcile without --remove performs no write of any
|
|
696
|
+
# kind -- return here, before any write path is reachable.
|
|
697
|
+
return 0
|
|
698
|
+
|
|
699
|
+
# D-13/D-16: --remove continuation. Fail closed on a non-interactive
|
|
700
|
+
# invocation missing --yes -- never block on input() forever, never
|
|
701
|
+
# silently proceed (mirrors run_sync's identical check verbatim).
|
|
702
|
+
if not yes and not sys.stdin.isatty():
|
|
703
|
+
print('--remove requires --yes when running non-interactively')
|
|
704
|
+
return 1
|
|
705
|
+
|
|
706
|
+
if not yes:
|
|
707
|
+
if mechanism == 'hard-delete':
|
|
708
|
+
# Same escalated-friction gate as run_sync's hard_delete
|
|
709
|
+
# branch: this primitive is irreversible and account-wide,
|
|
710
|
+
# so a reworded y/N is too easy to answer reflexively.
|
|
711
|
+
count = len(result.stuck)
|
|
712
|
+
print(f'IRREVERSIBLE: {count} row(s) will be permanently destroyed '
|
|
713
|
+
f'account-wide via hard-delete. This cannot be undone.')
|
|
714
|
+
answer = input(f'To confirm, type the number of rows to hard-delete ({count}): ')
|
|
715
|
+
if answer.strip() != str(count):
|
|
716
|
+
print('Aborted.')
|
|
717
|
+
return 0
|
|
718
|
+
else:
|
|
719
|
+
answer = input(f'About to attempt removal of {len(result.stuck)} stuck row(s) on '
|
|
720
|
+
f'"{frame.name}" (id: {frame.id}) using mechanism "{mechanism}". '
|
|
721
|
+
f'Proceed? [y/N] ')
|
|
722
|
+
if answer.strip().lower() not in ('y', 'yes'):
|
|
723
|
+
print('Aborted.')
|
|
724
|
+
return 0
|
|
725
|
+
|
|
726
|
+
# D-13: the same account-wide budget as sync/push -- reconcile has
|
|
727
|
+
# no --ignore-budget escape hatch, so this is always False.
|
|
728
|
+
write_budget = _build_write_budget(os.getenv('PUSHFRAME_EMAIL') or os.getenv('AURA_EMAIL'), False)
|
|
729
|
+
|
|
730
|
+
apply_reconciliation(result, aura, frame.id, mechanism=mechanism, budget=write_budget)
|
|
731
|
+
|
|
732
|
+
print(f'Removed: {len(result.removed)} succeeded, {len(result.failed)} failed')
|
|
733
|
+
for asset_id, err in result.failed:
|
|
734
|
+
print(f' ! {asset_id}: {err}')
|
|
735
|
+
|
|
736
|
+
return 1 if result.failed else 0
|
|
737
|
+
except RateLimitError as e:
|
|
738
|
+
# Anti-abuse throttle/lockout mid-removal -- apply_reconciliation
|
|
739
|
+
# aborted the batch rather than emitting N confusing per-item 401s.
|
|
740
|
+
print(f'Aborted: {e}')
|
|
741
|
+
return 1
|
|
742
|
+
except GeoMismatchError as e:
|
|
743
|
+
print(f'VPN/exit IP in {e.found}, account expects {e.expected} — switch your VPN and retry.')
|
|
744
|
+
return 1
|
|
745
|
+
except BudgetExhausted as e:
|
|
746
|
+
minutes = e.wait_seconds / 60
|
|
747
|
+
print(f'Write budget exhausted, come back in ~{minutes:.0f} min (or pass --no-wait / raise --max-wait).')
|
|
748
|
+
return 1
|
|
749
|
+
except Exception as e:
|
|
750
|
+
# WR-01 fail-loud (D-05 convention): surface post-login API drift
|
|
751
|
+
# instead of a raw traceback.
|
|
752
|
+
print(f'Failed to reconcile frame: {e}')
|
|
753
|
+
return 1
|
|
754
|
+
|
|
755
|
+
return 0
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# How each removal mode is named in the plan and in the run summary. The
|
|
759
|
+
# wording tracks the primitive that actually runs, so a report can never say
|
|
760
|
+
# "delete" on a run that hid, or vice versa (D-07).
|
|
761
|
+
_REMOVAL_VERB_PRESENT = {'hide': 'hide', 'delete': 'delete', 'hard_delete': 'hard-delete'}
|
|
762
|
+
_REMOVAL_VERB_PAST = {'hide': 'Hidden', 'delete': 'Removed', 'hard_delete': 'Hard-deleted'}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _build_write_budget(email: str, ignore_budget: bool) -> 'WriteBudget | None':
|
|
766
|
+
"""Factory (Phase 09, ANTI-06) constructing the per-account `WriteBudget`
|
|
767
|
+
at the CLI boundary, mirroring the S3Client()/SQSClient() construction
|
|
768
|
+
site (`execute_plan` itself never constructs one). Returns `None` when
|
|
769
|
+
`ignore_budget` is set (the `--ignore-budget` escape hatch) -- omitting
|
|
770
|
+
`budget` entirely from `exec_kwargs` bypasses the guard.
|
|
771
|
+
|
|
772
|
+
Only `sha1(email)[:12]` (a non-cryptographic filename-uniqueness hash,
|
|
773
|
+
not a security boundary) is used for the state-file name -- the email
|
|
774
|
+
itself is never persisted in the file body (T-09-02).
|
|
775
|
+
"""
|
|
776
|
+
if ignore_budget:
|
|
777
|
+
return None
|
|
778
|
+
state_path = AURA_STATE_DIR / f'budget-{hashlib.sha1(email.encode()).hexdigest()[:12]}.json'
|
|
779
|
+
return WriteBudget.load(
|
|
780
|
+
state_path, capacity=AURA_WRITE_BUDGET_CAPACITY, refill_per_min=AURA_WRITE_BUDGET_REFILL_PER_MIN,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _build_geo_check(country_override: str | None):
|
|
785
|
+
"""Factory (Phase 09, ANTI-06) constructing the zero-arg `geo_check`
|
|
786
|
+
closure at the CLI boundary. `country_override` (the `--country` flag)
|
|
787
|
+
takes precedence over `AURA_COUNTRY`; when neither is set (falsy),
|
|
788
|
+
returns `None` -- the geo guard is skipped entirely, per `check_geo`'s
|
|
789
|
+
own falsy-`expected_country` contract."""
|
|
790
|
+
country = country_override or AURA_COUNTRY
|
|
791
|
+
if not country:
|
|
792
|
+
return None
|
|
793
|
+
return lambda: check_geo(country, resolver=_default_resolver, fail_open=AURA_GEO_FAIL_OPEN)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def run_sync(dir_arg: str, frame_arg: str, apply: bool = False, yes: bool = False, aura=None, debug: bool = False,
|
|
797
|
+
no_delete: bool = False, limit: int = None, batch_size: int = None, chunk_delay: float = None,
|
|
798
|
+
verb: str = 'sync', max_wait: float = None, no_wait: bool = False,
|
|
799
|
+
country: str = None, ignore_budget: bool = False,
|
|
800
|
+
removal_mode: str = 'hide') -> int:
|
|
801
|
+
"""Sync command handler (SYNC-01/SYNC-03/SYNC-04). Resolves the target
|
|
802
|
+
frame, scans `dir_arg` locally, computes the upload/delete/unchanged plan
|
|
803
|
+
via `pushframe.sync`, and prints a full (untruncated) report.
|
|
804
|
+
|
|
805
|
+
Dry-run remains the default (`apply=False`): nothing is executed and this
|
|
806
|
+
function returns after printing the plan, exactly as before Phase 8.
|
|
807
|
+
|
|
808
|
+
When `apply=True`, after the plan is printed a confirmation gate runs
|
|
809
|
+
(D-01/D-02/D-03/D-04) before `execute_plan()` (Phase 8 Plan 02) is called
|
|
810
|
+
with real `S3Client()`/`SQSClient()` instances — the only place in this
|
|
811
|
+
module that constructs them. A non-interactive invocation without `--yes`
|
|
812
|
+
fails closed (D-03) rather than hanging on `input()`. On confirmation,
|
|
813
|
+
`execute_plan()`'s result is printed as a separated upload/delete
|
|
814
|
+
success/failure summary (D-10) and the exit code reflects any failure
|
|
815
|
+
(SYNC-04).
|
|
816
|
+
|
|
817
|
+
Returns a process exit code (0 success, 1 failure) — never calls
|
|
818
|
+
sys.exit directly. Accepts an optional injected `Aura` (dependency-
|
|
819
|
+
injection seam) so this is testable offline, mirroring `run_inspect`.
|
|
820
|
+
"""
|
|
821
|
+
aura = aura or Aura()
|
|
822
|
+
# Must run after Aura() construction (which registers the noisy sinks)
|
|
823
|
+
# and before login/get_frames (the HTTP calls that trigger them).
|
|
824
|
+
_configure_cli_logging(debug)
|
|
825
|
+
|
|
826
|
+
verb_present = _REMOVAL_VERB_PRESENT[removal_mode]
|
|
827
|
+
verb_past = _REMOVAL_VERB_PAST[removal_mode]
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
aura.login()
|
|
831
|
+
except RateLimitError as e:
|
|
832
|
+
# Anti-abuse throttle/lockout escalated to reject login (the HTTP 475
|
|
833
|
+
# seen in the select-asset-401-unauthorized session). Surface the
|
|
834
|
+
# back-off message explicitly rather than as a generic login failure.
|
|
835
|
+
print(f'Rate limited / locked out at login — {e}')
|
|
836
|
+
return 1
|
|
837
|
+
except Exception as e:
|
|
838
|
+
# Fail-loud (D-05 convention): bad credentials, network error, or API
|
|
839
|
+
# drift all surface here — a broad catch at the CLI boundary is correct.
|
|
840
|
+
print(f'Login failed: {e}')
|
|
841
|
+
return 1
|
|
842
|
+
|
|
843
|
+
try:
|
|
844
|
+
frames = aura.frame_api.get_frames()
|
|
845
|
+
resolved = resolve_frame(frame_arg, frames)
|
|
846
|
+
|
|
847
|
+
if resolved.status == 'ambiguous':
|
|
848
|
+
print(f"'{frame_arg}' matches more than one frame name — re-run with --frame <id>:")
|
|
849
|
+
for candidate in resolved.candidates:
|
|
850
|
+
print(f' - {candidate.name} (id: {candidate.id})')
|
|
851
|
+
return 1
|
|
852
|
+
|
|
853
|
+
if resolved.status == 'not_found':
|
|
854
|
+
print(f"No frame matches name or id '{frame_arg}'. Available frames:")
|
|
855
|
+
for candidate in resolved.candidates:
|
|
856
|
+
print(f' - {candidate.name} (id: {candidate.id})')
|
|
857
|
+
return 1
|
|
858
|
+
|
|
859
|
+
frame = resolved.frame
|
|
860
|
+
assets = aura.get_all_assets(frame.id)
|
|
861
|
+
|
|
862
|
+
# WR-02: scanned separately from the surrounding API calls so a
|
|
863
|
+
# local filesystem error (missing/invalid `dir_arg`, permission
|
|
864
|
+
# error reading a file) is reported distinctly from a remote
|
|
865
|
+
# API/auth failure instead of being collapsed into the same
|
|
866
|
+
# generic "Failed to sync frame" message below.
|
|
867
|
+
try:
|
|
868
|
+
scan = scan_directory(Path(dir_arg))
|
|
869
|
+
except OSError as e:
|
|
870
|
+
print(f'Failed to scan {dir_arg}: {e}')
|
|
871
|
+
return 1
|
|
872
|
+
|
|
873
|
+
plan = compute_plan(scan.local_hashes, assets, scan.skipped_non_image)
|
|
874
|
+
|
|
875
|
+
# Opt-in probe/push affordances. Defaults (no_delete=False, limit=None)
|
|
876
|
+
# leave the classic `sync` behaviour byte-identical. `no_delete` (always
|
|
877
|
+
# on for the `push` verb) makes the run purely additive -- it clears
|
|
878
|
+
# to_delete so no existing frame photo can ever be removed, the safe
|
|
879
|
+
# primitive for pushing from a "buffet" supply directory. `limit` caps
|
|
880
|
+
# how many uploads are attempted, for controlled anti-abuse budget
|
|
881
|
+
# probing. Both are applied BEFORE the plan is printed so the report
|
|
882
|
+
# reflects exactly what will run.
|
|
883
|
+
if no_delete:
|
|
884
|
+
# `push` is upload-only: it must not hide, remove, OR re-show
|
|
885
|
+
# anything. A re-show is still a visibility mutation of existing
|
|
886
|
+
# frame photos, so it is cleared alongside the removals.
|
|
887
|
+
plan.to_delete = []
|
|
888
|
+
plan.to_reshow = []
|
|
889
|
+
if limit is not None:
|
|
890
|
+
plan.to_upload = sorted(plan.to_upload)[:limit]
|
|
891
|
+
|
|
892
|
+
if verb == 'push':
|
|
893
|
+
print(f'Push plan for {frame.name} (id: {frame.id}) — additive (no deletes), DRY RUN, nothing will be changed')
|
|
894
|
+
else:
|
|
895
|
+
print(f'Sync plan for {frame.name} (id: {frame.id}) — DRY RUN, nothing will be changed')
|
|
896
|
+
print(f'To upload: {len(plan.to_upload)}')
|
|
897
|
+
if no_delete:
|
|
898
|
+
print('To delete: 0 (additive mode — existing frame photos left untouched)')
|
|
899
|
+
else:
|
|
900
|
+
# Name the verb that will actually run, so the plan can never read
|
|
901
|
+
# "delete" on a run that hides (or vice versa) -- D-07.
|
|
902
|
+
print(f'To {verb_present}: {len(plan.to_delete)}')
|
|
903
|
+
# Re-shows get their own line rather than folding into unchanged:
|
|
904
|
+
# they are a write, and the user should see it coming (D-08).
|
|
905
|
+
print(f'To re-show: {len(plan.to_reshow)}')
|
|
906
|
+
print(f'Unchanged: {plan.unchanged}')
|
|
907
|
+
if plan.already_hidden:
|
|
908
|
+
print(f'Already hidden: {plan.already_hidden} (no action needed)')
|
|
909
|
+
|
|
910
|
+
# WR-03: local_hashes (and to_upload built from it) is populated in
|
|
911
|
+
# filesystem-traversal order, which is OS/filesystem dependent and
|
|
912
|
+
# not sorted -- sort here so dry-run output is reproducible across
|
|
913
|
+
# runs/machines (e.g. diffable, stable for bug reports).
|
|
914
|
+
for path in sorted(plan.to_upload):
|
|
915
|
+
print(f' + {path}')
|
|
916
|
+
|
|
917
|
+
for asset in plan.to_delete:
|
|
918
|
+
print(f' - {asset.id} (taken {asset.taken_at_dt})')
|
|
919
|
+
|
|
920
|
+
for asset in plan.to_reshow:
|
|
921
|
+
print(f' ~ {asset.id} (taken {asset.taken_at_dt}) — re-show')
|
|
922
|
+
|
|
923
|
+
if plan.skipped_non_image > 0:
|
|
924
|
+
print(f'{plan.skipped_non_image} non-photo files skipped')
|
|
925
|
+
|
|
926
|
+
if plan.frame_no_hash > 0:
|
|
927
|
+
print(f'{plan.frame_no_hash} frame assets without a content hash (e.g. videos) left untouched')
|
|
928
|
+
|
|
929
|
+
if not apply:
|
|
930
|
+
return 0
|
|
931
|
+
|
|
932
|
+
# D-03: fail closed on a non-interactive invocation missing --yes --
|
|
933
|
+
# never block on input() forever, never silently proceed.
|
|
934
|
+
if not yes and not sys.stdin.isatty():
|
|
935
|
+
print('--apply requires --yes when running non-interactively')
|
|
936
|
+
return 1
|
|
937
|
+
|
|
938
|
+
# D-01/D-02/D-04: a single confirmation gate covers the whole plan
|
|
939
|
+
# (uploads + deletes together), echoing the resolved frame's name and
|
|
940
|
+
# id so a substring --frame match can't silently apply to the wrong
|
|
941
|
+
# frame.
|
|
942
|
+
if not yes:
|
|
943
|
+
if removal_mode == 'hard_delete':
|
|
944
|
+
# A reworded y/N is too easy to answer reflexively for an
|
|
945
|
+
# irreversible, account-wide destruction. Re-typing the exact
|
|
946
|
+
# count forces the user to look at the number first (D-04).
|
|
947
|
+
count = len(plan.to_delete)
|
|
948
|
+
print(f'IRREVERSIBLE: {count} photo(s) will be permanently destroyed '
|
|
949
|
+
f'account-wide, not just removed from this frame. This cannot be undone.')
|
|
950
|
+
answer = input(f'To confirm, type the number of photos to hard-delete ({count}): ')
|
|
951
|
+
if answer.strip() != str(count):
|
|
952
|
+
print('Aborted.')
|
|
953
|
+
return 0
|
|
954
|
+
else:
|
|
955
|
+
answer = input(f'About to apply this plan to "{frame.name}" (id: {frame.id}). Proceed? [y/N] ')
|
|
956
|
+
if answer.strip().lower() not in ('y', 'yes'):
|
|
957
|
+
print('Aborted.')
|
|
958
|
+
return 0
|
|
959
|
+
|
|
960
|
+
# Real AWS clients are constructed here only, on confirmed apply --
|
|
961
|
+
# execute_plan() itself never constructs them (offline-testable seam
|
|
962
|
+
# from Phase 8 Plan 02).
|
|
963
|
+
s3_client = S3Client()
|
|
964
|
+
sqs_client = SQSClient()
|
|
965
|
+
|
|
966
|
+
# Proactive write-rate-budget + geo pre-flight guard (Phase 09,
|
|
967
|
+
# ANTI-06) -- built here for BOTH verbs (push and sync both hit the
|
|
968
|
+
# exact same anti-abuse surface), so `sync --apply` also gets
|
|
969
|
+
# protection by default with zero new flags. Only `--ignore-budget`
|
|
970
|
+
# (push-only) omits `budget`; `write_budget`/`geo_check` being
|
|
971
|
+
# `None` is exec_kwargs's signal to omit the corresponding kwarg.
|
|
972
|
+
write_budget = _build_write_budget(os.getenv('PUSHFRAME_EMAIL') or os.getenv('AURA_EMAIL'), ignore_budget)
|
|
973
|
+
geo_check = _build_geo_check(country)
|
|
974
|
+
|
|
975
|
+
total = len(plan.to_upload) + len(plan.to_delete)
|
|
976
|
+
# tqdm writes to stderr, so stdout-based test assertions (D-10's
|
|
977
|
+
# summary, printed after the bar closes below) are unaffected.
|
|
978
|
+
with tqdm(total=total, desc='Applying', unit='item') as bar:
|
|
979
|
+
def _report_progress(kind, identifier, ok):
|
|
980
|
+
bar.update(1)
|
|
981
|
+
status = 'ok' if ok else 'FAIL'
|
|
982
|
+
bar.set_postfix_str(f'{kind} {status} {identifier}')
|
|
983
|
+
|
|
984
|
+
def _report_wait(remaining):
|
|
985
|
+
# Inter-chunk cooldown -- the bar doesn't advance, so surface
|
|
986
|
+
# the countdown in the postfix rather than looking frozen.
|
|
987
|
+
bar.set_postfix_str(f'cooldown {remaining:.0f}s before next batch')
|
|
988
|
+
|
|
989
|
+
# Only forward batch_size/chunk_delay when explicitly supplied so
|
|
990
|
+
# execute_plan keeps its own defaults (WRITE_BATCH_SIZE /
|
|
991
|
+
# WRITE_CHUNK_DELAY_SECONDS) otherwise -- no hardcoded values here.
|
|
992
|
+
exec_kwargs = {}
|
|
993
|
+
# execute_plan defaults to 'hide' too, so forwarding is always
|
|
994
|
+
# safe -- but forward explicitly so the CLI's choice is the one
|
|
995
|
+
# that runs, not a default that happens to agree.
|
|
996
|
+
exec_kwargs['removal_mode'] = removal_mode
|
|
997
|
+
if batch_size is not None:
|
|
998
|
+
exec_kwargs['batch_size'] = batch_size
|
|
999
|
+
if chunk_delay is not None:
|
|
1000
|
+
exec_kwargs['chunk_delay_seconds'] = chunk_delay
|
|
1001
|
+
# budget/geo_check forwarded whenever built (both verbs, by
|
|
1002
|
+
# default) -- omitted only when None (--ignore-budget, or no
|
|
1003
|
+
# AURA_COUNTRY/--country configured), preserving
|
|
1004
|
+
# execute_plan's own None defaults in that case. wait_on_budget/
|
|
1005
|
+
# max_wait_seconds forwarded ONLY when their override flag was
|
|
1006
|
+
# explicitly supplied, so a classic `sync --apply`/`push --apply`
|
|
1007
|
+
# with no new flags forwards nothing beyond the default guard
|
|
1008
|
+
# objects themselves (test_sync_defaults_do_not_override_execute_plan_defaults's guarantee).
|
|
1009
|
+
if write_budget is not None:
|
|
1010
|
+
exec_kwargs['budget'] = write_budget
|
|
1011
|
+
if geo_check is not None:
|
|
1012
|
+
exec_kwargs['geo_check'] = geo_check
|
|
1013
|
+
# WR-01: wire the AURA_WRITE_BUDGET_WAIT / AURA_WRITE_BUDGET_MAX_WAIT
|
|
1014
|
+
# env defaults into the guard so a user who configures them gets an
|
|
1015
|
+
# effect, with the per-run --no-wait / --max-wait flags taking
|
|
1016
|
+
# precedence over the env. Each kwarg is forwarded ONLY when it would
|
|
1017
|
+
# actually change execute_plan's own default (True / 3600.0) -- so a
|
|
1018
|
+
# run with neither an override flag nor a non-default env var still
|
|
1019
|
+
# forwards nothing, preserving the Phase 08 no-override contract.
|
|
1020
|
+
effective_wait = False if no_wait else AURA_WRITE_BUDGET_WAIT
|
|
1021
|
+
if effective_wait != _EXECUTE_PLAN_DEFAULT_WAIT:
|
|
1022
|
+
exec_kwargs['wait_on_budget'] = effective_wait
|
|
1023
|
+
effective_max_wait = max_wait if max_wait is not None else AURA_WRITE_BUDGET_MAX_WAIT
|
|
1024
|
+
if effective_max_wait != _EXECUTE_PLAN_DEFAULT_MAX_WAIT:
|
|
1025
|
+
exec_kwargs['max_wait_seconds'] = effective_max_wait
|
|
1026
|
+
|
|
1027
|
+
result = execute_plan(
|
|
1028
|
+
plan, aura, frame.id, s3_client=s3_client, sqs_client=sqs_client,
|
|
1029
|
+
progress=_report_progress, on_wait=_report_wait, **exec_kwargs,
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
# D-10: separated success/failure summary, each failed item named.
|
|
1033
|
+
print(f'Uploads: {result.upload_succeeded} succeeded, {len(result.upload_failures)} failed')
|
|
1034
|
+
for path, err in result.upload_failures:
|
|
1035
|
+
print(f' ! {path}: {err}')
|
|
1036
|
+
# REL-01/REL-03, D-08: printed unconditionally on every --apply run
|
|
1037
|
+
# (never behind --debug) -- how many chunks the 401 verify-then-retry
|
|
1038
|
+
# path recovered and how many duplicate uploads it prevented is the
|
|
1039
|
+
# single most useful signal for judging whether the 401 problem is
|
|
1040
|
+
# actually fixed, including the (common, reassuring) all-zero case.
|
|
1041
|
+
print(f'Retries: {result.chunks_retried} chunk(s) retried after a 401, '
|
|
1042
|
+
f'{result.items_already_landed} item(s) already landed (duplicate uploads prevented)')
|
|
1043
|
+
print(f'{verb_past}: {result.delete_succeeded} succeeded, {len(result.delete_failures)} failed')
|
|
1044
|
+
for asset_id, err in result.delete_failures:
|
|
1045
|
+
print(f' ! {asset_id}: {err}')
|
|
1046
|
+
print(f'Re-shown: {result.reshow_succeeded} succeeded, {len(result.reshow_failures)} failed')
|
|
1047
|
+
for asset_id, err in result.reshow_failures:
|
|
1048
|
+
print(f' ! {asset_id}: {err}')
|
|
1049
|
+
|
|
1050
|
+
return 1 if (result.upload_failures or result.delete_failures
|
|
1051
|
+
or result.reshow_failures) else 0
|
|
1052
|
+
except RateLimitError as e:
|
|
1053
|
+
# The API is throttling/locking out this account mid-apply (HTTP
|
|
1054
|
+
# 429/475). execute_plan aborted the batch rather than emitting N
|
|
1055
|
+
# confusing per-item 401s -- surface the single back-off message.
|
|
1056
|
+
print(f'Aborted: {e}')
|
|
1057
|
+
return 1
|
|
1058
|
+
except ConsecutiveWriteFailureError as e:
|
|
1059
|
+
# A RUN of consecutive write failures with no 429/475 signal -- the
|
|
1060
|
+
# plain-HTTP-401 form of the anti-abuse trip (or another systemic
|
|
1061
|
+
# cut-off). execute_plan aborted after the run rather than emitting N
|
|
1062
|
+
# confusing per-item errors. Report what succeeded first, then the
|
|
1063
|
+
# distinct back-off message (deliberately worded differently from the
|
|
1064
|
+
# RateLimitError "Aborted:" path above so the two are distinguishable).
|
|
1065
|
+
print(f'{e.result.upload_succeeded} uploads and {e.result.delete_succeeded} '
|
|
1066
|
+
f'deletes succeeded before the run of failures.')
|
|
1067
|
+
print(str(e))
|
|
1068
|
+
return 1
|
|
1069
|
+
except GeoMismatchError as e:
|
|
1070
|
+
# Root-cause mitigation for the VPN-geo-mismatch write-lockout
|
|
1071
|
+
# (Phase 09) -- execute_plan's geo pre-flight aborted before any
|
|
1072
|
+
# write happened.
|
|
1073
|
+
print(f'VPN/exit IP in {e.found}, account expects {e.expected} — switch your VPN and retry.')
|
|
1074
|
+
return 1
|
|
1075
|
+
except BudgetExhausted as e:
|
|
1076
|
+
# The proactive client-side write budget ran dry and either
|
|
1077
|
+
# --no-wait was passed or the computed wait exceeded --max-wait
|
|
1078
|
+
# (Phase 09) -- surface how long a retry would need to wait.
|
|
1079
|
+
minutes = e.wait_seconds / 60
|
|
1080
|
+
print(f'Write budget exhausted, come back in ~{minutes:.0f} min (or pass --no-wait / raise --max-wait).')
|
|
1081
|
+
return 1
|
|
1082
|
+
except Exception as e:
|
|
1083
|
+
# WR-01 fail-loud (D-05): surface post-login API drift instead of a
|
|
1084
|
+
# raw traceback.
|
|
1085
|
+
print(f'Failed to sync frame: {e}')
|
|
1086
|
+
return 1
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def main(argv=None) -> int:
|
|
1090
|
+
load_dotenv()
|
|
1091
|
+
# IDN-03 (phase 20): first run on an existing auraframes install migrates
|
|
1092
|
+
# the config home to ~/.config/pushframe/ — one notice line, idempotent,
|
|
1093
|
+
# never destructive. Runs before any command touches the config.
|
|
1094
|
+
from pushframe.migration import migration_notice
|
|
1095
|
+
_notice = migration_notice()
|
|
1096
|
+
if _notice:
|
|
1097
|
+
print(_notice)
|
|
1098
|
+
args = build_parser().parse_args(argv)
|
|
1099
|
+
|
|
1100
|
+
if args.command == 'status':
|
|
1101
|
+
return run_status(debug=args.debug)
|
|
1102
|
+
if args.command == 'google-link':
|
|
1103
|
+
return run_google_link(debug=args.debug)
|
|
1104
|
+
if args.command == 'google-album':
|
|
1105
|
+
return run_google_album(args.target, list_all=args.list, debug=args.debug)
|
|
1106
|
+
if args.command == 'google-sync':
|
|
1107
|
+
from pushframe.gsync import run_google_sync
|
|
1108
|
+
return run_google_sync(args.album, args.frame, apply=args.apply,
|
|
1109
|
+
yes=args.yes, debug=args.debug)
|
|
1110
|
+
if args.command == 'inspect':
|
|
1111
|
+
return run_inspect(args.frame, debug=args.debug)
|
|
1112
|
+
if args.command == 'sync':
|
|
1113
|
+
removal_mode = 'hard_delete' if args.hard_delete else ('delete' if args.delete else 'hide')
|
|
1114
|
+
return run_sync(args.dir, args.frame, apply=args.apply, yes=args.yes, debug=args.debug,
|
|
1115
|
+
removal_mode=removal_mode)
|
|
1116
|
+
if args.command == 'push':
|
|
1117
|
+
return run_sync(
|
|
1118
|
+
args.dir, args.frame, apply=args.apply, yes=args.yes, debug=args.debug,
|
|
1119
|
+
no_delete=True, limit=args.limit, batch_size=args.batch_size,
|
|
1120
|
+
chunk_delay=args.chunk_delay, verb='push',
|
|
1121
|
+
max_wait=args.max_wait, no_wait=args.no_wait,
|
|
1122
|
+
country=args.country, ignore_budget=args.ignore_budget,
|
|
1123
|
+
)
|
|
1124
|
+
if args.command == 'reconcile':
|
|
1125
|
+
return run_reconcile(
|
|
1126
|
+
args.frame, remove=args.remove, yes=args.yes, mechanism=args.mechanism,
|
|
1127
|
+
max_age_hours=args.max_age_hours, include_unknown_age=args.include_unknown_age,
|
|
1128
|
+
debug=args.debug,
|
|
1129
|
+
)
|
|
1130
|
+
raise ValueError(f'Unhandled command: {args.command}')
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
if __name__ == '__main__':
|
|
1134
|
+
sys.exit(main())
|