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
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, model_validator
6
+
7
+ from pushframe.models.meta import make_partial
8
+ from pushframe.models.user import User
9
+ from pushframe.utils.dt import parse_aura_dt
10
+
11
+
12
+ class AssetPadding(BaseModel):
13
+ top: float
14
+ right: float
15
+ bottom: float
16
+ left: float
17
+
18
+
19
+ class AssetSetting(BaseModel):
20
+ added_by_id: str
21
+ asset_id: str
22
+ created_at: str
23
+ frame_id: str
24
+ hidden: bool
25
+ id: str
26
+ last_impression_at: str
27
+ reason: str # TODO: Have only seen "user"
28
+ selected: bool
29
+ updated_at: str
30
+ updated_selected_at: str
31
+
32
+
33
+ class Asset(BaseModel):
34
+ auto_landscape_16_10_rect: Optional[str] = None
35
+ auto_portrait_4_5_rect: Optional[str] = None
36
+ burst_id: Any
37
+ burst_selection_types: Any
38
+ colorized_file_name: Optional[str] = None
39
+ # Optional with a None default, not required: whether the live
40
+ # `/frames/{id}/assets.json` payload sends this field at all is
41
+ # currently unknown (pydantic silently drops undeclared keys, so its
42
+ # prior absence here proved nothing either way). Declaring it Optional
43
+ # makes the value visible when the server does send it and None when it
44
+ # does not -- additive, and cannot break any existing caller (Phase 11
45
+ # Plan 03, D-15).
46
+ created_at: Optional[str] = None
47
+ created_at_on_client: Optional[str] = None
48
+ # data_uti/file_name/good_resolution/height/width/upload_priority/taken_at/
49
+ # uploaded_at are Optional because the live API returns assets that are still
50
+ # mid-server-side-processing (a freshly-uploaded placeholder): source_id,
51
+ # local_identifier, user and selected are populated, but the processed
52
+ # content metadata (dimensions, filenames, dates, data_uti) is null and
53
+ # good_resolution is omitted entirely until processing completes. See
54
+ # .planning/debug/resolved/inspect-asset-null-fields.md.
55
+ data_uti: Optional[str] = None
56
+ duplicate_of_id: Optional[str] = None
57
+ duration: Optional[float] = None
58
+ duration_unclipped: Optional[float] = None
59
+ exif_orientation: int
60
+ favorite: Optional[bool] = None
61
+ file_name: Optional[str] = None
62
+ glaciered_at: str
63
+ good_resolution: Optional[bool] = None
64
+ handled_at: Optional[str] = None
65
+ hdr: Optional[bool] = None
66
+ height: Optional[int] = None
67
+ horizontal_accuracy: Optional[float] = None
68
+ id: str
69
+ ios_media_subtypes: Optional[int] = None
70
+ is_live: Optional[bool] = None
71
+ is_subscription: bool
72
+ landscape_16_10_url: Optional[str] = None
73
+ landscape_16_10_url_padding: Optional[AssetPadding] = None
74
+ landscape_rect: Optional[str] = None
75
+ landscape_url: Optional[str] = None
76
+ landscape_url_padding: Optional[AssetPadding] = None
77
+ live_photo_off: Optional[bool] = None
78
+ local_identifier: str
79
+ location: Optional[list[float]] = None # Lat/Long, seems to default to (-77.8943033, 34.1978216)
80
+ location_name: Optional[str] = None
81
+ md5_hash: Optional[str] = None
82
+ minibar_landscape_url: Optional[str] = None
83
+ minibar_portrait_url: Optional[str] = None
84
+ minibar_url: Optional[str] = None
85
+ modified_at: Optional[str] = None
86
+ orientation: Optional[int] = None
87
+ original_file_name: Optional[str] = None
88
+ panorama: Optional[bool] = None
89
+ portrait_4_5_url: Optional[str] = None
90
+ portrait_4_5_url_padding: Optional[AssetPadding] = None
91
+ portrait_rect: Optional[str] = None
92
+ portrait_url: Optional[str] = None
93
+ portrait_url_padding: Optional[AssetPadding] = None
94
+ raw_file_name: Optional[str] = None
95
+ represents_burst: Any
96
+ rotation_cw: int
97
+ selected: bool
98
+ source_id: str
99
+ taken_at: Optional[str] = None
100
+ taken_at_granularity: Any
101
+ taken_at_user_override_at: Optional[str] = None
102
+ thumbnail_url: Optional[str] = None
103
+ unglacierable: Optional[bool] = None
104
+ upload_priority: Optional[int] = None
105
+ uploaded_at: Optional[str] = None
106
+ user: User
107
+ user_id: str
108
+ user_landscape_16_10_rect: Optional[str] = None
109
+ user_landscape_rect: Optional[str] = None
110
+ user_portrait_4_5_rect: Optional[str] = None
111
+ user_portrait_rect: Optional[str] = None
112
+ video_clip_excludes_audio: Optional[bool] = None
113
+ video_clip_start: Any
114
+ video_clipped_by_user_at: Optional[str] = None
115
+ video_file_name: Optional[str] = None
116
+ video_url: Optional[str] = None
117
+ widget_url: Optional[str] = None
118
+ width: Optional[int] = None
119
+
120
+ @property
121
+ def taken_at_dt(self):
122
+ # taken_at is None for an unprocessed placeholder asset (see the field
123
+ # comment above); parse_aura_dt(None) would raise, and the CLI reads
124
+ # this property for every listed asset (cli.py inspect/sync), so return
125
+ # None rather than crash on a not-yet-processed asset.
126
+ if self.taken_at is None:
127
+ return None
128
+ return parse_aura_dt(self.taken_at)
129
+
130
+ # `is_local_asset` (formerly `return self.id is None`) was removed:
131
+ # `id` above is a required `str`, so any `Asset` built through normal
132
+ # validated construction (the only path used by `FrameApi.get_assets`/
133
+ # `AssetApi.get_asset_by_local_identifier`) can never have `id=None`.
134
+ # The property was always `False` and its dependent branches in
135
+ # `AssetApi` were unreachable dead code. `Asset` (as opposed to
136
+ # `AssetPartial`) always represents a server-hydrated asset with a
137
+ # real `id`.
138
+
139
+
140
+ class AssetPartialId(BaseModel):
141
+ id: Optional[str] = None
142
+ local_identifier: Optional[str] = None
143
+ user_id: Optional[str] = None
144
+
145
+ @model_validator(mode='after')
146
+ def check_id_or_local_id(self) -> 'AssetPartialId':
147
+ if not self.id and not self.local_identifier:
148
+ raise ValueError('Either id or local_identifier is required')
149
+ return self
150
+
151
+ def to_request_format(self):
152
+ # 'user_id': user_id # in the iphone version user_id is not passed in
153
+ if self.id:
154
+ return {'asset_id': self.id}
155
+ else:
156
+ return {'asset_local_identifier': self.local_identifier}
157
+
158
+
159
+ AssetPartial = make_partial(Asset, "AssetPartial")
@@ -0,0 +1,105 @@
1
+ from enum import Enum
2
+ from typing import Optional
3
+
4
+ import pydantic
5
+ from pydantic import BaseModel
6
+
7
+ from pushframe.models.meta import make_partial
8
+ from pushframe.models.user import User
9
+
10
+
11
+ class Feature(Enum):
12
+ SKIP_VIDEO_PRELOAD = 'skip_video_preload'
13
+ UDP_COMMANDS = 'udp_commands'
14
+ MQTT_ENABLED = 'mqtt_enabled'
15
+ # The Aura API is undocumented and adds feature flags over time; map any
16
+ # value we don't recognise to UNKNOWN instead of raising on hydration, so a
17
+ # newly-added flag can't break the whole read path (live drift, Phase 2).
18
+ UNKNOWN = 'unknown'
19
+
20
+ @classmethod
21
+ def _missing_(cls, value):
22
+ return cls.UNKNOWN
23
+
24
+
25
+ class Frame(BaseModel):
26
+ id: str
27
+ name: str
28
+ user_id: str
29
+ software_version: str
30
+ build_version: str
31
+ hw_android_version: str
32
+ created_at: str
33
+ updated_at: str
34
+ handled_at: str
35
+ deleted_at: Optional[str] = None
36
+ updated_at_on_client: Optional[str] = None
37
+ orientation: int
38
+ auto_brightness: bool
39
+ min_brightness: int
40
+ max_brightness: int
41
+ brightness: Optional[int] = None
42
+ sense_motion: bool
43
+ default_speed: Optional[str] = None
44
+ slideshow_interval: int
45
+ slideshow_auto: bool
46
+ digits: int
47
+ contributors: Optional[list[User]] = None
48
+ contributor_tokens: list[dict]
49
+ hw_serial: str
50
+ matting_color: str
51
+ trim_color: str
52
+ is_handling: bool
53
+ calibrations_last_modified_at: str
54
+ gestures_on: bool
55
+ portrait_pairing_off: Optional[bool] = None
56
+ live_photos_on: bool
57
+ auto_processed_playlist_ids: list[object] # unknown
58
+ time_zone: str
59
+ wifi_network: str
60
+ cold_boot_at: Optional[str] = None
61
+ is_charity_water_frame: bool
62
+ num_assets: int
63
+ thanks_on: bool
64
+ frame_queue_url: Optional[str] = None
65
+ client_queue_url: str
66
+ scheduled_display_sleep: bool
67
+ scheduled_display_on_at: Optional[str] = None
68
+ scheduled_display_off_at: Optional[str] = None
69
+ forced_wifi_state: Optional[str] = None
70
+ forced_wifi_recipient_email: Optional[str] = None
71
+ is_analog_frame: bool
72
+ control_type: str
73
+ display_aspect_ratio: str
74
+ has_claimable_gift: Optional[bool] = None
75
+ gift_billing_hint: Optional[str] = None
76
+ locale: str
77
+ frame_type: Optional[int] = None
78
+ description: Optional[str] = None
79
+ representative_asset_id: Optional[str] = None
80
+ sort_mode: Optional[str] = None
81
+ email_address: str
82
+ features: Optional[list[Feature]] = None
83
+ letterbox_style: Optional[str] = None
84
+ user: User
85
+ playlists: list[dict] # TODO
86
+ delivered_frame_gift: Optional[dict] = None # TODO
87
+ last_feed_item: dict
88
+ last_impression: Optional[dict] = None
89
+ last_impression_at: str
90
+ child_albums: list
91
+ # The live API stopped returning `smart_adds` on /frames.json and
92
+ # /frames/{id}.json (live drift, Phase 10). Optional-with-default so an
93
+ # absent key can't break the whole read path -- same treatment as the
94
+ # Phase 2 `total_asset_count` -> `num_assets` move.
95
+ smart_adds: list = pydantic.Field(default_factory=list)
96
+ recent_assets: list
97
+
98
+ def is_portrait(self):
99
+ return self.orientation == 2 or self.orientation == 3
100
+
101
+ def get_frame_type(self):
102
+ return self.frame_type if self.frame_type else "normal"
103
+
104
+
105
+ FramePartial = make_partial(Frame, "FramePartial")
@@ -0,0 +1,11 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel, create_model
4
+
5
+
6
+ def make_partial(model: type[BaseModel], name: str) -> type[BaseModel]:
7
+ fields = {
8
+ fname: (Optional[finfo.annotation], None)
9
+ for fname, finfo in model.model_fields.items()
10
+ }
11
+ return create_model(name, __base__=model, **fields)
@@ -0,0 +1,24 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Person(BaseModel):
5
+ id: str
6
+ created_at: str
7
+ name: str
8
+ published_at: str
9
+ similar_people_ids: list[str]
10
+ thumb_file_name: str
11
+ updated_at: str
12
+ user_id: str
13
+
14
+
15
+ class PersonAssetSetting(BaseModel):
16
+ asset_local_identifier: str
17
+ created_at: str
18
+ detected_face_id: str
19
+ id: str
20
+ person_id: str
21
+ source_id: str
22
+ updated_at: str
23
+ user_action: str
24
+ user_action_updated_at: str
@@ -0,0 +1,22 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class User(BaseModel):
7
+ id: str
8
+ created_at: str
9
+ updated_at: str
10
+ name: str
11
+ email: str
12
+ short_id: Optional[str] = None
13
+ show_push_prompt: bool
14
+ latest_app_version: Optional[str] = None
15
+ attribution_id: Optional[str] = None
16
+ attribution_string: Optional[str] = None
17
+ test_account: Optional[bool] = None
18
+ avatar_file_name: Optional[str] = None
19
+ has_frame: Optional[bool] = None
20
+ analytics_optout: bool = None
21
+ admin_account: Optional[bool] = False
22
+ auth_token: str = None
pushframe/ratelimit.py ADDED
@@ -0,0 +1,222 @@
1
+ """Standalone, injectable write-rate-budget + geo pre-flight guard module
2
+ (Phase 09: proactive-write-rate-limiter-geo-guard).
3
+
4
+ `WriteBudget` is a client-side token bucket that makes the Pushd anti-abuse
5
+ write-lockout structurally hard to hit: callers must `acquire()` tokens
6
+ before issuing a batch of write network calls, either waiting for enough
7
+ tokens to refill or stopping cleanly before ever making the call.
8
+
9
+ `check_geo` is a pre-flight guard comparing the caller's current exit-IP
10
+ country against the account's expected country -- root-cause mitigation for
11
+ the VPN-geo-mismatch write-lockout this phase's research identified.
12
+
13
+ This module touches no existing source and has no side effects at import
14
+ time -- it never calls `datetime.utcnow()`, `time.sleep()`, or `httpx`
15
+ directly during any *tested* path. `now`, `sleep`, and `resolver` are all
16
+ injected by the caller (mirroring the `sleep=time.sleep` seam already used
17
+ by `pushframe.sync.execute_plan`), so this module is 100% offline-testable.
18
+
19
+ `WriteBudget.save()`/`load()` persist ONLY `tokens` + `updated_at` to a
20
+ per-account JSON state file -- never the email, password, or any auth
21
+ token (see the phase's threat register, T-09-02).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timedelta
29
+ from pathlib import Path
30
+
31
+ import httpx
32
+ from loguru import logger
33
+
34
+
35
+ class BudgetExhausted(Exception):
36
+ """Raised by `WriteBudget.acquire()` in stop mode (`wait=False`, or a
37
+ computed `wait_seconds` exceeding `max_wait`) instead of ever making the
38
+ gated write call. Carries `wait_seconds` so the CLI (Plan 09-02) can
39
+ print how long a retry would need to wait."""
40
+
41
+ def __init__(self, wait_seconds: float):
42
+ self.wait_seconds = wait_seconds
43
+ super().__init__(
44
+ f'Write budget exhausted; would need to wait {wait_seconds:.1f}s '
45
+ 'for enough tokens to refill.'
46
+ )
47
+
48
+
49
+ class GeoMismatchError(Exception):
50
+ """Raised by `check_geo()` when the resolved exit-IP country does not
51
+ match the account's expected country. Carries `found` + `expected` so
52
+ the CLI (Plan 09-02) can print an actionable "switch your VPN" message."""
53
+
54
+ def __init__(self, found: str, expected: str):
55
+ self.found = found
56
+ self.expected = expected
57
+ super().__init__(
58
+ f'VPN/exit IP in {found}, account expects {expected} -- '
59
+ 'switch your VPN and retry.'
60
+ )
61
+
62
+
63
+ @dataclass
64
+ class WriteBudget:
65
+ """A per-account client-side token bucket gating batches of Pushd write
66
+ network calls. Mirrors the `ExecutionResult`/`SyncPlan` plain-dataclass
67
+ style used in `pushframe/sync.py` (pydantic is reserved for API DTOs).
68
+
69
+ `path` is a constructor field (never hardcoded internally) so tests
70
+ always pass a pytest `tmp_path` value and production wiring (Plan 09-02)
71
+ computes the real `~/.config/pushframe/...` path at the CLI boundary.
72
+ """
73
+
74
+ capacity: float
75
+ refill_per_min: float
76
+ path: Path
77
+ tokens: float = 0.0
78
+ updated_at: datetime | None = None
79
+
80
+ def acquire(self, n, *, wait, max_wait, now, sleep, on_wait=None) -> None:
81
+ """Refill by elapsed minutes since `updated_at` (clamped to >=0 per
82
+ the clock-skew/None-updated_at Pitfall 2 fix), cap at `capacity`,
83
+ then either consume `n` tokens immediately, wait for them (sleeping
84
+ in 1s steps via the injected `sleep`, surfacing `on_wait(remaining)`
85
+ each second -- mirrors `execute_plan.interchunk_pause()`), or raise
86
+ `BudgetExhausted` if `wait=False` or the wait would exceed `max_wait`.
87
+
88
+ `now` is a VALUE supplied by the caller, never read internally --
89
+ this module never calls `datetime.utcnow()`.
90
+ """
91
+ elapsed_minutes = max(0.0, (now - (self.updated_at or now)).total_seconds() / 60.0)
92
+ self.tokens = min(self.capacity, self.tokens + self.refill_per_min * elapsed_minutes)
93
+ self.updated_at = now
94
+
95
+ if self.tokens >= n:
96
+ self.tokens -= n
97
+ return
98
+
99
+ # WR-03: a non-positive refill rate (e.g. AURA_WRITE_BUDGET_REFILL_PER_MIN=0
100
+ # to "pause" writes) can never satisfy the deficit -- the wait would be
101
+ # infinite -- and dividing by it below would raise ZeroDivisionError.
102
+ # Treat it as an immediate, clean stop instead.
103
+ if self.refill_per_min <= 0:
104
+ raise BudgetExhausted(float('inf'))
105
+
106
+ wait_seconds = (n - self.tokens) / self.refill_per_min * 60.0
107
+ if not wait or wait_seconds > max_wait:
108
+ raise BudgetExhausted(wait_seconds)
109
+
110
+ remaining = wait_seconds
111
+ while remaining > 0:
112
+ if on_wait:
113
+ on_wait(remaining)
114
+ step = 1.0 if remaining >= 1.0 else remaining
115
+ sleep(step)
116
+ remaining -= step
117
+
118
+ # Waiting `wait_seconds` accrues exactly the deficit (`n - tokens`)
119
+ # tokens -- reaching `n`, NOT `capacity` (wait_seconds was derived so
120
+ # the deficit closes exactly). Resetting to `capacity` here would
121
+ # over-grant `capacity - n` tokens on every wait and defeat the
122
+ # anti-burst rate limit. Advance `updated_at` by the waited interval
123
+ # so the next acquire() does not re-count it as fresh elapsed time.
124
+ self.tokens = min(self.capacity, self.tokens + self.refill_per_min * wait_seconds / 60.0)
125
+ self.updated_at = self.updated_at + timedelta(seconds=wait_seconds)
126
+ self.tokens -= n
127
+
128
+ def reconcile_tripped(self, now) -> None:
129
+ """Force the bucket to empty (tokens=0, updated_at=now) after a real
130
+ anti-abuse trip (`RateLimitError`/`ConsecutiveWriteFailureError`) so
131
+ the next run's proactive budget reflects reality even though the
132
+ server-side trip wasn't caused by this bucket running dry."""
133
+ self.tokens = 0
134
+ self.updated_at = now
135
+
136
+ def save(self) -> None:
137
+ """Write `tokens` + `updated_at` (isoformat, or null) as JSON to
138
+ `self.path`, creating parent directories as needed. No path
139
+ argument -- always writes to the field set at construction, so
140
+ callers can call a bare `budget.save()`. Persists no credential
141
+ material (T-09-02).
142
+
143
+ WR-02: the write is atomic (temp file in the same dir + `os.replace`)
144
+ so an interrupted write (Ctrl-C, disk full, crash mid-write) can never
145
+ leave a truncated JSON file that would brick every subsequent run."""
146
+ self.path.parent.mkdir(parents=True, exist_ok=True)
147
+ payload = json.dumps({
148
+ 'tokens': self.tokens,
149
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
150
+ })
151
+ tmp_path = self.path.with_name(self.path.name + '.tmp')
152
+ tmp_path.write_text(payload)
153
+ os.replace(tmp_path, self.path)
154
+
155
+ @classmethod
156
+ def load(cls, path: Path, *, capacity: float, refill_per_min: float) -> 'WriteBudget':
157
+ """Reconstruct a `WriteBudget` from `path`. If `path` does not
158
+ exist, returns a fresh bucket (tokens=0, updated_at=None) without
159
+ raising -- the common case for a first-ever run.
160
+
161
+ WR-02: a corrupt/truncated state file (bad JSON, missing keys, or an
162
+ unparseable `updated_at`) also falls back to a fresh bucket with a
163
+ warning rather than raising -- a single bad file must never brick every
164
+ subsequent `push` / `sync --apply`."""
165
+ if not path.exists():
166
+ return cls(capacity=capacity, refill_per_min=refill_per_min, path=path)
167
+ try:
168
+ data = json.loads(path.read_text())
169
+ tokens = data['tokens']
170
+ updated_at = datetime.fromisoformat(data['updated_at']) if data['updated_at'] else None
171
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError):
172
+ logger.warning(f'Corrupt write-budget state at {path}; starting fresh.')
173
+ return cls(capacity=capacity, refill_per_min=refill_per_min, path=path)
174
+ return cls(
175
+ capacity=capacity, refill_per_min=refill_per_min, path=path,
176
+ tokens=tokens,
177
+ updated_at=updated_at,
178
+ )
179
+
180
+
181
+ def _default_resolver() -> str:
182
+ """Production default resolver for `check_geo`: a bare, short-timeout
183
+ `httpx.get` to ipinfo.io's unauthenticated legacy endpoint. Deliberately
184
+ NOT routed through `pushframe.client.Client` -- that abstraction is
185
+ Pushd-specific (base URL, headers, 429/475 classification) and would be
186
+ misused here. No retry/backoff (YAGNI) -- `check_geo`'s fail-open
187
+ default is the correct mitigation for a resolver hiccup.
188
+
189
+ Never invoked by this module's own test suite (would violate the
190
+ 100%-offline test requirement) -- tests always inject a fake `resolver`.
191
+ """
192
+ response = httpx.get('https://ipinfo.io/json', timeout=4.0)
193
+ response.raise_for_status()
194
+ return response.json()['country']
195
+
196
+
197
+ def check_geo(expected_country, *, resolver=_default_resolver, fail_open: bool = True):
198
+ """Pre-flight guard comparing the caller's current exit-IP country
199
+ (via `resolver()`) against `expected_country`. Raises `GeoMismatchError`
200
+ on a mismatch; returns None (no-op) on a match or when `expected_country`
201
+ is falsy (feature disabled/unconfigured -- resolver is not even called).
202
+
203
+ Fails OPEN by default (`fail_open=True`): a resolver exception (network
204
+ error, ipinfo.io outage/throttle) logs a warning and returns None rather
205
+ than blocking a legitimate write -- mirroring this codebase's existing
206
+ "best-effort/observational, never gates the primary operation" precedent
207
+ (`execute_plan`'s SQS poll, `pushframe/sync.py:460-461`). Pass
208
+ `fail_open=False` to fail CLOSED instead (the resolver's exception
209
+ propagates).
210
+ """
211
+ if not expected_country:
212
+ return None
213
+ try:
214
+ found = resolver()
215
+ except Exception as e:
216
+ if fail_open:
217
+ logger.warning(f'Geo pre-flight resolver failed ({e}); proceeding (fail-open).')
218
+ return None
219
+ raise
220
+ if found.upper() != expected_country.upper():
221
+ raise GeoMismatchError(found, expected_country)
222
+ return None