socialcli 0.3.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.

Potentially problematic release.


This version of socialcli might be problematic. Click here for more details.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
@@ -0,0 +1,177 @@
1
+ """Validate native peer data before exposing approval or operation evidence."""
2
+ import json
3
+
4
+ from .approval import SCHEMA, hash_value, validate_payload
5
+ from .models import NativeJob, NativeObservation
6
+
7
+
8
+ def validate_data(rows, *, command, brand, jobs, request_args):
9
+ by_id = {job['id']: job for job in jobs}
10
+ specific = {'approve', 'dispatch', 'reconcile', 'handoff', 'submit-marker', 'cancel', 'reschedule'}
11
+ expected = request_args[1] if command in specific and len(request_args) > 1 else None
12
+
13
+ def shape(value, required, optional=()):
14
+ if not isinstance(value, dict) or not set(required) <= set(value) or set(value) - set(required) - set(optional):
15
+ raise ValueError('Invalid native data shape')
16
+
17
+ def job(value):
18
+ parsed = NativeJob.model_validate_json(value if isinstance(value, str) else json.dumps(value))
19
+ if parsed.brand != brand or expected is not None and parsed.id != expected:
20
+ raise ValueError('Mismatched native data identity')
21
+ return parsed
22
+
23
+ def target(identifier):
24
+ if expected is not None and identifier != expected or identifier not in by_id:
25
+ raise ValueError('Unknown native result target')
26
+ return by_id[identifier]
27
+
28
+ def first_comment(value, known):
29
+ shape(value, ('status',), ('publication_id','change_id','comment_id'))
30
+ if value['status'] == 'not_requested':
31
+ if set(value) != {'status'}:
32
+ raise ValueError('Invalid unrequested first-comment result')
33
+ return
34
+ statuses = {'pending','manual_review','observed_existing','proposed','applying','uncertain',
35
+ 'rejected','verified','failed','conflict','partial','accepted_unverifiable'}
36
+ if value['status'] not in statuses:
37
+ raise ValueError('Unknown native first-comment state')
38
+ from uuid import UUID
39
+ try:
40
+ UUID(value['publication_id'])
41
+ except (KeyError, ValueError, TypeError, AttributeError):
42
+ raise ValueError('Invalid native publication recovery ID') from None
43
+ for key in ('change_id', 'comment_id'):
44
+ if value.get(key) is not None and (not isinstance(value[key],str) or not value[key].strip()):
45
+ raise ValueError('Invalid native comment identity')
46
+ if value['status'] == 'verified' and (known['state'] != 'published'
47
+ or not value.get('change_id') or not value.get('comment_id')):
48
+ raise ValueError('Verified native comment lacks published media or durable receipt')
49
+
50
+ def approval(value):
51
+ shape(value, ('schema', 'intent', 'payload', 'media'))
52
+ if value['schema'] != SCHEMA:
53
+ raise ValueError('Invalid native approval schema')
54
+ intent = value['intent']
55
+ shape(intent, ('brand','platform','account_id','legacy_entry_id','publish_at',
56
+ 'timezone_name','dispatch_after','route','media_hash','content_hash'), ('schedule_mode',))
57
+ parsed = job(dict(intent, id=expected or 'remote-preview', created_at=intent['dispatch_after'], updated_at=intent['dispatch_after']))
58
+ validate_payload(parsed.platform, value['payload'])
59
+ if not isinstance(value['media'], list):
60
+ raise ValueError('Invalid native media hashes')
61
+ for media in value['media']:
62
+ shape(media, ('path', 'sha256'))
63
+ if hash_value(value['payload']) != parsed.content_hash or hash_value(value['media']) != parsed.media_hash:
64
+ raise ValueError('Native approval content mismatch')
65
+
66
+ def preview(value):
67
+ shape(value, ('kind','job_id','remote_ref','account_id','platform','route','before','after','payload','consequence'))
68
+ before, after = job(value['before']), job(value['after'])
69
+ target(value['job_id'])
70
+ if (before.id != after.id or before.id != value['job_id'] or before.account_id != after.account_id
71
+ or before.platform != after.platform or before.remote_id != value['remote_ref']
72
+ or before.account_id != value['account_id'] or before.platform != value['platform']
73
+ or value['kind'] not in {'cancel','reschedule'} or value['route'] != 'official_ui'
74
+ or hash_value(value['payload']) != before.content_hash or before.content_hash != after.content_hash):
75
+ raise ValueError('Mismatched native action preview')
76
+ if command in {'cancel','reschedule'} and value['kind'] != command:
77
+ raise ValueError('Wrong native action kind')
78
+
79
+ def action(value):
80
+ shape(value, ('id','kind','digest','status','job','preview'), ('submitted_at','created_at'))
81
+ parsed = job(value['job'])
82
+ preview(value['preview'])
83
+ if (value['status'] not in {'approved','submitted','confirmed'} or value['kind'] != value['preview']['kind']
84
+ or value['digest'] != hash_value(value['preview']) or parsed.model_dump(mode='json') != value['preview']['after']):
85
+ raise ValueError('Invalid native action')
86
+
87
+ for annotated in rows:
88
+ row = {k: v for k, v in annotated.items() if k != 'next_action'}
89
+ if row.get('source') == 'checkpoint':
90
+ shape(row, ('job_id','attempt_id','remote_id','source'))
91
+ # Snapshots may include other jobs in the same explicit brand.
92
+ known = by_id.get(row['job_id'])
93
+ if known is None or known['attempt_id'] != row['attempt_id'] or not isinstance(row['remote_id'], str) or not row['remote_id'].strip():
94
+ raise ValueError('Invalid checkpoint identity')
95
+ elif command == 'prepare':
96
+ approval(row)
97
+ elif command in {'dispatch','reconcile'}:
98
+ required = ('job_id','state','notices') + (('observations','instruction') if command == 'reconcile' else ())
99
+ shape(row, required, ('first_comment',))
100
+ known = target(row['job_id'])
101
+ if 'first_comment' in row:
102
+ first_comment(row['first_comment'], known)
103
+ if row['state'] != known['state'] or not isinstance(row['notices'], list):
104
+ raise ValueError('Mismatched native result state')
105
+ for raw in row.get('observations', []):
106
+ obs = NativeObservation.model_validate_json(json.dumps(raw))
107
+ if obs.job_id != known['id'] or obs.platform != known['platform'] or obs.account_id != known['account_id']:
108
+ raise ValueError('Mismatched native observation')
109
+ elif command in {'cancel','reschedule'}:
110
+ if 'document' in row:
111
+ shape(row, ('document','digest'))
112
+ preview(row['document'])
113
+ if row['digest'] != hash_value(row['document']):
114
+ raise ValueError('Mismatched action digest')
115
+ else:
116
+ action(row)
117
+ elif command == 'handoff':
118
+ if 'action' in row:
119
+ shape(row, ('action','upload_allowed','instruction'))
120
+ action(row['action'])
121
+ if row['upload_allowed'] is not False:
122
+ raise ValueError('Action handoff must not upload')
123
+ else:
124
+ shape(row, ('schema','state','job','payload','schedule','approval_digest','content_hash','media_hash','media','media_integrity','delivery','operator_policy'))
125
+ shape(row['job'], ('id','brand','platform','account_id'))
126
+ known = target(row['job']['id'])
127
+ if row['job'] != {k: known[k] for k in ('id','brand','platform','account_id')}:
128
+ raise ValueError('Mismatched handoff identity')
129
+ for key in ('state','approval_digest','content_hash','media_hash'):
130
+ if row[key] != known[key]:
131
+ raise ValueError('Mismatched handoff state')
132
+ from datetime import datetime
133
+ from zoneinfo import ZoneInfo
134
+ when = datetime.fromisoformat(known['publish_at'].replace('Z', '+00:00'))
135
+ shape(row['schedule'], ('local','utc','timezone'))
136
+ if (row['schema'] != 'socialctl.native-schedule.handoff.v1'
137
+ or row['schedule']['timezone'] != known['timezone_name']
138
+ or datetime.fromisoformat(row['schedule']['utc'].replace('Z', '+00:00')) != when
139
+ or row['schedule']['local'] != when.astimezone(ZoneInfo(known['timezone_name'])).isoformat()):
140
+ raise ValueError('Mismatched handoff schedule')
141
+ if hash_value(row['payload']) != known['content_hash']:
142
+ raise ValueError('Mismatched handoff payload')
143
+ elif command == 'deliver':
144
+ shape(row, ('delivered','awaiting_ui','instruction'), ('needs_attention','first_comments'))
145
+ comments = row.get('first_comments',[])
146
+ if not isinstance(comments,list):
147
+ raise ValueError('Invalid native first-comment list')
148
+ by_comment_job = {}
149
+ for comment in comments:
150
+ shape(comment, ('job_id','status'), ('publication_id','change_id','comment_id'))
151
+ known = target(comment['job_id'])
152
+ if comment['job_id'] in by_comment_job:
153
+ raise ValueError('Duplicate native first-comment result')
154
+ by_comment_job[comment['job_id']] = comment
155
+ first_comment({k:v for k,v in comment.items() if k != 'job_id'},known)
156
+ if not isinstance(row['awaiting_ui'], list) or any(target(i)['state'] != 'awaiting_ui' for i in row['awaiting_ui']):
157
+ raise ValueError('Invalid delivered jobs')
158
+ attention = row.get('needs_attention', [])
159
+ if not isinstance(attention, list) or any(target(i)['state'] not in {'blocked', 'uncertain'}
160
+ and by_comment_job.get(i,{}).get('status') not in {'uncertain','manual_review','observed_existing','rejected','failed','conflict','applying','partial','accepted_unverifiable'} for i in attention):
161
+ raise ValueError('Invalid jobs requiring attention')
162
+ elif command == 'migration-prepare':
163
+ shape(row, ('version','brand','brand_root','manifest','manifest_hash','inventory','legacy_entries','legacy_results','native_jobs','transfers','excluded','protections','instruction','digest'))
164
+ if row['version'] != 1 or row['brand'] != brand or row['digest'] != hash_value({k:v for k,v in row.items() if k != 'digest'}):
165
+ raise ValueError('Invalid migration proposal')
166
+ for raw in row['native_jobs']:
167
+ job(raw)
168
+ for transfer in row['transfers']:
169
+ job(transfer['job'])
170
+ approval(transfer['native_preview'])
171
+ elif command in {'migration-apply','migration-recover'}:
172
+ shape(row, ('status','digest','publication_approved'), ('prepared','historical_guard_retained'))
173
+ if row['status'] != 'committed' or row['publication_approved'] is not False or row['digest'] != request_args[request_args.index('--digest')+1]:
174
+ raise ValueError('Invalid migration result')
175
+ else:
176
+ # status/approve/submit-marker emit snapshots only.
177
+ raise ValueError('Unexpected native result variant')
@@ -0,0 +1,151 @@
1
+ """Bound native command envelopes; provider state and command exit stay separate."""
2
+ import json
3
+ import sys
4
+ from contextlib import redirect_stdout
5
+ from contextvars import ContextVar
6
+
7
+ import typer
8
+
9
+ from socialctl.agent_errors import error_document, error_for_code
10
+ from .approval import reject_secrets
11
+ from .models import NativeJob
12
+
13
+ active_report = ContextVar('native_report', default=None)
14
+ READ_ONLY = {'status', 'handoff', 'migration-prepare'}
15
+
16
+
17
+ def with_action(value):
18
+ codes = {'prepared': 'approval_required', 'approved': 'native_wait',
19
+ 'waiting_window': 'native_wait', 'ready': 'native_dispatch',
20
+ 'awaiting_ui': 'native_ui_required', 'ui_required': 'native_ui_required',
21
+ 'conflict': 'operation_rejected', 'dispatching': 'operation_uncertain',
22
+ 'uncertain': 'operation_uncertain', 'verifying': 'native_verify',
23
+ 'pending_verification': 'native_verify', 'native_scheduled': 'native_verify',
24
+ 'blocked': 'operation_rejected', 'unsupported': 'unsupported_operation'}
25
+ result = {k: v for k, v in value.items() if k != 'next_action'}
26
+ code = codes.get(result.get('state'))
27
+ if code:
28
+ result['next_action'] = error_for_code(code, read_only=False)
29
+ return result
30
+
31
+
32
+ def validate_remote(text, *, command, brand, exit_code, request_args=()):
33
+ if len(text) > 1_000_000:
34
+ raise ValueError('Oversized native envelope')
35
+ value = json.loads(text)
36
+ fields = {'version', 'kind', 'command', 'brand', 'status', 'exit_code', 'data', 'jobs', 'jobs_complete', 'errors'}
37
+ if (not isinstance(value, dict) or set(value) != fields or type(value['version']) is not int
38
+ or value['version'] != 1 or value['kind'] != 'native-schedule'
39
+ or value['command'] != command or value['brand'] != brand
40
+ or type(value['exit_code']) is not int or value['exit_code'] != exit_code
41
+ or not 0 <= exit_code <= 255 or value['status'] != ('ok' if exit_code == 0 else 'error')
42
+ or type(value['jobs_complete']) is not bool):
43
+ raise ValueError('Mismatched native envelope')
44
+ for key in ('data', 'jobs', 'errors'):
45
+ if not isinstance(value[key], list) or any(not isinstance(row, dict) for row in value[key]):
46
+ raise ValueError('Invalid native envelope rows')
47
+ for row in value['jobs']:
48
+ base = {k: v for k, v in row.items() if k != 'next_action'}
49
+ reject_secrets(base)
50
+ NativeJob.model_validate_json(json.dumps(base))
51
+ if base['brand'] != brand:
52
+ raise ValueError('Mismatched native job brand')
53
+ for row in value['data']:
54
+ reject_secrets({k: v for k, v in row.items() if k != 'next_action'})
55
+ from .remote_data import validate_data
56
+ validate_data(value['data'], command=command, brand=brand, jobs=value['jobs'], request_args=request_args)
57
+ # Only internal catalogue codes supply remote action semantics, never an
58
+ # arbitrary peer's message/action. Data/job safety is checked independently.
59
+ value['errors'] = [error_for_code(row.get('code'), read_only=command in READ_ONLY) for row in value['errors']]
60
+ value['jobs'] = [with_action(row) for row in value['jobs']]
61
+ value['data'] = [with_action(row) for row in value['data']]
62
+ return value
63
+
64
+
65
+ class NativeReport:
66
+ def __init__(self, command, brand):
67
+ self.command, self.brand = command, brand
68
+ self.data, self.jobs, self.errors = [], [], []
69
+ self.store = None
70
+ self.started = False
71
+ self.jobs_complete = False
72
+ self.remote = None
73
+
74
+ def __enter__(self):
75
+ self.token = active_report.set(self)
76
+ self.redirect = redirect_stdout(sys.stderr)
77
+ self.redirect.__enter__()
78
+ return self
79
+
80
+ def bind(self, store):
81
+ self.store = store
82
+ self.snapshot()
83
+
84
+ def snapshot(self):
85
+ rows = [job.model_dump(mode='json') for job in self.store.list_jobs()]
86
+ reject_secrets(rows)
87
+ self.jobs = [with_action(row) for row in rows]
88
+ # Durable checkpoints can precede a job's final remote_id (for example,
89
+ # interruption after upload START). Expose IDs only, never upload URLs.
90
+ for row in rows:
91
+ for attempt in self.store.get_attempts(row['id']):
92
+ checkpoint = attempt['checkpoint']
93
+ remote_id = checkpoint.get('video_id') or checkpoint.get('remote_id')
94
+ if remote_id:
95
+ evidence = {'job_id': row['id'], 'attempt_id': attempt['id'],
96
+ 'remote_id': remote_id, 'source': 'checkpoint'}
97
+ reject_secrets(evidence)
98
+ if evidence not in self.data:
99
+ self.data.append(evidence)
100
+
101
+ def __exit__(self, kind, exc, traceback):
102
+ code = 0 if exc is None else (exc.exit_code or 0) if isinstance(exc, typer.Exit) else 130 if isinstance(exc, KeyboardInterrupt) else 1
103
+ try:
104
+ if self.remote is None:
105
+ if exc is not None and code:
106
+ known = next((row['next_action'] for row in reversed(self.data)
107
+ if 'next_action' in row), None)
108
+ self.errors.append(known if isinstance(exc, typer.Exit) and known else
109
+ error_for_code('operation_failed') if isinstance(exc, typer.Exit) else
110
+ error_for_code('operation_uncertain') if self.started else
111
+ error_document(exc, read_only=self.command in READ_ONLY))
112
+ if self.store is not None:
113
+ try:
114
+ self.snapshot()
115
+ self.jobs_complete = True
116
+ except Exception:
117
+ self.errors.append(error_for_code('reporting_incomplete'))
118
+ code = code or 1
119
+ payload = {'version': 1, 'kind': 'native-schedule', 'command': self.command,
120
+ 'brand': self.brand, 'status': 'ok' if code == 0 else 'error',
121
+ 'exit_code': code, 'data': self.data, 'jobs': self.jobs,
122
+ 'jobs_complete': self.jobs_complete, 'errors': self.errors}
123
+ else:
124
+ payload = self.remote
125
+ code = payload['exit_code']
126
+ finally:
127
+ active_report.reset(self.token)
128
+ self.redirect.__exit__(kind, exc, traceback)
129
+ typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
130
+ if code:
131
+ raise typer.Exit(code) from None
132
+ return exc is not None
133
+
134
+
135
+ def bind_store(store):
136
+ report = active_report.get()
137
+ if report is not None:
138
+ report.bind(store)
139
+
140
+
141
+ def capture(value):
142
+ report = active_report.get()
143
+ if report is not None:
144
+ reject_secrets(value)
145
+ report.data.append(with_action(value))
146
+
147
+
148
+ def begin_submission():
149
+ report = active_report.get()
150
+ if report is not None:
151
+ report.started = True