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.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- socialctl/youtube_data_service.py +225 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'YouTube Data API v3 resumable video upload.\n\nThe adapter validates one user-supplied video, initializes a resumable upload,\nstreams the original bytes and records the returned resource ID. Upload success\nis distinct from visibility observations. Expected I/O, network and response\nfailures become PostResult errors rather than escaping the adapter boundary.\nKnown credentials are redacted from provider errors.'
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from socialctl.adapters.base import ADAPTADORES, Adapter
|
|
11
|
+
from socialctl.adapters.errores import mensaje_de_error
|
|
12
|
+
from socialctl.auth import obtener_token
|
|
13
|
+
from socialctl.brands import Brand
|
|
14
|
+
from socialctl.formatter import (
|
|
15
|
+
PLATFORM_SPECS,
|
|
16
|
+
privacidad_efectiva,
|
|
17
|
+
texto_publicado,
|
|
18
|
+
youtube_tags,
|
|
19
|
+
)
|
|
20
|
+
from socialctl.models import Platform, PlatformPost, PostResult, PostStatus
|
|
21
|
+
|
|
22
|
+
URL_UPLOAD = "https://www.googleapis.com/upload/youtube/v3/videos"
|
|
23
|
+
URL_VIDEOS = "https://www.googleapis.com/youtube/v3/videos"
|
|
24
|
+
|
|
25
|
+
# MIME type declarado en la subida resumible. Este proyecto no detecta el
|
|
26
|
+
# tipo MIME real de cada fichero (los adaptadores no llevan una tabla de
|
|
27
|
+
# extensión→MIME); "video/*" es el valor de comodín que la propia
|
|
28
|
+
# documentación de Google da como ejemplo válido para
|
|
29
|
+
# X-Upload-Content-Type ("The MIME type of the file (e.g., 'video/*')",
|
|
30
|
+
# developers.google.com/youtube/v3/guides/using_resumable_upload_protocol),
|
|
31
|
+
# así que sirve sin necesidad de esa tabla. Se usa el mismo valor en el
|
|
32
|
+
# POST inicial y en el PUT final porque la documentación exige que
|
|
33
|
+
# coincidan ("both of which must match the values provided in the initial
|
|
34
|
+
# request").
|
|
35
|
+
_TIPO_MIME_VIDEO = "video/*"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class YouTubeAdapter(Adapter):
|
|
39
|
+
'Upload one video through the YouTube Data API v3 resumable protocol.'
|
|
40
|
+
|
|
41
|
+
platform = Platform.YOUTUBE
|
|
42
|
+
|
|
43
|
+
def publish(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
|
|
44
|
+
'Publish a validated post; convert every failure to an error PostResult.'
|
|
45
|
+
spec = PLATFORM_SPECS[self.platform]
|
|
46
|
+
if len(post.media) > spec.max_media:
|
|
47
|
+
return self._error(
|
|
48
|
+
f"this endpoint supports at most {spec.max_media} file; "
|
|
49
|
+
'it does not publish carousels'
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
return self._publicar(post, brand, client)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
return self._error(
|
|
56
|
+
f"an unexpected YouTube error occurred "
|
|
57
|
+
f"({type(exc).__name__}): {exc}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _publicar(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
|
|
61
|
+
'Execute publication with explicit handling of expected failures.'
|
|
62
|
+
if not post.media:
|
|
63
|
+
return self._error(
|
|
64
|
+
'no video to upload: the post has no media attached'
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
video = post.media[0]
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
token = obtener_token(brand, self.platform, client)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
return self._error(str(exc))
|
|
73
|
+
|
|
74
|
+
# El tamaño real del fichero hace falta ya para el POST inicial (la
|
|
75
|
+
# cabecera X-Upload-Content-Length, ver más abajo), no solo para el
|
|
76
|
+
# PUT: por eso se calcula aquí, antes de tocar la red, con el mismo
|
|
77
|
+
# manejo de errores de E/S que antes solo cubría el PUT. Como
|
|
78
|
+
# beneficio adicional, un fichero ya borrado o ilegible se detecta
|
|
79
|
+
# antes de gastar una petición de red en abrir una sesión de subida
|
|
80
|
+
# que no se podría completar.
|
|
81
|
+
try:
|
|
82
|
+
tamano = video.path.stat().st_size
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return self._error(
|
|
85
|
+
f"the video file no longer exists on disk: {video.path}"
|
|
86
|
+
)
|
|
87
|
+
except IsADirectoryError:
|
|
88
|
+
return self._error(
|
|
89
|
+
f"the video path points to a directory, not a file: {video.path}"
|
|
90
|
+
)
|
|
91
|
+
except PermissionError:
|
|
92
|
+
return self._error(
|
|
93
|
+
f"the video file is not readable: {video.path}"
|
|
94
|
+
)
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
return self._error(
|
|
97
|
+
f"could not read the video file from disk ({video.path}): {exc}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# privacidad_efectiva() (socialctl/formatter.py) ya resuelve el valor
|
|
101
|
+
# por defecto ("private") cuando post.privacy no se ha indicado: por
|
|
102
|
+
# decisión explícita del proyecto, ningún vídeo sale público sin que
|
|
103
|
+
# el usuario lo pida (el canal tiene ~5000 suscriptores). "public",
|
|
104
|
+
# "private" y "unlisted" son los tres valores que admite
|
|
105
|
+
# status.privacyStatus (confirmado con Context7, ver
|
|
106
|
+
# PLATFORM_SPECS[Platform.YOUTUBE].valores_privacidad).
|
|
107
|
+
requested_privacy = privacidad_efectiva(post)
|
|
108
|
+
metadatos = {
|
|
109
|
+
"snippet": {
|
|
110
|
+
"title": post.title,
|
|
111
|
+
"description": texto_publicado(post),
|
|
112
|
+
"tags": youtube_tags(post),
|
|
113
|
+
},
|
|
114
|
+
"status": {"privacyStatus": requested_privacy},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
inicio = client.post(
|
|
119
|
+
URL_UPLOAD,
|
|
120
|
+
params={"uploadType": "resumable", "part": "snippet,status"},
|
|
121
|
+
headers={
|
|
122
|
+
"Authorization": f"Bearer {token}",
|
|
123
|
+
# Ambas son "Required" en la tabla de cabeceras del paso 1
|
|
124
|
+
# del flujo resumible (Step 1 - Start a resumable
|
|
125
|
+
# session): el tamaño total del fichero y su MIME type.
|
|
126
|
+
"X-Upload-Content-Length": str(tamano),
|
|
127
|
+
"X-Upload-Content-Type": _TIPO_MIME_VIDEO,
|
|
128
|
+
},
|
|
129
|
+
json=metadatos,
|
|
130
|
+
)
|
|
131
|
+
except httpx.TimeoutException:
|
|
132
|
+
return self._error(
|
|
133
|
+
'timed out contacting YouTube to '
|
|
134
|
+
'initialize the upload'
|
|
135
|
+
)
|
|
136
|
+
except httpx.HTTPError:
|
|
137
|
+
return self._error(
|
|
138
|
+
'could not connect to YouTube to initialize the upload'
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if inicio.status_code != 200:
|
|
142
|
+
return self._error(mensaje_de_error(inicio, token))
|
|
143
|
+
|
|
144
|
+
destino = inicio.headers.get("Location")
|
|
145
|
+
if not destino:
|
|
146
|
+
return self._error('YouTube did not return an upload URL')
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
with video.path.open("rb") as fichero:
|
|
150
|
+
subida = client.put(
|
|
151
|
+
destino,
|
|
152
|
+
headers={
|
|
153
|
+
"Authorization": f"Bearer {token}",
|
|
154
|
+
"Content-Length": str(tamano),
|
|
155
|
+
# "Required" en el paso 3 del flujo resumible (Step 3
|
|
156
|
+
# - Upload the video file), y debe coincidir con el
|
|
157
|
+
# X-Upload-Content-Type enviado en el POST inicial.
|
|
158
|
+
"Content-Type": _TIPO_MIME_VIDEO,
|
|
159
|
+
},
|
|
160
|
+
content=fichero,
|
|
161
|
+
timeout=None,
|
|
162
|
+
)
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
return self._error(
|
|
165
|
+
f"the video file no longer exists on disk: {video.path}"
|
|
166
|
+
)
|
|
167
|
+
except IsADirectoryError:
|
|
168
|
+
return self._error(
|
|
169
|
+
f"the video path points to a directory, not a file: {video.path}"
|
|
170
|
+
)
|
|
171
|
+
except PermissionError:
|
|
172
|
+
return self._error(
|
|
173
|
+
f"the video file is not readable: {video.path}"
|
|
174
|
+
)
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
# Cualquier otro fallo de E/S al leer el fichero (disco dañado,
|
|
177
|
+
# demasiados descriptores abiertos, etc.): no es ninguno de los
|
|
178
|
+
# casos anteriores, pero tampoco debe escapar como excepción.
|
|
179
|
+
return self._error(
|
|
180
|
+
f"could not read the video file from disk ({video.path}): {exc}"
|
|
181
|
+
)
|
|
182
|
+
except httpx.InvalidURL as exc:
|
|
183
|
+
# La URL de subida (`destino`) la devuelve YouTube en la cabecera
|
|
184
|
+
# `Location` de la respuesta al POST inicial: no es un literal
|
|
185
|
+
# nuestro, así que puede llegar mal formada (puerto no numérico,
|
|
186
|
+
# longitud desmesurada, etc.) y `httpx` la rechaza al construir la
|
|
187
|
+
# petición, antes de llegar a la red.
|
|
188
|
+
return self._error(
|
|
189
|
+
f"YouTube returned an invalid upload URL: {exc}"
|
|
190
|
+
)
|
|
191
|
+
except httpx.TimeoutException:
|
|
192
|
+
return self._error(
|
|
193
|
+
'timed out uploading the video to YouTube'
|
|
194
|
+
)
|
|
195
|
+
except httpx.HTTPError:
|
|
196
|
+
return self._error(
|
|
197
|
+
'could not connect to YouTube to upload the video'
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if subida.status_code not in (200, 201):
|
|
201
|
+
return self._error(mensaje_de_error(subida, token))
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
recurso_subido = subida.json()
|
|
205
|
+
video_id = recurso_subido["id"]
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
return self._error(
|
|
208
|
+
'YouTube returned an invalid JSON response after '
|
|
209
|
+
'uploading the video'
|
|
210
|
+
)
|
|
211
|
+
except KeyError:
|
|
212
|
+
return self._error(
|
|
213
|
+
'YouTube returned no video ID after the upload'
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if not isinstance(video_id, str) or not video_id:
|
|
217
|
+
return self._error('YouTube returned an invalid video ID after the upload')
|
|
218
|
+
|
|
219
|
+
result = PostResult(
|
|
220
|
+
platform=self.platform,
|
|
221
|
+
status=PostStatus.PUBLICADO,
|
|
222
|
+
platform_id=video_id,
|
|
223
|
+
url=f"https://www.youtube.com/watch?v={video_id}",
|
|
224
|
+
requested_privacy=requested_privacy,
|
|
225
|
+
)
|
|
226
|
+
try:
|
|
227
|
+
self._apply_observation(result, recurso_subido)
|
|
228
|
+
except Exception:
|
|
229
|
+
# The ID is already authoritative. Optional fields in the upload
|
|
230
|
+
# response can be malformed, but can never turn that confirmed
|
|
231
|
+
# upload back into a retryable error.
|
|
232
|
+
result.warnings.append(
|
|
233
|
+
'The upload was confirmed, but its '
|
|
234
|
+
'initial visibility could not be interpreted.'
|
|
235
|
+
)
|
|
236
|
+
self._readback(result, token, client)
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _observation(
|
|
241
|
+
resource: object,
|
|
242
|
+
) -> tuple[str | None, str | None, str | None, bool] | None:
|
|
243
|
+
if not isinstance(resource, dict):
|
|
244
|
+
return None
|
|
245
|
+
status = resource.get("status")
|
|
246
|
+
processing = resource.get("processingDetails")
|
|
247
|
+
status = status if isinstance(status, dict) else {}
|
|
248
|
+
processing = processing if isinstance(processing, dict) else {}
|
|
249
|
+
privacy = status.get("privacyStatus")
|
|
250
|
+
publish_at = status.get("publishAt")
|
|
251
|
+
processing_status = processing.get("processingStatus")
|
|
252
|
+
privacy = privacy if isinstance(privacy, str) and privacy else None
|
|
253
|
+
valid_visibility = privacy is not None
|
|
254
|
+
if isinstance(publish_at, str) and publish_at:
|
|
255
|
+
try:
|
|
256
|
+
parsed_publish_at = datetime.fromisoformat(
|
|
257
|
+
publish_at.replace("Z", "+00:00")
|
|
258
|
+
)
|
|
259
|
+
except ValueError:
|
|
260
|
+
publish_at = None
|
|
261
|
+
else:
|
|
262
|
+
if parsed_publish_at.utcoffset() is None:
|
|
263
|
+
publish_at = None
|
|
264
|
+
elif publish_at is not None:
|
|
265
|
+
publish_at = None
|
|
266
|
+
else:
|
|
267
|
+
publish_at = None
|
|
268
|
+
processing_status = (
|
|
269
|
+
processing_status
|
|
270
|
+
if isinstance(processing_status, str) and processing_status
|
|
271
|
+
else None
|
|
272
|
+
)
|
|
273
|
+
if not valid_visibility and processing_status is None:
|
|
274
|
+
return None
|
|
275
|
+
return privacy, publish_at, processing_status, valid_visibility
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def _apply_observation(cls, result: PostResult, resource: object) -> bool:
|
|
279
|
+
observation = cls._observation(resource)
|
|
280
|
+
if observation is None:
|
|
281
|
+
return False
|
|
282
|
+
privacy, publish_at, processing_status, valid_visibility = observation
|
|
283
|
+
if valid_visibility:
|
|
284
|
+
# `privacyStatus` anchors a complete visibility snapshot. Its
|
|
285
|
+
# absence in a processing-only readback must not erase a status
|
|
286
|
+
# returned by the upload or move that older status to a new time.
|
|
287
|
+
result.observed_privacy = privacy
|
|
288
|
+
result.observed_publish_at = publish_at
|
|
289
|
+
result.visibility_observed_at = datetime.now(timezone.utc).isoformat()
|
|
290
|
+
if processing_status is not None:
|
|
291
|
+
# Processing has no separate timestamp in PostResult. Preserve
|
|
292
|
+
# the existing visibility timestamp when this is the only field
|
|
293
|
+
# returned, rather than attributing old privacy data to this GET.
|
|
294
|
+
result.observed_processing_status = processing_status
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
def _readback(self, result: PostResult, token: str, client: httpx.Client) -> None:
|
|
298
|
+
"""Read one resource snapshot; upload success remains authoritative."""
|
|
299
|
+
try:
|
|
300
|
+
response = client.get(
|
|
301
|
+
URL_VIDEOS,
|
|
302
|
+
params={
|
|
303
|
+
"id": result.platform_id,
|
|
304
|
+
"part": "status,processingDetails",
|
|
305
|
+
},
|
|
306
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
307
|
+
)
|
|
308
|
+
if response.status_code != 200:
|
|
309
|
+
result.warnings.append(
|
|
310
|
+
'The upload was confirmed, but its '
|
|
311
|
+
f"visibility could not be verified (YouTube returned HTTP {response.status_code})."
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
payload = response.json()
|
|
315
|
+
items = payload.get("items") if isinstance(payload, dict) else None
|
|
316
|
+
resource = items[0] if isinstance(items, list) and items else None
|
|
317
|
+
if resource is None or not self._apply_observation(result, resource):
|
|
318
|
+
result.warnings.append(
|
|
319
|
+
'The upload was confirmed, but YouTube did not return a '
|
|
320
|
+
'usable visibility observation.'
|
|
321
|
+
)
|
|
322
|
+
except Exception:
|
|
323
|
+
result.warnings.append(
|
|
324
|
+
'The upload was confirmed, but its visibility could not be verified.'
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _error(self, mensaje: str) -> PostResult:
|
|
328
|
+
return PostResult(platform=self.platform, status=PostStatus.ERROR, error=mensaje)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
ADAPTADORES[Platform.YOUTUBE] = YouTubeAdapter
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Conservative next-action hints from typed failures, never string guessing."""
|
|
2
|
+
from socialctl.connections.saas_client import MESSAGES, ServiceRejected, TransportFailure
|
|
3
|
+
|
|
4
|
+
_ACTIONS = {
|
|
5
|
+
'privacy_acceptance_required': 'user_action',
|
|
6
|
+
'assistant_consent_required': 'user_action',
|
|
7
|
+
'platform_not_allowed': 'user_action',
|
|
8
|
+
'installation_permissions_invalid': 'user_action',
|
|
9
|
+
'provider_authorization_expired': 'reconnect',
|
|
10
|
+
'provider_permissions_changed': 'reconnect',
|
|
11
|
+
'renewal_uncertain': 'reconcile',
|
|
12
|
+
'renewal_pending': 'retry_later',
|
|
13
|
+
'renewal_retryable': 'retry_later',
|
|
14
|
+
'renewal_required': 'user_action',
|
|
15
|
+
'provider_configuration': 'contact_support',
|
|
16
|
+
'installation_revoked': 'sign_in',
|
|
17
|
+
'installation_expired': 'sign_in',
|
|
18
|
+
'activation_required': 'recover',
|
|
19
|
+
'subscription_required': 'user_action',
|
|
20
|
+
'brand_limit': 'user_action',
|
|
21
|
+
'connection_inactive': 'user_action',
|
|
22
|
+
'connection_unavailable': 'user_action',
|
|
23
|
+
'connection_replaced': 'user_action',
|
|
24
|
+
'connection_changed': 'user_action',
|
|
25
|
+
'user_authorization_required': 'user_action',
|
|
26
|
+
'operation_conflict': 'user_action',
|
|
27
|
+
'operation_rejected': 'inspect',
|
|
28
|
+
'operation_uncertain': 'reconcile',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Only fixed internal codes acquire action semantics. Provider text is diagnostic,
|
|
33
|
+
# never a command, an expiry signal or evidence that a write was not accepted.
|
|
34
|
+
_CATALOG = {
|
|
35
|
+
'approval_mismatch': ('user_action', 'The supplied approval does not match this exact preview. Obtain the owner approval before applying.'),
|
|
36
|
+
'approval_required': ('user_action', 'Show the complete preview and obtain explicit approval of its exact fingerprint.'),
|
|
37
|
+
'operation_cancelled': ('inspect', 'Approval was cancelled. No write was started by this command.'),
|
|
38
|
+
'publication_pending': ('user_action', 'Complete or verify this existing submission in the platform; do not upload it again.'),
|
|
39
|
+
'comment_incomplete': ('reconcile', 'Inspect the existing first-comment attempt and its ChangeSet. Do not upload the media again.'),
|
|
40
|
+
'validation_failed': ('correct_input', 'Review every blocking validation item in the complete preview before approval.'),
|
|
41
|
+
'reporting_incomplete': ('inspect', 'Some report observations could not be collected. Inspect the durable result and journal before further action; do not replay a write.'),
|
|
42
|
+
'native_wait': ('wait', 'Keep this approved job and wait for its delivery window; do not create a duplicate.'),
|
|
43
|
+
'native_dispatch': ('inspect', 'Inspect the approved route and delivery readiness before dispatching this existing job.'),
|
|
44
|
+
'native_ui_required': ('user_action', 'Continue the exact approved handoff in the official platform UI. A submitted boundary permits readback only.'),
|
|
45
|
+
'native_verify': ('reconcile', 'Collect fresh official evidence for this existing job. Scheduling alone does not prove publication.'),
|
|
46
|
+
'operation_failed': ('inspect', 'Inspect the current state before taking another action.'),
|
|
47
|
+
'service_rejected': ('inspect', 'Review account status and service availability.'),
|
|
48
|
+
'local_configuration': ('user_action', 'Check local URL, proxy and CA configuration. No server rejection was received; keep credentials unchanged.'),
|
|
49
|
+
'invalid_local_state': ('user_action', 'Review arguments, local account state and protected credential storage.'),
|
|
50
|
+
'read_partial': ('inspect', 'Keep verified observations and inspect coverage before another read.'),
|
|
51
|
+
'read_deadline': ('adjust_read', 'Keep verified observations; reduce read scope or increase the read budget.'),
|
|
52
|
+
'response_too_large': ('adjust_read', 'Reduce read scope or page size; do not repeat the same oversized response.'),
|
|
53
|
+
'read_interrupted': ('inspect', 'Keep verified observations. Resume only if the read is still requested.'),
|
|
54
|
+
'not_attempted': ('inspect', 'This read was not attempted; inspect completed coverage before continuing.'),
|
|
55
|
+
'transport_failure': ('retry_later', 'Wait before a bounded read retry. Keep credentials and all verified observations.'),
|
|
56
|
+
'provider_unavailable': ('retry_later', 'Wait before a bounded read retry; the provider is unavailable.'),
|
|
57
|
+
'rate_limited': ('retry_later', 'Wait for the provider rate limit before a bounded read retry.'),
|
|
58
|
+
'authorization_unavailable': ('user_action', 'Review the account authorization; this failure alone does not prove token expiry.'),
|
|
59
|
+
'permissions_required': ('user_action', 'Review granted permissions and account eligibility with the owner.'),
|
|
60
|
+
'comments_disabled': ('inspect', 'Comments are disabled on this video. Reconnecting does not enable them; do not change video settings without owner approval.'),
|
|
61
|
+
'target_unavailable': ('correct_input', 'Verify the requested target and account before another read.'),
|
|
62
|
+
'request_rejected': ('correct_input', 'Review supported arguments and target before another request.'),
|
|
63
|
+
'operation_rejected': ('inspect', 'Inspect the target, stored state and approval before another operation.'),
|
|
64
|
+
'operation_uncertain': ('reconcile', 'Reconcile the existing operation. Do not replay its write.'),
|
|
65
|
+
'operation_already_verified': ('inspect', 'An identical operation was already verified. No new write was started. Inspect the existing receipt; do not duplicate it.'),
|
|
66
|
+
'operation_accepted_unverified': ('inspect', 'The provider accepted the operation. Independent verification is unavailable; this is not a rejection. Preserve the receipt and do not replay the write.'),
|
|
67
|
+
'provider_response_invalid': ('inspect', 'Preserve verified observations and inspect the incomplete provider response.'),
|
|
68
|
+
'storage_failure': ('repair_storage', 'Inspect local storage and preserve saved observations before recovery.'),
|
|
69
|
+
'local_data_busy': ('retry_later', 'Another SocialCli operation is using local data. Wait for it to finish, then retry. Do not delete or repair the files.'),
|
|
70
|
+
'persistence_failed': ('repair_storage', 'Preserve verified observations and repair local persistence before collecting again.'),
|
|
71
|
+
'unsupported_operation': ('unsupported', 'This integration does not support the requested operation.'),
|
|
72
|
+
'account_not_configured': ('user_action', 'Ask the owner to select and authorize the intended account; never infer one.'),
|
|
73
|
+
'invalid_input': ('correct_input', 'Review the supported arguments and explicit workspace.'),
|
|
74
|
+
'installation_access_required': ('sign_in', 'Ask the owner to sign in to SocialCli again.'),
|
|
75
|
+
'invalid_report_options': ('correct_input', 'Review the report date, window, item limit and platform.'),
|
|
76
|
+
'report_workspace_unavailable': ('inspect_workspace', 'Inspect the explicitly selected report workspace.'),
|
|
77
|
+
'snapshot_identity_mismatch': ('inspect_snapshot', 'Preserve the snapshot and inspect its brand/date binding.'),
|
|
78
|
+
'snapshot_unreadable': ('inspect_snapshot', 'Preserve the unreadable snapshot before recovery.'),
|
|
79
|
+
'snapshots_unavailable': ('collect_metrics', 'Collect authorized metrics before requesting a saved-snapshot report.'),
|
|
80
|
+
'platform_read_unavailable': ('collect_metrics', 'Collect authorized metrics for this platform; retain other verified observations.'),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def error_for_code(code: str, *, read_only: bool = True) -> dict:
|
|
85
|
+
if isinstance(code, str) and code in MESSAGES:
|
|
86
|
+
action, message = _ACTIONS.get(code, 'inspect'), MESSAGES[code]
|
|
87
|
+
else:
|
|
88
|
+
code = code if isinstance(code, str) and code in _CATALOG else 'operation_failed'
|
|
89
|
+
action, message = _CATALOG[code]
|
|
90
|
+
if not read_only and (action == 'retry_later' or code in {'read_deadline', 'response_too_large'}):
|
|
91
|
+
code = 'operation_uncertain'
|
|
92
|
+
action, message = 'reconcile', 'Verify the existing operation before another write; its outcome may be uncertain.'
|
|
93
|
+
return {'code':code, 'action':action,
|
|
94
|
+
'retryable':bool(read_only and action == 'retry_later'),
|
|
95
|
+
'requires_user_action':action in {'reconnect','sign_in','user_action','contact_support'},
|
|
96
|
+
'message':message}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def error_document(exc: BaseException, *, read_only: bool) -> dict:
|
|
100
|
+
import httpx
|
|
101
|
+
from socialctl.read_budget import LocalConfigurationError
|
|
102
|
+
from socialctl.auth import AuthError
|
|
103
|
+
from socialctl.read_budget import ReadDeadlineExceeded, ReadResponseTooLarge
|
|
104
|
+
from socialctl.management.changes import ChangeError
|
|
105
|
+
from socialctl.management.youtube_resources import ResourceError, ResourceUncertain
|
|
106
|
+
from socialctl.management.youtube import YouTubeManagementError, YouTubeUpdateUncertain
|
|
107
|
+
from socialctl.management.meta_comments import CommentError, CommentUncertain
|
|
108
|
+
from socialctl.management.meta_schema import MetaError, MetaUncertain
|
|
109
|
+
from socialctl.metricas.base import SinPermiso
|
|
110
|
+
from socialctl.youtube_data import YouTubeDataBusy, YouTubeDataCleanupError
|
|
111
|
+
|
|
112
|
+
# A read flow may refresh a token via POST. A lost response to that operation
|
|
113
|
+
# cannot be classified as a harmless GET retry merely from the CLI command.
|
|
114
|
+
if isinstance(exc, TransportFailure) and exc.request_method not in {None,'GET','HEAD','OPTIONS'}:
|
|
115
|
+
read_only = False
|
|
116
|
+
if isinstance(exc, httpx.RequestError):
|
|
117
|
+
try: request = exc.request
|
|
118
|
+
except RuntimeError: request = None
|
|
119
|
+
if request is not None and request.method not in {'GET','HEAD','OPTIONS'}:
|
|
120
|
+
read_only = False
|
|
121
|
+
if isinstance(exc, ServiceRejected):
|
|
122
|
+
code = exc.code if exc.code in MESSAGES else {
|
|
123
|
+
401:'installation_access_required',402:'subscription_required',
|
|
124
|
+
429:'rate_limited',503:'provider_unavailable'}.get(exc.status,'service_rejected')
|
|
125
|
+
elif isinstance(exc, (ResourceUncertain, YouTubeUpdateUncertain, CommentUncertain, MetaUncertain)):
|
|
126
|
+
code = 'operation_uncertain'
|
|
127
|
+
elif isinstance(exc, (AuthError, YouTubeManagementError, ResourceError, CommentError, MetaError)) and getattr(exc, '_socialcli_error_code', None) is not None:
|
|
128
|
+
code = exc._socialcli_error_code
|
|
129
|
+
elif isinstance(exc, ReadDeadlineExceeded): code = 'read_deadline'
|
|
130
|
+
elif isinstance(exc, ReadResponseTooLarge): code = 'response_too_large'
|
|
131
|
+
elif isinstance(exc, KeyboardInterrupt): code = 'read_interrupted'
|
|
132
|
+
elif isinstance(exc, AuthError): code = 'authorization_unavailable'
|
|
133
|
+
elif isinstance(exc, SinPermiso): code = 'permissions_required'
|
|
134
|
+
elif isinstance(exc, (LocalConfigurationError,httpx.InvalidURL,httpx.LocalProtocolError,httpx.UnsupportedProtocol)):
|
|
135
|
+
code = 'local_configuration'
|
|
136
|
+
elif isinstance(exc, (TransportFailure,httpx.TransportError)): code = 'transport_failure'
|
|
137
|
+
elif isinstance(exc, ResourceError) and exc.http_status == 403 and exc.provider_reason in {'commentsDisabled', 'quotaExceeded'}:
|
|
138
|
+
code = 'comments_disabled' if exc.provider_reason == 'commentsDisabled' else 'rate_limited'
|
|
139
|
+
elif isinstance(exc, httpx.HTTPStatusError) or isinstance(exc, ResourceError) and exc.http_status:
|
|
140
|
+
status = exc.response.status_code if isinstance(exc,httpx.HTTPStatusError) else exc.http_status
|
|
141
|
+
if isinstance(exc,httpx.HTTPStatusError) and exc.request.method not in {'GET','HEAD','OPTIONS'}:
|
|
142
|
+
read_only = False
|
|
143
|
+
code = ({401:'authorization_unavailable',403:'permissions_required',404:'target_unavailable',
|
|
144
|
+
429:'rate_limited'}.get(status,'provider_unavailable' if status>=500 else 'request_rejected')
|
|
145
|
+
if type(status) is int else 'operation_rejected')
|
|
146
|
+
elif isinstance(exc, httpx.RequestError): code = 'provider_response_invalid'
|
|
147
|
+
elif isinstance(exc, ChangeError): code = 'operation_rejected'
|
|
148
|
+
elif isinstance(exc, YouTubeDataBusy): code = 'local_data_busy'
|
|
149
|
+
elif isinstance(exc, (OSError, YouTubeDataCleanupError)): code = 'storage_failure'
|
|
150
|
+
elif isinstance(exc, ValueError): code = 'invalid_local_state'
|
|
151
|
+
else: code = 'operation_failed'
|
|
152
|
+
return error_for_code(code,read_only=read_only)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def with_error_code(wrapper: Exception, cause: BaseException, *, read_only: bool,
|
|
156
|
+
fallback: str | None = None) -> Exception:
|
|
157
|
+
"""Keep only a fixed code across a sanitized wrapper, never raw exception data."""
|
|
158
|
+
code = error_document(cause, read_only=read_only)['code']
|
|
159
|
+
if code == 'operation_failed' and fallback is not None:
|
|
160
|
+
code = error_for_code(fallback, read_only=read_only)['code']
|
|
161
|
+
wrapper._socialcli_error_code = code
|
|
162
|
+
return wrapper
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def with_http_error_code(wrapper: Exception, response, *, read_only: bool) -> Exception:
|
|
166
|
+
import httpx
|
|
167
|
+
cause = httpx.HTTPStatusError('Provider request rejected', request=response.request,
|
|
168
|
+
response=response)
|
|
169
|
+
return with_error_code(wrapper, cause, read_only=read_only)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def emit_error(exc: Exception, *, read_only: bool) -> None:
|
|
173
|
+
import json
|
|
174
|
+
import typer
|
|
175
|
+
typer.echo(json.dumps({'version': 1, 'status': 'error',
|
|
176
|
+
'error': error_document(exc, read_only=read_only)}))
|
socialctl/agent_guide.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Topic selection keeps advanced contracts available without bloating bootstrap."""
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
TOPICS={
|
|
6
|
+
'setup':('Install the reviewed candidate','Authorize the installation, then the social account','Persist and recover','Publication gate','Explicit approval and preflight'),
|
|
7
|
+
'publish':('Publication gate','Temporary Instagram media','Canonical publishing workflow','Check supplied media locally','Explicit approval and preflight'),
|
|
8
|
+
'schedule':('Publication gate','Temporary Instagram media','Scheduling and temporary media','Native scheduling input examples (offline)','Explicit approval and preflight'),
|
|
9
|
+
'inbox':('Publication gate','Read metrics and prepare engagement','Read diagnostics','Comment API corrections','Explicit approval and preflight','Resumable batches and reply context'),
|
|
10
|
+
'errors':('Persist and recover','Recovery and error actions','Read diagnostics','Comment API corrections','Explicit approval and preflight','Resumable batches and reply context'),
|
|
11
|
+
'permissions':('Installation connection access',),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def guide(topic):
|
|
16
|
+
if topic not in TOPICS and topic!='all':
|
|
17
|
+
raise ValueError('Choose a guide topic: '+', '.join(TOPICS)+', all.')
|
|
18
|
+
full=files('socialctl.ui').joinpath('assets/agent-manual.md').read_text(encoding='utf-8')
|
|
19
|
+
if topic=='all':return full
|
|
20
|
+
sections=re.split(r'(?=^## )',full,flags=re.MULTILINE)
|
|
21
|
+
chosen=[section for section in sections if any(section.startswith('## '+heading) for heading in TOPICS[topic])]
|
|
22
|
+
return ('# SocialCli agent guide: '+topic+'\n\n'+''.join(chosen)+
|
|
23
|
+
'\nNext: socialcli agent-guide publish; socialcli agent-guide schedule; socialcli agent-guide inbox; socialcli agent-guide errors.\n')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Explicit approval transport shared by terminal and noninteractive commands."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ApprovalRequired(ValueError):
|
|
8
|
+
"""The caller supplied no explicit noninteractive approval."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_approval(value=None, *, approval_stdin=False, prompt='Enter the exact fingerprint to approve'):
|
|
12
|
+
"""Read one explicitly authorized source; an open pipe is never implicit consent."""
|
|
13
|
+
if value is not None and approval_stdin:
|
|
14
|
+
raise ValueError('Choose --approval-fingerprint OR --approval-stdin, not both.')
|
|
15
|
+
if value is not None:
|
|
16
|
+
return value
|
|
17
|
+
if approval_stdin:
|
|
18
|
+
typer.echo(f"{prompt} (--approval-stdin):")
|
|
19
|
+
value = sys.stdin.readline(4097)
|
|
20
|
+
if not value:
|
|
21
|
+
raise typer.Abort()
|
|
22
|
+
if len(value) > 4096 or not value.strip():
|
|
23
|
+
raise ValueError('Exact approval missing or invalid on stdin.')
|
|
24
|
+
return value.strip()
|
|
25
|
+
if not sys.stdin.isatty():
|
|
26
|
+
raise ApprovalRequired('Noninteractive approval required: show the complete preview, then pass --approval-fingerprint DIGEST or explicitly pipe one approval line with --approval-stdin. No input was read.')
|
|
27
|
+
return typer.prompt(prompt)
|