simplicio-video 0.5.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.
- simplicio_video/__init__.py +4 -0
- simplicio_video/__main__.py +5 -0
- simplicio_video/aio.py +88 -0
- simplicio_video/auth.py +493 -0
- simplicio_video/busca.py +1194 -0
- simplicio_video/busca_ajuda.py +51 -0
- simplicio_video/catalog.py +36 -0
- simplicio_video/cli.py +749 -0
- simplicio_video/clone.py +46 -0
- simplicio_video/commands/__init__.py +0 -0
- simplicio_video/commands/doctor.py +363 -0
- simplicio_video/commands/install.py +290 -0
- simplicio_video/commands/login.py +333 -0
- simplicio_video/commands/setup.py +76 -0
- simplicio_video/commands/update.py +561 -0
- simplicio_video/compiler.py +194 -0
- simplicio_video/contract.py +247 -0
- simplicio_video/data/angles/angles.json +456 -0
- simplicio_video/data/busca/cache.schema.json +179 -0
- simplicio_video/data/busca/contador.schema.json +25 -0
- simplicio_video/data/busca/registro.schema.json +137 -0
- simplicio_video/data/busca/tinyfish.json +113 -0
- simplicio_video/data/busca/tinyfish.schema.json +75 -0
- simplicio_video/data/formats/formats.json +1094 -0
- simplicio_video/data/formats/instrucoes.md +16 -0
- simplicio_video/data/motion/README.md +117 -0
- simplicio_video/data/motion/assets/fonts/Anton-Regular.ttf +0 -0
- simplicio_video/data/motion/assets/fonts/Fredoka.ttf +0 -0
- simplicio_video/data/motion/assets/fonts/Inter.ttf +0 -0
- simplicio_video/data/motion/assets/fonts/OFL-Anton.txt +93 -0
- simplicio_video/data/motion/assets/fonts/OFL-Fredoka.txt +93 -0
- simplicio_video/data/motion/assets/fonts/OFL-Inter.txt +93 -0
- simplicio_video/data/motion/assets/placeholder-logo.svg +4 -0
- simplicio_video/data/motion/assets/placeholder-photo.svg +7 -0
- simplicio_video/data/motion/catalog.json +1135 -0
- simplicio_video/data/motion/lib/retime.js +32 -0
- simplicio_video/data/motion/pattern.schema.json +176 -0
- simplicio_video/data/motion/patterns/balloons-confetti/block.html +85 -0
- simplicio_video/data/motion/patterns/balloons-confetti/pattern.json +94 -0
- simplicio_video/data/motion/patterns/balloons-confetti/preview.png +0 -0
- simplicio_video/data/motion/patterns/beat-burst/block.html +84 -0
- simplicio_video/data/motion/patterns/beat-burst/pattern.json +107 -0
- simplicio_video/data/motion/patterns/beat-burst/preview.png +0 -0
- simplicio_video/data/motion/patterns/beat-cut-accelerate/pattern.json +19 -0
- simplicio_video/data/motion/patterns/before-after-slider/pattern.json +18 -0
- simplicio_video/data/motion/patterns/bg-grid-glow-particles/block.html +73 -0
- simplicio_video/data/motion/patterns/bg-grid-glow-particles/pattern.json +57 -0
- simplicio_video/data/motion/patterns/bg-grid-glow-particles/preview.png +0 -0
- simplicio_video/data/motion/patterns/bg-parallax/block.html +96 -0
- simplicio_video/data/motion/patterns/bg-parallax/pattern.json +95 -0
- simplicio_video/data/motion/patterns/bg-parallax/preview.png +0 -0
- simplicio_video/data/motion/patterns/brand-lockup-soft/block.html +102 -0
- simplicio_video/data/motion/patterns/brand-lockup-soft/pattern.json +159 -0
- simplicio_video/data/motion/patterns/brand-lockup-soft/preview.png +0 -0
- simplicio_video/data/motion/patterns/caption-word-highlight/block.html +70 -0
- simplicio_video/data/motion/patterns/caption-word-highlight/pattern.json +58 -0
- simplicio_video/data/motion/patterns/caption-word-highlight/preview.png +0 -0
- simplicio_video/data/motion/patterns/card-3d-pushin-sweep/block.html +67 -0
- simplicio_video/data/motion/patterns/card-3d-pushin-sweep/pattern.json +58 -0
- simplicio_video/data/motion/patterns/card-3d-pushin-sweep/preview.png +0 -0
- simplicio_video/data/motion/patterns/card-flip-reveal/pattern.json +18 -0
- simplicio_video/data/motion/patterns/carousel-emphasis/pattern.json +19 -0
- simplicio_video/data/motion/patterns/circle-iris-transition/block.html +71 -0
- simplicio_video/data/motion/patterns/circle-iris-transition/pattern.json +70 -0
- simplicio_video/data/motion/patterns/circle-iris-transition/preview.png +0 -0
- simplicio_video/data/motion/patterns/cliptitle-chip-cascade/block.html +69 -0
- simplicio_video/data/motion/patterns/cliptitle-chip-cascade/pattern.json +65 -0
- simplicio_video/data/motion/patterns/cliptitle-chip-cascade/preview.png +0 -0
- simplicio_video/data/motion/patterns/counter-confetti/pattern.json +18 -0
- simplicio_video/data/motion/patterns/crash-zoom-punch/pattern.json +18 -0
- simplicio_video/data/motion/patterns/cta-button-spring/block.html +68 -0
- simplicio_video/data/motion/patterns/cta-button-spring/pattern.json +80 -0
- simplicio_video/data/motion/patterns/cta-button-spring/preview.png +0 -0
- simplicio_video/data/motion/patterns/foreground-fly-through/pattern.json +19 -0
- simplicio_video/data/motion/patterns/fx-transition/block.html +97 -0
- simplicio_video/data/motion/patterns/fx-transition/pattern.json +92 -0
- simplicio_video/data/motion/patterns/fx-transition/preview.png +0 -0
- simplicio_video/data/motion/patterns/hook-3s-retention/pattern.json +19 -0
- simplicio_video/data/motion/patterns/hud-scan-photo/block.html +72 -0
- simplicio_video/data/motion/patterns/hud-scan-photo/pattern.json +58 -0
- simplicio_video/data/motion/patterns/hud-scan-photo/preview.png +0 -0
- simplicio_video/data/motion/patterns/icon-mascot/block.html +139 -0
- simplicio_video/data/motion/patterns/icon-mascot/pattern.json +137 -0
- simplicio_video/data/motion/patterns/icon-mascot/preview.png +0 -0
- simplicio_video/data/motion/patterns/infinite-zoom-collage/pattern.json +18 -0
- simplicio_video/data/motion/patterns/item-relay-counter/block.html +82 -0
- simplicio_video/data/motion/patterns/item-relay-counter/pattern.json +65 -0
- simplicio_video/data/motion/patterns/item-relay-counter/preview.png +0 -0
- simplicio_video/data/motion/patterns/kinetic-bounce-title/block.html +81 -0
- simplicio_video/data/motion/patterns/kinetic-bounce-title/pattern.json +115 -0
- simplicio_video/data/motion/patterns/kinetic-bounce-title/preview.png +0 -0
- simplicio_video/data/motion/patterns/kinetic-letters-punch/block.html +73 -0
- simplicio_video/data/motion/patterns/kinetic-letters-punch/pattern.json +73 -0
- simplicio_video/data/motion/patterns/kinetic-letters-punch/preview.png +0 -0
- simplicio_video/data/motion/patterns/kinetic-words/block.html +97 -0
- simplicio_video/data/motion/patterns/kinetic-words/pattern.json +109 -0
- simplicio_video/data/motion/patterns/kinetic-words/preview.png +0 -0
- simplicio_video/data/motion/patterns/line-accent/block.html +79 -0
- simplicio_video/data/motion/patterns/line-accent/pattern.json +108 -0
- simplicio_video/data/motion/patterns/line-accent/preview.png +0 -0
- simplicio_video/data/motion/patterns/logo-drop-shockwave/block.html +75 -0
- simplicio_video/data/motion/patterns/logo-drop-shockwave/pattern.json +63 -0
- simplicio_video/data/motion/patterns/logo-drop-shockwave/preview.png +0 -0
- simplicio_video/data/motion/patterns/logo-portal/pattern.json +19 -0
- simplicio_video/data/motion/patterns/logo-shrink-lockup/pattern.json +18 -0
- simplicio_video/data/motion/patterns/logo-wireframe-shards/block.html +77 -0
- simplicio_video/data/motion/patterns/logo-wireframe-shards/pattern.json +64 -0
- simplicio_video/data/motion/patterns/logo-wireframe-shards/preview.png +0 -0
- simplicio_video/data/motion/patterns/marker-underline-title/block.html +65 -0
- simplicio_video/data/motion/patterns/marker-underline-title/pattern.json +65 -0
- simplicio_video/data/motion/patterns/marker-underline-title/preview.png +0 -0
- simplicio_video/data/motion/patterns/materializing-result/pattern.json +19 -0
- simplicio_video/data/motion/patterns/odometer-digit-roll/block.html +77 -0
- simplicio_video/data/motion/patterns/odometer-digit-roll/pattern.json +58 -0
- simplicio_video/data/motion/patterns/odometer-digit-roll/preview.png +0 -0
- simplicio_video/data/motion/patterns/one-shape-morph/block.html +74 -0
- simplicio_video/data/motion/patterns/one-shape-morph/pattern.json +58 -0
- simplicio_video/data/motion/patterns/one-shape-morph/preview.png +0 -0
- simplicio_video/data/motion/patterns/process-timeline/block.html +71 -0
- simplicio_video/data/motion/patterns/process-timeline/pattern.json +64 -0
- simplicio_video/data/motion/patterns/process-timeline/preview.png +0 -0
- simplicio_video/data/motion/patterns/reactive-icon-controller/pattern.json +19 -0
- simplicio_video/data/motion/patterns/recipe-ingredients-explainer/pattern.json +18 -0
- simplicio_video/data/motion/patterns/retime-piecewise/pattern.json +22 -0
- simplicio_video/data/motion/patterns/scramble-text/block.html +69 -0
- simplicio_video/data/motion/patterns/scramble-text/pattern.json +57 -0
- simplicio_video/data/motion/patterns/scramble-text/preview.png +0 -0
- simplicio_video/data/motion/patterns/selection-expansion/pattern.json +19 -0
- simplicio_video/data/motion/patterns/service-illustrations-wjr/pattern.json +20 -0
- simplicio_video/data/motion/patterns/service-panels-slide/block.html +88 -0
- simplicio_video/data/motion/patterns/service-panels-slide/pattern.json +71 -0
- simplicio_video/data/motion/patterns/service-panels-slide/preview.png +0 -0
- simplicio_video/data/motion/patterns/showreel-15s-techniques/pattern.json +18 -0
- simplicio_video/data/motion/patterns/spending-simulation-9x16/pattern.json +19 -0
- simplicio_video/data/motion/patterns/split-flap-title/pattern.json +19 -0
- simplicio_video/data/motion/patterns/spotlight-sweep/pattern.json +18 -0
- simplicio_video/data/motion/patterns/tooth-character/block.html +101 -0
- simplicio_video/data/motion/patterns/tooth-character/pattern.json +125 -0
- simplicio_video/data/motion/patterns/tooth-character/preview.png +0 -0
- simplicio_video/data/motion/patterns/toothbrush-sparkle/block.html +111 -0
- simplicio_video/data/motion/patterns/toothbrush-sparkle/pattern.json +112 -0
- simplicio_video/data/motion/patterns/toothbrush-sparkle/preview.png +0 -0
- simplicio_video/data/motion/patterns/transition-glitch-rgb/block.html +74 -0
- simplicio_video/data/motion/patterns/transition-glitch-rgb/pattern.json +71 -0
- simplicio_video/data/motion/patterns/transition-glitch-rgb/preview.png +0 -0
- simplicio_video/data/motion/patterns/ugc-replicate-structure/pattern.json +18 -0
- simplicio_video/data/motion/patterns/word-mask-highlight/block.html +67 -0
- simplicio_video/data/motion/patterns/word-mask-highlight/pattern.json +65 -0
- simplicio_video/data/motion/patterns/word-mask-highlight/preview.png +0 -0
- simplicio_video/data/motion/scripts/catalog.mjs +43 -0
- simplicio_video/data/motion/scripts/preview.sh +54 -0
- simplicio_video/data/openverse/api.json +135 -0
- simplicio_video/data/openverse/api.schema.json +68 -0
- simplicio_video/data/openverse/library.schema.json +301 -0
- simplicio_video/data/openverse/licenses.json +112 -0
- simplicio_video/data/openverse/licenses.schema.json +93 -0
- simplicio_video/data/schema/contract.schema.json +180 -0
- simplicio_video/data/schemas/cli/auth-status.schema.json +148 -0
- simplicio_video/data/schemas/cli/doctor.schema.json +87 -0
- simplicio_video/data/schemas/cli/install-ownership.schema.json +82 -0
- simplicio_video/data/schemas/cli/install-plan.schema.json +529 -0
- simplicio_video/data/schemas/cli/login.schema.json +184 -0
- simplicio_video/data/schemas/cli/logout.schema.json +62 -0
- simplicio_video/data/schemas/cli/preview-manifest.schema.json +37 -0
- simplicio_video/data/schemas/cli/render-manifest.schema.json +87 -0
- simplicio_video/data/schemas/cli/render-result.schema.json +35 -0
- simplicio_video/data/schemas/cli/run-result.schema.json +50 -0
- simplicio_video/data/schemas/cli/update-check.schema.json +58 -0
- simplicio_video/data/schemas/cli/validate-result.schema.json +34 -0
- simplicio_video/data/schemas/video-spec.schema.json +247 -0
- simplicio_video/data/skills/simplicio-video/SKILL.md +143 -0
- simplicio_video/data/skills/simplicio-video-social/SKILL.md +63 -0
- simplicio_video/data/social/limits.json +495 -0
- simplicio_video/data/social/limits.schema.json +136 -0
- simplicio_video/data/social/providers.json +469 -0
- simplicio_video/data/social/providers.schema.json +231 -0
- simplicio_video/data/templates/README.md +37 -0
- simplicio_video/data/templates/alimentacao/README.md +24 -0
- simplicio_video/data/templates/alimentacao/template.json +117 -0
- simplicio_video/data/templates/assistencia-tecnica-games/README.md +30 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/assets/fonts/Anton-Regular.ttf +0 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/assets/fonts/Inter.ttf +0 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/assets/fonts/OFL-Anton.txt +93 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/assets/fonts/OFL-Inter.txt +93 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/assets/placeholders/logo.svg +7 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/assets/placeholders/photo-bench.svg +7 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/assets/placeholders/photo-hero.svg +7 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/hyperframes.json +6 -0
- simplicio_video/data/templates/assistencia-tecnica-games/composition/index.template.html +609 -0
- simplicio_video/data/templates/assistencia-tecnica-games/template.json +159 -0
- simplicio_video/data/templates/beleza-estetica/README.md +24 -0
- simplicio_video/data/templates/beleza-estetica/template.json +114 -0
- simplicio_video/data/templates/brand-promo/composition/assets/fonts/Fredoka.ttf +0 -0
- simplicio_video/data/templates/brand-promo/composition/assets/fonts/Inter.ttf +0 -0
- simplicio_video/data/templates/brand-promo/composition/assets/fonts/OFL-Fredoka.txt +93 -0
- simplicio_video/data/templates/brand-promo/composition/assets/fonts/OFL-Inter.txt +93 -0
- simplicio_video/data/templates/brand-promo/composition/hyperframes.json +6 -0
- simplicio_video/data/templates/brand-promo/composition/index.template.html +40 -0
- simplicio_video/data/templates/brand-promo/template.json +58 -0
- simplicio_video/data/templates/imobiliario/README.md +24 -0
- simplicio_video/data/templates/imobiliario/template.json +114 -0
- simplicio_video/data/templates/institucional-advocacia/README.md +36 -0
- simplicio_video/data/templates/institucional-advocacia/composition/assets/fonts/Fredoka.ttf +0 -0
- simplicio_video/data/templates/institucional-advocacia/composition/assets/fonts/Inter.ttf +0 -0
- simplicio_video/data/templates/institucional-advocacia/composition/assets/fonts/OFL-Fredoka.txt +93 -0
- simplicio_video/data/templates/institucional-advocacia/composition/assets/fonts/OFL-Inter.txt +93 -0
- simplicio_video/data/templates/institucional-advocacia/composition/hyperframes.json +6 -0
- simplicio_video/data/templates/institucional-advocacia/composition/index.template.html +67 -0
- simplicio_video/data/templates/institucional-advocacia/template.json +132 -0
- simplicio_video/data/templates/institucional-estetica/README.md +16 -0
- simplicio_video/data/templates/institucional-estetica/composition/assets/fonts/Fredoka.ttf +0 -0
- simplicio_video/data/templates/institucional-estetica/composition/assets/fonts/Inter.ttf +0 -0
- simplicio_video/data/templates/institucional-estetica/composition/assets/fonts/OFL-Fredoka.txt +93 -0
- simplicio_video/data/templates/institucional-estetica/composition/assets/fonts/OFL-Inter.txt +93 -0
- simplicio_video/data/templates/institucional-estetica/composition/hyperframes.json +6 -0
- simplicio_video/data/templates/institucional-estetica/composition/index.template.html +70 -0
- simplicio_video/data/templates/institucional-estetica/template.json +136 -0
- simplicio_video/data/templates/institucional-odontologia/README.md +16 -0
- simplicio_video/data/templates/institucional-odontologia/composition/assets/fonts/Fredoka.ttf +0 -0
- simplicio_video/data/templates/institucional-odontologia/composition/assets/fonts/Inter.ttf +0 -0
- simplicio_video/data/templates/institucional-odontologia/composition/assets/fonts/OFL-Fredoka.txt +93 -0
- simplicio_video/data/templates/institucional-odontologia/composition/assets/fonts/OFL-Inter.txt +93 -0
- simplicio_video/data/templates/institucional-odontologia/composition/hyperframes.json +6 -0
- simplicio_video/data/templates/institucional-odontologia/composition/index.template.html +73 -0
- simplicio_video/data/templates/institucional-odontologia/template.json +137 -0
- simplicio_video/data/templates/saas-tech/README.md +24 -0
- simplicio_video/data/templates/saas-tech/template.json +119 -0
- simplicio_video/data/templates/saude-clinica/README.md +26 -0
- simplicio_video/data/templates/saude-clinica/template.json +128 -0
- simplicio_video/data/templates/saude-odontologia/README.md +19 -0
- simplicio_video/data/templates/saude-odontologia/composition/assets/fonts/Fredoka.ttf +0 -0
- simplicio_video/data/templates/saude-odontologia/composition/assets/fonts/Inter.ttf +0 -0
- simplicio_video/data/templates/saude-odontologia/composition/assets/fonts/OFL-Fredoka.txt +93 -0
- simplicio_video/data/templates/saude-odontologia/composition/assets/fonts/OFL-Inter.txt +93 -0
- simplicio_video/data/templates/saude-odontologia/composition/hyperframes.json +6 -0
- simplicio_video/data/templates/saude-odontologia/composition/index.template.html +84 -0
- simplicio_video/data/templates/saude-odontologia/template.json +137 -0
- simplicio_video/data/templates/sazonal-dia-das-criancas/README.md +23 -0
- simplicio_video/data/templates/sazonal-dia-das-criancas/composition/assets/fonts/Fredoka.ttf +0 -0
- simplicio_video/data/templates/sazonal-dia-das-criancas/composition/assets/fonts/Inter.ttf +0 -0
- simplicio_video/data/templates/sazonal-dia-das-criancas/composition/assets/fonts/OFL-Fredoka.txt +93 -0
- simplicio_video/data/templates/sazonal-dia-das-criancas/composition/assets/fonts/OFL-Inter.txt +93 -0
- simplicio_video/data/templates/sazonal-dia-das-criancas/composition/hyperframes.json +6 -0
- simplicio_video/data/templates/sazonal-dia-das-criancas/composition/index.template.html +86 -0
- simplicio_video/data/templates/sazonal-dia-das-criancas/template.json +142 -0
- simplicio_video/data/templates/template.schema.json +210 -0
- simplicio_video/data/templates/varejo/README.md +24 -0
- simplicio_video/data/templates/varejo/template.json +119 -0
- simplicio_video/distribution.py +64 -0
- simplicio_video/edit.py +62 -0
- simplicio_video/engine/build.mjs +273 -0
- simplicio_video/engine/master.sh +15 -0
- simplicio_video/engine/openvoice_convert.py +18 -0
- simplicio_video/engine/remote_render.py +246 -0
- simplicio_video/formats.py +101 -0
- simplicio_video/hostos.py +202 -0
- simplicio_video/installer.py +979 -0
- simplicio_video/jobs.py +893 -0
- simplicio_video/mcp_server.py +372 -0
- simplicio_video/mock_gemini.py +93 -0
- simplicio_video/moneyprinter.py +505 -0
- simplicio_video/montage.py +157 -0
- simplicio_video/opencut.py +59 -0
- simplicio_video/openverse.py +1626 -0
- simplicio_video/openverse_contract.py +229 -0
- simplicio_video/openverse_sheet.py +249 -0
- simplicio_video/paths.py +84 -0
- simplicio_video/pipeline.py +38 -0
- simplicio_video/publish.py +151 -0
- simplicio_video/realoficial.py +34 -0
- simplicio_video/remote.py +212 -0
- simplicio_video/render.py +413 -0
- simplicio_video/scaffold.py +84 -0
- simplicio_video/skill_sync.py +34 -0
- simplicio_video/social.py +868 -0
- simplicio_video/status.py +154 -0
- simplicio_video/swap.py +806 -0
- simplicio_video/swap_providers/__init__.py +25 -0
- simplicio_video/swap_providers/_unverified.py +38 -0
- simplicio_video/swap_providers/alibaba.py +58 -0
- simplicio_video/swap_providers/base.py +210 -0
- simplicio_video/swap_providers/fake.py +145 -0
- simplicio_video/swap_providers/fal.py +53 -0
- simplicio_video/swap_providers/gemini.py +155 -0
- simplicio_video/swap_providers/http.py +67 -0
- simplicio_video/swap_providers/kie.py +56 -0
- simplicio_video/swap_providers/wavespeed.py +54 -0
- simplicio_video/tools.py +144 -0
- simplicio_video/toonflow.py +177 -0
- simplicio_video/voice.py +403 -0
- simplicio_video/voice_clone.py +81 -0
- simplicio_video/words.py +82 -0
- simplicio_video-0.5.0.dist-info/METADATA +108 -0
- simplicio_video-0.5.0.dist-info/RECORD +299 -0
- simplicio_video-0.5.0.dist-info/WHEEL +5 -0
- simplicio_video-0.5.0.dist-info/entry_points.txt +3 -0
- simplicio_video-0.5.0.dist-info/licenses/LICENSE +661 -0
- simplicio_video-0.5.0.dist-info/licenses/NOTICE +9 -0
- simplicio_video-0.5.0.dist-info/top_level.txt +1 -0
simplicio_video/aio.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""asyncio helpers shared by `swap` and `voice`: concurrency only where the time is spent waiting (network, polling).
|
|
2
|
+
|
|
3
|
+
Asyncio does not speed up CPU work (render, ffmpeg encodes stay serial). The HTTP clients stay on the standard library
|
|
4
|
+
(urllib, blocking), so each blocking call runs in a short-lived DAEMON thread (`athread`) and the event loop only
|
|
5
|
+
coordinates. A daemon thread, not `asyncio.to_thread`, on purpose: after Ctrl+C the interpreter must not wait for a
|
|
6
|
+
request that is still open (up to the HTTP timeout) before it exits.
|
|
7
|
+
|
|
8
|
+
Known limit: after a cancel or a failure, a daemon thread keeps running its HTTP request to the end (a thread cannot be
|
|
9
|
+
interrupted). Callers must therefore not assume "cancelled" means "stopped at the provider": swap waits a bounded time for an
|
|
10
|
+
in-flight submit and records the handle (or `possibly_charged`), and voice lets in-flight requests finish and keeps their takes.
|
|
11
|
+
In a long-lived process (the MCP server) such a thread may still write its raw take after `voice.run` already returned.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import threading
|
|
18
|
+
from typing import Any, Awaitable, Callable, TypeVar
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def athread(fn: Callable[..., T], *args: Any) -> T:
|
|
24
|
+
"""Run the blocking `fn(*args)` in a daemon thread and await its result (exceptions, SystemExit included, propagate)."""
|
|
25
|
+
loop = asyncio.get_running_loop()
|
|
26
|
+
fut: asyncio.Future = loop.create_future()
|
|
27
|
+
|
|
28
|
+
def _deliver(setter, value) -> None:
|
|
29
|
+
if not fut.done(): # a cancelled await leaves a done future behind: drop the late result
|
|
30
|
+
setter(value)
|
|
31
|
+
|
|
32
|
+
def work() -> None:
|
|
33
|
+
try:
|
|
34
|
+
res = fn(*args)
|
|
35
|
+
except BaseException as e: # noqa: BLE001 (forwarded to the awaiting task, SystemExit from VoiceError too)
|
|
36
|
+
try:
|
|
37
|
+
loop.call_soon_threadsafe(_deliver, fut.set_exception, e)
|
|
38
|
+
except RuntimeError: # loop already closed (Ctrl+C): nobody is waiting
|
|
39
|
+
pass
|
|
40
|
+
else:
|
|
41
|
+
try:
|
|
42
|
+
loop.call_soon_threadsafe(_deliver, fut.set_result, res)
|
|
43
|
+
except RuntimeError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
threading.Thread(target=work, daemon=True, name="simplicio-io").start()
|
|
47
|
+
return await fut
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_sync(coro: Awaitable[T]) -> T:
|
|
51
|
+
"""Run a coroutine to completion from sync code, also when this thread already runs an event loop (the MCP SDK calls
|
|
52
|
+
sync tool functions from inside its loop): then the coroutine gets its own loop in a helper thread."""
|
|
53
|
+
try:
|
|
54
|
+
asyncio.get_running_loop()
|
|
55
|
+
except RuntimeError:
|
|
56
|
+
return asyncio.run(coro) # type: ignore[arg-type]
|
|
57
|
+
box: dict[str, Any] = {}
|
|
58
|
+
|
|
59
|
+
def target() -> None:
|
|
60
|
+
try:
|
|
61
|
+
box["value"] = asyncio.run(coro) # type: ignore[arg-type]
|
|
62
|
+
except BaseException as e: # noqa: BLE001
|
|
63
|
+
box["error"] = e
|
|
64
|
+
|
|
65
|
+
th = threading.Thread(target=target, name="simplicio-loop")
|
|
66
|
+
th.start()
|
|
67
|
+
th.join()
|
|
68
|
+
if "error" in box:
|
|
69
|
+
raise box["error"]
|
|
70
|
+
return box["value"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def clamp(requested: int | None, default: int, cap: int | None = None) -> int:
|
|
74
|
+
"""Effective concurrency: the request (or the default), at least 1, at most the cap."""
|
|
75
|
+
n = default if requested is None else int(requested)
|
|
76
|
+
n = max(1, n)
|
|
77
|
+
return min(n, cap) if cap else n
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def positive_int(text: str) -> int:
|
|
81
|
+
"""argparse `type` for --concurrency: an integer of 1 or more."""
|
|
82
|
+
try:
|
|
83
|
+
n = int(text)
|
|
84
|
+
except ValueError:
|
|
85
|
+
raise argparse.ArgumentTypeError(f"esperava um inteiro, recebi {text!r}") from None
|
|
86
|
+
if n < 1:
|
|
87
|
+
raise argparse.ArgumentTypeError("precisa ser 1 ou mais")
|
|
88
|
+
return n
|
simplicio_video/auth.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""The Simplicio login file, shared with the Simplicio Runtime. The video CLI only READS it.
|
|
2
|
+
|
|
3
|
+
Ported from simplicio-loop simplicio_loop/auth.py (2034e2d), the read side only: no write, no refresh, no token application,
|
|
4
|
+
no 24/7 watcher path (decision of the owner: `login` is a mirror, it stores no credential and the engine consumes no token).
|
|
5
|
+
`login`, `logout`, `auth status` and `doctor` all go through here.
|
|
6
|
+
|
|
7
|
+
* PATH: env `SIMPLICIO_AUTH_FILE` (the name the Runtime reads), else ~/.simplicio/login.json. Same schema as the Runtime:
|
|
8
|
+
`access_token`, `refresh_token`, `access_expires_at`, `refresh_token_expires_at`, `verification.validated`.
|
|
9
|
+
* SAFETY (POSIX): a login file that group or others can read, that is a symlink, a FIFO or a directory, or that belongs to
|
|
10
|
+
another user, is refused, and so is a folder that group or others can write (unless it is sticky and ours) and a
|
|
11
|
+
`login.lock` that is another user's or that others can open. The checks use the descriptor that is read.
|
|
12
|
+
* SAFETY (Windows): chmod and uid mean nothing there, so the mode and owner checks are skipped and the ACLs are NOT verified.
|
|
13
|
+
A symlink or any reparse point of the link kind (symlink, junction) is still refused. Windows has no O_NOFOLLOW, so the
|
|
14
|
+
link check and the open are not one atomic step there.
|
|
15
|
+
* HOSTILE OR CORRUPT FILE: the read is capped at 1 MiB, deep nesting, a number that is no date (`1e999`, year 33658) and a
|
|
16
|
+
folder or file this user cannot open are all a `reason_code` (`login_invalid`, `login_permissions`), never an exception.
|
|
17
|
+
* LOCK: `logout` deletes the file under the exclusive lock on the sidecar `login.lock`, the same file the Runtime locks
|
|
18
|
+
(runtime_auth.rs `auth_lock`: the login file name with the extension changed to `lock`, 20 s). fcntl on POSIX, msvcrt on
|
|
19
|
+
Windows. NOT VERIFIED: that msvcrt.locking and the Runtime's LockFileEx exclude each other on a real Windows host.
|
|
20
|
+
* LOGOUT ONLY DELETES A LOGIN: `SIMPLICIO_AUTH_FILE` can point anywhere, so `logout --yes` first checks that the target is a
|
|
21
|
+
small regular file holding a JSON object with the shape of the Runtime's StoredAuth (`access_token` and `access_expires_at`),
|
|
22
|
+
and refuses (`not_a_login_file`) anything else. A symlink is refused too (`login_symlink`, as the read does), before the lock.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import errno
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import stat
|
|
33
|
+
import subprocess
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Iterator, Mapping, Optional
|
|
37
|
+
|
|
38
|
+
from . import hostos
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import fcntl
|
|
42
|
+
msvcrt = None
|
|
43
|
+
except ImportError: # Windows
|
|
44
|
+
fcntl = None
|
|
45
|
+
import msvcrt
|
|
46
|
+
|
|
47
|
+
RUNTIME_ENV = "SIMPLICIO_AUTH_FILE"
|
|
48
|
+
# Windows reparse tags of the link kind (winnt.h). The `stat` module defines them only on Windows builds.
|
|
49
|
+
IO_REPARSE_TAG_SYMLINK = 0xA000000C
|
|
50
|
+
IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003
|
|
51
|
+
RUNTIME_DIR = ".simplicio"
|
|
52
|
+
LOCK_WAIT_S = 20.0 # the Runtime waits 20 s for the same lock
|
|
53
|
+
LOCK_POLL_S = 0.05
|
|
54
|
+
MAX_LOGIN_BYTES = 1 << 20 # a real login is about 2 KiB: a megabyte is already absurd, and bounds what a hostile file costs
|
|
55
|
+
|
|
56
|
+
_VERSION = re.compile(r"simplicio\s+v?(\d+\.\d+\.\d+)")
|
|
57
|
+
_EMAIL = re.compile(r"([^@\s])[^@\s]*@([^@\s]+\.[^@\s]+)")
|
|
58
|
+
_DIGITS = re.compile(r"[0-9]{1,13}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LoginError(Exception):
|
|
62
|
+
"""The login file cannot be used. `reason_code` is stable, the message says what to do and never holds a token."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, reason_code: str, message: str = "", fix: Optional[str] = None) -> None:
|
|
65
|
+
super().__init__(message or reason_code)
|
|
66
|
+
self.reason_code = reason_code
|
|
67
|
+
self.fix = fix # the exact command that repairs it, when there is one
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- where ----------------------------------------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def runtime_login_path(environ: Optional[Mapping[str, str]] = None) -> Path:
|
|
74
|
+
"""The file the Runtime itself uses: SIMPLICIO_AUTH_FILE, else ~/.simplicio/login.json."""
|
|
75
|
+
environ = os.environ if environ is None else environ
|
|
76
|
+
explicit = (environ.get(RUNTIME_ENV) or "").strip()
|
|
77
|
+
return Path(explicit) if explicit else hostos.home_dir(environ) / RUNTIME_DIR / "login.json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def login_path(environ: Optional[Mapping[str, str]] = None) -> Path:
|
|
81
|
+
"""The file this program uses: the Runtime's file (the video CLI has no login file of its own)."""
|
|
82
|
+
return runtime_login_path(environ)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def lock_path(path: Path) -> Path:
|
|
86
|
+
"""`login.json` -> `login.lock`: the sidecar the Runtime locks (the extension is replaced, as Rust does)."""
|
|
87
|
+
return Path(path).with_suffix(".lock")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- errors that are a reason_code, not a traceback ---------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _link_info(info: os.stat_result) -> bool:
|
|
94
|
+
if stat.S_ISLNK(info.st_mode):
|
|
95
|
+
return True
|
|
96
|
+
return getattr(info, "st_reparse_tag", 0) in (IO_REPARSE_TAG_SYMLINK, IO_REPARSE_TAG_MOUNT_POINT)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_link(path: Path) -> bool:
|
|
100
|
+
"""A symlink, or on Windows a reparse point of the link kind (symlink, junction).
|
|
101
|
+
|
|
102
|
+
Other reparse points (a OneDrive placeholder, a dedup stub) are real files and are not refused."""
|
|
103
|
+
try:
|
|
104
|
+
return _link_info(os.lstat(path))
|
|
105
|
+
except OSError:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _symlink_error(path: Path, apagando: bool = False) -> LoginError:
|
|
110
|
+
if apagando: # the logout: a link is never deleted (it could stand for ~/.bashrc), so nothing is touched
|
|
111
|
+
return LoginError("login_symlink", f"{path} é um link simbólico, então nada foi apagado (o logout só apaga um arquivo de "
|
|
112
|
+
"login de verdade, e a leitura do login também recusa link). Se for mesmo o login, remova o link à mão.")
|
|
113
|
+
return LoginError("login_symlink", f"{path} é um link simbólico; remova-o e rode: simplicio-video login")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _denied(path: Path, verb: str = "ler", folder: Optional[Path] = None) -> LoginError:
|
|
117
|
+
"""The OS said no (EACCES, EPERM): a folder or file of another user, often left by a `sudo`, or a mode that closes it."""
|
|
118
|
+
where = folder or Path(path).parent
|
|
119
|
+
fix = (f"ajuste o dono e as permissões de {where}" if hostos.is_windows()
|
|
120
|
+
else f'sudo chown -R "$USER" "{where}" (e rode de novo; o login só funciona numa pasta sua)')
|
|
121
|
+
return LoginError("login_permissions", f"sem permissão para {verb} {path} (a pasta ou o arquivo é de outro usuário? "
|
|
122
|
+
"depois de um `sudo` é comum)", fix)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _os_error(path: Path, exc: OSError, verb: str, folder: Optional[Path] = None) -> LoginError:
|
|
126
|
+
"""An OSError of one step on `path` as a LoginError: permission denied is `login_permissions`, anything else `login_invalid`."""
|
|
127
|
+
if exc.errno in (errno.EACCES, errno.EPERM):
|
|
128
|
+
return _denied(path, verb, folder)
|
|
129
|
+
return LoginError("login_invalid", f"não consegui {verb} {path}: {exc.strerror or exc}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _foreign_owner(info: os.stat_result) -> bool:
|
|
133
|
+
"""A file of another user, seen by a user who is not root (POSIX only).
|
|
134
|
+
|
|
135
|
+
A process with euid 0 is exempt: root can read and replace every file anyway, so the check protects nobody from root."""
|
|
136
|
+
return hasattr(os, "geteuid") and os.geteuid() != 0 and info.st_uid != os.geteuid()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_parent(path: Path) -> None:
|
|
140
|
+
"""The folder must not be writable by group or others (they could swap the file), unless it is sticky and ours."""
|
|
141
|
+
if hostos.is_windows():
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
info = os.stat(path.parent)
|
|
145
|
+
except OSError:
|
|
146
|
+
return # no folder yet, or one that cannot be looked at: the open that follows says which
|
|
147
|
+
if info.st_mode & 0o022 and not (info.st_mode & stat.S_ISVTX and info.st_uid == os.geteuid()):
|
|
148
|
+
raise LoginError("login_permissions", f"{path.parent} pode ser alterada pelo grupo ou por outros "
|
|
149
|
+
f"(modo {info.st_mode & 0o777:03o}); rode: chmod 700 {path.parent}", f"chmod 700 {path.parent}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --- read -----------------------------------------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _open_for_read(path: Path) -> int:
|
|
156
|
+
"""A descriptor for `path`, opened without following a symlink and without blocking on a FIFO.
|
|
157
|
+
|
|
158
|
+
LoginError: login_missing (also when a parent is a file), login_symlink, login_permissions, login_invalid."""
|
|
159
|
+
flags = (os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
160
|
+
| getattr(os, "O_BINARY", 0))
|
|
161
|
+
try:
|
|
162
|
+
return os.open(path, flags)
|
|
163
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
164
|
+
raise LoginError("login_missing", f"não há arquivo de login em {path}; rode: simplicio-video login") from None
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
if exc.errno == errno.ELOOP:
|
|
167
|
+
raise _symlink_error(path) from None
|
|
168
|
+
raise _os_error(path, exc, "ler") from None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _read_bounded(fd: int, path: Path) -> bytes:
|
|
172
|
+
"""At most MAX_LOGIN_BYTES + 1 bytes, so a file over the cap is told apart without loading it."""
|
|
173
|
+
chunks: list = []
|
|
174
|
+
total = 0
|
|
175
|
+
try:
|
|
176
|
+
while total <= MAX_LOGIN_BYTES:
|
|
177
|
+
chunk = os.read(fd, min(65536, MAX_LOGIN_BYTES + 1 - total))
|
|
178
|
+
if not chunk:
|
|
179
|
+
break
|
|
180
|
+
chunks.append(chunk)
|
|
181
|
+
total += len(chunk)
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
raise _os_error(path, exc, "ler") from None
|
|
184
|
+
return b"".join(chunks)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse(raw: bytes) -> Any:
|
|
188
|
+
"""The JSON in `raw`, or None when it is not UTF-8 JSON: a corrupt or hostile file (deep nesting included)."""
|
|
189
|
+
try:
|
|
190
|
+
return json.loads(raw.decode("utf-8"))
|
|
191
|
+
except (ValueError, RecursionError): # UnicodeDecodeError is a ValueError
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_login(path: Optional[Path] = None) -> dict:
|
|
196
|
+
"""The login file as a dict. Raises LoginError: login_missing, login_symlink, login_permissions, login_invalid.
|
|
197
|
+
|
|
198
|
+
Only a regular file is read (a FIFO would hang, a directory has no content), through O_NOFOLLOW and O_NONBLOCK where
|
|
199
|
+
they exist, and the checks use the descriptor that is read, so nothing can change between the check and the read.
|
|
200
|
+
"""
|
|
201
|
+
path = Path(path) if path else login_path()
|
|
202
|
+
if is_link(path):
|
|
203
|
+
raise _symlink_error(path)
|
|
204
|
+
_check_parent(path)
|
|
205
|
+
fd = _open_for_read(path)
|
|
206
|
+
try:
|
|
207
|
+
info = os.fstat(fd)
|
|
208
|
+
if not stat.S_ISREG(info.st_mode):
|
|
209
|
+
raise LoginError("login_invalid", f"{path} não é um arquivo comum; remova-o e rode: simplicio-video login")
|
|
210
|
+
if not hostos.is_windows() and _foreign_owner(info):
|
|
211
|
+
raise LoginError("login_permissions", f"{path} pertence a outro usuário; remova-o e rode: simplicio-video login",
|
|
212
|
+
f"remova {path} e rode: simplicio-video login")
|
|
213
|
+
if not hostos.is_windows() and info.st_mode & 0o077:
|
|
214
|
+
raise LoginError("login_permissions", f"{path} pode ser lido pelo grupo ou por outros "
|
|
215
|
+
f"(modo {info.st_mode & 0o777:03o}); rode: chmod 600 {path}", f"chmod 600 {path}")
|
|
216
|
+
raw = _read_bounded(fd, path)
|
|
217
|
+
finally:
|
|
218
|
+
os.close(fd)
|
|
219
|
+
if len(raw) > MAX_LOGIN_BYTES:
|
|
220
|
+
raise LoginError("login_invalid", f"{path} é grande demais para ser um login (mais de {MAX_LOGIN_BYTES // 1024} KiB); "
|
|
221
|
+
"rode: simplicio-video login")
|
|
222
|
+
data = _parse(raw)
|
|
223
|
+
if not isinstance(data, dict):
|
|
224
|
+
raise LoginError("login_invalid", f"{path} não é um arquivo de login (esperava um objeto JSON); "
|
|
225
|
+
"rode: simplicio-video login")
|
|
226
|
+
return data
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --- the lock -------------------------------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _try_lock(fd: int) -> bool:
|
|
233
|
+
"""One attempt at the exclusive lock; False while another process holds it."""
|
|
234
|
+
if fcntl is not None:
|
|
235
|
+
try:
|
|
236
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
if exc.errno in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK):
|
|
239
|
+
return False
|
|
240
|
+
raise
|
|
241
|
+
return True
|
|
242
|
+
try:
|
|
243
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
244
|
+
except OSError:
|
|
245
|
+
return False
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _unlock(fd: int) -> None:
|
|
250
|
+
if fcntl is not None:
|
|
251
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
252
|
+
else:
|
|
253
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
254
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@contextlib.contextmanager
|
|
258
|
+
def file_lock(path: Path, *, wait_s: float = LOCK_WAIT_S, strict: bool = True) -> Iterator[None]:
|
|
259
|
+
"""Exclusive advisory lock on the sidecar of `path`. The kernel drops it when the owner dies: a left-over file is no lock.
|
|
260
|
+
|
|
261
|
+
`strict` (the login lock) also refuses a loose folder and a lock file that is another user's or that others can open.
|
|
262
|
+
"""
|
|
263
|
+
lock = lock_path(Path(path))
|
|
264
|
+
if strict:
|
|
265
|
+
_check_parent(lock)
|
|
266
|
+
try:
|
|
267
|
+
lock.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
268
|
+
except OSError as exc:
|
|
269
|
+
raise _os_error(lock.parent, exc, "criar a pasta", folder=lock.parent) from None
|
|
270
|
+
if is_link(lock): # Windows has no O_NOFOLLOW: look before opening
|
|
271
|
+
raise LoginError("login_symlink", f"{lock} é um link simbólico; remova-o")
|
|
272
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_BINARY", 0)
|
|
273
|
+
try:
|
|
274
|
+
fd = os.open(lock, flags, 0o600)
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
if exc.errno == errno.ELOOP:
|
|
277
|
+
raise LoginError("login_symlink", f"{lock} é um link simbólico; remova-o") from None
|
|
278
|
+
raise _os_error(lock, exc, "abrir") from None
|
|
279
|
+
try:
|
|
280
|
+
info = os.fstat(fd)
|
|
281
|
+
if strict and not hostos.is_windows() and _foreign_owner(info): # another user's lock file would let it stall us
|
|
282
|
+
raise LoginError("login_permissions", f"{lock} pertence a outro usuário; remova-o")
|
|
283
|
+
if strict and not hostos.is_windows() and info.st_mode & 0o077:
|
|
284
|
+
raise LoginError("login_permissions", f"{lock} pode ser aberto pelo grupo ou por outros "
|
|
285
|
+
f"(modo {info.st_mode & 0o777:03o}); rode: chmod 600 {lock}")
|
|
286
|
+
deadline = time.monotonic() + wait_s
|
|
287
|
+
while True:
|
|
288
|
+
try:
|
|
289
|
+
taken = _try_lock(fd)
|
|
290
|
+
except OSError as exc: # a file system without locks (some network mounts): not a busy lock, not a traceback
|
|
291
|
+
raise LoginError("login_invalid", f"não consegui travar {lock}: {exc.strerror or exc}") from None
|
|
292
|
+
if taken:
|
|
293
|
+
break
|
|
294
|
+
if time.monotonic() >= deadline:
|
|
295
|
+
raise LoginError("login_lock_timeout",
|
|
296
|
+
f"outro programa segura {lock} (o Runtime renova o login ali); tente de novo")
|
|
297
|
+
time.sleep(LOCK_POLL_S)
|
|
298
|
+
try:
|
|
299
|
+
yield
|
|
300
|
+
finally:
|
|
301
|
+
_unlock(fd)
|
|
302
|
+
finally:
|
|
303
|
+
os.close(fd)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --- logout: delete the file, but only a login ------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _not_a_login(path: Path, why: str) -> LoginError:
|
|
310
|
+
return LoginError("not_a_login_file", f"{path} {why}, então nada foi apagado. Se for mesmo um login estragado, "
|
|
311
|
+
"remova-o à mão.")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def is_login_shaped(data: Any) -> bool:
|
|
315
|
+
"""The shape of the Runtime's `StoredAuth` (runtime_auth.rs): `access_token` a string (empty while a device login is
|
|
316
|
+
pending) and `access_expires_at` a number. `refresh_token` and `verification` are optional there, so they do not count:
|
|
317
|
+
the JSON credentials of gcloud (a `refresh_token`, a `client_secret`) are not a login."""
|
|
318
|
+
if not isinstance(data, dict):
|
|
319
|
+
return False
|
|
320
|
+
expires = data.get("access_expires_at")
|
|
321
|
+
return isinstance(data.get("access_token"), str) and isinstance(expires, (int, float)) and not isinstance(expires, bool)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _require_login_shape(path: Path) -> None:
|
|
325
|
+
"""Raise `not_a_login_file` unless `path` is a small regular file with a JSON object that has a login's keys.
|
|
326
|
+
|
|
327
|
+
The MODE is not checked on purpose: a login that others can read is exactly the one to get rid of. LoginError of the
|
|
328
|
+
open (login_missing: it is already gone; login_permissions; login_symlink: swapped for a link meanwhile) pass through."""
|
|
329
|
+
fd = _open_for_read(path)
|
|
330
|
+
try:
|
|
331
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
332
|
+
raise _not_a_login(path, "não é um arquivo comum (pasta, fifo, dispositivo...)")
|
|
333
|
+
raw = _read_bounded(fd, path)
|
|
334
|
+
finally:
|
|
335
|
+
os.close(fd)
|
|
336
|
+
if len(raw) > MAX_LOGIN_BYTES:
|
|
337
|
+
raise _not_a_login(path, f"é grande demais para ser um login (mais de {MAX_LOGIN_BYTES // 1024} KiB)")
|
|
338
|
+
if not is_login_shaped(_parse(raw)):
|
|
339
|
+
raise _not_a_login(path, "não é um JSON com access_token e access_expires_at (a forma de um login do Runtime)")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _still_there(path: Path) -> bool:
|
|
343
|
+
"""True when `path` is a login that can go; False when it is already gone. Raises `not_a_login_file` for a stranger."""
|
|
344
|
+
try:
|
|
345
|
+
_require_login_shape(path)
|
|
346
|
+
except LoginError as exc:
|
|
347
|
+
if exc.reason_code == "login_missing":
|
|
348
|
+
return False
|
|
349
|
+
raise
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def clear_login(path: Optional[Path] = None) -> bool:
|
|
354
|
+
"""Delete the login file under the lock (this logs the Runtime out too). True if a file was removed.
|
|
355
|
+
|
|
356
|
+
A missing file is not an error and creates nothing (no folder, no lock file). Anything that is not a login file (a folder, a
|
|
357
|
+
FIFO, an ssh key that SIMPLICIO_AUTH_FILE points at...) is refused with `not_a_login_file` and left alone, without even a
|
|
358
|
+
lock file next to it. A symlink (a reparse point of the link kind on Windows) is refused with `login_symlink` before the
|
|
359
|
+
lock, and neither it nor its target is touched. The shape is checked once before the lock and again under it, because the
|
|
360
|
+
file can change while this waits for the Runtime."""
|
|
361
|
+
path = Path(path) if path else login_path()
|
|
362
|
+
try:
|
|
363
|
+
info = os.lstat(path)
|
|
364
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
365
|
+
return False
|
|
366
|
+
except OSError as exc:
|
|
367
|
+
raise _os_error(path, exc, "examinar") from None
|
|
368
|
+
if _link_info(info):
|
|
369
|
+
raise _symlink_error(path, apagando=True)
|
|
370
|
+
if stat.S_ISDIR(info.st_mode):
|
|
371
|
+
raise _not_a_login(path, "é uma pasta")
|
|
372
|
+
if not _still_there(path):
|
|
373
|
+
return False
|
|
374
|
+
with file_lock(path):
|
|
375
|
+
if not _still_there(path):
|
|
376
|
+
return False
|
|
377
|
+
try:
|
|
378
|
+
os.unlink(path)
|
|
379
|
+
except FileNotFoundError:
|
|
380
|
+
return False
|
|
381
|
+
except OSError as exc:
|
|
382
|
+
raise _os_error(path, exc, "apagar") from None
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# --- what the file says (never a token) -----------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _timestamp(value: Any, name: str) -> int:
|
|
390
|
+
"""A login's `*_expires_at`: seconds since the epoch, 0 for none or unknown. Anything else is `login_invalid`.
|
|
391
|
+
|
|
392
|
+
`1e999` (OverflowError in `int`), NaN, a negative number, a bool, a list, a long string and `999999999999` (year 33658:
|
|
393
|
+
a valid i64 for the Runtime, an instant no date can hold) all land here instead of in a traceback."""
|
|
394
|
+
if value is None or value == "":
|
|
395
|
+
return 0
|
|
396
|
+
seconds: Optional[int] = None
|
|
397
|
+
if isinstance(value, bool):
|
|
398
|
+
pass
|
|
399
|
+
elif isinstance(value, int):
|
|
400
|
+
seconds = value
|
|
401
|
+
elif isinstance(value, float):
|
|
402
|
+
seconds = int(value) if math.isfinite(value) else None
|
|
403
|
+
elif isinstance(value, str) and _DIGITS.fullmatch(value.strip()):
|
|
404
|
+
seconds = int(value)
|
|
405
|
+
if seconds is None or not 0 <= seconds <= hostos.MAX_TIMESTAMP:
|
|
406
|
+
raise LoginError("login_invalid", f"o campo {name} do arquivo de login não é um instante válido (segundos desde 1970, "
|
|
407
|
+
"até o ano 9999); rode: simplicio-video login")
|
|
408
|
+
return seconds
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def mask_email(email: str) -> str:
|
|
412
|
+
"""`maria@example.org` -> `m***@example.org`; anything that is not an address gives ''."""
|
|
413
|
+
found = _EMAIL.fullmatch(email.strip())
|
|
414
|
+
return f"{found.group(1)}***@{found.group(2)}" if found else ""
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def summary(login: Mapping[str, Any], now: Optional[float] = None) -> dict:
|
|
418
|
+
"""Public facts of a login: no token, no unmasked e-mail. Raises LoginError (login_invalid) for a date that is no instant."""
|
|
419
|
+
stamp = time.time() if now is None else now
|
|
420
|
+
verification = login.get("verification")
|
|
421
|
+
validated = verification.get("validated") if isinstance(verification, dict) else None
|
|
422
|
+
validated = validated if isinstance(validated, dict) else {}
|
|
423
|
+
user = validated.get("user") if isinstance(validated.get("user"), dict) else {}
|
|
424
|
+
entitlement = validated.get("entitlement")
|
|
425
|
+
access_expires = _timestamp(login.get("access_expires_at"), "access_expires_at")
|
|
426
|
+
refresh_expires = _timestamp(login.get("refresh_token_expires_at"), "refresh_token_expires_at")
|
|
427
|
+
return {
|
|
428
|
+
"email": mask_email(str(user.get("email") or "")),
|
|
429
|
+
"access_expires_at": access_expires,
|
|
430
|
+
"access_expired": access_expires <= stamp,
|
|
431
|
+
"refresh_expires_at": refresh_expires,
|
|
432
|
+
"refresh_expired": bool(refresh_expires) and refresh_expires <= stamp, # 0 = persistent or unknown
|
|
433
|
+
"has_refresh_token": bool(str(login.get("refresh_token") or "").strip()),
|
|
434
|
+
"entitlement": ({key: entitlement.get(key) for key in ("tier", "status", "source", "plan", "active")}
|
|
435
|
+
if isinstance(entitlement, dict) else None),
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# --- the Runtime ----------------------------------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def runtime_binary(environ: Optional[Mapping[str, str]] = None) -> Optional[Path]:
|
|
443
|
+
"""The Runtime: `simplicio` on PATH (PATHEXT on Windows, never the current directory), else its copy in ~/.simplicio/bin."""
|
|
444
|
+
environ = os.environ if environ is None else environ
|
|
445
|
+
found = hostos.which("simplicio", environ.get("PATH", ""), environ)
|
|
446
|
+
if found:
|
|
447
|
+
return found
|
|
448
|
+
managed = hostos.home_dir(environ) / RUNTIME_DIR / "bin" / hostos.exe_name("simplicio")
|
|
449
|
+
return managed if hostos.is_file(managed) and os.access(managed, os.X_OK) else None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def runtime_version(binary: Path, timeout: float = 10) -> Optional[str]:
|
|
453
|
+
"""`simplicio --version` -> '3.10.0', or None when it does not answer. Its output is read as UTF-8 with `replace`."""
|
|
454
|
+
try:
|
|
455
|
+
done = subprocess.run([str(binary), "--version"], capture_output=True, encoding="utf-8", errors="replace",
|
|
456
|
+
timeout=timeout, stdin=subprocess.DEVNULL)
|
|
457
|
+
except (OSError, subprocess.SubprocessError):
|
|
458
|
+
return None
|
|
459
|
+
found = _VERSION.search(done.stdout) if done.returncode == 0 else None
|
|
460
|
+
return found.group(1) if found else None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
_FIXES = {"login_missing": "simplicio-video login", "login_invalid": "simplicio-video login",
|
|
464
|
+
"login_symlink": "remova o link simbólico e rode: simplicio-video login", "login_expired": "simplicio-video login"}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def describe(environ: Optional[Mapping[str, str]] = None, now: Optional[float] = None) -> dict:
|
|
468
|
+
"""The state of the login as a plain dict (used by `auth status`, `login` and `doctor`): never a token.
|
|
469
|
+
|
|
470
|
+
`logged_in` is true when the file is valid and either the access token has not expired or a refresh token
|
|
471
|
+
that has not expired can renew it. `reason_code` is `ok` or why not; `fix` is the command that repairs it.
|
|
472
|
+
"""
|
|
473
|
+
environ = os.environ if environ is None else environ
|
|
474
|
+
path, runtime_path = login_path(environ), runtime_login_path(environ)
|
|
475
|
+
binary = runtime_binary(environ)
|
|
476
|
+
doc: dict[str, Any] = {
|
|
477
|
+
"schema": "simplicio.auth-status/v1", "logged_in": False, "reason_code": "ok", "fix": None,
|
|
478
|
+
"path": str(path), "runtime_path": str(runtime_path),
|
|
479
|
+
"shared_with_runtime": os.path.normcase(os.path.abspath(path)) == os.path.normcase(os.path.abspath(runtime_path)),
|
|
480
|
+
"runtime": {"found": binary is not None, "path": str(binary) if binary else None,
|
|
481
|
+
"version": runtime_version(binary) if binary else None},
|
|
482
|
+
}
|
|
483
|
+
try:
|
|
484
|
+
login = read_login(path)
|
|
485
|
+
facts = summary(login, now)
|
|
486
|
+
except LoginError as exc:
|
|
487
|
+
doc.update(reason_code=exc.reason_code, detail=str(exc), fix=exc.fix or _FIXES.get(exc.reason_code))
|
|
488
|
+
return doc
|
|
489
|
+
usable = not facts["access_expired"] or (facts["has_refresh_token"] and not facts["refresh_expired"])
|
|
490
|
+
doc.update(facts)
|
|
491
|
+
doc.update(logged_in=usable, reason_code="ok" if usable else "login_expired",
|
|
492
|
+
fix=None if usable else _FIXES["login_expired"])
|
|
493
|
+
return doc
|