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
|
@@ -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")
|
pushframe/models/meta.py
ADDED
|
@@ -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
|
pushframe/models/user.py
ADDED
|
@@ -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
|