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.
- praisonai/__init__.py +54 -0
- praisonai/__main__.py +15 -0
- praisonai/acp/__init__.py +54 -0
- praisonai/acp/config.py +159 -0
- praisonai/acp/server.py +587 -0
- praisonai/acp/session.py +219 -0
- praisonai/adapters/__init__.py +50 -0
- praisonai/adapters/readers.py +395 -0
- praisonai/adapters/rerankers.py +315 -0
- praisonai/adapters/retrievers.py +394 -0
- praisonai/adapters/vector_stores.py +409 -0
- praisonai/agent_scheduler.py +337 -0
- praisonai/agents_generator.py +903 -0
- praisonai/api/call.py +292 -0
- praisonai/auto.py +1197 -0
- praisonai/capabilities/__init__.py +275 -0
- praisonai/capabilities/a2a.py +140 -0
- praisonai/capabilities/assistants.py +283 -0
- praisonai/capabilities/audio.py +320 -0
- praisonai/capabilities/batches.py +469 -0
- praisonai/capabilities/completions.py +336 -0
- praisonai/capabilities/container_files.py +155 -0
- praisonai/capabilities/containers.py +93 -0
- praisonai/capabilities/embeddings.py +158 -0
- praisonai/capabilities/files.py +467 -0
- praisonai/capabilities/fine_tuning.py +293 -0
- praisonai/capabilities/guardrails.py +182 -0
- praisonai/capabilities/images.py +330 -0
- praisonai/capabilities/mcp.py +190 -0
- praisonai/capabilities/messages.py +270 -0
- praisonai/capabilities/moderations.py +154 -0
- praisonai/capabilities/ocr.py +217 -0
- praisonai/capabilities/passthrough.py +204 -0
- praisonai/capabilities/rag.py +207 -0
- praisonai/capabilities/realtime.py +160 -0
- praisonai/capabilities/rerank.py +165 -0
- praisonai/capabilities/responses.py +266 -0
- praisonai/capabilities/search.py +109 -0
- praisonai/capabilities/skills.py +133 -0
- praisonai/capabilities/vector_store_files.py +334 -0
- praisonai/capabilities/vector_stores.py +304 -0
- praisonai/capabilities/videos.py +141 -0
- praisonai/chainlit_ui.py +304 -0
- praisonai/chat/__init__.py +106 -0
- praisonai/chat/app.py +125 -0
- praisonai/cli/__init__.py +26 -0
- praisonai/cli/app.py +213 -0
- praisonai/cli/commands/__init__.py +75 -0
- praisonai/cli/commands/acp.py +70 -0
- praisonai/cli/commands/completion.py +333 -0
- praisonai/cli/commands/config.py +166 -0
- praisonai/cli/commands/debug.py +142 -0
- praisonai/cli/commands/diag.py +55 -0
- praisonai/cli/commands/doctor.py +166 -0
- praisonai/cli/commands/environment.py +179 -0
- praisonai/cli/commands/lsp.py +112 -0
- praisonai/cli/commands/mcp.py +210 -0
- praisonai/cli/commands/profile.py +457 -0
- praisonai/cli/commands/run.py +228 -0
- praisonai/cli/commands/schedule.py +150 -0
- praisonai/cli/commands/serve.py +97 -0
- praisonai/cli/commands/session.py +212 -0
- praisonai/cli/commands/traces.py +145 -0
- praisonai/cli/commands/version.py +101 -0
- praisonai/cli/configuration/__init__.py +18 -0
- praisonai/cli/configuration/loader.py +353 -0
- praisonai/cli/configuration/paths.py +114 -0
- praisonai/cli/configuration/schema.py +164 -0
- praisonai/cli/features/__init__.py +268 -0
- praisonai/cli/features/acp.py +236 -0
- praisonai/cli/features/action_orchestrator.py +546 -0
- praisonai/cli/features/agent_scheduler.py +773 -0
- praisonai/cli/features/agent_tools.py +474 -0
- praisonai/cli/features/agents.py +375 -0
- praisonai/cli/features/at_mentions.py +471 -0
- praisonai/cli/features/auto_memory.py +182 -0
- praisonai/cli/features/autonomy_mode.py +490 -0
- praisonai/cli/features/background.py +356 -0
- praisonai/cli/features/base.py +168 -0
- praisonai/cli/features/capabilities.py +1326 -0
- praisonai/cli/features/checkpoints.py +338 -0
- praisonai/cli/features/code_intelligence.py +652 -0
- praisonai/cli/features/compaction.py +294 -0
- praisonai/cli/features/compare.py +534 -0
- praisonai/cli/features/cost_tracker.py +514 -0
- praisonai/cli/features/debug.py +810 -0
- praisonai/cli/features/deploy.py +517 -0
- praisonai/cli/features/diag.py +289 -0
- praisonai/cli/features/doctor/__init__.py +63 -0
- praisonai/cli/features/doctor/checks/__init__.py +24 -0
- praisonai/cli/features/doctor/checks/acp_checks.py +240 -0
- praisonai/cli/features/doctor/checks/config_checks.py +366 -0
- praisonai/cli/features/doctor/checks/db_checks.py +366 -0
- praisonai/cli/features/doctor/checks/env_checks.py +543 -0
- praisonai/cli/features/doctor/checks/lsp_checks.py +199 -0
- praisonai/cli/features/doctor/checks/mcp_checks.py +349 -0
- praisonai/cli/features/doctor/checks/memory_checks.py +268 -0
- praisonai/cli/features/doctor/checks/network_checks.py +251 -0
- praisonai/cli/features/doctor/checks/obs_checks.py +328 -0
- praisonai/cli/features/doctor/checks/performance_checks.py +235 -0
- praisonai/cli/features/doctor/checks/permissions_checks.py +259 -0
- praisonai/cli/features/doctor/checks/selftest_checks.py +322 -0
- praisonai/cli/features/doctor/checks/serve_checks.py +426 -0
- praisonai/cli/features/doctor/checks/skills_checks.py +231 -0
- praisonai/cli/features/doctor/checks/tools_checks.py +371 -0
- praisonai/cli/features/doctor/engine.py +266 -0
- praisonai/cli/features/doctor/formatters.py +310 -0
- praisonai/cli/features/doctor/handler.py +397 -0
- praisonai/cli/features/doctor/models.py +264 -0
- praisonai/cli/features/doctor/registry.py +239 -0
- praisonai/cli/features/endpoints.py +1019 -0
- praisonai/cli/features/eval.py +560 -0
- praisonai/cli/features/external_agents.py +231 -0
- praisonai/cli/features/fast_context.py +410 -0
- praisonai/cli/features/flow_display.py +566 -0
- praisonai/cli/features/git_integration.py +651 -0
- praisonai/cli/features/guardrail.py +171 -0
- praisonai/cli/features/handoff.py +185 -0
- praisonai/cli/features/hooks.py +583 -0
- praisonai/cli/features/image.py +384 -0
- praisonai/cli/features/interactive_runtime.py +585 -0
- praisonai/cli/features/interactive_tools.py +380 -0
- praisonai/cli/features/interactive_tui.py +603 -0
- praisonai/cli/features/jobs.py +632 -0
- praisonai/cli/features/knowledge.py +531 -0
- praisonai/cli/features/lite.py +244 -0
- praisonai/cli/features/lsp_cli.py +225 -0
- praisonai/cli/features/mcp.py +169 -0
- praisonai/cli/features/message_queue.py +587 -0
- praisonai/cli/features/metrics.py +211 -0
- praisonai/cli/features/n8n.py +673 -0
- praisonai/cli/features/observability.py +293 -0
- praisonai/cli/features/ollama.py +361 -0
- praisonai/cli/features/output_style.py +273 -0
- praisonai/cli/features/package.py +631 -0
- praisonai/cli/features/performance.py +308 -0
- praisonai/cli/features/persistence.py +636 -0
- praisonai/cli/features/profile.py +226 -0
- praisonai/cli/features/profiler/__init__.py +81 -0
- praisonai/cli/features/profiler/core.py +558 -0
- praisonai/cli/features/profiler/optimizations.py +652 -0
- praisonai/cli/features/profiler/suite.py +386 -0
- praisonai/cli/features/profiling.py +350 -0
- praisonai/cli/features/queue/__init__.py +73 -0
- praisonai/cli/features/queue/manager.py +395 -0
- praisonai/cli/features/queue/models.py +286 -0
- praisonai/cli/features/queue/persistence.py +564 -0
- praisonai/cli/features/queue/scheduler.py +484 -0
- praisonai/cli/features/queue/worker.py +372 -0
- praisonai/cli/features/recipe.py +1723 -0
- praisonai/cli/features/recipes.py +449 -0
- praisonai/cli/features/registry.py +229 -0
- praisonai/cli/features/repo_map.py +860 -0
- praisonai/cli/features/router.py +466 -0
- praisonai/cli/features/sandbox_executor.py +515 -0
- praisonai/cli/features/serve.py +829 -0
- praisonai/cli/features/session.py +222 -0
- praisonai/cli/features/skills.py +856 -0
- praisonai/cli/features/slash_commands.py +650 -0
- praisonai/cli/features/telemetry.py +179 -0
- praisonai/cli/features/templates.py +1384 -0
- praisonai/cli/features/thinking.py +305 -0
- praisonai/cli/features/todo.py +334 -0
- praisonai/cli/features/tools.py +680 -0
- praisonai/cli/features/tui/__init__.py +83 -0
- praisonai/cli/features/tui/app.py +580 -0
- praisonai/cli/features/tui/cli.py +566 -0
- praisonai/cli/features/tui/debug.py +511 -0
- praisonai/cli/features/tui/events.py +99 -0
- praisonai/cli/features/tui/mock_provider.py +328 -0
- praisonai/cli/features/tui/orchestrator.py +652 -0
- praisonai/cli/features/tui/screens/__init__.py +50 -0
- praisonai/cli/features/tui/screens/main.py +245 -0
- praisonai/cli/features/tui/screens/queue.py +174 -0
- praisonai/cli/features/tui/screens/session.py +124 -0
- praisonai/cli/features/tui/screens/settings.py +148 -0
- praisonai/cli/features/tui/widgets/__init__.py +56 -0
- praisonai/cli/features/tui/widgets/chat.py +261 -0
- praisonai/cli/features/tui/widgets/composer.py +224 -0
- praisonai/cli/features/tui/widgets/queue_panel.py +200 -0
- praisonai/cli/features/tui/widgets/status.py +167 -0
- praisonai/cli/features/tui/widgets/tool_panel.py +248 -0
- praisonai/cli/features/workflow.py +720 -0
- praisonai/cli/legacy.py +236 -0
- praisonai/cli/main.py +5559 -0
- praisonai/cli/schedule_cli.py +54 -0
- praisonai/cli/state/__init__.py +31 -0
- praisonai/cli/state/identifiers.py +161 -0
- praisonai/cli/state/sessions.py +313 -0
- praisonai/code/__init__.py +93 -0
- praisonai/code/agent_tools.py +344 -0
- praisonai/code/diff/__init__.py +21 -0
- praisonai/code/diff/diff_strategy.py +432 -0
- praisonai/code/tools/__init__.py +27 -0
- praisonai/code/tools/apply_diff.py +221 -0
- praisonai/code/tools/execute_command.py +275 -0
- praisonai/code/tools/list_files.py +274 -0
- praisonai/code/tools/read_file.py +206 -0
- praisonai/code/tools/search_replace.py +248 -0
- praisonai/code/tools/write_file.py +217 -0
- praisonai/code/utils/__init__.py +46 -0
- praisonai/code/utils/file_utils.py +307 -0
- praisonai/code/utils/ignore_utils.py +308 -0
- praisonai/code/utils/text_utils.py +276 -0
- praisonai/db/__init__.py +64 -0
- praisonai/db/adapter.py +531 -0
- praisonai/deploy/__init__.py +62 -0
- praisonai/deploy/api.py +231 -0
- praisonai/deploy/docker.py +454 -0
- praisonai/deploy/doctor.py +367 -0
- praisonai/deploy/main.py +327 -0
- praisonai/deploy/models.py +179 -0
- praisonai/deploy/providers/__init__.py +33 -0
- praisonai/deploy/providers/aws.py +331 -0
- praisonai/deploy/providers/azure.py +358 -0
- praisonai/deploy/providers/base.py +101 -0
- praisonai/deploy/providers/gcp.py +314 -0
- praisonai/deploy/schema.py +208 -0
- praisonai/deploy.py +185 -0
- praisonai/endpoints/__init__.py +53 -0
- praisonai/endpoints/a2u_server.py +410 -0
- praisonai/endpoints/discovery.py +165 -0
- praisonai/endpoints/providers/__init__.py +28 -0
- praisonai/endpoints/providers/a2a.py +253 -0
- praisonai/endpoints/providers/a2u.py +208 -0
- praisonai/endpoints/providers/agents_api.py +171 -0
- praisonai/endpoints/providers/base.py +231 -0
- praisonai/endpoints/providers/mcp.py +263 -0
- praisonai/endpoints/providers/recipe.py +206 -0
- praisonai/endpoints/providers/tools_mcp.py +150 -0
- praisonai/endpoints/registry.py +131 -0
- praisonai/endpoints/server.py +161 -0
- praisonai/inbuilt_tools/__init__.py +24 -0
- praisonai/inbuilt_tools/autogen_tools.py +117 -0
- praisonai/inc/__init__.py +2 -0
- praisonai/inc/config.py +96 -0
- praisonai/inc/models.py +155 -0
- praisonai/integrations/__init__.py +56 -0
- praisonai/integrations/base.py +303 -0
- praisonai/integrations/claude_code.py +270 -0
- praisonai/integrations/codex_cli.py +255 -0
- praisonai/integrations/cursor_cli.py +195 -0
- praisonai/integrations/gemini_cli.py +222 -0
- praisonai/jobs/__init__.py +67 -0
- praisonai/jobs/executor.py +425 -0
- praisonai/jobs/models.py +230 -0
- praisonai/jobs/router.py +314 -0
- praisonai/jobs/server.py +186 -0
- praisonai/jobs/store.py +203 -0
- praisonai/llm/__init__.py +66 -0
- praisonai/llm/registry.py +382 -0
- praisonai/mcp_server/__init__.py +152 -0
- praisonai/mcp_server/adapters/__init__.py +74 -0
- praisonai/mcp_server/adapters/agents.py +128 -0
- praisonai/mcp_server/adapters/capabilities.py +168 -0
- praisonai/mcp_server/adapters/cli_tools.py +568 -0
- praisonai/mcp_server/adapters/extended_capabilities.py +462 -0
- praisonai/mcp_server/adapters/knowledge.py +93 -0
- praisonai/mcp_server/adapters/memory.py +104 -0
- praisonai/mcp_server/adapters/prompts.py +306 -0
- praisonai/mcp_server/adapters/resources.py +124 -0
- praisonai/mcp_server/adapters/tools_bridge.py +280 -0
- praisonai/mcp_server/auth/__init__.py +48 -0
- praisonai/mcp_server/auth/api_key.py +291 -0
- praisonai/mcp_server/auth/oauth.py +460 -0
- praisonai/mcp_server/auth/oidc.py +289 -0
- praisonai/mcp_server/auth/scopes.py +260 -0
- praisonai/mcp_server/cli.py +852 -0
- praisonai/mcp_server/elicitation.py +445 -0
- praisonai/mcp_server/icons.py +302 -0
- praisonai/mcp_server/recipe_adapter.py +573 -0
- praisonai/mcp_server/recipe_cli.py +824 -0
- praisonai/mcp_server/registry.py +703 -0
- praisonai/mcp_server/sampling.py +422 -0
- praisonai/mcp_server/server.py +490 -0
- praisonai/mcp_server/tasks.py +443 -0
- praisonai/mcp_server/transports/__init__.py +18 -0
- praisonai/mcp_server/transports/http_stream.py +376 -0
- praisonai/mcp_server/transports/stdio.py +132 -0
- praisonai/persistence/__init__.py +84 -0
- praisonai/persistence/config.py +238 -0
- praisonai/persistence/conversation/__init__.py +25 -0
- praisonai/persistence/conversation/async_mysql.py +427 -0
- praisonai/persistence/conversation/async_postgres.py +410 -0
- praisonai/persistence/conversation/async_sqlite.py +371 -0
- praisonai/persistence/conversation/base.py +151 -0
- praisonai/persistence/conversation/json_store.py +250 -0
- praisonai/persistence/conversation/mysql.py +387 -0
- praisonai/persistence/conversation/postgres.py +401 -0
- praisonai/persistence/conversation/singlestore.py +240 -0
- praisonai/persistence/conversation/sqlite.py +341 -0
- praisonai/persistence/conversation/supabase.py +203 -0
- praisonai/persistence/conversation/surrealdb.py +287 -0
- praisonai/persistence/factory.py +301 -0
- praisonai/persistence/hooks/__init__.py +18 -0
- praisonai/persistence/hooks/agent_hooks.py +297 -0
- praisonai/persistence/knowledge/__init__.py +26 -0
- praisonai/persistence/knowledge/base.py +144 -0
- praisonai/persistence/knowledge/cassandra.py +232 -0
- praisonai/persistence/knowledge/chroma.py +295 -0
- praisonai/persistence/knowledge/clickhouse.py +242 -0
- praisonai/persistence/knowledge/cosmosdb_vector.py +438 -0
- praisonai/persistence/knowledge/couchbase.py +286 -0
- praisonai/persistence/knowledge/lancedb.py +216 -0
- praisonai/persistence/knowledge/langchain_adapter.py +291 -0
- praisonai/persistence/knowledge/lightrag_adapter.py +212 -0
- praisonai/persistence/knowledge/llamaindex_adapter.py +256 -0
- praisonai/persistence/knowledge/milvus.py +277 -0
- praisonai/persistence/knowledge/mongodb_vector.py +306 -0
- praisonai/persistence/knowledge/pgvector.py +335 -0
- praisonai/persistence/knowledge/pinecone.py +253 -0
- praisonai/persistence/knowledge/qdrant.py +301 -0
- praisonai/persistence/knowledge/redis_vector.py +291 -0
- praisonai/persistence/knowledge/singlestore_vector.py +299 -0
- praisonai/persistence/knowledge/surrealdb_vector.py +309 -0
- praisonai/persistence/knowledge/upstash_vector.py +266 -0
- praisonai/persistence/knowledge/weaviate.py +223 -0
- praisonai/persistence/migrations/__init__.py +10 -0
- praisonai/persistence/migrations/manager.py +251 -0
- praisonai/persistence/orchestrator.py +406 -0
- praisonai/persistence/state/__init__.py +21 -0
- praisonai/persistence/state/async_mongodb.py +200 -0
- praisonai/persistence/state/base.py +107 -0
- praisonai/persistence/state/dynamodb.py +226 -0
- praisonai/persistence/state/firestore.py +175 -0
- praisonai/persistence/state/gcs.py +155 -0
- praisonai/persistence/state/memory.py +245 -0
- praisonai/persistence/state/mongodb.py +158 -0
- praisonai/persistence/state/redis.py +190 -0
- praisonai/persistence/state/upstash.py +144 -0
- praisonai/persistence/tests/__init__.py +3 -0
- praisonai/persistence/tests/test_all_backends.py +633 -0
- praisonai/profiler.py +1214 -0
- praisonai/recipe/__init__.py +134 -0
- praisonai/recipe/bridge.py +278 -0
- praisonai/recipe/core.py +893 -0
- praisonai/recipe/exceptions.py +54 -0
- praisonai/recipe/history.py +402 -0
- praisonai/recipe/models.py +266 -0
- praisonai/recipe/operations.py +440 -0
- praisonai/recipe/policy.py +422 -0
- praisonai/recipe/registry.py +849 -0
- praisonai/recipe/runtime.py +214 -0
- praisonai/recipe/security.py +711 -0
- praisonai/recipe/serve.py +859 -0
- praisonai/recipe/server.py +613 -0
- praisonai/scheduler/__init__.py +45 -0
- praisonai/scheduler/agent_scheduler.py +552 -0
- praisonai/scheduler/base.py +124 -0
- praisonai/scheduler/daemon_manager.py +225 -0
- praisonai/scheduler/state_manager.py +155 -0
- praisonai/scheduler/yaml_loader.py +193 -0
- praisonai/scheduler.py +194 -0
- praisonai/setup/__init__.py +1 -0
- praisonai/setup/build.py +21 -0
- praisonai/setup/post_install.py +23 -0
- praisonai/setup/setup_conda_env.py +25 -0
- praisonai/setup.py +16 -0
- praisonai/templates/__init__.py +116 -0
- praisonai/templates/cache.py +364 -0
- praisonai/templates/dependency_checker.py +358 -0
- praisonai/templates/discovery.py +391 -0
- praisonai/templates/loader.py +564 -0
- praisonai/templates/registry.py +511 -0
- praisonai/templates/resolver.py +206 -0
- praisonai/templates/security.py +327 -0
- praisonai/templates/tool_override.py +498 -0
- praisonai/templates/tools_doctor.py +256 -0
- praisonai/test.py +105 -0
- praisonai/train.py +562 -0
- praisonai/train_vision.py +306 -0
- praisonai/ui/agents.py +824 -0
- praisonai/ui/callbacks.py +57 -0
- praisonai/ui/chainlit_compat.py +246 -0
- praisonai/ui/chat.py +532 -0
- praisonai/ui/code.py +717 -0
- praisonai/ui/colab.py +474 -0
- praisonai/ui/colab_chainlit.py +81 -0
- praisonai/ui/components/aicoder.py +284 -0
- praisonai/ui/context.py +283 -0
- praisonai/ui/database_config.py +56 -0
- praisonai/ui/db.py +294 -0
- praisonai/ui/realtime.py +488 -0
- praisonai/ui/realtimeclient/__init__.py +756 -0
- praisonai/ui/realtimeclient/tools.py +242 -0
- praisonai/ui/sql_alchemy.py +710 -0
- praisonai/upload_vision.py +140 -0
- praisonai/version.py +1 -0
- praisonai-3.0.0.dist-info/METADATA +3493 -0
- praisonai-3.0.0.dist-info/RECORD +393 -0
- praisonai-3.0.0.dist-info/WHEEL +5 -0
- praisonai-3.0.0.dist-info/entry_points.txt +4 -0
- 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))
|