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,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