PraisonAI 3.0.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.
Files changed (393) hide show
  1. praisonai/__init__.py +54 -0
  2. praisonai/__main__.py +15 -0
  3. praisonai/acp/__init__.py +54 -0
  4. praisonai/acp/config.py +159 -0
  5. praisonai/acp/server.py +587 -0
  6. praisonai/acp/session.py +219 -0
  7. praisonai/adapters/__init__.py +50 -0
  8. praisonai/adapters/readers.py +395 -0
  9. praisonai/adapters/rerankers.py +315 -0
  10. praisonai/adapters/retrievers.py +394 -0
  11. praisonai/adapters/vector_stores.py +409 -0
  12. praisonai/agent_scheduler.py +337 -0
  13. praisonai/agents_generator.py +903 -0
  14. praisonai/api/call.py +292 -0
  15. praisonai/auto.py +1197 -0
  16. praisonai/capabilities/__init__.py +275 -0
  17. praisonai/capabilities/a2a.py +140 -0
  18. praisonai/capabilities/assistants.py +283 -0
  19. praisonai/capabilities/audio.py +320 -0
  20. praisonai/capabilities/batches.py +469 -0
  21. praisonai/capabilities/completions.py +336 -0
  22. praisonai/capabilities/container_files.py +155 -0
  23. praisonai/capabilities/containers.py +93 -0
  24. praisonai/capabilities/embeddings.py +158 -0
  25. praisonai/capabilities/files.py +467 -0
  26. praisonai/capabilities/fine_tuning.py +293 -0
  27. praisonai/capabilities/guardrails.py +182 -0
  28. praisonai/capabilities/images.py +330 -0
  29. praisonai/capabilities/mcp.py +190 -0
  30. praisonai/capabilities/messages.py +270 -0
  31. praisonai/capabilities/moderations.py +154 -0
  32. praisonai/capabilities/ocr.py +217 -0
  33. praisonai/capabilities/passthrough.py +204 -0
  34. praisonai/capabilities/rag.py +207 -0
  35. praisonai/capabilities/realtime.py +160 -0
  36. praisonai/capabilities/rerank.py +165 -0
  37. praisonai/capabilities/responses.py +266 -0
  38. praisonai/capabilities/search.py +109 -0
  39. praisonai/capabilities/skills.py +133 -0
  40. praisonai/capabilities/vector_store_files.py +334 -0
  41. praisonai/capabilities/vector_stores.py +304 -0
  42. praisonai/capabilities/videos.py +141 -0
  43. praisonai/chainlit_ui.py +304 -0
  44. praisonai/chat/__init__.py +106 -0
  45. praisonai/chat/app.py +125 -0
  46. praisonai/cli/__init__.py +26 -0
  47. praisonai/cli/app.py +213 -0
  48. praisonai/cli/commands/__init__.py +75 -0
  49. praisonai/cli/commands/acp.py +70 -0
  50. praisonai/cli/commands/completion.py +333 -0
  51. praisonai/cli/commands/config.py +166 -0
  52. praisonai/cli/commands/debug.py +142 -0
  53. praisonai/cli/commands/diag.py +55 -0
  54. praisonai/cli/commands/doctor.py +166 -0
  55. praisonai/cli/commands/environment.py +179 -0
  56. praisonai/cli/commands/lsp.py +112 -0
  57. praisonai/cli/commands/mcp.py +210 -0
  58. praisonai/cli/commands/profile.py +457 -0
  59. praisonai/cli/commands/run.py +228 -0
  60. praisonai/cli/commands/schedule.py +150 -0
  61. praisonai/cli/commands/serve.py +97 -0
  62. praisonai/cli/commands/session.py +212 -0
  63. praisonai/cli/commands/traces.py +145 -0
  64. praisonai/cli/commands/version.py +101 -0
  65. praisonai/cli/configuration/__init__.py +18 -0
  66. praisonai/cli/configuration/loader.py +353 -0
  67. praisonai/cli/configuration/paths.py +114 -0
  68. praisonai/cli/configuration/schema.py +164 -0
  69. praisonai/cli/features/__init__.py +268 -0
  70. praisonai/cli/features/acp.py +236 -0
  71. praisonai/cli/features/action_orchestrator.py +546 -0
  72. praisonai/cli/features/agent_scheduler.py +773 -0
  73. praisonai/cli/features/agent_tools.py +474 -0
  74. praisonai/cli/features/agents.py +375 -0
  75. praisonai/cli/features/at_mentions.py +471 -0
  76. praisonai/cli/features/auto_memory.py +182 -0
  77. praisonai/cli/features/autonomy_mode.py +490 -0
  78. praisonai/cli/features/background.py +356 -0
  79. praisonai/cli/features/base.py +168 -0
  80. praisonai/cli/features/capabilities.py +1326 -0
  81. praisonai/cli/features/checkpoints.py +338 -0
  82. praisonai/cli/features/code_intelligence.py +652 -0
  83. praisonai/cli/features/compaction.py +294 -0
  84. praisonai/cli/features/compare.py +534 -0
  85. praisonai/cli/features/cost_tracker.py +514 -0
  86. praisonai/cli/features/debug.py +810 -0
  87. praisonai/cli/features/deploy.py +517 -0
  88. praisonai/cli/features/diag.py +289 -0
  89. praisonai/cli/features/doctor/__init__.py +63 -0
  90. praisonai/cli/features/doctor/checks/__init__.py +24 -0
  91. praisonai/cli/features/doctor/checks/acp_checks.py +240 -0
  92. praisonai/cli/features/doctor/checks/config_checks.py +366 -0
  93. praisonai/cli/features/doctor/checks/db_checks.py +366 -0
  94. praisonai/cli/features/doctor/checks/env_checks.py +543 -0
  95. praisonai/cli/features/doctor/checks/lsp_checks.py +199 -0
  96. praisonai/cli/features/doctor/checks/mcp_checks.py +349 -0
  97. praisonai/cli/features/doctor/checks/memory_checks.py +268 -0
  98. praisonai/cli/features/doctor/checks/network_checks.py +251 -0
  99. praisonai/cli/features/doctor/checks/obs_checks.py +328 -0
  100. praisonai/cli/features/doctor/checks/performance_checks.py +235 -0
  101. praisonai/cli/features/doctor/checks/permissions_checks.py +259 -0
  102. praisonai/cli/features/doctor/checks/selftest_checks.py +322 -0
  103. praisonai/cli/features/doctor/checks/serve_checks.py +426 -0
  104. praisonai/cli/features/doctor/checks/skills_checks.py +231 -0
  105. praisonai/cli/features/doctor/checks/tools_checks.py +371 -0
  106. praisonai/cli/features/doctor/engine.py +266 -0
  107. praisonai/cli/features/doctor/formatters.py +310 -0
  108. praisonai/cli/features/doctor/handler.py +397 -0
  109. praisonai/cli/features/doctor/models.py +264 -0
  110. praisonai/cli/features/doctor/registry.py +239 -0
  111. praisonai/cli/features/endpoints.py +1019 -0
  112. praisonai/cli/features/eval.py +560 -0
  113. praisonai/cli/features/external_agents.py +231 -0
  114. praisonai/cli/features/fast_context.py +410 -0
  115. praisonai/cli/features/flow_display.py +566 -0
  116. praisonai/cli/features/git_integration.py +651 -0
  117. praisonai/cli/features/guardrail.py +171 -0
  118. praisonai/cli/features/handoff.py +185 -0
  119. praisonai/cli/features/hooks.py +583 -0
  120. praisonai/cli/features/image.py +384 -0
  121. praisonai/cli/features/interactive_runtime.py +585 -0
  122. praisonai/cli/features/interactive_tools.py +380 -0
  123. praisonai/cli/features/interactive_tui.py +603 -0
  124. praisonai/cli/features/jobs.py +632 -0
  125. praisonai/cli/features/knowledge.py +531 -0
  126. praisonai/cli/features/lite.py +244 -0
  127. praisonai/cli/features/lsp_cli.py +225 -0
  128. praisonai/cli/features/mcp.py +169 -0
  129. praisonai/cli/features/message_queue.py +587 -0
  130. praisonai/cli/features/metrics.py +211 -0
  131. praisonai/cli/features/n8n.py +673 -0
  132. praisonai/cli/features/observability.py +293 -0
  133. praisonai/cli/features/ollama.py +361 -0
  134. praisonai/cli/features/output_style.py +273 -0
  135. praisonai/cli/features/package.py +631 -0
  136. praisonai/cli/features/performance.py +308 -0
  137. praisonai/cli/features/persistence.py +636 -0
  138. praisonai/cli/features/profile.py +226 -0
  139. praisonai/cli/features/profiler/__init__.py +81 -0
  140. praisonai/cli/features/profiler/core.py +558 -0
  141. praisonai/cli/features/profiler/optimizations.py +652 -0
  142. praisonai/cli/features/profiler/suite.py +386 -0
  143. praisonai/cli/features/profiling.py +350 -0
  144. praisonai/cli/features/queue/__init__.py +73 -0
  145. praisonai/cli/features/queue/manager.py +395 -0
  146. praisonai/cli/features/queue/models.py +286 -0
  147. praisonai/cli/features/queue/persistence.py +564 -0
  148. praisonai/cli/features/queue/scheduler.py +484 -0
  149. praisonai/cli/features/queue/worker.py +372 -0
  150. praisonai/cli/features/recipe.py +1723 -0
  151. praisonai/cli/features/recipes.py +449 -0
  152. praisonai/cli/features/registry.py +229 -0
  153. praisonai/cli/features/repo_map.py +860 -0
  154. praisonai/cli/features/router.py +466 -0
  155. praisonai/cli/features/sandbox_executor.py +515 -0
  156. praisonai/cli/features/serve.py +829 -0
  157. praisonai/cli/features/session.py +222 -0
  158. praisonai/cli/features/skills.py +856 -0
  159. praisonai/cli/features/slash_commands.py +650 -0
  160. praisonai/cli/features/telemetry.py +179 -0
  161. praisonai/cli/features/templates.py +1384 -0
  162. praisonai/cli/features/thinking.py +305 -0
  163. praisonai/cli/features/todo.py +334 -0
  164. praisonai/cli/features/tools.py +680 -0
  165. praisonai/cli/features/tui/__init__.py +83 -0
  166. praisonai/cli/features/tui/app.py +580 -0
  167. praisonai/cli/features/tui/cli.py +566 -0
  168. praisonai/cli/features/tui/debug.py +511 -0
  169. praisonai/cli/features/tui/events.py +99 -0
  170. praisonai/cli/features/tui/mock_provider.py +328 -0
  171. praisonai/cli/features/tui/orchestrator.py +652 -0
  172. praisonai/cli/features/tui/screens/__init__.py +50 -0
  173. praisonai/cli/features/tui/screens/main.py +245 -0
  174. praisonai/cli/features/tui/screens/queue.py +174 -0
  175. praisonai/cli/features/tui/screens/session.py +124 -0
  176. praisonai/cli/features/tui/screens/settings.py +148 -0
  177. praisonai/cli/features/tui/widgets/__init__.py +56 -0
  178. praisonai/cli/features/tui/widgets/chat.py +261 -0
  179. praisonai/cli/features/tui/widgets/composer.py +224 -0
  180. praisonai/cli/features/tui/widgets/queue_panel.py +200 -0
  181. praisonai/cli/features/tui/widgets/status.py +167 -0
  182. praisonai/cli/features/tui/widgets/tool_panel.py +248 -0
  183. praisonai/cli/features/workflow.py +720 -0
  184. praisonai/cli/legacy.py +236 -0
  185. praisonai/cli/main.py +5559 -0
  186. praisonai/cli/schedule_cli.py +54 -0
  187. praisonai/cli/state/__init__.py +31 -0
  188. praisonai/cli/state/identifiers.py +161 -0
  189. praisonai/cli/state/sessions.py +313 -0
  190. praisonai/code/__init__.py +93 -0
  191. praisonai/code/agent_tools.py +344 -0
  192. praisonai/code/diff/__init__.py +21 -0
  193. praisonai/code/diff/diff_strategy.py +432 -0
  194. praisonai/code/tools/__init__.py +27 -0
  195. praisonai/code/tools/apply_diff.py +221 -0
  196. praisonai/code/tools/execute_command.py +275 -0
  197. praisonai/code/tools/list_files.py +274 -0
  198. praisonai/code/tools/read_file.py +206 -0
  199. praisonai/code/tools/search_replace.py +248 -0
  200. praisonai/code/tools/write_file.py +217 -0
  201. praisonai/code/utils/__init__.py +46 -0
  202. praisonai/code/utils/file_utils.py +307 -0
  203. praisonai/code/utils/ignore_utils.py +308 -0
  204. praisonai/code/utils/text_utils.py +276 -0
  205. praisonai/db/__init__.py +64 -0
  206. praisonai/db/adapter.py +531 -0
  207. praisonai/deploy/__init__.py +62 -0
  208. praisonai/deploy/api.py +231 -0
  209. praisonai/deploy/docker.py +454 -0
  210. praisonai/deploy/doctor.py +367 -0
  211. praisonai/deploy/main.py +327 -0
  212. praisonai/deploy/models.py +179 -0
  213. praisonai/deploy/providers/__init__.py +33 -0
  214. praisonai/deploy/providers/aws.py +331 -0
  215. praisonai/deploy/providers/azure.py +358 -0
  216. praisonai/deploy/providers/base.py +101 -0
  217. praisonai/deploy/providers/gcp.py +314 -0
  218. praisonai/deploy/schema.py +208 -0
  219. praisonai/deploy.py +185 -0
  220. praisonai/endpoints/__init__.py +53 -0
  221. praisonai/endpoints/a2u_server.py +410 -0
  222. praisonai/endpoints/discovery.py +165 -0
  223. praisonai/endpoints/providers/__init__.py +28 -0
  224. praisonai/endpoints/providers/a2a.py +253 -0
  225. praisonai/endpoints/providers/a2u.py +208 -0
  226. praisonai/endpoints/providers/agents_api.py +171 -0
  227. praisonai/endpoints/providers/base.py +231 -0
  228. praisonai/endpoints/providers/mcp.py +263 -0
  229. praisonai/endpoints/providers/recipe.py +206 -0
  230. praisonai/endpoints/providers/tools_mcp.py +150 -0
  231. praisonai/endpoints/registry.py +131 -0
  232. praisonai/endpoints/server.py +161 -0
  233. praisonai/inbuilt_tools/__init__.py +24 -0
  234. praisonai/inbuilt_tools/autogen_tools.py +117 -0
  235. praisonai/inc/__init__.py +2 -0
  236. praisonai/inc/config.py +96 -0
  237. praisonai/inc/models.py +155 -0
  238. praisonai/integrations/__init__.py +56 -0
  239. praisonai/integrations/base.py +303 -0
  240. praisonai/integrations/claude_code.py +270 -0
  241. praisonai/integrations/codex_cli.py +255 -0
  242. praisonai/integrations/cursor_cli.py +195 -0
  243. praisonai/integrations/gemini_cli.py +222 -0
  244. praisonai/jobs/__init__.py +67 -0
  245. praisonai/jobs/executor.py +425 -0
  246. praisonai/jobs/models.py +230 -0
  247. praisonai/jobs/router.py +314 -0
  248. praisonai/jobs/server.py +186 -0
  249. praisonai/jobs/store.py +203 -0
  250. praisonai/llm/__init__.py +66 -0
  251. praisonai/llm/registry.py +382 -0
  252. praisonai/mcp_server/__init__.py +152 -0
  253. praisonai/mcp_server/adapters/__init__.py +74 -0
  254. praisonai/mcp_server/adapters/agents.py +128 -0
  255. praisonai/mcp_server/adapters/capabilities.py +168 -0
  256. praisonai/mcp_server/adapters/cli_tools.py +568 -0
  257. praisonai/mcp_server/adapters/extended_capabilities.py +462 -0
  258. praisonai/mcp_server/adapters/knowledge.py +93 -0
  259. praisonai/mcp_server/adapters/memory.py +104 -0
  260. praisonai/mcp_server/adapters/prompts.py +306 -0
  261. praisonai/mcp_server/adapters/resources.py +124 -0
  262. praisonai/mcp_server/adapters/tools_bridge.py +280 -0
  263. praisonai/mcp_server/auth/__init__.py +48 -0
  264. praisonai/mcp_server/auth/api_key.py +291 -0
  265. praisonai/mcp_server/auth/oauth.py +460 -0
  266. praisonai/mcp_server/auth/oidc.py +289 -0
  267. praisonai/mcp_server/auth/scopes.py +260 -0
  268. praisonai/mcp_server/cli.py +852 -0
  269. praisonai/mcp_server/elicitation.py +445 -0
  270. praisonai/mcp_server/icons.py +302 -0
  271. praisonai/mcp_server/recipe_adapter.py +573 -0
  272. praisonai/mcp_server/recipe_cli.py +824 -0
  273. praisonai/mcp_server/registry.py +703 -0
  274. praisonai/mcp_server/sampling.py +422 -0
  275. praisonai/mcp_server/server.py +490 -0
  276. praisonai/mcp_server/tasks.py +443 -0
  277. praisonai/mcp_server/transports/__init__.py +18 -0
  278. praisonai/mcp_server/transports/http_stream.py +376 -0
  279. praisonai/mcp_server/transports/stdio.py +132 -0
  280. praisonai/persistence/__init__.py +84 -0
  281. praisonai/persistence/config.py +238 -0
  282. praisonai/persistence/conversation/__init__.py +25 -0
  283. praisonai/persistence/conversation/async_mysql.py +427 -0
  284. praisonai/persistence/conversation/async_postgres.py +410 -0
  285. praisonai/persistence/conversation/async_sqlite.py +371 -0
  286. praisonai/persistence/conversation/base.py +151 -0
  287. praisonai/persistence/conversation/json_store.py +250 -0
  288. praisonai/persistence/conversation/mysql.py +387 -0
  289. praisonai/persistence/conversation/postgres.py +401 -0
  290. praisonai/persistence/conversation/singlestore.py +240 -0
  291. praisonai/persistence/conversation/sqlite.py +341 -0
  292. praisonai/persistence/conversation/supabase.py +203 -0
  293. praisonai/persistence/conversation/surrealdb.py +287 -0
  294. praisonai/persistence/factory.py +301 -0
  295. praisonai/persistence/hooks/__init__.py +18 -0
  296. praisonai/persistence/hooks/agent_hooks.py +297 -0
  297. praisonai/persistence/knowledge/__init__.py +26 -0
  298. praisonai/persistence/knowledge/base.py +144 -0
  299. praisonai/persistence/knowledge/cassandra.py +232 -0
  300. praisonai/persistence/knowledge/chroma.py +295 -0
  301. praisonai/persistence/knowledge/clickhouse.py +242 -0
  302. praisonai/persistence/knowledge/cosmosdb_vector.py +438 -0
  303. praisonai/persistence/knowledge/couchbase.py +286 -0
  304. praisonai/persistence/knowledge/lancedb.py +216 -0
  305. praisonai/persistence/knowledge/langchain_adapter.py +291 -0
  306. praisonai/persistence/knowledge/lightrag_adapter.py +212 -0
  307. praisonai/persistence/knowledge/llamaindex_adapter.py +256 -0
  308. praisonai/persistence/knowledge/milvus.py +277 -0
  309. praisonai/persistence/knowledge/mongodb_vector.py +306 -0
  310. praisonai/persistence/knowledge/pgvector.py +335 -0
  311. praisonai/persistence/knowledge/pinecone.py +253 -0
  312. praisonai/persistence/knowledge/qdrant.py +301 -0
  313. praisonai/persistence/knowledge/redis_vector.py +291 -0
  314. praisonai/persistence/knowledge/singlestore_vector.py +299 -0
  315. praisonai/persistence/knowledge/surrealdb_vector.py +309 -0
  316. praisonai/persistence/knowledge/upstash_vector.py +266 -0
  317. praisonai/persistence/knowledge/weaviate.py +223 -0
  318. praisonai/persistence/migrations/__init__.py +10 -0
  319. praisonai/persistence/migrations/manager.py +251 -0
  320. praisonai/persistence/orchestrator.py +406 -0
  321. praisonai/persistence/state/__init__.py +21 -0
  322. praisonai/persistence/state/async_mongodb.py +200 -0
  323. praisonai/persistence/state/base.py +107 -0
  324. praisonai/persistence/state/dynamodb.py +226 -0
  325. praisonai/persistence/state/firestore.py +175 -0
  326. praisonai/persistence/state/gcs.py +155 -0
  327. praisonai/persistence/state/memory.py +245 -0
  328. praisonai/persistence/state/mongodb.py +158 -0
  329. praisonai/persistence/state/redis.py +190 -0
  330. praisonai/persistence/state/upstash.py +144 -0
  331. praisonai/persistence/tests/__init__.py +3 -0
  332. praisonai/persistence/tests/test_all_backends.py +633 -0
  333. praisonai/profiler.py +1214 -0
  334. praisonai/recipe/__init__.py +134 -0
  335. praisonai/recipe/bridge.py +278 -0
  336. praisonai/recipe/core.py +893 -0
  337. praisonai/recipe/exceptions.py +54 -0
  338. praisonai/recipe/history.py +402 -0
  339. praisonai/recipe/models.py +266 -0
  340. praisonai/recipe/operations.py +440 -0
  341. praisonai/recipe/policy.py +422 -0
  342. praisonai/recipe/registry.py +849 -0
  343. praisonai/recipe/runtime.py +214 -0
  344. praisonai/recipe/security.py +711 -0
  345. praisonai/recipe/serve.py +859 -0
  346. praisonai/recipe/server.py +613 -0
  347. praisonai/scheduler/__init__.py +45 -0
  348. praisonai/scheduler/agent_scheduler.py +552 -0
  349. praisonai/scheduler/base.py +124 -0
  350. praisonai/scheduler/daemon_manager.py +225 -0
  351. praisonai/scheduler/state_manager.py +155 -0
  352. praisonai/scheduler/yaml_loader.py +193 -0
  353. praisonai/scheduler.py +194 -0
  354. praisonai/setup/__init__.py +1 -0
  355. praisonai/setup/build.py +21 -0
  356. praisonai/setup/post_install.py +23 -0
  357. praisonai/setup/setup_conda_env.py +25 -0
  358. praisonai/setup.py +16 -0
  359. praisonai/templates/__init__.py +116 -0
  360. praisonai/templates/cache.py +364 -0
  361. praisonai/templates/dependency_checker.py +358 -0
  362. praisonai/templates/discovery.py +391 -0
  363. praisonai/templates/loader.py +564 -0
  364. praisonai/templates/registry.py +511 -0
  365. praisonai/templates/resolver.py +206 -0
  366. praisonai/templates/security.py +327 -0
  367. praisonai/templates/tool_override.py +498 -0
  368. praisonai/templates/tools_doctor.py +256 -0
  369. praisonai/test.py +105 -0
  370. praisonai/train.py +562 -0
  371. praisonai/train_vision.py +306 -0
  372. praisonai/ui/agents.py +824 -0
  373. praisonai/ui/callbacks.py +57 -0
  374. praisonai/ui/chainlit_compat.py +246 -0
  375. praisonai/ui/chat.py +532 -0
  376. praisonai/ui/code.py +717 -0
  377. praisonai/ui/colab.py +474 -0
  378. praisonai/ui/colab_chainlit.py +81 -0
  379. praisonai/ui/components/aicoder.py +284 -0
  380. praisonai/ui/context.py +283 -0
  381. praisonai/ui/database_config.py +56 -0
  382. praisonai/ui/db.py +294 -0
  383. praisonai/ui/realtime.py +488 -0
  384. praisonai/ui/realtimeclient/__init__.py +756 -0
  385. praisonai/ui/realtimeclient/tools.py +242 -0
  386. praisonai/ui/sql_alchemy.py +710 -0
  387. praisonai/upload_vision.py +140 -0
  388. praisonai/version.py +1 -0
  389. praisonai-3.0.0.dist-info/METADATA +3493 -0
  390. praisonai-3.0.0.dist-info/RECORD +393 -0
  391. praisonai-3.0.0.dist-info/WHEEL +5 -0
  392. praisonai-3.0.0.dist-info/entry_points.txt +4 -0
  393. praisonai-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,849 @@
1
+ """
2
+ Recipe Registry Module
3
+
4
+ Provides local and remote registry support for recipe distribution.
5
+ Supports:
6
+ - Local filesystem registry (~/.praison/registry)
7
+ - Local HTTP registry (http://localhost:7777)
8
+ - Remote HTTP registry (https://registry.example.com)
9
+ - Publish, pull, search, list operations
10
+ - Atomic writes and file locking for concurrency safety
11
+ """
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import shutil
17
+ import tarfile
18
+ import tempfile
19
+ import re
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Union, Protocol
23
+ from urllib.parse import urlparse
24
+
25
+
26
+ # Default registry paths
27
+ DEFAULT_REGISTRY_PATH = Path.home() / ".praison" / "registry"
28
+ DEFAULT_RUNS_PATH = Path.home() / ".praison" / "runs"
29
+ DEFAULT_REGISTRY_PORT = 7777
30
+
31
+
32
+ class RegistryError(Exception):
33
+ """Base exception for registry operations."""
34
+ pass
35
+
36
+
37
+ class RecipeNotFoundError(RegistryError):
38
+ """Recipe not found in registry."""
39
+ pass
40
+
41
+
42
+ class RecipeExistsError(RegistryError):
43
+ """Recipe version already exists in registry."""
44
+ pass
45
+
46
+
47
+ class RegistryAuthError(RegistryError):
48
+ """Authentication failed for registry."""
49
+ pass
50
+
51
+
52
+ class RegistryNetworkError(RegistryError):
53
+ """Network error connecting to registry."""
54
+ pass
55
+
56
+
57
+ class RegistryConflictError(RegistryError):
58
+ """Conflict error (e.g., already exists without force)."""
59
+ pass
60
+
61
+
62
+ class RegistryProtocol(Protocol):
63
+ """Protocol defining registry interface for type checking."""
64
+
65
+ def publish(
66
+ self,
67
+ bundle_path: Union[str, Path],
68
+ force: bool = False,
69
+ metadata: Optional[Dict[str, Any]] = None,
70
+ ) -> Dict[str, Any]: ...
71
+
72
+ def pull(
73
+ self,
74
+ name: str,
75
+ version: Optional[str] = None,
76
+ output_dir: Optional[Path] = None,
77
+ verify_checksum: bool = True,
78
+ ) -> Dict[str, Any]: ...
79
+
80
+ def list_recipes(
81
+ self,
82
+ tags: Optional[List[str]] = None,
83
+ ) -> List[Dict[str, Any]]: ...
84
+
85
+ def search(
86
+ self,
87
+ query: str,
88
+ tags: Optional[List[str]] = None,
89
+ ) -> List[Dict[str, Any]]: ...
90
+
91
+ def get_versions(self, name: str) -> List[str]: ...
92
+
93
+ def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]: ...
94
+
95
+ def delete(self, name: str, version: Optional[str] = None) -> bool: ...
96
+
97
+
98
+ def _get_timestamp() -> str:
99
+ """Get current timestamp in ISO format."""
100
+ return datetime.now(timezone.utc).isoformat()
101
+
102
+
103
+ def _calculate_checksum(file_path: Path) -> str:
104
+ """Calculate SHA256 checksum of a file."""
105
+ sha256 = hashlib.sha256()
106
+ with open(file_path, "rb") as f:
107
+ for chunk in iter(lambda: f.read(8192), b""):
108
+ sha256.update(chunk)
109
+ return sha256.hexdigest()
110
+
111
+
112
+ def _normalize_name(name: str) -> str:
113
+ """Normalize recipe name per PEP 503 rules."""
114
+ return re.sub(r"[-_.]+", "-", name).lower()
115
+
116
+
117
+ def _validate_name(name: str) -> bool:
118
+ """Validate recipe name format."""
119
+ if not name or len(name) > 128:
120
+ return False
121
+ return bool(re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$', name))
122
+
123
+
124
+ def _validate_version(version: str) -> bool:
125
+ """Validate version string (semver-like)."""
126
+ if not version:
127
+ return False
128
+ return bool(re.match(r'^\d+\.\d+\.\d+([a-zA-Z0-9._-]*)?$', version))
129
+
130
+
131
+ def _atomic_write(file_path: Path, data: bytes) -> None:
132
+ """Write data atomically using temp file + rename."""
133
+ file_path.parent.mkdir(parents=True, exist_ok=True)
134
+ fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix='.tmp')
135
+ try:
136
+ os.write(fd, data)
137
+ os.fsync(fd)
138
+ os.close(fd)
139
+ os.rename(tmp_path, file_path)
140
+ except Exception:
141
+ os.close(fd)
142
+ if os.path.exists(tmp_path):
143
+ os.unlink(tmp_path)
144
+ raise
145
+
146
+
147
+ def _atomic_write_json(file_path: Path, data: Dict[str, Any]) -> None:
148
+ """Write JSON data atomically."""
149
+ _atomic_write(file_path, json.dumps(data, indent=2).encode('utf-8'))
150
+
151
+
152
+ class LocalRegistry:
153
+ """
154
+ Local filesystem-based recipe registry.
155
+
156
+ Storage structure:
157
+ ~/.praison/registry/
158
+ ├── index.json # Registry index
159
+ └── recipes/
160
+ └── <name>/
161
+ └── <version>/
162
+ ├── manifest.json
163
+ └── <name>-<version>.praison
164
+ """
165
+
166
+ def __init__(self, path: Optional[Path] = None):
167
+ """Initialize local registry."""
168
+ self.path = Path(path) if path else DEFAULT_REGISTRY_PATH
169
+ self.recipes_path = self.path / "recipes"
170
+ self.index_path = self.path / "index.json"
171
+ self._ensure_structure()
172
+
173
+ def _ensure_structure(self):
174
+ """Ensure registry directory structure exists."""
175
+ self.path.mkdir(parents=True, exist_ok=True)
176
+ self.recipes_path.mkdir(parents=True, exist_ok=True)
177
+ if not self.index_path.exists():
178
+ self._save_index({"recipes": {}, "updated": _get_timestamp()})
179
+
180
+ def _load_index(self) -> Dict[str, Any]:
181
+ """Load registry index."""
182
+ if self.index_path.exists():
183
+ with open(self.index_path) as f:
184
+ return json.load(f)
185
+ return {"recipes": {}, "updated": _get_timestamp()}
186
+
187
+ def _save_index(self, index: Dict[str, Any]):
188
+ """Save registry index."""
189
+ index["updated"] = _get_timestamp()
190
+ with open(self.index_path, "w") as f:
191
+ json.dump(index, f, indent=2)
192
+
193
+ def publish(
194
+ self,
195
+ bundle_path: Union[str, Path],
196
+ force: bool = False,
197
+ metadata: Optional[Dict[str, Any]] = None,
198
+ ) -> Dict[str, Any]:
199
+ """
200
+ Publish a recipe bundle to the registry.
201
+
202
+ Args:
203
+ bundle_path: Path to .praison bundle file
204
+ force: Overwrite existing version if True
205
+ metadata: Additional metadata to store
206
+
207
+ Returns:
208
+ Dict with name, version, path, checksum
209
+
210
+ Raises:
211
+ RecipeExistsError: If version exists and force=False
212
+ RegistryError: If bundle is invalid
213
+ """
214
+ bundle_path = Path(bundle_path)
215
+ if not bundle_path.exists():
216
+ raise RegistryError(f"Bundle not found: {bundle_path}")
217
+
218
+ # Extract manifest from bundle
219
+ try:
220
+ with tarfile.open(bundle_path, "r:gz") as tar:
221
+ manifest_file = tar.extractfile("manifest.json")
222
+ if not manifest_file:
223
+ raise RegistryError("Bundle missing manifest.json")
224
+ manifest = json.load(manifest_file)
225
+ except tarfile.TarError as e:
226
+ raise RegistryError(f"Invalid bundle format: {e}")
227
+
228
+ name = manifest.get("name")
229
+ version = manifest.get("version")
230
+
231
+ if not name or not version:
232
+ raise RegistryError("Bundle manifest missing name or version")
233
+
234
+ # Check if version exists
235
+ recipe_dir = self.recipes_path / name / version
236
+ if recipe_dir.exists() and not force:
237
+ raise RecipeExistsError(f"Recipe {name}@{version} already exists. Use --force to overwrite.")
238
+
239
+ # Create recipe directory
240
+ recipe_dir.mkdir(parents=True, exist_ok=True)
241
+
242
+ # Copy bundle
243
+ bundle_name = f"{name}-{version}.praison"
244
+ dest_path = recipe_dir / bundle_name
245
+ shutil.copy2(bundle_path, dest_path)
246
+
247
+ # Calculate checksum
248
+ checksum = _calculate_checksum(dest_path)
249
+
250
+ # Create registry manifest
251
+ registry_manifest = {
252
+ "name": name,
253
+ "version": version,
254
+ "description": manifest.get("description", ""),
255
+ "tags": manifest.get("tags", []),
256
+ "author": manifest.get("author", ""),
257
+ "checksum": checksum,
258
+ "published_at": _get_timestamp(),
259
+ "bundle_path": str(dest_path),
260
+ "files": manifest.get("files", []),
261
+ **(metadata or {}),
262
+ }
263
+
264
+ # Save manifest
265
+ manifest_path = recipe_dir / "manifest.json"
266
+ with open(manifest_path, "w") as f:
267
+ json.dump(registry_manifest, f, indent=2)
268
+
269
+ # Update index
270
+ index = self._load_index()
271
+ if name not in index["recipes"]:
272
+ index["recipes"][name] = {"versions": {}, "latest": version}
273
+
274
+ index["recipes"][name]["versions"][version] = {
275
+ "checksum": checksum,
276
+ "published_at": registry_manifest["published_at"],
277
+ }
278
+ index["recipes"][name]["latest"] = version
279
+ self._save_index(index)
280
+
281
+ return {
282
+ "name": name,
283
+ "version": version,
284
+ "path": str(dest_path),
285
+ "checksum": checksum,
286
+ "published_at": registry_manifest["published_at"],
287
+ }
288
+
289
+ def pull(
290
+ self,
291
+ name: str,
292
+ version: Optional[str] = None,
293
+ output_dir: Optional[Path] = None,
294
+ verify_checksum: bool = True,
295
+ ) -> Dict[str, Any]:
296
+ """
297
+ Pull a recipe from the registry.
298
+
299
+ Args:
300
+ name: Recipe name
301
+ version: Version to pull (default: latest)
302
+ output_dir: Directory to extract to
303
+ verify_checksum: Verify bundle checksum
304
+
305
+ Returns:
306
+ Dict with name, version, path
307
+
308
+ Raises:
309
+ RecipeNotFoundError: If recipe/version not found
310
+ """
311
+ index = self._load_index()
312
+
313
+ if name not in index["recipes"]:
314
+ raise RecipeNotFoundError(f"Recipe not found: {name}")
315
+
316
+ recipe_info = index["recipes"][name]
317
+ version = version or recipe_info.get("latest")
318
+
319
+ if version not in recipe_info["versions"]:
320
+ raise RecipeNotFoundError(f"Version not found: {name}@{version}")
321
+
322
+ # Get bundle path
323
+ bundle_name = f"{name}-{version}.praison"
324
+ bundle_path = self.recipes_path / name / version / bundle_name
325
+
326
+ if not bundle_path.exists():
327
+ raise RecipeNotFoundError(f"Bundle file missing: {bundle_path}")
328
+
329
+ # Verify checksum
330
+ if verify_checksum:
331
+ expected = recipe_info["versions"][version]["checksum"]
332
+ actual = _calculate_checksum(bundle_path)
333
+ if expected != actual:
334
+ raise RegistryError(f"Checksum mismatch for {name}@{version}")
335
+
336
+ # Extract to output directory
337
+ output_dir = Path(output_dir) if output_dir else Path.cwd()
338
+ output_dir.mkdir(parents=True, exist_ok=True)
339
+
340
+ recipe_dir = output_dir / name
341
+ recipe_dir.mkdir(parents=True, exist_ok=True)
342
+
343
+ with tarfile.open(bundle_path, "r:gz") as tar:
344
+ tar.extractall(recipe_dir)
345
+
346
+ return {
347
+ "name": name,
348
+ "version": version,
349
+ "path": str(recipe_dir),
350
+ "bundle_path": str(bundle_path),
351
+ }
352
+
353
+ def list_recipes(
354
+ self,
355
+ tags: Optional[List[str]] = None,
356
+ ) -> List[Dict[str, Any]]:
357
+ """
358
+ List all recipes in the registry.
359
+
360
+ Args:
361
+ tags: Filter by tags (optional)
362
+
363
+ Returns:
364
+ List of recipe info dicts
365
+ """
366
+ index = self._load_index()
367
+ recipes = []
368
+
369
+ for name, info in index["recipes"].items():
370
+ # Load full manifest for latest version
371
+ latest = info.get("latest")
372
+ if latest:
373
+ manifest_path = self.recipes_path / name / latest / "manifest.json"
374
+ if manifest_path.exists():
375
+ with open(manifest_path) as f:
376
+ manifest = json.load(f)
377
+
378
+ # Filter by tags if specified
379
+ if tags:
380
+ recipe_tags = manifest.get("tags", [])
381
+ if not any(t in recipe_tags for t in tags):
382
+ continue
383
+
384
+ recipes.append({
385
+ "name": name,
386
+ "version": latest,
387
+ "description": manifest.get("description", ""),
388
+ "tags": manifest.get("tags", []),
389
+ "versions": list(info["versions"].keys()),
390
+ })
391
+
392
+ return recipes
393
+
394
+ def search(
395
+ self,
396
+ query: str,
397
+ tags: Optional[List[str]] = None,
398
+ ) -> List[Dict[str, Any]]:
399
+ """
400
+ Search recipes by name, description, or tags.
401
+
402
+ Args:
403
+ query: Search query
404
+ tags: Filter by tags (optional)
405
+
406
+ Returns:
407
+ List of matching recipe info dicts
408
+ """
409
+ all_recipes = self.list_recipes(tags=tags)
410
+ query_lower = query.lower()
411
+
412
+ results = []
413
+ for recipe in all_recipes:
414
+ # Search in name, description, tags
415
+ if (
416
+ query_lower in recipe["name"].lower()
417
+ or query_lower in recipe.get("description", "").lower()
418
+ or any(query_lower in t.lower() for t in recipe.get("tags", []))
419
+ ):
420
+ results.append(recipe)
421
+
422
+ return results
423
+
424
+ def get_versions(self, name: str) -> List[str]:
425
+ """Get all versions of a recipe."""
426
+ index = self._load_index()
427
+ if name not in index["recipes"]:
428
+ raise RecipeNotFoundError(f"Recipe not found: {name}")
429
+ return list(index["recipes"][name]["versions"].keys())
430
+
431
+ def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
432
+ """Get detailed info about a recipe version."""
433
+ index = self._load_index()
434
+ if name not in index["recipes"]:
435
+ raise RecipeNotFoundError(f"Recipe not found: {name}")
436
+
437
+ recipe_info = index["recipes"][name]
438
+ version = version or recipe_info.get("latest")
439
+
440
+ manifest_path = self.recipes_path / name / version / "manifest.json"
441
+ if not manifest_path.exists():
442
+ raise RecipeNotFoundError(f"Version not found: {name}@{version}")
443
+
444
+ with open(manifest_path) as f:
445
+ return json.load(f)
446
+
447
+ def delete(self, name: str, version: Optional[str] = None) -> bool:
448
+ """
449
+ Delete a recipe or specific version.
450
+
451
+ Args:
452
+ name: Recipe name
453
+ version: Version to delete (None = all versions)
454
+
455
+ Returns:
456
+ True if deleted
457
+ """
458
+ index = self._load_index()
459
+ if name not in index["recipes"]:
460
+ raise RecipeNotFoundError(f"Recipe not found: {name}")
461
+
462
+ if version:
463
+ # Delete specific version
464
+ version_dir = self.recipes_path / name / version
465
+ if version_dir.exists():
466
+ shutil.rmtree(version_dir)
467
+
468
+ if version in index["recipes"][name]["versions"]:
469
+ del index["recipes"][name]["versions"][version]
470
+
471
+ # Update latest if needed
472
+ versions = list(index["recipes"][name]["versions"].keys())
473
+ if versions:
474
+ index["recipes"][name]["latest"] = sorted(versions)[-1]
475
+ else:
476
+ del index["recipes"][name]
477
+ else:
478
+ # Delete all versions
479
+ recipe_dir = self.recipes_path / name
480
+ if recipe_dir.exists():
481
+ shutil.rmtree(recipe_dir)
482
+ del index["recipes"][name]
483
+
484
+ self._save_index(index)
485
+ return True
486
+
487
+
488
+ class HttpRegistry:
489
+ """
490
+ HTTP-based recipe registry client.
491
+
492
+ Works with both local HTTP registry (http://localhost:7777) and
493
+ remote HTTP registries (https://registry.example.com).
494
+
495
+ Supports:
496
+ - Token-based authentication (Bearer token)
497
+ - Multipart file upload for publish
498
+ - ETag/If-None-Match for efficient downloads
499
+ - Proper error handling with specific exceptions
500
+ """
501
+
502
+ def __init__(
503
+ self,
504
+ url: str,
505
+ token: Optional[str] = None,
506
+ timeout: int = 30,
507
+ ):
508
+ """
509
+ Initialize HTTP registry client.
510
+
511
+ Args:
512
+ url: Registry base URL (http://localhost:7777 or https://...)
513
+ token: Authentication token (or set PRAISONAI_REGISTRY_TOKEN env var)
514
+ timeout: Request timeout in seconds
515
+ """
516
+ self.url = url.rstrip("/")
517
+ self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
518
+ self.timeout = timeout
519
+ self._etag_cache: Dict[str, str] = {}
520
+
521
+ def _get_headers(self, content_type: str = "application/json") -> Dict[str, str]:
522
+ """Get request headers with optional auth."""
523
+ headers = {"Content-Type": content_type}
524
+ if self.token:
525
+ headers["Authorization"] = f"Bearer {self.token}"
526
+ return headers
527
+
528
+ def _request(
529
+ self,
530
+ method: str,
531
+ path: str,
532
+ data: Optional[bytes] = None,
533
+ headers: Optional[Dict[str, str]] = None,
534
+ ) -> Dict[str, Any]:
535
+ """Make HTTP request to registry."""
536
+ import urllib.request
537
+ import urllib.error
538
+
539
+ url = f"{self.url}{path}"
540
+ req_headers = headers or self._get_headers()
541
+
542
+ try:
543
+ req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
544
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
545
+ # Cache ETag for future requests
546
+ etag = response.headers.get("ETag")
547
+ if etag:
548
+ self._etag_cache[path] = etag
549
+ return json.loads(response.read().decode())
550
+ except urllib.error.HTTPError as e:
551
+ if e.code == 401:
552
+ raise RegistryAuthError("Authentication failed. Check your token.")
553
+ elif e.code == 403:
554
+ raise RegistryAuthError("Access denied. Token may lack required permissions.")
555
+ elif e.code == 404:
556
+ raise RecipeNotFoundError(f"Not found: {path}")
557
+ elif e.code == 409:
558
+ raise RegistryConflictError("Recipe version already exists. Use --force to overwrite.")
559
+ else:
560
+ body = e.read().decode() if e.fp else ""
561
+ raise RegistryError(f"Registry error ({e.code}): {body}")
562
+ except urllib.error.URLError as e:
563
+ raise RegistryNetworkError(f"Connection error: {e.reason}")
564
+
565
+ def _download_file(self, path: str, dest_path: Path) -> Dict[str, Any]:
566
+ """Download file from registry with ETag support."""
567
+ import urllib.request
568
+ import urllib.error
569
+
570
+ url = f"{self.url}{path}"
571
+ headers = self._get_headers()
572
+
573
+ # Add If-None-Match if we have cached ETag
574
+ if path in self._etag_cache:
575
+ headers["If-None-Match"] = self._etag_cache[path]
576
+
577
+ try:
578
+ req = urllib.request.Request(url, headers=headers, method="GET")
579
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
580
+ etag = response.headers.get("ETag")
581
+ if etag:
582
+ self._etag_cache[path] = etag
583
+
584
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
585
+ with open(dest_path, "wb") as f:
586
+ shutil.copyfileobj(response, f)
587
+
588
+ return {"downloaded": True, "path": str(dest_path)}
589
+ except urllib.error.HTTPError as e:
590
+ if e.code == 304:
591
+ return {"downloaded": False, "cached": True}
592
+ elif e.code == 404:
593
+ raise RecipeNotFoundError(f"Not found: {path}")
594
+ else:
595
+ raise RegistryError(f"Download error ({e.code})")
596
+ except urllib.error.URLError as e:
597
+ raise RegistryNetworkError(f"Connection error: {e.reason}")
598
+
599
+ def _upload_file(self, path: str, file_path: Path, force: bool = False) -> Dict[str, Any]:
600
+ """Upload file to registry using multipart form."""
601
+ import urllib.request
602
+ import urllib.error
603
+ import uuid
604
+
605
+ boundary = f"----PraisonAI{uuid.uuid4().hex}"
606
+
607
+ # Build multipart body
608
+ body_parts = []
609
+
610
+ # Add force field
611
+ body_parts.append(f"--{boundary}".encode())
612
+ body_parts.append(b'Content-Disposition: form-data; name="force"')
613
+ body_parts.append(b"")
614
+ body_parts.append(b"true" if force else b"false")
615
+
616
+ # Add file
617
+ body_parts.append(f"--{boundary}".encode())
618
+ body_parts.append(f'Content-Disposition: form-data; name="bundle"; filename="{file_path.name}"'.encode())
619
+ body_parts.append(b"Content-Type: application/gzip")
620
+ body_parts.append(b"")
621
+ with open(file_path, "rb") as f:
622
+ body_parts.append(f.read())
623
+
624
+ body_parts.append(f"--{boundary}--".encode())
625
+ body_parts.append(b"")
626
+
627
+ body = b"\r\n".join(body_parts)
628
+
629
+ headers = self._get_headers(f"multipart/form-data; boundary={boundary}")
630
+
631
+ url = f"{self.url}{path}"
632
+ try:
633
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
634
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
635
+ return json.loads(response.read().decode())
636
+ except urllib.error.HTTPError as e:
637
+ if e.code == 401:
638
+ raise RegistryAuthError("Authentication required for publish")
639
+ elif e.code == 409:
640
+ raise RegistryConflictError("Recipe version already exists. Use --force to overwrite.")
641
+ else:
642
+ body_text = e.read().decode() if e.fp else ""
643
+ raise RegistryError(f"Upload error ({e.code}): {body_text}")
644
+ except urllib.error.URLError as e:
645
+ raise RegistryNetworkError(f"Connection error: {e.reason}")
646
+
647
+ def health(self) -> Dict[str, Any]:
648
+ """Check registry health."""
649
+ return self._request("GET", "/healthz")
650
+
651
+ def publish(
652
+ self,
653
+ bundle_path: Union[str, Path],
654
+ force: bool = False,
655
+ metadata: Optional[Dict[str, Any]] = None,
656
+ ) -> Dict[str, Any]:
657
+ """
658
+ Publish bundle to HTTP registry.
659
+
660
+ Args:
661
+ bundle_path: Path to .praison bundle file
662
+ force: Overwrite existing version if True
663
+ metadata: Additional metadata (ignored for HTTP, included in bundle)
664
+
665
+ Returns:
666
+ Dict with name, version, checksum
667
+ """
668
+ bundle_path = Path(bundle_path)
669
+ if not bundle_path.exists():
670
+ raise RegistryError(f"Bundle not found: {bundle_path}")
671
+
672
+ # Extract name/version from bundle to construct path
673
+ try:
674
+ with tarfile.open(bundle_path, "r:gz") as tar:
675
+ manifest_file = tar.extractfile("manifest.json")
676
+ if not manifest_file:
677
+ raise RegistryError("Bundle missing manifest.json")
678
+ manifest = json.load(manifest_file)
679
+ except tarfile.TarError as e:
680
+ raise RegistryError(f"Invalid bundle format: {e}")
681
+
682
+ name = manifest.get("name")
683
+ version = manifest.get("version")
684
+
685
+ if not name or not version:
686
+ raise RegistryError("Bundle manifest missing name or version")
687
+
688
+ # Upload to /v1/recipes/{name}/{version}
689
+ return self._upload_file(f"/v1/recipes/{name}/{version}", bundle_path, force=force)
690
+
691
+ def pull(
692
+ self,
693
+ name: str,
694
+ version: Optional[str] = None,
695
+ output_dir: Optional[Path] = None,
696
+ verify_checksum: bool = True,
697
+ ) -> Dict[str, Any]:
698
+ """
699
+ Pull recipe from HTTP registry.
700
+
701
+ Args:
702
+ name: Recipe name
703
+ version: Version to pull (default: latest)
704
+ output_dir: Directory to extract to
705
+ verify_checksum: Verify bundle checksum
706
+
707
+ Returns:
708
+ Dict with name, version, path
709
+ """
710
+ # Get recipe info to determine version
711
+ if not version:
712
+ info = self._request("GET", f"/v1/recipes/{name}")
713
+ version = info.get("latest")
714
+ if not version:
715
+ raise RecipeNotFoundError(f"No versions found for: {name}")
716
+
717
+ output_dir = Path(output_dir) if output_dir else Path.cwd()
718
+ bundle_path = output_dir / f"{name}-{version}.praison"
719
+
720
+ # Download bundle
721
+ download_path = f"/v1/recipes/{name}/{version}/download"
722
+ self._download_file(download_path, bundle_path)
723
+
724
+ # Verify checksum if requested
725
+ if verify_checksum:
726
+ info = self._request("GET", f"/v1/recipes/{name}/{version}")
727
+ expected = info.get("checksum")
728
+ if expected:
729
+ actual = _calculate_checksum(bundle_path)
730
+ if expected != actual:
731
+ bundle_path.unlink()
732
+ raise RegistryError(f"Checksum mismatch for {name}@{version}")
733
+
734
+ # Extract
735
+ recipe_dir = output_dir / name
736
+ recipe_dir.mkdir(parents=True, exist_ok=True)
737
+
738
+ with tarfile.open(bundle_path, "r:gz") as tar:
739
+ tar.extractall(recipe_dir)
740
+
741
+ return {
742
+ "name": name,
743
+ "version": version,
744
+ "path": str(recipe_dir),
745
+ "bundle_path": str(bundle_path),
746
+ }
747
+
748
+ def list_recipes(
749
+ self,
750
+ tags: Optional[List[str]] = None,
751
+ page: int = 1,
752
+ per_page: int = 50,
753
+ ) -> List[Dict[str, Any]]:
754
+ """
755
+ List recipes from HTTP registry.
756
+
757
+ Args:
758
+ tags: Filter by tags
759
+ page: Page number (1-indexed)
760
+ per_page: Results per page
761
+
762
+ Returns:
763
+ List of recipe info dicts
764
+ """
765
+ params = f"?page={page}&per_page={per_page}"
766
+ if tags:
767
+ params += f"&tags={','.join(tags)}"
768
+ result = self._request("GET", f"/v1/recipes{params}")
769
+ return result.get("recipes", [])
770
+
771
+ def search(
772
+ self,
773
+ query: str,
774
+ tags: Optional[List[str]] = None,
775
+ ) -> List[Dict[str, Any]]:
776
+ """
777
+ Search recipes in HTTP registry.
778
+
779
+ Args:
780
+ query: Search query
781
+ tags: Filter by tags
782
+
783
+ Returns:
784
+ List of matching recipe info dicts
785
+ """
786
+ from urllib.parse import quote as url_quote
787
+ params = f"?q={url_quote(query)}"
788
+ if tags:
789
+ params += f"&tags={','.join(tags)}"
790
+ result = self._request("GET", f"/v1/search{params}")
791
+ return result.get("results", [])
792
+
793
+ def get_versions(self, name: str) -> List[str]:
794
+ """Get all versions of a recipe."""
795
+ result = self._request("GET", f"/v1/recipes/{name}")
796
+ return result.get("versions", [])
797
+
798
+ def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
799
+ """Get detailed info about a recipe version."""
800
+ if version:
801
+ return self._request("GET", f"/v1/recipes/{name}/{version}")
802
+ return self._request("GET", f"/v1/recipes/{name}")
803
+
804
+ def delete(self, name: str, version: Optional[str] = None) -> bool:
805
+ """
806
+ Delete a recipe or specific version.
807
+
808
+ Args:
809
+ name: Recipe name
810
+ version: Version to delete (None = all versions)
811
+
812
+ Returns:
813
+ True if deleted
814
+ """
815
+ if version:
816
+ self._request("DELETE", f"/v1/recipes/{name}/{version}")
817
+ else:
818
+ self._request("DELETE", f"/v1/recipes/{name}")
819
+ return True
820
+
821
+
822
+ # Alias for backwards compatibility
823
+ RemoteRegistry = HttpRegistry
824
+
825
+
826
+ def get_registry(
827
+ registry: Optional[str] = None,
828
+ token: Optional[str] = None,
829
+ ) -> Union[LocalRegistry, RemoteRegistry]:
830
+ """
831
+ Get appropriate registry instance.
832
+
833
+ Args:
834
+ registry: Registry path or URL (default: local)
835
+ token: Auth token for remote registry
836
+
837
+ Returns:
838
+ LocalRegistry or RemoteRegistry instance
839
+ """
840
+ if registry is None:
841
+ return LocalRegistry()
842
+
843
+ # Check if it's a URL
844
+ parsed = urlparse(registry)
845
+ if parsed.scheme in ("http", "https"):
846
+ return RemoteRegistry(registry, token=token)
847
+
848
+ # Local path
849
+ return LocalRegistry(Path(registry))