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,295 @@
1
+ """Immutable exact proposals for one Analytics group or Reporting job effect."""
2
+ from contextlib import ExitStack
3
+ from datetime import datetime
4
+ import hmac
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Literal
8
+ import uuid
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from socialctl.management.changes import ApprovalMismatch, now_utc
13
+ from socialctl.management.resource_changes import ResourceStore, fingerprint, _event
14
+ from socialctl.management.youtube_resources import ResourceError, ResourceRejected, ResourceUncertain
15
+ from socialctl.metricas.analytics_client import ANALYTICS, REPORTING, complete, opaque, encoded
16
+ from socialctl.metricas.reporting_client import ANALYTICS_SCOPES
17
+
18
+ FIELDS = {"group-create": {"title", "item_type"}, "group-update": {"group_id", "title"},
19
+ "group-delete": {"group_id"}, "group-item-create": {"group_id", "resource_id"},
20
+ "group-item-delete": {"group_id", "item_id"}, "job-create": {"report_type_id", "name"},
21
+ "job-delete": {"job_id", "report_type_id"}}
22
+
23
+
24
+ def validate_edit(raw):
25
+ if not isinstance(raw, dict) or type(raw.get("version")) is not int or raw["version"] != 1 or raw.get("action") not in FIELDS:
26
+ raise ResourceError("unsupported Analytics edit action/version")
27
+ required = FIELDS[raw["action"]] | {"version", "action"}
28
+ if set(raw) != required:
29
+ raise ResourceError("edit requires exact action fields; group update changes only title")
30
+ for field in required - {"version", "action", "title", "name", "item_type"}:
31
+ opaque(raw[field])
32
+ for field in {"title", "name"} & required:
33
+ if not isinstance(raw[field], str) or not raw[field].strip() or len(raw[field]) > 150 or "\x00" in raw[field]:
34
+ raise ResourceError("title/name requires nonempty text, local limit 150")
35
+ if "item_type" in raw and raw["item_type"] not in {"youtube#video", "youtube#channel", "youtube#playlist"}:
36
+ raise ResourceError("content-owner asset groups not enabled")
37
+ return json.loads(json.dumps(raw))
38
+
39
+
40
+ class AnalyticsChange(BaseModel):
41
+ model_config = ConfigDict(extra="forbid")
42
+ version: Literal[1] = 1
43
+ kind: Literal["youtube-analytics"] = "youtube-analytics"
44
+ id: str
45
+ target_brand: str
46
+ brand_root: str
47
+ target_account: str
48
+ edit: dict
49
+ before: dict
50
+ after: dict
51
+ effects: list[str]
52
+ credential_observation: dict
53
+ fingerprint: str = ""
54
+ created_at: datetime
55
+ updated_at: datetime
56
+ status: Literal["proposed", "applying", "uncertain", "failed", "conflict", "verified"] = "proposed"
57
+ verified: bool = False
58
+ result_id: str | None = None
59
+ receipt: dict | None = None
60
+ journal: list[dict] = Field(default_factory=list)
61
+
62
+
63
+ class AnalyticsStore(ResourceStore):
64
+ model_type = AnalyticsChange
65
+ fingerprint_for = staticmethod(fingerprint)
66
+
67
+ def __init__(self, brand_root):
68
+ self.root = Path(brand_root) / ".socialctl" / "youtube-analytics-changes"
69
+
70
+ def load(self, change_id):
71
+ if self.path_for(change_id).is_symlink():
72
+ raise ResourceError("proposal symlink rejected")
73
+ return super().load(change_id)
74
+
75
+
76
+ def scopes(client, edit):
77
+ # Analytics group mutations explicitly accept the analytics grants too;
78
+ # do not demand a broader account grant when one is already sufficient.
79
+ client.require_scope(ANALYTICS_SCOPES if edit["action"].startswith("job-") else
80
+ ANALYTICS_SCOPES | {"https://www.googleapis.com/auth/youtube"})
81
+
82
+
83
+ def baseline(client, edit):
84
+ action = edit["action"]
85
+ if action == "group-create":
86
+ rows = complete(client.groups())
87
+ if any(r["snippet"]["title"] == edit["title"] and r["contentDetails"]["itemType"] == edit["item_type"] for r in rows):
88
+ raise ResourceError("matching group already exists; no implicit replacement")
89
+ return {"existing_group_ids": sorted(r["id"] for r in rows)}
90
+ if action == "job-create":
91
+ types = [r for r in complete(client.report_types()) if r["id"] == edit["report_type_id"]]
92
+ if len(types) != 1 or types[0].get("systemManaged", False):
93
+ raise ResourceError("report type unavailable or system managed")
94
+ jobs = complete(client.jobs())
95
+ if any(j["reportTypeId"] == edit["report_type_id"] for j in jobs):
96
+ raise ResourceError("job already exists for report type; no duplicate create")
97
+ return {"report_type": types[0], "existing_job_ids": sorted(j["id"] for j in jobs)}
98
+ if action == "job-delete":
99
+ row = client.job(edit["job_id"], edit["report_type_id"])
100
+ if row.get("systemManaged", False):
101
+ raise ResourceError("system-managed job cannot be deleted")
102
+ return row
103
+ group = client.group(edit["group_id"])
104
+ if action.startswith("group-item"):
105
+ items = complete(client.group_items(edit["group_id"]))
106
+ if action == "group-item-create":
107
+ owned = client.bind_group_resource(group, edit["resource_id"])
108
+ members = [r for r in items if r["resource"]["id"] == edit["resource_id"]]
109
+ if len(members) > 1:
110
+ raise ResourceError("ambiguous group membership")
111
+ return {"group": group, "members": members, "owned_resource_id": owned["id"]}
112
+ matches = [r for r in items if r["id"] == edit["item_id"]]
113
+ if len(matches) != 1:
114
+ raise ResourceError("item ID not verified in exact group")
115
+ return {"group": group, "item": matches[0]}
116
+ return group
117
+
118
+
119
+ def after_state(edit):
120
+ action = edit["action"]
121
+ if action == "group-create":
122
+ return {"snippet": {"title": edit["title"]}, "contentDetails": {"itemType": edit["item_type"]}}
123
+ if action == "group-update":
124
+ return {"id": edit["group_id"], "snippet": {"title": edit["title"]}}
125
+ if action == "group-item-create":
126
+ return {"groupId": edit["group_id"], "resource": {"id": edit["resource_id"]}}
127
+ if action == "job-create":
128
+ return {"reportTypeId": edit["report_type_id"], "name": edit["name"]}
129
+ return {"deleted_id": edit.get("item_id", edit.get("group_id", edit.get("job_id")))}
130
+
131
+
132
+ def prepare(client, store, raw):
133
+ edit = validate_edit(raw)
134
+ account = client.identity()
135
+ scopes(client, edit)
136
+ before = baseline(client, edit)
137
+ moment = now_utc()
138
+ effects = ["Exactly one remote " + edit["action"],
139
+ "No rollback guarantee; group IDs are independent of playlists; membership does not edit media.",
140
+ "Provider has no documented conditional version guard here; read/preflight cannot eliminate remote race."]
141
+ action_effect = {
142
+ "job-create": "Job creation starts recurring provider report generation; first report may take up to 48h.",
143
+ "job-delete": "Job deletion stops generation; stored report provenance retained, no local purge.",
144
+ "group-delete": "Group deletion removes its grouping/membership; original videos/channels/playlists remain.",
145
+ }.get(edit["action"])
146
+ if action_effect:
147
+ effects.append(action_effect)
148
+ change = AnalyticsChange(id=str(uuid.uuid4()), target_brand=client.brand.nombre,
149
+ brand_root=str(client.brand.raiz.resolve()), target_account=account, edit=edit,
150
+ before=before, after=after_state(edit), credential_observation=client.credential_observation,
151
+ effects=effects,
152
+ created_at=moment, updated_at=moment)
153
+ _event(change, "prepared")
154
+ change.fingerprint = fingerprint(change)
155
+ store.save(change)
156
+ return change
157
+
158
+
159
+ def identity(client, change):
160
+ if client.brand.nombre != change.target_brand or str(client.brand.raiz.resolve()) != change.brand_root or client.identity() != change.target_account:
161
+ raise ResourceError("proposal brand/account/root mismatch")
162
+
163
+
164
+ def operation_key(change):
165
+ e = change.edit
166
+ if e["action"].startswith("job-"):
167
+ target = ["job", e["report_type_id"]]
168
+ elif e["action"] == "group-create":
169
+ target = ["group-create", e["title"], e["item_type"]]
170
+ elif e["action"].startswith("group-item"):
171
+ target = ["group-item", e["group_id"], e.get("resource_id") or change.before["item"]["resource"]["id"]]
172
+ else:
173
+ target = ["group", e["group_id"]]
174
+ return json.dumps([change.target_account, *target], sort_keys=True)
175
+
176
+
177
+ def mutate(client, change):
178
+ e, a = change.edit, change.edit["action"]
179
+ if a.startswith("job-"):
180
+ url = REPORTING + "/jobs" + ("/" + encoded(e["job_id"]) if a == "job-delete" else "")
181
+ else:
182
+ url = ANALYTICS + ("/groupItems" if a.startswith("group-item") else "/groups")
183
+ method = "DELETE" if a.endswith("delete") else "PUT" if a == "group-update" else "POST"
184
+ kwargs = ({"params": {"id": change.after["deleted_id"]}} if method == "DELETE" and not a.startswith("job-") else {})
185
+ if method != "DELETE":
186
+ kwargs["json"] = change.after
187
+ response = client.service_request(method, url, **kwargs)
188
+ if method == "DELETE":
189
+ # Reporting documents an empty successful body, unlike Analytics' exact
190
+ # 204 contract. Some Google transports serialize google.protobuf.Empty
191
+ # as {}, also explicitly accepted for Reporting only.
192
+ accepted = response.status_code == 204 and not response.content
193
+ if a == "job-delete" and response.status_code == 200:
194
+ accepted = not response.content or response.content.strip() == b"{}"
195
+ if not accepted:
196
+ raise ResourceUncertain("delete response did not match documented empty success")
197
+ return {"http_status": response.status_code, "deleted_id": change.after["deleted_id"], "empty_success": True}, None
198
+ if a == "group-item-create" and response.status_code == 204:
199
+ return {"http_status": 204, "duplicate_membership": True, "group_id": e["group_id"], "resource_id": e["resource_id"]}, None
200
+ raw = client._json(response, writing=True)
201
+ try:
202
+ rid = opaque(raw.get("id"))
203
+ except ResourceError:
204
+ rid = None
205
+ return {"http_status": response.status_code, "response": raw}, rid
206
+
207
+
208
+ def readback(client, store, change):
209
+ identity(client, change)
210
+ a, e = change.edit["action"], change.edit
211
+ verified = False
212
+ try:
213
+ if change.receipt:
214
+ response = change.receipt.get("response", {})
215
+ if a == "group-item-create" and change.receipt.get("duplicate_membership"):
216
+ rows = complete(client.group_items(e["group_id"]))
217
+ verified = len([r for r in rows if r["resource"]["id"] == e["resource_id"]]) == 1
218
+ elif a.endswith("delete") and change.receipt.get("empty_success"):
219
+ rows = complete(client.jobs() if a == "job-delete" else client.group_items(e["group_id"]) if a == "group-item-delete" else client.groups())
220
+ verified = all(r["id"] != change.after["deleted_id"] for r in rows)
221
+ elif change.result_id:
222
+ rid = change.result_id
223
+ if a.startswith("group-item"):
224
+ rows = [r for r in complete(client.group_items(e["group_id"])) if r["id"] == rid]
225
+ verified = response.get("groupId") == e["group_id"] and response.get("resource", {}).get("id") == e["resource_id"] and len(rows) == 1 and rows[0]["resource"]["id"] == e["resource_id"]
226
+ elif a.startswith("group-"):
227
+ row = client.group(rid)
228
+ kind = e.get("item_type") or change.before["contentDetails"]["itemType"]
229
+ verified = (a != "group-update" or rid == e["group_id"]) and response.get("snippet", {}).get("title") == e["title"] and row["snippet"]["title"] == e["title"] and row["contentDetails"]["itemType"] == kind
230
+ if a == "group-create":
231
+ verified = verified and response.get("contentDetails", {}).get("itemType") == kind
232
+ elif a == "job-create":
233
+ row = client.job(rid, e["report_type_id"])
234
+ verified = all(response.get(k) == v and row.get(k) == v for k, v in change.after.items())
235
+ _event(change, "readback", matched=verified, limitation="receipt required; missing/partial lists do not prove absence")
236
+ except (ResourceError, TypeError, KeyError, AttributeError):
237
+ _event(change, "readback_unavailable")
238
+ if verified:
239
+ change.status, change.verified = "verified", True
240
+ else:
241
+ change.status = "uncertain"
242
+ store.save(change)
243
+ return change
244
+
245
+
246
+ def reconcile(client, store, change_id):
247
+ with store.apply_lock(change_id):
248
+ change = store.load(change_id)
249
+ identity(client, change)
250
+ if change.status in {"applying", "uncertain"}:
251
+ return readback(client, store, change)
252
+ return change
253
+
254
+
255
+ def apply(client, store, change_id, approval):
256
+ with ExitStack() as locks:
257
+ locks.enter_context(store.apply_lock(change_id))
258
+ change = store.load(change_id)
259
+ if not hmac.compare_digest(approval, change.fingerprint):
260
+ raise ApprovalMismatch("approval does not match exact immutable fingerprint")
261
+ locks.enter_context(store.apply_lock(str(uuid.uuid5(uuid.NAMESPACE_URL, change.target_account + ":analytics-effects"))))
262
+ identity(client, change)
263
+ if change.status in {"applying", "uncertain"}:
264
+ return readback(client, store, change)
265
+ if change.status != "proposed":
266
+ return change
267
+ validate_edit(change.edit)
268
+ scopes(client, change.edit)
269
+ for path in store.root.glob("*.json"):
270
+ other = store.load(path.stem)
271
+ if other.id != change.id and other.status in {"applying", "uncertain"} and operation_key(other) == operation_key(change):
272
+ raise ResourceError("uncertain earlier effect blocks a new UUID; reconcile without retry")
273
+ if baseline(client, change.edit) != change.before:
274
+ change.status = "conflict"
275
+ _event(change, "preflight_conflict")
276
+ store.save(change)
277
+ raise ResourceError("remote baseline changed; exact approval no longer applies")
278
+ change.status = "applying"
279
+ _event(change, "write_intent", approval_digest=approval)
280
+ store.save(change)
281
+ try:
282
+ change.receipt, change.result_id = mutate(client, change)
283
+ except ResourceRejected as exc:
284
+ change.status = "failed"
285
+ _event(change, "write_rejected", http_status=exc.http_status,
286
+ provider_reason=exc.provider_reason)
287
+ except Exception:
288
+ change.status = "uncertain"
289
+ _event(change, "write_uncertain")
290
+ else:
291
+ change.status = "uncertain"
292
+ _event(change, "write_receipt", result_id=change.result_id)
293
+ # Receipt is durable before any verification request.
294
+ store.save(change)
295
+ return readback(client, store, change) if change.status == "uncertain" else change
@@ -0,0 +1,191 @@
1
+ """Named Analytics/Reporting commands; reads never create jobs or refresh OAuth."""
2
+ from datetime import date
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import httpx
7
+ import typer
8
+
9
+ from socialctl.approval_cli import read_approval
10
+ import yaml
11
+
12
+ from socialctl.metricas.analytics_changes import AnalyticsStore, prepare as prepare_change, apply as apply_change, reconcile as reconcile_change
13
+ from socialctl.metricas.analytics_ingest import ObservationStore, read_supplied
14
+ from socialctl.metricas.analytics_schema import ReportQuery
15
+ from socialctl.metricas.reporting_client import ReportingClient
16
+
17
+
18
+ def make_http_client():
19
+ return httpx.Client(timeout=60, follow_redirects=False)
20
+
21
+
22
+ def output(value):
23
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
24
+
25
+
26
+ def preview(change):
27
+ return "DRY-RUN — COMPLETE PREVIEW Analytics/Reporting\n" + output(change.model_dump(mode="json")) + "\nExact fingerprint for approval: " + change.fingerprint
28
+
29
+
30
+ def load_mapping(path):
31
+ try:
32
+ value = yaml.safe_load(read_supplied(path, 65536))
33
+ except yaml.YAMLError:
34
+ raise ValueError("invalid supplied JSON/YAML") from None
35
+ if not isinstance(value, dict):
36
+ raise ValueError("supplied JSON/YAML must be a mapping")
37
+ return value
38
+
39
+
40
+ def register(content_app, default_root):
41
+ from socialctl.management.cli import _brand, _fail
42
+ analytics = typer.Typer(help="Analytics/Reporting: official periods, CSV and exact proposals.")
43
+ content_app.add_typer(analytics, name="youtube-analytics")
44
+
45
+ def read(root, brand, callback):
46
+ selected = _brand(root, brand)
47
+ try:
48
+ with make_http_client() as http:
49
+ value = callback(ReportingClient(selected, http))
50
+ except (ValueError, OSError, KeyError, TypeError) as exc:
51
+ _fail(str(exc))
52
+ typer.echo(output(value))
53
+ if isinstance(value, dict) and value.get("complete") is False:
54
+ raise typer.Exit(1)
55
+
56
+ @analytics.command("query")
57
+ def query(file: Path = typer.Option(..., "--file"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
58
+ def run(client):
59
+ result = client.query(ReportQuery.model_validate(load_mapping(file)))
60
+ saved = ObservationStore(client.brand.raiz, client.configured_channel_id).save_query(result)
61
+ return {**result, "stored": saved}
62
+ read(root, brand, run)
63
+
64
+ @analytics.command("compare")
65
+ def compare(end: str = typer.Option(..., "--end"), metric: list[str] = typer.Option(None, "--metric"), video_id: list[str] = typer.Option(None, "--video-id"),
66
+ brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
67
+ def run(client):
68
+ result = client.compare(date.fromisoformat(end), metric, video_id)
69
+ store = ObservationStore(client.brand.raiz, client.configured_channel_id)
70
+ for window in result["windows"]:
71
+ for name in ("current", "previous"):
72
+ window[name]["stored"] = store.save_query(window[name])
73
+ result["complete"] = all(w[k]["complete"] for w in result["windows"] for k in ("current", "previous"))
74
+ return result
75
+ read(root, brand, run)
76
+
77
+ @analytics.command("batch-stats")
78
+ def batch_stats(video_id: list[str] = typer.Option(..., "--video-id"), part: list[str] = typer.Option(None, "--part"),
79
+ brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
80
+ read(root, brand, lambda c: c.batch_stats(video_id, part))
81
+
82
+ @analytics.command("groups-list")
83
+ def groups_list(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"), max_pages: int = typer.Option(100, "--max-pages", min=1, max=100)):
84
+ read(root, brand, lambda c: c.groups(max_pages))
85
+
86
+ @analytics.command("group-show")
87
+ def group_show(group_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
88
+ read(root, brand, lambda c: c.group(group_id))
89
+
90
+ @analytics.command("group-items-list")
91
+ def group_items_list(group_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
92
+ read(root, brand, lambda c: c.group_items(group_id))
93
+
94
+ @analytics.command("report-types-list")
95
+ def report_types_list(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"), max_pages: int = typer.Option(100, "--max-pages", min=1, max=100)):
96
+ read(root, brand, lambda c: c.report_types(max_pages))
97
+
98
+ @analytics.command("jobs-list")
99
+ def jobs_list(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"), max_pages: int = typer.Option(100, "--max-pages", min=1, max=100)):
100
+ read(root, brand, lambda c: c.jobs(max_pages))
101
+
102
+ @analytics.command("job-show")
103
+ def job_show(job_id: str, report_type_id: str = typer.Option(..., "--report-type-id"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
104
+ read(root, brand, lambda c: c.job(job_id, report_type_id))
105
+
106
+ @analytics.command("reports-list")
107
+ def reports_list(job_id: str, report_type_id: str = typer.Option(..., "--report-type-id"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"),
108
+ max_pages: int = typer.Option(100, "--max-pages", min=1, max=100), created_after: str | None = typer.Option(None, "--created-after"),
109
+ start_time_at_or_after: str | None = typer.Option(None, "--start-time-at-or-after"), start_time_before: str | None = typer.Option(None, "--start-time-before")):
110
+ read(root, brand, lambda c: c.reports(job_id, report_type_id, max_pages, created_after, start_time_at_or_after, start_time_before))
111
+
112
+ @analytics.command("report-show")
113
+ def report_show(job_id: str, report_id: str, report_type_id: str = typer.Option(..., "--report-type-id"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
114
+ read(root, brand, lambda c: c.report(job_id, report_type_id, report_id))
115
+
116
+ @analytics.command("report-download")
117
+ def report_download(job_id: str, report_id: str, report_type_id: str = typer.Option(..., "--report-type-id"), start: str = typer.Option(..., "--start"), end: str = typer.Option(..., "--end"),
118
+ brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
119
+ """Download and atomically import CSV and provenance into private storage."""
120
+ def run(client):
121
+ binding, data = client.download(job_id=job_id, report_id=report_id, report_type_id=report_type_id, start_date=start, end_date=end)
122
+ store = ObservationStore(client.brand.raiz, client.configured_channel_id)
123
+ value = store.import_csv(data, binding)
124
+ return {"id": value["id"], "rows": len(value["rows"]), "binding": binding, "raw_path": str(store.root / (value["id"] + ".csv")), "checkpoint": "complete"}
125
+ read(root, brand, run)
126
+
127
+ @analytics.command("studio-import")
128
+ def studio_import(file: Path = typer.Option(..., "--file"), metadata: Path = typer.Option(..., "--metadata"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
129
+ selected = _brand(root, brand)
130
+ try:
131
+ binding = load_mapping(metadata)
132
+ if binding.get("source") != "youtubeStudio":
133
+ raise ValueError("studio-import requires source youtubeStudio; supplied files cannot assert API authentication")
134
+ account = selected.cuentas.get("youtube", {}).get("channel_id")
135
+ store = ObservationStore(selected.raiz, account)
136
+ value = store.import_csv(read_supplied(file), binding)
137
+ except (ValueError, OSError, KeyError, TypeError) as exc:
138
+ _fail(str(exc))
139
+ typer.echo(output({"id": value["id"], "rows": len(value["rows"]), "provenance": value["provenance"]}))
140
+
141
+ @analytics.command("observations")
142
+ def observations(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
143
+ selected = _brand(root, brand)
144
+ try:
145
+ value = ObservationStore(selected.raiz, selected.cuentas.get("youtube", {}).get("channel_id")).active()
146
+ except (ValueError, OSError) as exc:
147
+ _fail(str(exc))
148
+ typer.echo(output(value))
149
+
150
+ @analytics.command("prepare")
151
+ def prepare(file: Path = typer.Option(..., "--file"), dry_run: bool = typer.Option(False, "--dry-run"), brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
152
+ selected = _brand(root, brand)
153
+ try:
154
+ with make_http_client() as http:
155
+ change = prepare_change(ReportingClient(selected, http), AnalyticsStore(selected.raiz), load_mapping(file))
156
+ except (ValueError, OSError) as exc:
157
+ _fail(str(exc))
158
+ typer.echo(preview(change))
159
+
160
+ @analytics.command("apply")
161
+ def apply(change_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root"),
162
+ approval_fingerprint: str | None = typer.Option(None, "--approval-fingerprint", "--approval", "--approval-digest", help="Exact fingerprint from the approved complete preview."),
163
+ approval_stdin: bool = typer.Option(False, "--approval-stdin", help="Explicitly read one approval line from stdin.")):
164
+ selected = _brand(root, brand)
165
+ store = AnalyticsStore(selected.raiz)
166
+ try:
167
+ change = store.load(change_id)
168
+ typer.echo(preview(change))
169
+ approval = read_approval(approval_fingerprint, approval_stdin=approval_stdin)
170
+ if approval != change.fingerprint:
171
+ raise ValueError("approval does not match fingerprint")
172
+ with make_http_client() as http:
173
+ result = apply_change(ReportingClient(selected, http), store, change_id, approval)
174
+ except (ValueError, OSError) as exc:
175
+ _fail(str(exc))
176
+ typer.echo(output(result.model_dump(mode="json")))
177
+ if not result.verified:
178
+ raise typer.Exit(1)
179
+
180
+ @analytics.command("status")
181
+ def status(change_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
182
+ selected = _brand(root, brand)
183
+ try:
184
+ value = AnalyticsStore(selected.raiz).load(change_id)
185
+ except ValueError as exc:
186
+ _fail(str(exc))
187
+ typer.echo(output(value.model_dump(mode="json")))
188
+
189
+ @analytics.command("reconcile")
190
+ def reconcile(change_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(default_root, "--root")):
191
+ read(root, brand, lambda c: reconcile_change(c, AnalyticsStore(c.brand.raiz), change_id).model_dump(mode="json"))