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,25 @@
|
|
|
1
|
+
from pushframe.api.baseApi import BaseApi
|
|
2
|
+
from pushframe.models.person import Person
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PeopleApi(BaseApi):
|
|
6
|
+
|
|
7
|
+
def get_people(self):
|
|
8
|
+
json_response = self._client.get('/people.json')
|
|
9
|
+
return [Person(**json_person) for json_person in json_response.get('people')]
|
|
10
|
+
|
|
11
|
+
def get_people_assets(self, cursor: str = None):
|
|
12
|
+
json_response = self._client.get('/people/all_assets.json', query_params={'cursor': cursor})
|
|
13
|
+
|
|
14
|
+
return json_response
|
|
15
|
+
|
|
16
|
+
def get_person(self, person_id: str):
|
|
17
|
+
# TODO: This may not be operational
|
|
18
|
+
json_response = self._client.get(f'/people/{person_id}.json')
|
|
19
|
+
|
|
20
|
+
return json_response
|
|
21
|
+
|
|
22
|
+
def get_person_assets(self, person_id: str):
|
|
23
|
+
# TODO: This may not be operational
|
|
24
|
+
json_response = self._client.get(f'/people/{person_id}/assets.json')
|
|
25
|
+
return json_response
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from pushframe.api.baseApi import BaseApi
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PlaylistApi(BaseApi):
|
|
5
|
+
|
|
6
|
+
def get_playlist_assets(self, playlist_id: str, frame_id: str, _filter: str = None, limit: int = None,
|
|
7
|
+
cursor: str = None):
|
|
8
|
+
self._client.get(f'/playlists/{playlist_id}/assets.json',
|
|
9
|
+
query_params={'frame_id': frame_id, 'filter': _filter, 'limit': limit, 'cursor': cursor})
|
pushframe/aura.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
from pushframe.api.accountApi import AccountApi
|
|
10
|
+
from pushframe.api.activityApi import ActivityApi
|
|
11
|
+
from pushframe.api.assetApi import AssetApi
|
|
12
|
+
from pushframe.api.frameApi import FrameApi
|
|
13
|
+
from pushframe.api.peopleApi import PeopleApi
|
|
14
|
+
from pushframe.aws.s3client import S3Client
|
|
15
|
+
from pushframe.aws.sqsclient import SQSClient
|
|
16
|
+
from pushframe.client import Client, WriteEndpointError
|
|
17
|
+
from pushframe.exif import ExifWriter
|
|
18
|
+
from pushframe.export import get_image_from_asset
|
|
19
|
+
from pushframe.models.asset import Asset, AssetPartialId
|
|
20
|
+
from pushframe.utils.io import build_path, write_model
|
|
21
|
+
|
|
22
|
+
# MOD-04 (Phase 19): process-level guard against the loguru sink leak.
|
|
23
|
+
# `_init_logger()` used to add a stderr sink AND a `logs/file_{time}.log`
|
|
24
|
+
# file sink on EVERY `Aura()` construction, so a process that builds several
|
|
25
|
+
# instances (CLIs, tests via offline_aura) spawned one log file per instance
|
|
26
|
+
# and accumulated duplicate sinks. The FIRST construction in a process
|
|
27
|
+
# configures logging; subsequent ones are no-ops for sinks. This is a
|
|
28
|
+
# module-level flag on purpose (NOT a per-instance one — distinct instances
|
|
29
|
+
# were the leak), and it deliberately performs no wholesale sink teardown:
|
|
30
|
+
# cli.py's _configure_cli_logging() stays the single reconfigure point.
|
|
31
|
+
_LOGGER_READY = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Aura:
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: Client | None = None):
|
|
37
|
+
self._init_logger()
|
|
38
|
+
self._client = client or Client()
|
|
39
|
+
self.account_api = AccountApi(self._client)
|
|
40
|
+
self.frame_api = FrameApi(self._client)
|
|
41
|
+
self.people_api = PeopleApi(self._client)
|
|
42
|
+
self.activity_api = ActivityApi(self._client)
|
|
43
|
+
self.asset_api = AssetApi(self._client)
|
|
44
|
+
self.exif_writer = ExifWriter()
|
|
45
|
+
|
|
46
|
+
def login(self, email: str = None, password: str = None):
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
:param email: The email of the account to authenticate with, defaults to ENVIRON['AURA_EMAIL']
|
|
50
|
+
:param password: The password of the account to authenticate with, defaults to ENVIRON['AURA_PASSWORD']
|
|
51
|
+
:return: Authenticated Aura object
|
|
52
|
+
"""
|
|
53
|
+
if email is None:
|
|
54
|
+
email = os.getenv('PUSHFRAME_EMAIL') or os.getenv('AURA_EMAIL')
|
|
55
|
+
if password is None:
|
|
56
|
+
password = os.getenv('PUSHFRAME_PASSWORD') or os.getenv('AURA_PASSWORD')
|
|
57
|
+
|
|
58
|
+
user = self.account_api.login(email, password)
|
|
59
|
+
|
|
60
|
+
self._client.add_default_headers({
|
|
61
|
+
'x-token-auth': user.auth_token,
|
|
62
|
+
'x-user-id': user.id
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def main(self):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def get_all_assets(self, frame_id: str, limit: int = 1000, page_delay: float = 0.0):
|
|
71
|
+
paginated_assets, cursor = self.frame_api.get_assets(frame_id, limit=limit)
|
|
72
|
+
assets = paginated_assets
|
|
73
|
+
while cursor:
|
|
74
|
+
paginated_assets, cursor = self.frame_api.get_assets(frame_id, limit=limit, cursor=cursor)
|
|
75
|
+
if page_delay:
|
|
76
|
+
time.sleep(page_delay)
|
|
77
|
+
assets.extend(paginated_assets)
|
|
78
|
+
|
|
79
|
+
return assets
|
|
80
|
+
|
|
81
|
+
def dump_frame(self, frame_id: str, path: str, download_images: bool = True, download_activities: bool = True):
|
|
82
|
+
frame, _ = self.frame_api.get_frame(frame_id)
|
|
83
|
+
frame_dir = build_path(path, f'{frame.name}-{frame.id}/')
|
|
84
|
+
|
|
85
|
+
write_model(frame, build_path(frame_dir, 'frame.json'))
|
|
86
|
+
|
|
87
|
+
if download_activities:
|
|
88
|
+
activities = self.frame_api.get_activities(frame.id)
|
|
89
|
+
write_model(activities, build_path(frame_dir, 'activities.json'))
|
|
90
|
+
|
|
91
|
+
assets = self.get_all_assets(frame_id)
|
|
92
|
+
write_model(assets, build_path(frame_dir, 'assets.json'))
|
|
93
|
+
|
|
94
|
+
if download_images:
|
|
95
|
+
self.download_images_from_assets(assets, build_path(frame_dir, f'asset_images/'))
|
|
96
|
+
|
|
97
|
+
def download_images_from_assets(self, assets: list[Asset], base_path: str):
|
|
98
|
+
failed_to_retrieve = []
|
|
99
|
+
for asset in tqdm(assets):
|
|
100
|
+
try:
|
|
101
|
+
get_image_from_asset(asset, base_path, self.exif_writer)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
failed_to_retrieve.append(asset)
|
|
104
|
+
if len(failed_to_retrieve) > 0:
|
|
105
|
+
logger.debug(f'Failed to retrieve {len(failed_to_retrieve)} assets.')
|
|
106
|
+
|
|
107
|
+
def clone(self, source_frame, target_frame):
|
|
108
|
+
# TODO: Read assets from one frame and immediately clones them to another (in memory)
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def upload_images(self):
|
|
112
|
+
# TODO: tqdm, for each asset, upload_image
|
|
113
|
+
# TODO: How do we know where the original dumped asset images are?
|
|
114
|
+
# Could rebuild the file path or store it in assets.json
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def upload_image(self, frame_id: str, image_path: str, asset: Asset):
|
|
118
|
+
"""
|
|
119
|
+
Uploads a single image to S3 and associates it with the given frame via the legacy
|
|
120
|
+
single-item path (`select_asset` + `batch_update`), distinct from the batched multi-file
|
|
121
|
+
path `sync.execute_plan` uses.
|
|
122
|
+
|
|
123
|
+
:param frame_id: The frame to associate the uploaded asset with.
|
|
124
|
+
:param image_path: Local filesystem path to the image file to upload.
|
|
125
|
+
:param asset: Asset metadata to update after the S3 upload lands.
|
|
126
|
+
:raises WriteEndpointError: if `batch_update` does not acknowledge this asset's
|
|
127
|
+
local_identifier -- REL-07/D-18: `batch_update`'s `unacknowledged` set is a real
|
|
128
|
+
per-item failure signal, not something this legacy caller may silently discard.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
image = Image.open(image_path)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.error(e)
|
|
134
|
+
return
|
|
135
|
+
local_identifier = asset.local_identifier
|
|
136
|
+
self.frame_api.select_asset(frame_id, AssetPartialId(local_identifier=local_identifier))
|
|
137
|
+
queue_url = self.get_sqs(frame_id)
|
|
138
|
+
self.sqsClient.receive_message(queue_url, wait_time_seconds=5)
|
|
139
|
+
self.frame_api.select_asset(frame_id, AssetPartialId(local_identifier=local_identifier))
|
|
140
|
+
client = S3Client()
|
|
141
|
+
filename, md5 = client.upload_file(
|
|
142
|
+
open(image_path, 'rb').read(), '.jpg')
|
|
143
|
+
|
|
144
|
+
asset.file_name = filename
|
|
145
|
+
asset.md5_hash = md5
|
|
146
|
+
asset.height = image.height
|
|
147
|
+
asset.width = image.width
|
|
148
|
+
|
|
149
|
+
batch_result = self.asset_api.batch_update(asset)
|
|
150
|
+
if batch_result.unacknowledged:
|
|
151
|
+
raise WriteEndpointError(
|
|
152
|
+
f"upload_image: frame {frame_id} -- batch_update did not acknowledge "
|
|
153
|
+
f"local_identifier(s) {batch_result.unacknowledged}"
|
|
154
|
+
)
|
|
155
|
+
message = self.sqsClient.receive_message(queue_url, wait_time_seconds=5)
|
|
156
|
+
print(message)
|
|
157
|
+
|
|
158
|
+
def get_sqs(self, frame_id: str):
|
|
159
|
+
self.sqsClient = SQSClient()
|
|
160
|
+
return self.sqsClient.get_queue_url(frame_id)
|
|
161
|
+
|
|
162
|
+
def _init_logger(self):
|
|
163
|
+
"""Configure the process's loguru sinks exactly once (MOD-04, D-03):
|
|
164
|
+
the first Aura() construction registers the stderr + file sinks; any
|
|
165
|
+
further construction is a sink no-op. Performs no teardown — cli.py's
|
|
166
|
+
_configure_cli_logging() owns wholesale reconfiguration.
|
|
167
|
+
"""
|
|
168
|
+
global _LOGGER_READY
|
|
169
|
+
if _LOGGER_READY:
|
|
170
|
+
return
|
|
171
|
+
# Ensure the loguru file sink's target dir exists before the first
|
|
172
|
+
# instantiation so it does not raise FileNotFoundError (D-08).
|
|
173
|
+
os.makedirs('logs/', exist_ok=True)
|
|
174
|
+
# (The old commented-out "set this to debug if needed" sink-teardown
|
|
175
|
+
# vestige is gone: with the process guard, tearing down every sink on
|
|
176
|
+
# a later construction would silence the process for good.)
|
|
177
|
+
logger.add(sys.stderr, level="INFO", format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
178
|
+
"<level>{level: <8}</level> | "
|
|
179
|
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
|
180
|
+
"<level>{message}</level> Context: {extra}")
|
|
181
|
+
logger.add('logs/file_{time}.log')
|
|
182
|
+
_LOGGER_READY = True
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import boto3
|
|
2
|
+
import botocore
|
|
3
|
+
|
|
4
|
+
SESSION_CONFIG = botocore.config.Config(
|
|
5
|
+
user_agent="aws-sdk-android/2.13.1 Linux/5.4.61-android11 Dalvik/2.1.0/0 en_US"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# TODO: Convert this to use os.environ vars?
|
|
10
|
+
class AWSClient:
|
|
11
|
+
|
|
12
|
+
def __init__(self, pool_id: str, region_name='us-east-1'):
|
|
13
|
+
# boto3.set_stream_logger('', logging.DEBUG)
|
|
14
|
+
|
|
15
|
+
self.region_name = region_name
|
|
16
|
+
self.cognito = boto3.client('cognito-identity', region_name=self.region_name)
|
|
17
|
+
if pool_id:
|
|
18
|
+
self.auth(pool_id)
|
|
19
|
+
|
|
20
|
+
def auth(self, pool_id):
|
|
21
|
+
ident_resp = self.cognito.get_id(IdentityPoolId=pool_id)
|
|
22
|
+
cred_resp = self.cognito.get_credentials_for_identity(IdentityId=ident_resp['IdentityId'])
|
|
23
|
+
self.credentials = cred_resp['Credentials']
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import boto3
|
|
3
|
+
import uuid
|
|
4
|
+
import hashlib
|
|
5
|
+
|
|
6
|
+
from pushframe.aws.awsclient import AWSClient
|
|
7
|
+
from pushframe.utils.settings import AWS_S3_BUCKET, AWS_UPLOAD_IDENTITY_POOL_ID
|
|
8
|
+
|
|
9
|
+
# MOD-02 (Phase 19): the bucket name and pool ID moved to settings.py
|
|
10
|
+
# (env-overridable, unchanged defaults). Kept as aliases so existing
|
|
11
|
+
# references keep working; no literal remains in this module.
|
|
12
|
+
BUCKET_KEY = AWS_S3_BUCKET
|
|
13
|
+
UPLOAD_IDENTITY_POOL_ID = AWS_UPLOAD_IDENTITY_POOL_ID
|
|
14
|
+
AWS_UPLOAD_PART_SIZE = 16384
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_md5(data):
|
|
18
|
+
return base64.b64encode(hashlib.md5(data).digest()).decode('utf-8')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class S3Client(AWSClient):
|
|
22
|
+
s3_client = None
|
|
23
|
+
|
|
24
|
+
def __init__(self, pool_id=None, region_name='us-east-1'):
|
|
25
|
+
super().__init__(pool_id if pool_id else UPLOAD_IDENTITY_POOL_ID, region_name)
|
|
26
|
+
|
|
27
|
+
def auth(self, pool_id):
|
|
28
|
+
super().auth(pool_id)
|
|
29
|
+
self.s3_client = boto3.client('s3', aws_access_key_id=self.credentials['AccessKeyId'],
|
|
30
|
+
aws_secret_access_key=self.credentials['SecretKey'],
|
|
31
|
+
aws_session_token=self.credentials['SessionToken'])
|
|
32
|
+
|
|
33
|
+
def upload_file(self, data, extension):
|
|
34
|
+
filename = f'{str(uuid.uuid4())}{extension}'
|
|
35
|
+
self.s3_client.put_object(Body=data, Bucket=BUCKET_KEY, Key=filename)
|
|
36
|
+
|
|
37
|
+
return filename, get_md5(data)
|
|
38
|
+
|
|
39
|
+
def get_file(self, filename):
|
|
40
|
+
return self.s3_client.head_object(Bucket=BUCKET_KEY, Key=filename)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import boto3
|
|
2
|
+
|
|
3
|
+
from pushframe.aws.awsclient import AWSClient
|
|
4
|
+
from pushframe.utils.settings import AWS_SQS_IDENTITY_POOL_ID
|
|
5
|
+
|
|
6
|
+
# MOD-02 (Phase 19): the pool ID moved to settings.py (env-overridable,
|
|
7
|
+
# unchanged default). Kept as an alias so existing references keep working;
|
|
8
|
+
# no literal remains in this module.
|
|
9
|
+
SQS_IDENTITY_POOL_ID = AWS_SQS_IDENTITY_POOL_ID
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# TODO: Might want to thread this out, as the wait time could be an issue
|
|
13
|
+
class SQSClient(AWSClient):
|
|
14
|
+
sqs_client: None
|
|
15
|
+
|
|
16
|
+
def __init__(self, pool_id=None, region_name='us-east-1'):
|
|
17
|
+
super().__init__(pool_id if pool_id else SQS_IDENTITY_POOL_ID, region_name)
|
|
18
|
+
|
|
19
|
+
def auth(self, pool_id):
|
|
20
|
+
super().auth(pool_id)
|
|
21
|
+
self.sqs_client = boto3.client('sqs', aws_access_key_id=self.credentials['AccessKeyId'],
|
|
22
|
+
aws_secret_access_key=self.credentials['SecretKey'],
|
|
23
|
+
aws_session_token=self.credentials['SessionToken'],
|
|
24
|
+
region_name=self.region_name)
|
|
25
|
+
|
|
26
|
+
def get_queue_url(self, frame_id: str):
|
|
27
|
+
return self.sqs_client.get_queue_url(QueueName=f'frame-{frame_id}-client').get('QueueUrl')
|
|
28
|
+
|
|
29
|
+
def receive_message(self, queue_url: str, max_num_messages: int = 10, wait_time_seconds: int = 20):
|
|
30
|
+
response = self.sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=max_num_messages,
|
|
31
|
+
WaitTimeSeconds=wait_time_seconds, AttributeNames=['All'])
|
|
32
|
+
|
|
33
|
+
return response
|
pushframe/cache.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
CACHE_DIR = 'cache/'
|
|
5
|
+
|
|
6
|
+
# TODO: Silly caching, should probably rework.
|
|
7
|
+
|
|
8
|
+
def save_to_cache(file_name, data):
|
|
9
|
+
path = os.path.join(CACHE_DIR, file_name + '.json')
|
|
10
|
+
with open(path, 'w') as f:
|
|
11
|
+
json.dump(data, f)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cache(file_name, use_arg=False):
|
|
15
|
+
def decorator(function):
|
|
16
|
+
def wrapper(*args, **kwargs):
|
|
17
|
+
if use_arg:
|
|
18
|
+
path = os.path.join(CACHE_DIR, file_name + "-" + args[1] + '.json')
|
|
19
|
+
else:
|
|
20
|
+
path = os.path.join(CACHE_DIR, file_name + '.json')
|
|
21
|
+
if os.path.isfile(path):
|
|
22
|
+
with open(path, 'r') as f:
|
|
23
|
+
ret = json.load(f)
|
|
24
|
+
else:
|
|
25
|
+
with open(path, 'w') as f:
|
|
26
|
+
ret = function(*args, **kwargs)
|
|
27
|
+
json.dump(ret, f)
|
|
28
|
+
return ret
|
|
29
|
+
|
|
30
|
+
return wrapper
|
|
31
|
+
|
|
32
|
+
return decorator
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def async_cache(file_name):
|
|
36
|
+
def decorator(function):
|
|
37
|
+
async def wrapper(*args, **kwargs):
|
|
38
|
+
path = os.path.join(CACHE_DIR, file_name + '.json')
|
|
39
|
+
if os.path.isfile(path):
|
|
40
|
+
with open(path, 'r') as f:
|
|
41
|
+
ret = json.load(f)
|
|
42
|
+
else:
|
|
43
|
+
with open(path, 'w') as f:
|
|
44
|
+
ret = await function(*args, **kwargs)
|
|
45
|
+
json.dump(ret, f)
|
|
46
|
+
return ret
|
|
47
|
+
|
|
48
|
+
return wrapper
|
|
49
|
+
|
|
50
|
+
return decorator
|