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.
Files changed (49) hide show
  1. pushframe/__init__.py +4 -0
  2. pushframe/api/__init__.py +0 -0
  3. pushframe/api/accountApi.py +72 -0
  4. pushframe/api/activityApi.py +87 -0
  5. pushframe/api/assetApi.py +194 -0
  6. pushframe/api/baseApi.py +6 -0
  7. pushframe/api/frameApi.py +271 -0
  8. pushframe/api/notificationApi.py +15 -0
  9. pushframe/api/peopleApi.py +25 -0
  10. pushframe/api/playlistApi.py +9 -0
  11. pushframe/aura.py +182 -0
  12. pushframe/aws/__init__.py +0 -0
  13. pushframe/aws/awsclient.py +23 -0
  14. pushframe/aws/s3client.py +40 -0
  15. pushframe/aws/sqsclient.py +33 -0
  16. pushframe/cache.py +50 -0
  17. pushframe/cli.py +1134 -0
  18. pushframe/client.py +267 -0
  19. pushframe/exif.py +147 -0
  20. pushframe/export.py +53 -0
  21. pushframe/google/__init__.py +43 -0
  22. pushframe/google/bootstrap.py +134 -0
  23. pushframe/google/cache.py +167 -0
  24. pushframe/google/client.py +140 -0
  25. pushframe/google/enumerate.py +270 -0
  26. pushframe/google/manifest.py +111 -0
  27. pushframe/google/parsers.py +345 -0
  28. pushframe/google/redaction.py +33 -0
  29. pushframe/google/vault.py +126 -0
  30. pushframe/gsync.py +463 -0
  31. pushframe/migration.py +86 -0
  32. pushframe/models/__init__.py +0 -0
  33. pushframe/models/activity.py +79 -0
  34. pushframe/models/asset.py +159 -0
  35. pushframe/models/frame.py +105 -0
  36. pushframe/models/meta.py +11 -0
  37. pushframe/models/person.py +24 -0
  38. pushframe/models/user.py +22 -0
  39. pushframe/ratelimit.py +222 -0
  40. pushframe/reconcile.py +384 -0
  41. pushframe/sync.py +1105 -0
  42. pushframe/utils/dt.py +15 -0
  43. pushframe/utils/io.py +23 -0
  44. pushframe/utils/settings.py +59 -0
  45. pushframe-5.0.0.dist-info/METADATA +53 -0
  46. pushframe-5.0.0.dist-info/RECORD +49 -0
  47. pushframe-5.0.0.dist-info/WHEEL +4 -0
  48. pushframe-5.0.0.dist-info/entry_points.txt +2 -0
  49. pushframe-5.0.0.dist-info/licenses/LICENSE +31 -0
pushframe/reconcile.py ADDED
@@ -0,0 +1,384 @@
1
+ """Data hygiene for stuck placeholder rows on an existing frame -- deliberately
2
+ NOT part of the sync loop (`pushframe/sync.py`).
3
+
4
+ `select_asset` calls whose upload never completed leave rows with no
5
+ `uploaded_at`, no `file_name` and no `md5_hash`. Neither existing removal
6
+ primitive clears them (`AssetApi.delete_asset` returns 200 and removes
7
+ nothing; `FrameApi.remove_asset` 404s). This module accounts for them: it
8
+ reports how many exist (unconditionally, whether or not any removal
9
+ mechanism works) and, only when explicitly asked, attempts a bounded,
10
+ gated removal.
11
+
12
+ Following `pushframe/sync.py`'s own pure-diff/mutating-execute split:
13
+ `find_placeholders` is this module's pure classifying function -- no I/O,
14
+ no network, no mutation of its input. `apply_reconciliation` is this
15
+ module's ONLY mutating function -- the sole place that calls a removal
16
+ primitive against a live frame, reachable only when the caller explicitly
17
+ asks for it.
18
+
19
+ This module imports nothing Google-side, so it is decoupled from wherever
20
+ the Google side of this milestone eventually lands. It DOES reuse
21
+ `pushframe/sync.py`'s existing pacing primitives (`_chunked`,
22
+ `WRITE_THROTTLE_SECONDS`, `WRITE_BATCH_SIZE`) rather than duplicating them --
23
+ that is a one-way dependency only: `pushframe/sync.py` never imports from
24
+ or calls into this module (grep-verified absent, mirroring D-06's
25
+ structural-isolation convention in `sync.py` itself).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import time
30
+ from dataclasses import dataclass, field
31
+
32
+ from pushframe.client import RateLimitError
33
+ from pushframe.models.asset import AssetPartialId
34
+ from pushframe.sync import WRITE_BATCH_SIZE, WRITE_THROTTLE_SECONDS, _chunked
35
+ from pushframe.utils.dt import get_utc_now, parse_aura_dt
36
+
37
+ # How long a row matching the placeholder predicate (D-14) must sit before it
38
+ # is even a candidate for removal (D-15). A row created seconds ago by a
39
+ # legitimate in-progress upload matches the predicate exactly -- the server
40
+ # processes an upload asynchronously, so a freshly-registered asset is
41
+ # indistinguishable from a genuinely stuck one until enough time has passed
42
+ # for normal processing to have finished (`tests/test_cli_inspect.py::
43
+ # test_inspect_tolerates_unprocessed_placeholder_asset` already covers a
44
+ # mid-processing asset with the exact same null shape). 24 hours is generous
45
+ # relative to how quickly the app's own uploads are observed to process
46
+ # (minutes, not hours) -- the cost of waiting an extra day before a genuinely
47
+ # stuck row is reported as removable is far lower than the cost of proposing
48
+ # to remove a photo that was still mid-upload. Overridable via
49
+ # `find_placeholders`'/`apply_reconciliation`'s `age_threshold_seconds`
50
+ # (the CLI's `--max-age-hours`).
51
+ RECONCILE_AGE_THRESHOLD_SECONDS = 86400
52
+
53
+
54
+ @dataclass
55
+ class ReconcileResult:
56
+ """Classification of a frame's assets against the placeholder predicate
57
+ (D-14) and the age guard (D-15). Mirrors `pushframe/sync.py`'s
58
+ `ExecutionResult`/`SyncPlan` plain-dataclass shape.
59
+
60
+ `stuck`/`recently_created`/`unknown_age` are populated by
61
+ `find_placeholders` (pure); `removed`/`failed` are populated by
62
+ `apply_reconciliation` (mutating) acting on `stuck` only -- a row in
63
+ `recently_created` or `unknown_age` is structurally never passed to a
64
+ removal mechanism (D-15).
65
+ """
66
+ stuck: list = field(default_factory=list)
67
+ recently_created: list = field(default_factory=list)
68
+ unknown_age: list = field(default_factory=list)
69
+ removed: list = field(default_factory=list)
70
+ failed: list = field(default_factory=list)
71
+ total_scanned: int = 0
72
+
73
+ @property
74
+ def placeholder_count(self) -> int:
75
+ """The single number both `inspect` and `reconcile` print -- computed
76
+ from one place so the two can never disagree (D-13)."""
77
+ return len(self.stuck) + len(self.recently_created) + len(self.unknown_age)
78
+
79
+
80
+ def _creation_instant(asset):
81
+ """Resolve `asset.created_at` to a parsed datetime, or `None` when the
82
+ value is absent, empty, or unparseable. Never raises -- an asset from an
83
+ undocumented, drifting API must never crash this module's pure
84
+ classifier; an unresolvable creation time is handled by the caller as
85
+ `unknown_age` (D-15's "fails toward not deleting" rule), not as an
86
+ exception.
87
+ """
88
+ raw = getattr(asset, 'created_at', None)
89
+ if not raw:
90
+ return None
91
+ try:
92
+ return parse_aura_dt(raw)
93
+ except (ValueError, TypeError):
94
+ return None
95
+
96
+
97
+ #: Valid values for `find_placeholders`' `unknown_age_policy` keyword-only
98
+ #: argument (Plan 11-06, Task 1). `'unknown_age'` is the default and
99
+ #: reproduces D-15's original behaviour byte-for-byte. `'stuck'` is the
100
+ #: explicit opt-in that supersedes D-15's *unconditional* form -- see the
101
+ #: `unknown_age_policy` docstring section below for why the unconditional
102
+ #: form needed correcting.
103
+ UNKNOWN_AGE_POLICIES = frozenset({'unknown_age', 'stuck'})
104
+
105
+
106
+ def find_placeholders(assets, *, now=None,
107
+ age_threshold_seconds: float = RECONCILE_AGE_THRESHOLD_SECONDS,
108
+ unknown_age_policy: str = 'unknown_age') -> ReconcileResult:
109
+ """Classify `assets` into stuck / recently-created / unknown-age
110
+ placeholders (D-13/D-14/D-15). Pure -- no I/O, no network, no mutation
111
+ of `assets` or any element of it.
112
+
113
+ D-14: a row is a placeholder candidate only under the STRICT three-way
114
+ conjunction `uploaded_at is None and file_name is None and md5_hash is
115
+ None`. This is deliberately narrower than the diff engine's own
116
+ hashless-asset bucket in `pushframe/sync.py`'s `compute_plan`, which
117
+ counts every hashless asset including every video on the frame -- a
118
+ video is hashless by design but does carry `file_name` and
119
+ `uploaded_at`, so it trips at most one of the three conditions here and
120
+ is excluded. A partially-hydrated asset (e.g. only `md5_hash` null,
121
+ mid-server-side processing) likewise trips at most one condition and is
122
+ excluded. This predicate is UNCHANGED by `unknown_age_policy` below --
123
+ a row that never matched the three-way conjunction in the first place
124
+ is not affected by anything past this point, no matter which policy is
125
+ in effect.
126
+
127
+ D-15, corrected by plan 11-06 (2026-09-03): a matching row's creation
128
+ time decides which bucket it lands in -- but only WHEN a creation time
129
+ is actually resolvable. `_creation_instant` returning `None` used to be
130
+ handled unconditionally: parked in `unknown_age`, exactly like a
131
+ too-young row, no matter what. Plan 11-05 established live, from the
132
+ raw JSON payload rather than the parsed model, that
133
+ `/frames/{id}/assets.json` NEVER sends a `created_at` key at all -- not
134
+ "sometimes unresolvable while processing", but structurally absent on
135
+ every asset, including fully-processed ones. Against that API, the
136
+ unconditional form does not fail *conservatively* -- it fails *inertly*:
137
+ every placeholder row, no matter how old, is permanently unreachable by
138
+ the removal path, and no `age_threshold_seconds` value can ever change
139
+ that. A guard that can never let a genuinely stuck row through is not
140
+ doing D-15's job of telling old rows from young ones; it is silently
141
+ disabling the feature it guards.
142
+
143
+ `unknown_age_policy` corrects this without touching what made D-15
144
+ correct in the first place:
145
+
146
+ - `'unknown_age'` (the default): reproduces the original, unconditional
147
+ behaviour EXACTLY -- an unresolvable creation time lands in
148
+ `unknown_age`, never `stuck`. A call to `find_placeholders` that does
149
+ not pass this argument is byte-for-byte identical to before this
150
+ correction existed. Nothing becomes eligible for removal by accident.
151
+ - `'stuck'`: an explicit, caller-named opt-in. An unresolvable creation
152
+ time is now treated as eligible (`stuck`) rather than parked. This
153
+ argument is keyword-only and takes an explicit string naming what it
154
+ does -- there is no positional slot a stray argument could fall into,
155
+ and no bare boolean whose meaning depends on reading the call site.
156
+ Choosing this policy is a decision the CALLER makes deliberately, not
157
+ a mode `find_placeholders` defaults into.
158
+
159
+ The strict three-way-null predicate above is untouched by either
160
+ policy. `recently_created` semantics are also untouched: a row with a
161
+ RESOLVABLE instant that is simply too young still lands in
162
+ `recently_created` regardless of `unknown_age_policy` -- the policy
163
+ governs only the unresolvable case, never the "young but known" case.
164
+
165
+ :param assets: The frame's assets (e.g. from `Aura.get_all_assets`).
166
+ :param now: The current instant, injected (mirrors `execute_plan`'s
167
+ `clock` seam) so tests can control time deterministically. Defaults
168
+ to `get_utc_now()` when `None`.
169
+ :param age_threshold_seconds: Minimum age (in seconds) for a matching
170
+ row to be reported as `stuck` rather than `recently_created`. See
171
+ `RECONCILE_AGE_THRESHOLD_SECONDS`.
172
+ :param unknown_age_policy: `'unknown_age'` (default) or `'stuck'`. See
173
+ above. Any other value raises `ValueError` -- fails closed on a
174
+ typo rather than silently falling back to a default that changes
175
+ classification.
176
+ :return: A `ReconcileResult` with `stuck`/`recently_created`/
177
+ `unknown_age` populated and `removed`/`failed` left empty (this
178
+ function never mutates anything).
179
+ """
180
+ if unknown_age_policy not in UNKNOWN_AGE_POLICIES:
181
+ raise ValueError(
182
+ f"unknown_age_policy={unknown_age_policy!r} is not one of {sorted(UNKNOWN_AGE_POLICIES)!r}"
183
+ )
184
+
185
+ if now is None:
186
+ now = get_utc_now()
187
+
188
+ result = ReconcileResult(total_scanned=len(assets))
189
+
190
+ for asset in assets:
191
+ if not (asset.uploaded_at is None and asset.file_name is None and asset.md5_hash is None):
192
+ # Not a placeholder candidate at all -- a video (hashless but
193
+ # named and uploaded) or a partially-hydrated asset each trip at
194
+ # most one of the three conditions and are skipped entirely.
195
+ continue
196
+
197
+ instant = _creation_instant(asset)
198
+ if instant is None:
199
+ if unknown_age_policy == 'stuck':
200
+ result.stuck.append(asset)
201
+ else:
202
+ result.unknown_age.append(asset)
203
+ continue
204
+
205
+ age_seconds = (now - instant).total_seconds()
206
+ if age_seconds < age_threshold_seconds:
207
+ result.recently_created.append(asset)
208
+ else:
209
+ result.stuck.append(asset)
210
+
211
+ return result
212
+
213
+
214
+ # D-16, UPDATED by plan 11-06 (2026-09-03): 'remove' (`FrameApi.remove_asset`)
215
+ # is now CONFIRMED to clear these rows -- see 11-LIVE-FINDINGS.md's "Plan
216
+ # 11-06" section for the command, raw HTTP response and follow-up-read
217
+ # verification. Before that plan, no removal mechanism had been confirmed
218
+ # to work (`delete_asset` returns 200 and removes nothing; `remove_asset`
219
+ # had previously 404d -- Phase 10 UAT), because the age guard made
220
+ # `result.stuck` permanently empty against this API (see
221
+ # `find_placeholders`' `unknown_age_policy` docstring). The cap below
222
+ # predates that finding and still applies: a first live attempt is a
223
+ # bounded, time-boxed probe, not a bulk operation, regardless of which
224
+ # mechanism the caller passes -- a cap keeps a mechanism that silently does
225
+ # nothing from burning the whole write budget before the operator notices,
226
+ # and keeps a mechanism that turns out to be destructive from acting on
227
+ # every known row at once. `apply_reconciliation` raises when
228
+ # `len(result.stuck)` exceeds this unless the caller explicitly passes a
229
+ # higher `candidate_limit`.
230
+ RECONCILE_PROBE_CANDIDATE_LIMIT = 25
231
+
232
+
233
+ def _complete_placeholder(aura, frame_id, chunk):
234
+ """Placeholder for the 'complete' mechanism (D-16): treating a stuck row
235
+ as an incomplete upload to FINISH -- batch_update-ing it with real
236
+ file_name/md5_hash/uploaded_at so it becomes an ordinary asset the
237
+ existing hide/remove paths already handle -- rather than a bad row to
238
+ delete. Not yet implemented, and remains UNTESTED as of plan 11-06
239
+ (2026-09-03) -- not because it failed, but because it was never needed:
240
+ plan 11-06's own live probe used `unknown_age_policy='stuck'` (this
241
+ module's new opt-in) to reach a non-empty `stuck` bucket for the first
242
+ time, tried 'remove' first, and 'remove' (`FrameApi.remove_asset`)
243
+ cleared all 3 targeted rows outright -- confirmed by a follow-up read,
244
+ not just an HTTP 200. With a working, non-destructive, already-built
245
+ mechanism in hand, the probe never proceeded to 'hard-delete' or
246
+ 'complete' on those rows (see 11-LIVE-FINDINGS.md, "Plan 11-06"
247
+ section, for the full mechanism table and raw evidence). 'complete'
248
+ stays a reasonable idea for a future need (e.g. if 'remove' ever stops
249
+ working, or a caller wants FINISHED rows rather than absent ones), but
250
+ building and live-testing it is no longer this phase's blocking
251
+ question -- REL-05 already has a confirmed answer without it."""
252
+ raise NotImplementedError(
253
+ "The 'complete' mechanism is not yet implemented. It was not needed by plan 11-06 "
254
+ "(2026-09-03): 'remove' (FrameApi.remove_asset) already cleared the probed rows "
255
+ "outright once the age guard's unknown_age_policy='stuck' opt-in made them eligible "
256
+ "-- see the docstring above and 11-LIVE-FINDINGS.md."
257
+ )
258
+
259
+
260
+ # Dispatch table mirroring `pushframe/sync.py`'s `_REMOVAL_PRIMITIVE` shape.
261
+ # UPDATED by plan 11-06 (2026-09-03): 'remove' is CONFIRMED WORKING -- see
262
+ # 11-LIVE-FINDINGS.md's "Plan 11-06" section for the command, raw HTTP
263
+ # response (`{"number_failed":0}`) and the follow-up-read verification that
264
+ # the 3 targeted rows were actually gone, not just acknowledged with a 200.
265
+ # This supersedes the Phase 10 UAT finding that `remove_asset` 404d --
266
+ # that prior probe never had a genuinely `stuck`-classified row to send,
267
+ # because the age guard's pre-11-06 unconditional form made `result.stuck`
268
+ # permanently empty against this API (see `find_placeholders`'
269
+ # `unknown_age_policy` docstring). 'hard-delete' remains unconfirmed --
270
+ # plan 11-06's probe never needed it, since 'remove' cleared every targeted
271
+ # row. 'complete' is still the untried third option (D-16); it raises
272
+ # until a future plan builds it out, though REL-05 no longer depends on it.
273
+ _RECONCILE_PRIMITIVE = {
274
+ 'remove': lambda aura, frame_id, chunk: aura.frame_api.remove_asset(
275
+ frame_id, [AssetPartialId(id=asset.id) for asset in chunk]),
276
+ 'hard-delete': lambda aura, frame_id, chunk: [
277
+ aura.asset_api.delete_asset(asset) for asset in chunk],
278
+ 'complete': _complete_placeholder,
279
+ }
280
+
281
+ # Requests a removal chunk actually costs, per mechanism -- 'remove' is a
282
+ # batch endpoint (one call regardless of chunk size, mirroring
283
+ # pushframe/sync.py's _REMOVAL_REQUEST_COST['delete']); 'hard-delete' has
284
+ # no batch form and is charged per asset for the same reason
285
+ # pushframe/sync.py charges hard_delete per asset (a chunk-level charge of
286
+ # 1 would let it run the budget dry unnoticed). 'complete' is provisionally
287
+ # charged like 'hard-delete' (a per-asset batch_update call) pending 11-05.
288
+ _RECONCILE_REQUEST_COST = {
289
+ 'remove': lambda chunk: 1,
290
+ 'hard-delete': lambda chunk: len(chunk),
291
+ 'complete': lambda chunk: len(chunk),
292
+ }
293
+
294
+
295
+ def apply_reconciliation(result: ReconcileResult, aura, frame_id: str, *, mechanism: str = 'remove',
296
+ budget=None, wait_on_budget: bool = True, max_wait_seconds: float = 3600.0,
297
+ clock=get_utc_now, sleep=time.sleep, throttle_seconds: float = WRITE_THROTTLE_SECONDS,
298
+ batch_size: int = WRITE_BATCH_SIZE,
299
+ candidate_limit: int = RECONCILE_PROBE_CANDIDATE_LIMIT,
300
+ progress=lambda *args: None) -> ReconcileResult:
301
+ """This module's ONLY mutating function (D-16) -- the sole place that
302
+ calls a removal primitive against a live frame.
303
+
304
+ Operates on `result.stuck` and NOTHING else -- every other classification
305
+ bucket `find_placeholders` can populate is structurally unreachable from
306
+ this function's body, which is the enforcement of D-15's "never a
307
+ removal candidate" rule: a young or unresolvable-age row simply cannot
308
+ reach a removal primitive through this code path, independent of any
309
+ caller discipline.
310
+
311
+ Raises a `ValueError` (D-16) when `len(result.stuck)` exceeds
312
+ `candidate_limit` -- no removal mechanism is confirmed to work on these
313
+ rows yet, so the first live use of this path is a bounded, time-boxed
314
+ probe, not a bulk operation. Pass a higher `candidate_limit` to
315
+ override.
316
+
317
+ :param result: A `ReconcileResult` from `find_placeholders`. Only
318
+ `result.stuck` is read; `result.removed`/`result.failed` are
319
+ populated in place and the same object is returned.
320
+ :param aura: An authenticated `Aura` instance.
321
+ :param frame_id: The frame `result.stuck`'s rows belong to.
322
+ :param mechanism: Which primitive to attempt -- `'remove'`
323
+ (`FrameApi.remove_asset`, batch), `'hard-delete'`
324
+ (`AssetApi.delete_asset`, per-asset, irreversible and account-wide),
325
+ or `'complete'` (not yet implemented, see `_complete_placeholder`).
326
+ :param budget: Optional `pushframe.ratelimit.WriteBudget` gating each
327
+ chunk exactly like `execute_plan` -- the account-wide budget,
328
+ shared with `sync`/`push`. `None` (the default) skips every
329
+ budget-related touch point, a true byte-for-byte no-op.
330
+ :param throttle_seconds: Seconds to pause before each write network
331
+ call, mirroring `execute_plan`'s `throttle_seconds`. 0 disables.
332
+ :param batch_size: Maximum number of candidates per removal-primitive
333
+ call, mirroring `execute_plan`'s `batch_size`.
334
+ :param candidate_limit: See `RECONCILE_PROBE_CANDIDATE_LIMIT`.
335
+ :param progress: Optional reporter called once per resolved candidate as
336
+ `progress('reconcile', asset_id, ok)`. Defaults to a no-op.
337
+ :return: The same `ReconcileResult`, with `removed`/`failed` populated.
338
+
339
+ Raises `RateLimitError` (from the client layer) WITHOUT catching it,
340
+ mirroring `execute_plan`'s removal loop: a 429/475 throttle or lockout
341
+ aborts the whole batch immediately rather than being recorded as one of
342
+ N per-item failures.
343
+ """
344
+ candidates = result.stuck
345
+ if len(candidates) > candidate_limit:
346
+ raise ValueError(
347
+ f'{len(candidates)} stuck placeholder row(s) exceeds RECONCILE_PROBE_CANDIDATE_LIMIT '
348
+ f'({candidate_limit}) -- no removal mechanism is yet confirmed to work on these rows '
349
+ f'(D-16), so this refuses to act on a large batch at once. Pass a higher '
350
+ f'candidate_limit to override.'
351
+ )
352
+
353
+ def throttle() -> None:
354
+ if throttle_seconds > 0:
355
+ sleep(throttle_seconds)
356
+
357
+ for chunk in _chunked(candidates, batch_size):
358
+ if budget is not None:
359
+ budget.acquire(_RECONCILE_REQUEST_COST[mechanism](chunk), wait=wait_on_budget,
360
+ max_wait=max_wait_seconds, now=clock(), sleep=sleep)
361
+ try:
362
+ throttle()
363
+ _RECONCILE_PRIMITIVE[mechanism](aura, frame_id, chunk)
364
+ for asset in chunk:
365
+ result.removed.append(asset.id)
366
+ progress('reconcile', asset.id, True)
367
+ except RateLimitError:
368
+ if budget is not None:
369
+ budget.reconcile_tripped(clock())
370
+ budget.save()
371
+ raise
372
+ except Exception as e:
373
+ # remove_asset/delete_asset raising attributes the WHOLE chunk
374
+ # as failed -- there is no per-item signal to fall back on
375
+ # (remove_asset returns only a count; a mid-loop delete_asset
376
+ # failure aborts the remaining per-asset calls in this chunk).
377
+ for asset in chunk:
378
+ result.failed.append((asset.id, str(e)))
379
+ progress('reconcile', asset.id, False)
380
+
381
+ if budget is not None:
382
+ budget.save()
383
+
384
+ return result