pulse-engine 0.2.39__tar.gz → 0.2.42__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/PKG-INFO +4 -1
  2. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/pyproject.toml +14 -2
  3. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/digital_news.py +62 -11
  4. pulse_engine-0.2.42/src/pulse_engine/adapters/google_sheets.py +292 -0
  5. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/providers/deepgram.py +1 -3
  6. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/runners/prefect_pipeline_flow.py +26 -1
  7. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/sandbox/backend.py +9 -1
  8. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/README.md +0 -0
  9. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/__init__.py +0 -0
  10. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/__init__.py +0 -0
  11. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/audio_transcription.py +0 -0
  12. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/batcher.py +0 -0
  13. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/digital_news_metadata.py +0 -0
  14. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/document_event_campaign_mapper.py +0 -0
  15. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/exceptions.py +0 -0
  16. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/mart_export.py +0 -0
  17. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/models.py +0 -0
  18. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/opensearch_sql.py +0 -0
  19. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/opensearch_storage.py +0 -0
  20. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/prompts.py +0 -0
  21. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/report_generator.py +0 -0
  22. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/speech_content.py +0 -0
  23. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/speech_metadata.py +0 -0
  24. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/__init__.py +0 -0
  25. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/base.py +0 -0
  26. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/config.py +0 -0
  27. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/factory.py +0 -0
  28. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/providers/__init__.py +0 -0
  29. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/providers/openai_whisper.py +0 -0
  30. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/providers/selfhosted_job.py +0 -0
  31. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/providers/youtube_captions.py +0 -0
  32. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/service.py +0 -0
  33. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/transcription/source.py +0 -0
  34. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/twitter.py +0 -0
  35. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/youtube_downloader.py +0 -0
  36. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/adapters/youtube_metadata.py +0 -0
  37. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/api/__init__.py +0 -0
  38. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/api/v1/__init__.py +0 -0
  39. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/api/v1/auth.py +0 -0
  40. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/api/v1/health.py +0 -0
  41. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/api/v1/router.py +0 -0
  42. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/__init__.py +0 -0
  43. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/main.py +0 -0
  44. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/cookiecutter.json +0 -0
  45. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +0 -0
  46. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +0 -0
  47. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +0 -0
  48. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +0 -0
  49. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +0 -0
  50. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
  51. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
  52. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +0 -0
  53. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/client.py +0 -0
  54. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/config.py +0 -0
  55. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/core/__init__.py +0 -0
  56. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/core/error_handlers.py +0 -0
  57. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/core/exceptions.py +0 -0
  58. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/core/job_token.py +0 -0
  59. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/core/logging.py +0 -0
  60. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/core/scope.py +0 -0
  61. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/core/security.py +0 -0
  62. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/__init__.py +0 -0
  63. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/models.py +0 -0
  64. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/pipeline_sync.py +0 -0
  65. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/repository.py +0 -0
  66. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/router.py +0 -0
  67. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/runner.py +0 -0
  68. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/schemas.py +0 -0
  69. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/service.py +0 -0
  70. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/tasks.py +0 -0
  71. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/cron/translator.py +0 -0
  72. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/database.py +0 -0
  73. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/dependencies.py +0 -0
  74. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/deployment/__init__.py +0 -0
  75. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/deployment/infra_provisioner.py +0 -0
  76. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/evolution/__init__.py +0 -0
  77. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/evolution/adapter.py +0 -0
  78. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/evolution/client.py +0 -0
  79. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/evolution/models.py +0 -0
  80. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/evolution/router.py +0 -0
  81. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/evolution/service.py +0 -0
  82. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/extractor/__init__.py +0 -0
  83. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/extractor/adapters/__init__.py +0 -0
  84. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/extractor/base.py +0 -0
  85. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/main.py +0 -0
  86. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/mcp/__init__.py +0 -0
  87. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/mcp/__main__.py +0 -0
  88. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/mcp/server.py +0 -0
  89. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/mcp/tools_kb.py +0 -0
  90. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/mcp/tools_modules.py +0 -0
  91. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/mcp/tools_pipelines.py +0 -0
  92. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/mcp/tools_processor.py +0 -0
  93. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/middleware/__init__.py +0 -0
  94. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/middleware/rate_limit.py +0 -0
  95. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/middleware/request_id.py +0 -0
  96. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/middleware/security_headers.py +0 -0
  97. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/middleware/tenant.py +0 -0
  98. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/notifications/__init__.py +0 -0
  99. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/notifications/apprise_notifier.py +0 -0
  100. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/__init__.py +0 -0
  101. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/config_parser.py +0 -0
  102. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/expression.py +0 -0
  103. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/models.py +0 -0
  104. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/repositories.py +0 -0
  105. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/router_modules.py +0 -0
  106. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/router_pipelines.py +0 -0
  107. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/schemas.py +0 -0
  108. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/service.py +0 -0
  109. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/translators/__init__.py +0 -0
  110. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/translators/airflow_status.py +0 -0
  111. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/translators/airflow_translator.py +0 -0
  112. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/translators/base.py +0 -0
  113. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/translators/prefect_status.py +0 -0
  114. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/pipeline/translators/prefect_translator.py +0 -0
  115. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/__init__.py +0 -0
  116. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/base.py +0 -0
  117. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/core/__init__.py +0 -0
  118. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/core/analysis.py +0 -0
  119. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/core/chunking.py +0 -0
  120. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/core/prompts.py +0 -0
  121. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/core/topic_splitter.py +0 -0
  122. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/defaults/__init__.py +0 -0
  123. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/defaults/core_processor.py +0 -0
  124. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/defaults/postprocessor.py +0 -0
  125. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/defaults/preprocessor.py +0 -0
  126. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/llm/__init__.py +0 -0
  127. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/llm/provider.py +0 -0
  128. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/ocr/gemini.py +0 -0
  129. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/pipeline.py +0 -0
  130. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/postprocessor/__init__.py +0 -0
  131. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/postprocessor/embeddings.py +0 -0
  132. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/postprocessor/tasks.py +0 -0
  133. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/preprocessor/__init__.py +0 -0
  134. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/preprocessor/tasks.py +0 -0
  135. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/router.py +0 -0
  136. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/processor/schemas.py +0 -0
  137. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/registry.py +0 -0
  138. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/__init__.py +0 -0
  139. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/agent.py +0 -0
  140. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/fixtures.py +0 -0
  141. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/models.py +0 -0
  142. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/prompts.py +0 -0
  143. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/service.py +0 -0
  144. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/staging.py +0 -0
  145. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/tools.py +0 -0
  146. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/validation.py +0 -0
  147. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/reporting/wiring.py +0 -0
  148. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/runners/__init__.py +0 -0
  149. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/runners/lambda_runner.py +0 -0
  150. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/runners/pipeline_runner.py +0 -0
  151. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/runners/prefect_runner.py +0 -0
  152. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/s3.py +0 -0
  153. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/sandbox/__init__.py +0 -0
  154. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/sandbox/lambda_microvm.py +0 -0
  155. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/sandbox/microvm_agent/Dockerfile +0 -0
  156. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/sandbox/microvm_agent/__init__.py +0 -0
  157. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/sandbox/microvm_agent/server.py +0 -0
  158. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/sandbox/session.py +0 -0
  159. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/secrets.py +0 -0
  160. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/services/__init__.py +0 -0
  161. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/services/bootstrap.py +0 -0
  162. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/services/opensearch.py +0 -0
  163. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/__init__.py +0 -0
  164. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/catalog.py +0 -0
  165. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/models.py +0 -0
  166. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/repo.py +0 -0
  167. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/seed.py +0 -0
  168. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/seed_data/skill_files.json +0 -0
  169. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/seed_data/skills.json +0 -0
  170. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/sync.py +0 -0
  171. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/skills/tools.py +0 -0
  172. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/storage/__init__.py +0 -0
  173. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/storage/connectors/__init__.py +0 -0
  174. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/storage/connectors/athena.py +0 -0
  175. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/storage/connectors/base.py +0 -0
  176. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/storage/connectors/opensearch.py +0 -0
  177. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/storage/knowledge_base.py +0 -0
  178. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/storage/router.py +0 -0
  179. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/storage/schemas.py +0 -0
  180. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/testing/__init__.py +0 -0
  181. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/testing/fixtures.py +0 -0
  182. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/testing/mocks.py +0 -0
  183. {pulse_engine-0.2.39 → pulse_engine-0.2.42}/src/pulse_engine/worker.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pulse-engine
3
- Version: 0.2.39
3
+ Version: 0.2.42
4
4
  Summary: Pulse Engine — Hybrid framework for building Pulse products
5
5
  Author: Pulse Team
6
6
  Requires-Python: >=3.11,<3.13
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Programming Language :: Python :: 3.11
9
9
  Classifier: Programming Language :: Python :: 3.12
10
+ Provides-Extra: sheets
10
11
  Provides-Extra: worker
11
12
  Requires-Dist: alembic (>=1.14.0,<2.0.0)
12
13
  Requires-Dist: apprise (==1.11.0)
@@ -23,6 +24,8 @@ Requires-Dist: deepagents (>=0.6,<0.7)
23
24
  Requires-Dist: deepgram-sdk (>=3.7.0,<4.0.0)
24
25
  Requires-Dist: email-validator (>=2.3.0,<3.0.0)
25
26
  Requires-Dist: fastapi (>=0.115.0,<0.116.0)
27
+ Requires-Dist: google-auth (>=2.35,<3.0) ; extra == "sheets"
28
+ Requires-Dist: gspread (>=6.1,<7.0) ; extra == "sheets"
26
29
  Requires-Dist: httpx (>=0.28.0,<0.29.0)
27
30
  Requires-Dist: importlib-metadata (>=9.0.0,<10.0.0)
28
31
  Requires-Dist: langchain (>=1.2.13,<2.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pulse-engine"
3
- version = "0.2.39"
3
+ version = "0.2.42"
4
4
  description = "Pulse Engine — Hybrid framework for building Pulse products"
5
5
  authors = ["Pulse Team"]
6
6
  readme = "README.md"
@@ -53,10 +53,16 @@ langchain-agentcore-codeinterpreter = "^0.0.5"
53
53
  bedrock-agentcore = "^1.17.0"
54
54
  yt-dlp = "^2026.7.4"
55
55
  openpyxl = "^3.1"
56
+ # Optional: only GoogleSheetsAdapter needs these, and it imports them lazily, so an
57
+ # install that never reads a spreadsheet does not pay for them. Enable with the
58
+ # "sheets" extra.
59
+ gspread = {version = "^6.1", optional = true}
60
+ google-auth = {version = "^2.35", optional = true}
56
61
 
57
62
 
58
63
  [tool.poetry.extras]
59
64
  worker = ["prefect"]
65
+ sheets = ["gspread", "google-auth"]
60
66
 
61
67
  [tool.poetry.group.dev.dependencies]
62
68
  pytest = "^8.3.0"
@@ -111,9 +117,15 @@ module = ["langdetect.*", "tiktoken.*", "bs4.*", "jose", "jose.*", "boto3", "bot
111
117
  ignore_missing_imports = true
112
118
 
113
119
  [[tool.mypy.overrides]]
114
- module = ["prefect", "prefect.*", "entrypoint", "fitz", "twikit", "twikit.*", "yt_dlp", "yt_dlp.*", "google", "google.*", "apprise", "apprise.*", "deepagents", "deepagents.*", "langchain_agentcore_codeinterpreter", "langchain_agentcore_codeinterpreter.*", "bedrock_agentcore", "bedrock_agentcore.*", "openpyxl", "openpyxl.*"]
120
+ module = ["prefect", "prefect.*", "entrypoint", "fitz", "twikit", "twikit.*", "yt_dlp", "yt_dlp.*", "google", "google.*", "apprise", "apprise.*", "deepagents", "deepagents.*", "langchain_agentcore_codeinterpreter", "langchain_agentcore_codeinterpreter.*", "bedrock_agentcore", "bedrock_agentcore.*", "openpyxl", "openpyxl.*", "gspread", "gspread.*"]
115
121
  ignore_missing_imports = true
116
122
 
123
+ [[tool.mypy.overrides]]
124
+ # google-auth ships no annotations for `from_service_account_info`, so the one call this
125
+ # adapter makes into it is untyped. Scoped to this module rather than pragma'd inline.
126
+ module = ["pulse_engine.adapters.google_sheets"]
127
+ disallow_untyped_calls = false
128
+
117
129
  [[tool.mypy.overrides]]
118
130
  module = ["scripts.*"]
119
131
  ignore_errors = true
@@ -44,6 +44,11 @@ class _ArticleSchema(BaseModel):
44
44
  content: str = Field(description="The full main article text content")
45
45
 
46
46
 
47
+ class TransientFetchError(Exception):
48
+ """Raised for any failure worth retrying: network issues, rate limits,
49
+ server errors, or a 404 that may be a stale CDN edge-cache response."""
50
+
51
+
47
52
  class DigitalNewsAdapter:
48
53
  """Fetches article content via Cloudflare Browser Rendering + LLM.
49
54
 
@@ -65,13 +70,19 @@ class DigitalNewsAdapter:
65
70
  adapter = DigitalNewsAdapter(llm=ChatOllama(model="llama3"))
66
71
  """
67
72
 
73
+ _RETRYABLE_TARGET_STATUSES = [404, 429, 500, 502, 503, 504]
74
+
68
75
  def __init__(
69
76
  self,
70
77
  llm: Any,
71
78
  cloudflare_timeout: float = 60.0,
79
+ max_retries: int = 3,
80
+ retry_delay: float = 5.0,
72
81
  ) -> None:
73
82
  self._llm = llm
74
83
  self._cloudflare_timeout = cloudflare_timeout
84
+ self._max_retries = max_retries
85
+ self._retry_delay = retry_delay
75
86
 
76
87
  self._account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID")
77
88
  if not self._account_id:
@@ -83,8 +94,30 @@ class DigitalNewsAdapter:
83
94
 
84
95
  async def fetch(self, url: str) -> ArticleContent:
85
96
  """Fetch and extract content from a single article URL."""
86
- markdown = await self._fetch_markdown(url)
87
- return await self._extract_content(markdown)
97
+ url = url.strip()
98
+ last_exc: Exception | None = None
99
+
100
+ for attempt in range(1, self._max_retries + 1):
101
+ try:
102
+ markdown = await self._fetch_markdown(url)
103
+ return await self._extract_content(markdown)
104
+ except TransientFetchError as exc:
105
+ last_exc = exc
106
+ if attempt < self._max_retries:
107
+ logger.warning(
108
+ "fetch_retry", url=url, attempt=attempt, reason=str(exc)
109
+ )
110
+ await asyncio.sleep(self._retry_delay)
111
+ else:
112
+ logger.warning(
113
+ "fetch_retries_exhausted",
114
+ url=url,
115
+ attempts=attempt,
116
+ reason=str(exc),
117
+ )
118
+
119
+ assert last_exc is not None
120
+ raise last_exc
88
121
 
89
122
  async def fetch_many(
90
123
  self,
@@ -114,20 +147,38 @@ class DigitalNewsAdapter:
114
147
  async with httpx.AsyncClient(
115
148
  timeout=self._cloudflare_timeout, follow_redirects=True
116
149
  ) as client:
117
- resp = await client.post(
118
- cf_url,
119
- headers={
120
- "Authorization": f"Bearer {self._bearer_token}",
121
- "Content-Type": "application/json",
122
- },
123
- json={"url": url},
124
- )
150
+ try:
151
+ resp = await client.post(
152
+ cf_url,
153
+ headers={
154
+ "Authorization": f"Bearer {self._bearer_token}",
155
+ "Content-Type": "application/json",
156
+ },
157
+ json={"url": url},
158
+ )
159
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
160
+ raise TransientFetchError(
161
+ f"network error contacting Cloudflare for {url}: {exc}"
162
+ ) from exc
163
+
164
+ # Rate limiting or server-side failure from Cloudflare's own API.
165
+ if resp.status_code == 429 or resp.status_code >= 500:
166
+ raise TransientFetchError(
167
+ f"Cloudflare API returned {resp.status_code} for {url}"
168
+ )
125
169
  resp.raise_for_status()
126
170
 
127
171
  data = resp.json()
172
+
173
+ target_status = (data.get("meta") or {}).get("status")
174
+ if target_status in self._RETRYABLE_TARGET_STATUSES:
175
+ raise TransientFetchError(
176
+ f"target page returned status {target_status} for {url}"
177
+ )
178
+
128
179
  markdown: str = data.get("result", "")
129
180
  if not markdown or not markdown.strip():
130
- raise RuntimeError(f"Empty markdown returned by Cloudflare for {url}")
181
+ raise TransientFetchError(f"Empty markdown returned by Cloudflare: {url}")
131
182
  return markdown
132
183
 
133
184
  async def _extract_content(self, markdown: str) -> ArticleContent:
@@ -0,0 +1,292 @@
1
+ """GoogleSheetsAdapter — read-only access to a Google Sheets workbook.
2
+
3
+ Auth, quota handling and the API's rough edges live here; what the rows *mean* stays
4
+ with the product. A caller supplies a service-account key and a workbook id and gets
5
+ rows back — it never sees gspread, a credentials object, or a 429.
6
+
7
+ Two of those rough edges are worth naming, because both were learned from a running
8
+ system and neither is visible in the API:
9
+
10
+ * **Only rate limits are retried.** A quota rejection is transient and worth waiting
11
+ out; a missing tab or a revoked key is not, and retrying it six times turns a clear
12
+ failure into a slow one that reports the wrong cause. Anything that is not a rate
13
+ limit propagates on the first attempt.
14
+ * **Tab names drift.** The same logical tab is "Channel Codes" in one workbook and
15
+ "channel codes" in the next, because a human typed it. An exact match is tried
16
+ first, then a case- and whitespace-insensitive one, so a renamed tab does not fail
17
+ a run.
18
+
19
+ ``gspread`` is imported lazily and declared under the ``sheets`` extra. It is a
20
+ dependency of this module, not of the engine, and an installation that never reads a
21
+ spreadsheet should not pay for it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import time
29
+ from collections.abc import Callable, Iterator
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Read-only: this adapter has no write path, and the narrower scope means a leaked key
36
+ # cannot be used to modify a workbook.
37
+ READONLY_SCOPES = (
38
+ "https://www.googleapis.com/auth/spreadsheets.readonly",
39
+ "https://www.googleapis.com/auth/drive.readonly",
40
+ )
41
+
42
+ _RATE_LIMIT_MARKERS = ("429", "quota exceeded", "rate limit")
43
+
44
+
45
+ class SheetError(Exception):
46
+ """A spreadsheet could not be read: bad credentials, missing tab, bad workbook
47
+ id."""
48
+
49
+
50
+ class SheetRateLimitError(SheetError):
51
+ """Quota rejected the read and the retries were exhausted.
52
+
53
+ Distinct from `SheetError` so a caller can tell "come back later" from "this will
54
+ never work", and alert differently on each.
55
+ """
56
+
57
+
58
+ def _is_rate_limit(exc: Exception) -> bool:
59
+ message = str(exc).lower()
60
+ return any(marker in message for marker in _RATE_LIMIT_MARKERS)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class TabScan:
65
+ """One worksheet seen during a scan.
66
+
67
+ `title` and `first_cell` are read as the scan reaches the tab; `values()` is
68
+ deferred, because the caller that needs this — a workbook whose tabs *are* the
69
+ records, keyed by a cell rather than by name — decides from `first_cell` whether
70
+ the rows are wanted. Reading every tab eagerly would spend a request on each one
71
+ it skips.
72
+ """
73
+
74
+ title: str
75
+ first_cell: str
76
+ _values: Callable[[], list[list[Any]]]
77
+ error: str | None = None
78
+ """Why this tab could not be read, or None.
79
+
80
+ Set instead of raising, because a workbook whose tabs are records is exactly the
81
+ place where one malformed tab must not cost the whole walk. A quota rejection is
82
+ different — that is workbook-wide, so it still propagates.
83
+ """
84
+
85
+ def values(self) -> list[list[Any]]:
86
+ """The tab's rows, fetched on first ask."""
87
+ return self._values()
88
+
89
+
90
+ class GoogleSheetsAdapter:
91
+ """Read worksheets from a Google Sheets workbook.
92
+
93
+ Args:
94
+ credentials_json: Service-account key as a JSON string.
95
+ max_retries: Attempts before giving up on a rate-limited read. Default 6.
96
+ backoff_base_seconds: Base for exponential backoff between retries. Default 2.0,
97
+ giving 2s, 4s, 8s … — about two minutes over six attempts, which is
98
+ the order of a Sheets per-minute quota window.
99
+ sleep: Injected so tests can drive the backoff without waiting. Production
100
+ callers
101
+ leave this alone.
102
+ client: A pre-built gspread client, for tests. When given, `credentials_json` is
103
+ not used and no authorisation is attempted.
104
+
105
+ Usage::
106
+
107
+ adapter = GoogleSheetsAdapter(credentials_json=key)
108
+ rows = adapter.read_records(spreadsheet_id="1abc…", tab="Master")
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ credentials_json: str = "",
114
+ *,
115
+ max_retries: int = 6,
116
+ backoff_base_seconds: float = 2.0,
117
+ sleep: Callable[[float], None] = time.sleep,
118
+ client: Any | None = None,
119
+ ) -> None:
120
+ if max_retries < 1:
121
+ raise ValueError(f"max_retries must be at least 1, got {max_retries}")
122
+ self._credentials_json = credentials_json
123
+ self._max_retries = max_retries
124
+ self._backoff_base_seconds = backoff_base_seconds
125
+ self._sleep = sleep
126
+ self._client = client
127
+
128
+ # -- connection ----------------------------------------------------------------
129
+
130
+ @property
131
+ def client(self) -> Any:
132
+ """The authorised gspread client, built once on first use."""
133
+ if self._client is None:
134
+ self._client = self._authorize(self._credentials_json)
135
+ return self._client
136
+
137
+ @staticmethod
138
+ def _authorize(credentials_json: str) -> Any:
139
+ # Validated before gspread is imported: a blank or malformed key is a config
140
+ # error, and reporting it should not depend on the optional library being
141
+ # installed. It also fails faster, on the cheaper check.
142
+ if not credentials_json.strip():
143
+ raise SheetError("credentials_json is required to read a spreadsheet")
144
+ try:
145
+ info = json.loads(credentials_json)
146
+ except json.JSONDecodeError as exc:
147
+ raise SheetError(f"credentials_json is not valid JSON: {exc}") from exc
148
+
149
+ import gspread
150
+ from google.oauth2.service_account import Credentials
151
+
152
+ credentials = Credentials.from_service_account_info(
153
+ info, scopes=list(READONLY_SCOPES)
154
+ )
155
+ return gspread.authorize(credentials)
156
+
157
+ def _worksheet(self, spreadsheet_id: str, tab: str) -> Any:
158
+ try:
159
+ spreadsheet = self.client.open_by_key(spreadsheet_id)
160
+ except Exception as exc:
161
+ if _is_rate_limit(exc):
162
+ raise
163
+ raise SheetError(f"cannot open workbook {spreadsheet_id!r}: {exc}") from exc
164
+
165
+ try:
166
+ return spreadsheet.worksheet(tab)
167
+ except Exception:
168
+ # Tab names drift in case and spacing between workbooks; a relaxed match
169
+ # before giving up keeps a renamed "Channel Codes" from failing the run.
170
+ wanted = tab.strip().lower()
171
+ for worksheet in spreadsheet.worksheets():
172
+ if str(worksheet.title).strip().lower() == wanted:
173
+ return worksheet
174
+ raise SheetError(f"tab {tab!r} not found in the workbook") from None
175
+
176
+ def _with_retry(self, what: str, call: Callable[[], Any]) -> Any:
177
+ """Run `call`, waiting out rate limits and nothing else."""
178
+ last_error: Exception | None = None
179
+ for attempt in range(self._max_retries):
180
+ try:
181
+ return call()
182
+ except Exception as exc:
183
+ if not _is_rate_limit(exc):
184
+ raise
185
+ last_error = exc
186
+ delay = self._backoff_base_seconds * (2**attempt)
187
+ logger.warning(
188
+ "sheets rate limited on %s (attempt %d/%d), sleeping %.2fs",
189
+ what,
190
+ attempt + 1,
191
+ self._max_retries,
192
+ delay,
193
+ )
194
+ self._sleep(delay)
195
+
196
+ raise SheetRateLimitError(
197
+ f"quota rejected the read of {what} after {self._max_retries} attempts: "
198
+ f"{last_error}"
199
+ )
200
+
201
+ # -- reads ---------------------------------------------------------------------
202
+
203
+ def read_records(self, spreadsheet_id: str, tab: str) -> list[dict[str, Any]]:
204
+ """One worksheet as a list of row dicts, keyed by the header row.
205
+
206
+ `expected_headers=[]` disables gspread's duplicate-header check: a tracker
207
+ maintained by hand routinely has two blank or repeated column titles, and the
208
+ caller decides which columns it cares about anyway.
209
+ """
210
+
211
+ def _read() -> list[dict[str, Any]]:
212
+ worksheet = self._worksheet(spreadsheet_id, tab)
213
+ rows: list[dict[str, Any]] = worksheet.get_all_records(expected_headers=[])
214
+ return rows
215
+
216
+ return list(self._with_retry(repr(tab), _read))
217
+
218
+ def read_values(self, spreadsheet_id: str, tab: str) -> list[list[Any]]:
219
+ """One worksheet as raw rows, header included and nothing coerced."""
220
+
221
+ def _read() -> list[list[Any]]:
222
+ worksheet = self._worksheet(spreadsheet_id, tab)
223
+ values: list[list[Any]] = worksheet.get_all_values()
224
+ return values
225
+
226
+ return list(self._with_retry(repr(tab), _read))
227
+
228
+ def read_first_cell(self, spreadsheet_id: str, tab: str) -> str:
229
+ """A1 of one worksheet, empty string when the sheet is blank.
230
+
231
+ Cheap enough to probe a tab's identity without pulling every row.
232
+ """
233
+
234
+ def _read() -> str:
235
+ worksheet = self._worksheet(spreadsheet_id, tab)
236
+ return str(worksheet.acell("A1").value or "")
237
+
238
+ return str(self._with_retry(repr(tab), _read))
239
+
240
+ def list_worksheet_titles(self, spreadsheet_id: str) -> list[str]:
241
+ """Every tab title in the workbook, in sheet order."""
242
+
243
+ def _read() -> list[str]:
244
+ spreadsheet = self.client.open_by_key(spreadsheet_id)
245
+ return [str(w.title) for w in spreadsheet.worksheets()]
246
+
247
+ return list(self._with_retry(f"workbook {spreadsheet_id!r}", _read))
248
+
249
+ def scan_tabs(self, spreadsheet_id: str) -> Iterator[TabScan]:
250
+ """Walk every tab in the workbook from a single open.
251
+
252
+ For a workbook that keys its records by a cell rather than by tab name, the
253
+ tab name is not known ahead of time, so `read_records` cannot address it.
254
+ Addressing tabs by name would also re-open the workbook per call, turning one
255
+ open into roughly 2N for N tabs — the wrong trade against a read quota.
256
+
257
+ Yields lazily so a caller can stop early, and defers the row read so a tab it
258
+ skips costs one cell rather than a whole sheet.
259
+ """
260
+ spreadsheet = self._with_retry(
261
+ f"workbook {spreadsheet_id!r}",
262
+ lambda: self.client.open_by_key(spreadsheet_id),
263
+ )
264
+
265
+ for worksheet in spreadsheet.worksheets():
266
+ title = str(worksheet.title)
267
+
268
+ def _first(w: Any = worksheet, t: str = title) -> str:
269
+ return str(self._with_retry(repr(t), lambda: w.acell("A1").value or ""))
270
+
271
+ def _values(w: Any = worksheet, t: str = title) -> list[list[Any]]:
272
+ rows: list[list[Any]] = list(
273
+ self._with_retry(repr(t), lambda: w.get_all_values())
274
+ )
275
+ return rows
276
+
277
+ # A tab that cannot be read is reported, not raised. Raising would end
278
+ # the generator and take every remaining tab with it — and a generator
279
+ # cannot be resumed past an exception, so the caller could not skip one
280
+ # bad tab even if it wanted to. A rate limit is not a per-tab problem and
281
+ # still propagates from `_with_retry` once its retries are spent.
282
+ try:
283
+ first_cell, error = _first(), None
284
+ except SheetRateLimitError:
285
+ raise
286
+ except Exception as exc: # noqa: BLE001 — reported per tab, not swallowed
287
+ logger.warning("sheets could not read A1 of %r: %s", title, exc)
288
+ first_cell, error = "", str(exc)
289
+
290
+ yield TabScan(
291
+ title=title, first_cell=first_cell, _values=_values, error=error
292
+ )
@@ -53,9 +53,7 @@ class DeepgramProvider(TranscriptionProvider):
53
53
  # back at the caller and discard the one useful output of detection —
54
54
  # which is what lets a caller assert the source language was plausible.
55
55
  detected = str(
56
- alt.get("language")
57
- or channel.get("detected_language")
58
- or language
56
+ alt.get("language") or channel.get("detected_language") or language
59
57
  )
60
58
 
61
59
  utterances = results.get("utterances") or []
@@ -40,9 +40,34 @@ logger = logging.getLogger(__name__)
40
40
 
41
41
  # Runner script injected as the container CMD for both ECS and VM dispatch.
42
42
  # Bypasses awslambdaric (Lambda-only) and runs the product entrypoint directly.
43
+ # Steps on ECS, Fargate and MicroVMs run the script below under `python -c`, so they
44
+ # never reach a product's Lambda handler and inherit no logging setup. Without this the
45
+ # root logger sits at its default WARNING with no handler, and every logger.info a
46
+ # module writes is discarded by logging.lastResort, which emits WARNING and above only.
47
+ #
48
+ # stderr, explicitly: the runner prints the step's JSON result on stdout and the MicroVM
49
+ # path captures that stream, so a log line written there would land inside the payload.
50
+ #
51
+ # Configures only a process that configured nothing. An absent handler is what marks
52
+ # the default state, so it is also the signal that no one has chosen a verbosity: a
53
+ # caller who called basicConfig(level=ERROR) meant it, and raising that to INFO would
54
+ # overrule the decision while a second handler duplicated every line.
55
+ #
56
+ # The Lambda handler in each product reads AWS_LAMBDA_LOG_LEVEL for the same purpose,
57
+ # because awslambdaric always installs a handler and absence cannot be the signal there.
58
+ _LOGGING_PREAMBLE = (
59
+ "import logging as _logging, sys as _sys\n"
60
+ "_root = _logging.getLogger()\n"
61
+ "if not _root.handlers:\n"
62
+ " _root.addHandler(_logging.StreamHandler(_sys.stderr))\n"
63
+ " if _root.getEffectiveLevel() > _logging.INFO:\n"
64
+ " _root.setLevel(_logging.INFO)\n"
65
+ )
66
+
43
67
  _VM_RUNNER_SCRIPT = (
44
68
  "import os, json, sys, traceback, boto3\n"
45
- "sys.path.insert(0, '/app')\n"
69
+ + _LOGGING_PREAMBLE
70
+ + "sys.path.insert(0, '/app')\n"
46
71
  "sys.path.insert(0, '/app/src')\n"
47
72
  "try:\n"
48
73
  " from botocore.exceptions import ClientError as _CE\n"
@@ -85,7 +85,15 @@ def make_lambda_microvm_backend(
85
85
  if client is None: # pragma: no cover — real AWS path
86
86
  import boto3
87
87
 
88
- client = boto3.client("lambda-microvms", region_name=region) # type: ignore[call-overload]
88
+ # `lambda-microvms` is newer than the pinned boto3-stubs, which type
89
+ # `client()` as an overload set over known service names. The pinned stubs
90
+ # reject the literal; a newer installed set accepts it. A `type: ignore` is
91
+ # therefore wrong in one direction or the other: absent, CI fails on the
92
+ # unknown overload; present, it fails locally as an unused ignore, because
93
+ # warn_unused_ignores is on. Calling through an untyped reference sidesteps
94
+ # overload resolution entirely and holds under both.
95
+ make_client: Any = boto3.client
96
+ client = make_client("lambda-microvms", region_name=region)
89
97
 
90
98
  # Ship the resumed VM's stdout to CloudWatch. Without a `logging` config the
91
99
  # microVM's runtime output goes nowhere (no log group is auto-created), so the
File without changes