aethergraph 0.1.0a1__py3-none-any.whl → 0.1.0a2__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.
- aethergraph/__init__.py +4 -10
- aethergraph/__main__.py +293 -0
- aethergraph/api/v1/__init__.py +0 -0
- aethergraph/api/v1/agents.py +46 -0
- aethergraph/api/v1/apps.py +70 -0
- aethergraph/api/v1/artifacts.py +415 -0
- aethergraph/api/v1/channels.py +89 -0
- aethergraph/api/v1/deps.py +168 -0
- aethergraph/api/v1/graphs.py +259 -0
- aethergraph/api/v1/identity.py +25 -0
- aethergraph/api/v1/memory.py +353 -0
- aethergraph/api/v1/misc.py +47 -0
- aethergraph/api/v1/pagination.py +29 -0
- aethergraph/api/v1/runs.py +568 -0
- aethergraph/api/v1/schemas.py +535 -0
- aethergraph/api/v1/session.py +323 -0
- aethergraph/api/v1/stats.py +201 -0
- aethergraph/api/v1/viz.py +152 -0
- aethergraph/config/config.py +22 -0
- aethergraph/config/loader.py +3 -2
- aethergraph/config/storage.py +209 -0
- aethergraph/contracts/__init__.py +0 -0
- aethergraph/contracts/services/__init__.py +0 -0
- aethergraph/contracts/services/artifacts.py +27 -14
- aethergraph/contracts/services/memory.py +45 -17
- aethergraph/contracts/services/metering.py +129 -0
- aethergraph/contracts/services/runs.py +50 -0
- aethergraph/contracts/services/sessions.py +87 -0
- aethergraph/contracts/services/state_stores.py +3 -0
- aethergraph/contracts/services/viz.py +44 -0
- aethergraph/contracts/storage/artifact_index.py +88 -0
- aethergraph/contracts/storage/artifact_store.py +99 -0
- aethergraph/contracts/storage/async_kv.py +34 -0
- aethergraph/contracts/storage/blob_store.py +50 -0
- aethergraph/contracts/storage/doc_store.py +35 -0
- aethergraph/contracts/storage/event_log.py +31 -0
- aethergraph/contracts/storage/vector_index.py +48 -0
- aethergraph/core/__init__.py +0 -0
- aethergraph/core/execution/forward_scheduler.py +13 -2
- aethergraph/core/execution/global_scheduler.py +21 -15
- aethergraph/core/execution/step_forward.py +10 -1
- aethergraph/core/graph/__init__.py +0 -0
- aethergraph/core/graph/graph_builder.py +8 -4
- aethergraph/core/graph/graph_fn.py +156 -15
- aethergraph/core/graph/graph_spec.py +8 -0
- aethergraph/core/graph/graphify.py +146 -27
- aethergraph/core/graph/node_spec.py +0 -2
- aethergraph/core/graph/node_state.py +3 -0
- aethergraph/core/graph/task_graph.py +39 -1
- aethergraph/core/runtime/__init__.py +0 -0
- aethergraph/core/runtime/ad_hoc_context.py +64 -4
- aethergraph/core/runtime/base_service.py +28 -4
- aethergraph/core/runtime/execution_context.py +13 -15
- aethergraph/core/runtime/graph_runner.py +222 -37
- aethergraph/core/runtime/node_context.py +510 -6
- aethergraph/core/runtime/node_services.py +12 -5
- aethergraph/core/runtime/recovery.py +15 -1
- aethergraph/core/runtime/run_manager.py +783 -0
- aethergraph/core/runtime/run_manager_local.py +204 -0
- aethergraph/core/runtime/run_registration.py +2 -2
- aethergraph/core/runtime/run_types.py +89 -0
- aethergraph/core/runtime/runtime_env.py +136 -7
- aethergraph/core/runtime/runtime_metering.py +71 -0
- aethergraph/core/runtime/runtime_registry.py +36 -13
- aethergraph/core/runtime/runtime_services.py +194 -6
- aethergraph/core/tools/builtins/toolset.py +1 -1
- aethergraph/core/tools/toolkit.py +5 -0
- aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
- aethergraph/plugins/agents/default_chat_agent.py +171 -0
- aethergraph/plugins/agents/shared.py +81 -0
- aethergraph/plugins/channel/adapters/webui.py +112 -112
- aethergraph/plugins/channel/routes/webui_routes.py +367 -102
- aethergraph/plugins/channel/utils/slack_utils.py +115 -59
- aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
- aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
- aethergraph/runtime/__init__.py +15 -0
- aethergraph/server/app_factory.py +190 -34
- aethergraph/server/clients/channel_client.py +202 -0
- aethergraph/server/http/channel_http_routes.py +116 -0
- aethergraph/server/http/channel_ws_routers.py +45 -0
- aethergraph/server/loading.py +117 -0
- aethergraph/server/server.py +131 -0
- aethergraph/server/server_state.py +240 -0
- aethergraph/server/start.py +227 -66
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
- aethergraph/server/ui_static/index.html +15 -0
- aethergraph/server/ui_static/logo.png +0 -0
- aethergraph/services/artifacts/__init__.py +0 -0
- aethergraph/services/artifacts/facade.py +1239 -132
- aethergraph/services/auth/{dev.py → authn.py} +0 -8
- aethergraph/services/auth/authz.py +100 -0
- aethergraph/services/channel/__init__.py +0 -0
- aethergraph/services/channel/channel_bus.py +19 -1
- aethergraph/services/channel/factory.py +13 -1
- aethergraph/services/channel/ingress.py +311 -0
- aethergraph/services/channel/queue_adapter.py +75 -0
- aethergraph/services/channel/session.py +502 -19
- aethergraph/services/container/default_container.py +122 -43
- aethergraph/services/continuations/continuation.py +6 -0
- aethergraph/services/continuations/stores/fs_store.py +19 -0
- aethergraph/services/eventhub/event_hub.py +76 -0
- aethergraph/services/kv/__init__.py +0 -0
- aethergraph/services/kv/ephemeral.py +244 -0
- aethergraph/services/llm/__init__.py +0 -0
- aethergraph/services/llm/generic_client copy.py +691 -0
- aethergraph/services/llm/generic_client.py +1288 -187
- aethergraph/services/llm/providers.py +3 -1
- aethergraph/services/llm/types.py +47 -0
- aethergraph/services/llm/utils.py +284 -0
- aethergraph/services/logger/std.py +3 -0
- aethergraph/services/mcp/__init__.py +9 -0
- aethergraph/services/mcp/http_client.py +38 -0
- aethergraph/services/mcp/service.py +225 -1
- aethergraph/services/mcp/stdio_client.py +41 -6
- aethergraph/services/mcp/ws_client.py +44 -2
- aethergraph/services/memory/__init__.py +0 -0
- aethergraph/services/memory/distillers/llm_long_term.py +234 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
- aethergraph/services/memory/distillers/long_term.py +225 -0
- aethergraph/services/memory/facade/__init__.py +3 -0
- aethergraph/services/memory/facade/chat.py +440 -0
- aethergraph/services/memory/facade/core.py +447 -0
- aethergraph/services/memory/facade/distillation.py +424 -0
- aethergraph/services/memory/facade/rag.py +410 -0
- aethergraph/services/memory/facade/results.py +315 -0
- aethergraph/services/memory/facade/retrieval.py +139 -0
- aethergraph/services/memory/facade/types.py +77 -0
- aethergraph/services/memory/facade/utils.py +43 -0
- aethergraph/services/memory/facade_dep.py +1539 -0
- aethergraph/services/memory/factory.py +9 -3
- aethergraph/services/memory/utils.py +10 -0
- aethergraph/services/metering/eventlog_metering.py +470 -0
- aethergraph/services/metering/noop.py +25 -4
- aethergraph/services/rag/__init__.py +0 -0
- aethergraph/services/rag/facade.py +279 -23
- aethergraph/services/rag/index_factory.py +2 -2
- aethergraph/services/rag/node_rag.py +317 -0
- aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
- aethergraph/services/registry/__init__.py +0 -0
- aethergraph/services/registry/agent_app_meta.py +419 -0
- aethergraph/services/registry/registry_key.py +1 -1
- aethergraph/services/registry/unified_registry.py +74 -6
- aethergraph/services/scope/scope.py +159 -0
- aethergraph/services/scope/scope_factory.py +164 -0
- aethergraph/services/state_stores/serialize.py +5 -0
- aethergraph/services/state_stores/utils.py +2 -1
- aethergraph/services/viz/__init__.py +0 -0
- aethergraph/services/viz/facade.py +413 -0
- aethergraph/services/viz/viz_service.py +69 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
- aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
- aethergraph/storage/artifacts/cas_store.py +422 -0
- aethergraph/storage/artifacts/fs_cas.py +18 -0
- aethergraph/storage/artifacts/s3_cas.py +14 -0
- aethergraph/storage/artifacts/utils.py +124 -0
- aethergraph/storage/blob/fs_blob.py +86 -0
- aethergraph/storage/blob/s3_blob.py +115 -0
- aethergraph/storage/continuation_store/fs_cont.py +283 -0
- aethergraph/storage/continuation_store/inmem_cont.py +146 -0
- aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
- aethergraph/storage/docstore/fs_doc.py +63 -0
- aethergraph/storage/docstore/sqlite_doc.py +31 -0
- aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
- aethergraph/storage/eventlog/fs_event.py +136 -0
- aethergraph/storage/eventlog/sqlite_event.py +47 -0
- aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
- aethergraph/storage/factory.py +432 -0
- aethergraph/storage/fs_utils.py +28 -0
- aethergraph/storage/graph_state_store/state_store.py +64 -0
- aethergraph/storage/kv/inmem_kv.py +103 -0
- aethergraph/storage/kv/layered_kv.py +52 -0
- aethergraph/storage/kv/sqlite_kv.py +39 -0
- aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
- aethergraph/storage/memory/event_persist.py +68 -0
- aethergraph/storage/memory/fs_persist.py +118 -0
- aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
- aethergraph/{services → storage}/memory/indices.py +31 -7
- aethergraph/storage/metering/meter_event.py +55 -0
- aethergraph/storage/runs/doc_store.py +280 -0
- aethergraph/storage/runs/inmen_store.py +82 -0
- aethergraph/storage/runs/sqlite_run_store.py +403 -0
- aethergraph/storage/sessions/doc_store.py +183 -0
- aethergraph/storage/sessions/inmem_store.py +110 -0
- aethergraph/storage/sessions/sqlite_session_store.py +399 -0
- aethergraph/storage/vector_index/chroma_index.py +138 -0
- aethergraph/storage/vector_index/faiss_index.py +179 -0
- aethergraph/storage/vector_index/sqlite_index.py +187 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/METADATA +138 -31
- aethergraph-0.1.0a2.dist-info/RECORD +356 -0
- aethergraph-0.1.0a2.dist-info/entry_points.txt +3 -0
- aethergraph/services/artifacts/factory.py +0 -35
- aethergraph/services/artifacts/fs_store.py +0 -656
- aethergraph/services/artifacts/jsonl_index.py +0 -123
- aethergraph/services/artifacts/sqlite_index.py +0 -209
- aethergraph/services/memory/distillers/episode.py +0 -116
- aethergraph/services/memory/distillers/rolling.py +0 -74
- aethergraph/services/memory/facade.py +0 -633
- aethergraph/services/memory/persist_fs.py +0 -40
- aethergraph/services/rag/index/base.py +0 -27
- aethergraph/services/rag/index/faiss_index.py +0 -121
- aethergraph/services/rag/index/sqlite_index.py +0 -134
- aethergraph-0.1.0a1.dist-info/RECORD +0 -182
- aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/WHEEL +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/top_level.txt +0 -0
|
@@ -1,25 +1,32 @@
|
|
|
1
|
+
# services/artifacts/facade.py
|
|
1
2
|
from __future__ import annotations
|
|
2
3
|
|
|
3
|
-
import
|
|
4
|
+
import asyncio
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
4
6
|
from contextlib import asynccontextmanager
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
import json
|
|
5
9
|
from pathlib import Path
|
|
6
10
|
from typing import Any, Literal
|
|
7
11
|
from urllib.parse import urlparse
|
|
8
12
|
|
|
9
|
-
from aethergraph.contracts.services.artifacts import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
from aethergraph.contracts.services.artifacts import Artifact, AsyncArtifactStore
|
|
14
|
+
from aethergraph.contracts.storage.artifact_index import AsyncArtifactIndex
|
|
15
|
+
from aethergraph.core.runtime.runtime_metering import current_metering
|
|
16
|
+
from aethergraph.core.runtime.runtime_services import current_services
|
|
17
|
+
from aethergraph.services.artifacts.paths import _from_uri_or_path
|
|
18
|
+
from aethergraph.services.scope.scope import Scope
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Scope = Literal["node", "run", "graph", "all"]
|
|
20
|
+
ArtifactView = Literal["node", "graph", "run", "all"]
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
class ArtifactFacade:
|
|
21
|
-
"""
|
|
22
|
-
|
|
24
|
+
"""
|
|
25
|
+
Facade for artifact storage + indexing within a specific execution context.
|
|
26
|
+
|
|
27
|
+
- All *writes* go through the underlying AsyncArtifactStore AND AsyncArtifactIndex.
|
|
28
|
+
- Adds scoping helpers for search/list/best.
|
|
29
|
+
- Provides backend-agnostic "as_local_*" helpers that work with FS and S3.
|
|
23
30
|
"""
|
|
24
31
|
|
|
25
32
|
def __init__(
|
|
@@ -32,25 +39,268 @@ class ArtifactFacade:
|
|
|
32
39
|
tool_version: str,
|
|
33
40
|
store: AsyncArtifactStore,
|
|
34
41
|
index: AsyncArtifactIndex,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
self.
|
|
38
|
-
self.
|
|
42
|
+
scope: Scope | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.run_id = run_id
|
|
45
|
+
self.graph_id = graph_id
|
|
46
|
+
self.node_id = node_id
|
|
47
|
+
self.tool_name = tool_name
|
|
48
|
+
self.tool_version = tool_version
|
|
49
|
+
self.store = store
|
|
50
|
+
self.index = index
|
|
51
|
+
|
|
52
|
+
# set scope -- this should be done outside in NodeContext and passed in, but here is a fallback
|
|
53
|
+
self.scope = scope
|
|
54
|
+
|
|
55
|
+
# Keep track of the last created artifact
|
|
39
56
|
self.last_artifact: Artifact | None = None
|
|
40
57
|
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
# ---------- Helpers for scopes ----------
|
|
59
|
+
def _with_scope_labels(self, labels: dict[str, Any] | None) -> dict[str, Any]:
|
|
60
|
+
"""Merge given labels with scope labels."""
|
|
61
|
+
out: dict[str, Any] = dict(labels or {})
|
|
62
|
+
if self.scope:
|
|
63
|
+
out.update(self.scope.artifact_scope_labels())
|
|
64
|
+
return out
|
|
43
65
|
|
|
44
|
-
|
|
66
|
+
def _tenant_labels_for_search(self) -> dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
Tenant filter for search/list.
|
|
69
|
+
In cloud/demo mode, we AND these on.
|
|
70
|
+
In local mode, these are no-ops.
|
|
71
|
+
"""
|
|
72
|
+
if self.scope is None:
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
if self.scope.mode == "local":
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
labels: dict[str, Any] = {}
|
|
79
|
+
if self.scope.org_id:
|
|
80
|
+
labels["org_id"] = self.scope.org_id
|
|
81
|
+
if self.scope.user_id:
|
|
82
|
+
labels["user_id"] = self.scope.user_id
|
|
83
|
+
if self.scope.client_id:
|
|
84
|
+
labels["client_id"] = self.scope.client_id
|
|
85
|
+
return labels
|
|
86
|
+
|
|
87
|
+
def _view_labels(self, view: ArtifactView) -> dict[str, Any]:
|
|
88
|
+
"""Labels to filter by for a given ArtifactView.
|
|
89
|
+
view options:
|
|
90
|
+
- "node": filter by (run_id, graph_id, node_id)
|
|
91
|
+
- "graph": filter by (run_id, graph_id)
|
|
92
|
+
- "run": filter by (run_id) [default]
|
|
93
|
+
- "all": no implicit filters
|
|
94
|
+
|
|
95
|
+
In cloud/demo mode, we AND tenant filters on.
|
|
96
|
+
In local mode, tenants are no-ops.
|
|
97
|
+
"""
|
|
98
|
+
base: dict[str, Any] = {}
|
|
99
|
+
|
|
100
|
+
if view == "node":
|
|
101
|
+
base = {"run_id": self.run_id, "graph_id": self.graph_id, "node_id": self.node_id}
|
|
102
|
+
elif view == "graph":
|
|
103
|
+
base = {"run_id": self.run_id, "graph_id": self.graph_id}
|
|
104
|
+
elif view == "run":
|
|
105
|
+
base = {"run_id": self.run_id}
|
|
106
|
+
# "all" => no run/graph/node filter
|
|
107
|
+
|
|
108
|
+
base.update(self._tenant_labels_for_search())
|
|
109
|
+
return base
|
|
110
|
+
|
|
111
|
+
# Metering-enhanced record
|
|
112
|
+
async def _record(self, a: Artifact) -> None:
|
|
113
|
+
"""Record artifact in index, occurrence log, and update run/session stats."""
|
|
114
|
+
# 1) Sync canonical tenant fields from labels/scope into artifact
|
|
115
|
+
if self.scope is not None:
|
|
116
|
+
scope_labels = self.scope.artifact_scope_labels()
|
|
117
|
+
a.labels = {**scope_labels, **(a.labels or {})}
|
|
118
|
+
|
|
119
|
+
dims = self.scope.metering_dimensions()
|
|
120
|
+
a.org_id = a.org_id or dims.get("org_id")
|
|
121
|
+
a.user_id = a.user_id or dims.get("user_id")
|
|
122
|
+
a.client_id = a.client_id or dims.get("client_id")
|
|
123
|
+
a.app_id = a.app_id or dims.get("app_id")
|
|
124
|
+
a.session_id = a.session_id or dims.get("session_id")
|
|
125
|
+
# run_id / graph_id / node_id are already set
|
|
126
|
+
|
|
127
|
+
# 2) Record in index + occurrence log
|
|
128
|
+
await self.index.upsert(a)
|
|
129
|
+
await self.index.record_occurrence(a)
|
|
130
|
+
self.last_artifact = a
|
|
131
|
+
|
|
132
|
+
# 3) Metering hook for artifact writes
|
|
133
|
+
try:
|
|
134
|
+
meter = current_metering()
|
|
135
|
+
|
|
136
|
+
# Try a few common size fields, fallback to 0
|
|
137
|
+
size = (
|
|
138
|
+
getattr(a, "bytes", None)
|
|
139
|
+
or getattr(a, "size_bytes", None)
|
|
140
|
+
or getattr(a, "size", None)
|
|
141
|
+
or 0
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
await meter.record_artifact(
|
|
145
|
+
scope=self.scope, # Scope carries user/org/run/graph/app/session
|
|
146
|
+
kind=getattr(a, "kind", "unknown"),
|
|
147
|
+
bytes=int(size),
|
|
148
|
+
pinned=bool(getattr(a, "pinned", False)),
|
|
149
|
+
)
|
|
150
|
+
except Exception:
|
|
151
|
+
import logging
|
|
152
|
+
|
|
153
|
+
logging.getLogger("aethergraph.metering").exception("record_artifact_failed")
|
|
154
|
+
|
|
155
|
+
# 4) Update run/session stores (best-effort; don't break on failure)
|
|
156
|
+
try:
|
|
157
|
+
services = current_services()
|
|
158
|
+
except Exception:
|
|
159
|
+
return # outside runtime context, nothing to do
|
|
160
|
+
|
|
161
|
+
# Normalize timestamp
|
|
162
|
+
ts: datetime | None
|
|
163
|
+
if isinstance(a.created_at, datetime):
|
|
164
|
+
ts = a.created_at
|
|
165
|
+
elif isinstance(a.created_at, str):
|
|
166
|
+
try:
|
|
167
|
+
ts = datetime.fromisoformat(a.created_at)
|
|
168
|
+
except Exception:
|
|
169
|
+
ts = None
|
|
170
|
+
else:
|
|
171
|
+
ts = None
|
|
172
|
+
|
|
173
|
+
# Update run metadata
|
|
174
|
+
run_store = getattr(services, "run_store", None)
|
|
175
|
+
if run_store is not None and a.run_id:
|
|
176
|
+
record_artifact = getattr(run_store, "record_artifact", None)
|
|
177
|
+
if callable(record_artifact):
|
|
178
|
+
await record_artifact(
|
|
179
|
+
a.run_id,
|
|
180
|
+
artifact_id=a.artifact_id,
|
|
181
|
+
created_at=ts,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Update session metadata
|
|
185
|
+
session_store = getattr(services, "session_store", None)
|
|
186
|
+
session_id = a.session_id or getattr(self.scope, "session_id", None)
|
|
187
|
+
if session_store is not None and session_id:
|
|
188
|
+
sess_record_artifact = getattr(session_store, "record_artifact", None)
|
|
189
|
+
if callable(sess_record_artifact):
|
|
190
|
+
await sess_record_artifact(
|
|
191
|
+
session_id,
|
|
192
|
+
created_at=ts,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ---------- core staging/ingest ----------
|
|
196
|
+
async def stage_path(self, ext: str = "") -> str:
|
|
197
|
+
"""
|
|
198
|
+
Plan a staging file path for artifact creation.
|
|
199
|
+
|
|
200
|
+
This method requests a temporary file path from the underlying artifact store,
|
|
201
|
+
suitable for staging a new artifact. The file extension can be specified to
|
|
202
|
+
guide downstream handling (e.g., ".txt", ".json").
|
|
203
|
+
|
|
204
|
+
Examples:
|
|
205
|
+
Stage a temporary text file:
|
|
206
|
+
```python
|
|
207
|
+
staged_path = await context.artifacts().stage_path(".txt")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Stage a file with a custom extension:
|
|
211
|
+
```python
|
|
212
|
+
staged_path = await context.artifacts().stage_path(".log")
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
ext: Optional file extension for the staged file (e.g., ".txt", ".json").
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
str: The planned staging file path as a string.
|
|
220
|
+
"""
|
|
221
|
+
return await self.store.plan_staging_path(planned_ext=ext)
|
|
222
|
+
|
|
223
|
+
async def stage_dir(self, suffix: str = "") -> str:
|
|
224
|
+
"""
|
|
225
|
+
Plan a staging directory for artifact creation.
|
|
226
|
+
|
|
227
|
+
This method requests a temporary directory path from the underlying artifact store,
|
|
228
|
+
suitable for staging a directory artifact. The suffix can be used to distinguish
|
|
229
|
+
different staging contexts.
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
Stage a temporary directory:
|
|
233
|
+
```python
|
|
234
|
+
staged_dir = await context.artifacts().stage_dir()
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Stage a directory with a custom suffix:
|
|
238
|
+
```python
|
|
239
|
+
staged_dir = await context.artifacts().stage_dir("_images")
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
suffix: Optional string to append to the directory name for uniqueness.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
str: The planned staging directory path as a string.
|
|
247
|
+
"""
|
|
248
|
+
return await self.store.plan_staging_dir(suffix=suffix)
|
|
249
|
+
|
|
250
|
+
async def ingest_file(
|
|
45
251
|
self,
|
|
46
252
|
staged_path: str,
|
|
47
253
|
*,
|
|
48
254
|
kind: str,
|
|
49
|
-
labels=None,
|
|
50
|
-
metrics=None,
|
|
255
|
+
labels: dict | None = None,
|
|
256
|
+
metrics: dict | None = None,
|
|
51
257
|
suggested_uri: str | None = None,
|
|
52
258
|
pin: bool = False,
|
|
53
|
-
):
|
|
259
|
+
) -> Artifact:
|
|
260
|
+
"""
|
|
261
|
+
Ingest a staged file as an artifact and record it in the index.
|
|
262
|
+
|
|
263
|
+
This method takes a file that has been staged locally, persists it in the
|
|
264
|
+
artifact store, and records its metadata in the artifact index. It supports
|
|
265
|
+
adding labels, metrics, and logical URIs for organization.
|
|
266
|
+
|
|
267
|
+
Examples:
|
|
268
|
+
Ingest a staged model file:
|
|
269
|
+
```python
|
|
270
|
+
artifact = await context.artifacts().ingest_file(
|
|
271
|
+
staged_path="/tmp/model.bin",
|
|
272
|
+
kind="model",
|
|
273
|
+
labels={"domain": "vision"},
|
|
274
|
+
pin=True
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Ingest with a suggested URI:
|
|
279
|
+
```python
|
|
280
|
+
artifact = await context.artifacts().ingest_file(
|
|
281
|
+
staged_path="/tmp/data.csv",
|
|
282
|
+
kind="dataset",
|
|
283
|
+
suggested_uri="s3://bucket/data.csv"
|
|
284
|
+
)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
staged_path: The local path to the staged file.
|
|
289
|
+
kind: The artifact type (e.g., "model", "dataset").
|
|
290
|
+
labels: Optional dictionary of metadata labels.
|
|
291
|
+
metrics: Optional dictionary of numeric metrics.
|
|
292
|
+
suggested_uri: Optional logical URI for the artifact.
|
|
293
|
+
pin: If True, pins the artifact for retention.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Artifact: The fully persisted `Artifact` object with metadata and identifiers.
|
|
297
|
+
|
|
298
|
+
Notes:
|
|
299
|
+
The `staged_path` must point to an existing file. The method will handle
|
|
300
|
+
cleanup of the staged file if configured in the underlying store.
|
|
301
|
+
If you already have a file at a specific URI (e.g. "s3://bucket/file" or local file path), consider using `save_file` instead.
|
|
302
|
+
"""
|
|
303
|
+
labels = self._with_scope_labels(labels)
|
|
54
304
|
a = await self.store.ingest_staged_file(
|
|
55
305
|
staged_path=staged_path,
|
|
56
306
|
kind=kind,
|
|
@@ -64,20 +314,135 @@ class ArtifactFacade:
|
|
|
64
314
|
suggested_uri=suggested_uri,
|
|
65
315
|
pin=pin,
|
|
66
316
|
)
|
|
67
|
-
await self.
|
|
68
|
-
await self.index.record_occurrence(a)
|
|
317
|
+
await self._record(a)
|
|
69
318
|
return a
|
|
70
319
|
|
|
71
|
-
async def
|
|
320
|
+
async def ingest_dir(
|
|
321
|
+
self,
|
|
322
|
+
staged_dir: str,
|
|
323
|
+
**kwargs: Any,
|
|
324
|
+
) -> Artifact:
|
|
325
|
+
"""
|
|
326
|
+
Ingest a staged directory as a directory artifact and record it in the index.
|
|
327
|
+
|
|
328
|
+
This method takes a directory that has been staged locally, persists its contents
|
|
329
|
+
in the artifact store (optionally creating a manifest or archive), and records
|
|
330
|
+
its metadata in the artifact index. Additional keyword arguments are passed to
|
|
331
|
+
the store's ingest logic.
|
|
332
|
+
|
|
333
|
+
Examples:
|
|
334
|
+
Ingest a staged directory with manifest:
|
|
335
|
+
```python
|
|
336
|
+
artifact = await context.artifacts().ingest_dir(
|
|
337
|
+
staged_dir="/tmp/output_dir",
|
|
338
|
+
kind="directory",
|
|
339
|
+
labels={"type": "images"}
|
|
340
|
+
)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Ingest with custom metrics:
|
|
344
|
+
```python
|
|
345
|
+
artifact = await context.artifacts().ingest_dir(
|
|
346
|
+
staged_dir="/tmp/logs",
|
|
347
|
+
kind="log_dir",
|
|
348
|
+
metrics={"file_count": 12}
|
|
349
|
+
)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
staged_dir: The local path to the staged directory.
|
|
354
|
+
**kwargs: Additional keyword arguments for artifact metadata (e.g., kind, labels, metrics).
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Artifact: The fully persisted `Artifact` object with metadata and identifiers.
|
|
358
|
+
|
|
359
|
+
"""
|
|
360
|
+
labels = self._with_scope_labels(kwargs.pop("labels", None))
|
|
361
|
+
kwargs["labels"] = labels
|
|
362
|
+
a = await self.store.ingest_directory(
|
|
363
|
+
staged_dir=staged_dir,
|
|
364
|
+
run_id=self.run_id,
|
|
365
|
+
graph_id=self.graph_id,
|
|
366
|
+
node_id=self.node_id,
|
|
367
|
+
tool_name=self.tool_name,
|
|
368
|
+
tool_version=self.tool_version,
|
|
369
|
+
**kwargs,
|
|
370
|
+
)
|
|
371
|
+
await self._record(a)
|
|
372
|
+
return a
|
|
373
|
+
|
|
374
|
+
# ---------- core save APIs ----------
|
|
375
|
+
async def save_file(
|
|
72
376
|
self,
|
|
73
377
|
path: str,
|
|
74
378
|
*,
|
|
75
379
|
kind: str,
|
|
76
|
-
labels=None,
|
|
77
|
-
metrics=None,
|
|
380
|
+
labels: dict | None = None,
|
|
381
|
+
metrics: dict | None = None,
|
|
78
382
|
suggested_uri: str | None = None,
|
|
383
|
+
name: str | None = None,
|
|
79
384
|
pin: bool = False,
|
|
80
|
-
|
|
385
|
+
cleanup: bool = True,
|
|
386
|
+
) -> Artifact:
|
|
387
|
+
"""
|
|
388
|
+
Save an existing file and index it.
|
|
389
|
+
|
|
390
|
+
This method saves a file to the artifact store, associates it with the current
|
|
391
|
+
execution context, and records it in the artifact index. It supports adding
|
|
392
|
+
metadata such as labels, metrics, and a suggested URI for logical organization.
|
|
393
|
+
|
|
394
|
+
Examples:
|
|
395
|
+
Basic usage with a file path:
|
|
396
|
+
```python
|
|
397
|
+
artifact = await context.artifacts().save_file(
|
|
398
|
+
path="/tmp/output.txt",
|
|
399
|
+
kind="text",
|
|
400
|
+
labels={"category": "logs"},
|
|
401
|
+
)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Saving a file with a custom name and pinning it:
|
|
405
|
+
```python
|
|
406
|
+
artifact = await context.artifacts().save_file(
|
|
407
|
+
path="/tmp/data.csv",
|
|
408
|
+
kind="dataset",
|
|
409
|
+
name="data_backup.csv",
|
|
410
|
+
pin=True,
|
|
411
|
+
)
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
path: The local file path to save.
|
|
416
|
+
kind: A string representing the artifact type (e.g., "text", "dataset").
|
|
417
|
+
labels: A dictionary of metadata labels to associate with the artifact.
|
|
418
|
+
metrics: A dictionary of numerical metrics to associate with the artifact.
|
|
419
|
+
suggested_uri: A logical URI for the artifact (e.g., "s3://bucket/file").
|
|
420
|
+
name: A custom name for the artifact, used as the `filename` label.
|
|
421
|
+
pin: A boolean indicating whether to pin the artifact.
|
|
422
|
+
cleanup: A boolean indicating whether to delete the local file after saving.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Artifact: The saved `Artifact` object containing metadata and identifiers.
|
|
426
|
+
|
|
427
|
+
Notes:
|
|
428
|
+
The `name` parameter is used to set the `filename` label for the artifact.
|
|
429
|
+
If both `name` and `suggested_uri` are provided, `name` takes precedence for the filename.
|
|
430
|
+
|
|
431
|
+
"""
|
|
432
|
+
# Start with user labels
|
|
433
|
+
eff_labels: dict[str, Any] = dict(labels or {})
|
|
434
|
+
|
|
435
|
+
# If caller passed an explicit name, prefer that as filename label
|
|
436
|
+
if name:
|
|
437
|
+
eff_labels.setdefault("filename", name)
|
|
438
|
+
|
|
439
|
+
# If caller gave a suggested_uri but no explicit name, infer filename from it
|
|
440
|
+
if suggested_uri and "filename" not in eff_labels:
|
|
441
|
+
from pathlib import PurePath
|
|
442
|
+
|
|
443
|
+
eff_labels["filename"] = PurePath(suggested_uri).name
|
|
444
|
+
|
|
445
|
+
labels = self._with_scope_labels(eff_labels)
|
|
81
446
|
a = await self.store.save_file(
|
|
82
447
|
path=path,
|
|
83
448
|
kind=kind,
|
|
@@ -90,30 +455,202 @@ class ArtifactFacade:
|
|
|
90
455
|
metrics=metrics,
|
|
91
456
|
suggested_uri=suggested_uri,
|
|
92
457
|
pin=pin,
|
|
458
|
+
cleanup=cleanup,
|
|
93
459
|
)
|
|
94
|
-
await self.
|
|
95
|
-
await self.index.record_occurrence(a)
|
|
96
|
-
self.last_artifact = a
|
|
460
|
+
await self._record(a)
|
|
97
461
|
return a
|
|
98
462
|
|
|
99
|
-
async def save_text(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
463
|
+
async def save_text(
|
|
464
|
+
self,
|
|
465
|
+
payload: str,
|
|
466
|
+
*,
|
|
467
|
+
suggested_uri: str | None = None,
|
|
468
|
+
name: str | None = None,
|
|
469
|
+
kind: str = "text",
|
|
470
|
+
labels: dict | None = None,
|
|
471
|
+
metrics: dict | None = None,
|
|
472
|
+
pin: bool = False,
|
|
473
|
+
) -> Artifact:
|
|
474
|
+
"""
|
|
475
|
+
This method stages the text as a temporary `.txt` file, writes the payload,
|
|
476
|
+
and persists it as an artifact with associated metadata. It is accessed via
|
|
477
|
+
`context.artifacts().save_text(...)`.
|
|
105
478
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
479
|
+
Examples:
|
|
480
|
+
Basic usage to save a text artifact:
|
|
481
|
+
```python
|
|
482
|
+
await context.artifacts().save_text("Hello, world!")
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Saving with custom metadata and logical filename:
|
|
486
|
+
```python
|
|
487
|
+
await context.artifacts().save_text(
|
|
488
|
+
"Experiment results",
|
|
489
|
+
name="results.txt",
|
|
490
|
+
labels={"experiment": "A1"},
|
|
491
|
+
metrics={"accuracy": 0.98},
|
|
492
|
+
pin=True
|
|
493
|
+
)
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
payload: The text content to be saved as an artifact.
|
|
498
|
+
suggested_uri: Optional logical URI for the artifact. If not provided,
|
|
499
|
+
the `name` will be used if available.
|
|
500
|
+
name: Optional logical filename for the artifact.
|
|
501
|
+
kind: The artifact kind, defaults to `"text"`.
|
|
502
|
+
labels: Optional dictionary of string labels for categorization.
|
|
503
|
+
metrics: Optional dictionary of numeric metrics for tracking.
|
|
504
|
+
pin: If True, pins the artifact for retention.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Artifact: The fully persisted `Artifact` object containing metadata and storage reference.
|
|
508
|
+
"""
|
|
509
|
+
staged = await self.stage_path(".txt")
|
|
510
|
+
|
|
511
|
+
def _write() -> str:
|
|
512
|
+
p = Path(staged)
|
|
513
|
+
p.write_text(payload, encoding="utf-8")
|
|
514
|
+
return str(p)
|
|
515
|
+
|
|
516
|
+
staged = await asyncio.to_thread(_write)
|
|
517
|
+
|
|
518
|
+
# If user gave a logical filename but no suggested_uri, re-use it
|
|
519
|
+
if name and not suggested_uri:
|
|
520
|
+
suggested_uri = name
|
|
521
|
+
|
|
522
|
+
return await self.save_file(
|
|
523
|
+
path=staged,
|
|
524
|
+
kind=kind,
|
|
525
|
+
labels=labels,
|
|
526
|
+
metrics=metrics,
|
|
527
|
+
suggested_uri=suggested_uri,
|
|
528
|
+
name=name,
|
|
529
|
+
pin=pin,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
async def save_json(
|
|
533
|
+
self,
|
|
534
|
+
payload: dict,
|
|
535
|
+
*,
|
|
536
|
+
suggested_uri: str | None = None,
|
|
537
|
+
name: str | None = None,
|
|
538
|
+
kind: str = "json",
|
|
539
|
+
labels: dict | None = None,
|
|
540
|
+
metrics: dict | None = None,
|
|
541
|
+
pin: bool = False,
|
|
542
|
+
) -> Artifact:
|
|
543
|
+
"""
|
|
544
|
+
Save a JSON payload as an artifact with full context metadata.
|
|
545
|
+
|
|
546
|
+
This method stages the JSON data as a temporary `.json` file, writes the payload,
|
|
547
|
+
and persists it as an artifact with associated metadata. It is accessed via
|
|
548
|
+
`context.artifacts().save_json(...)`.
|
|
112
549
|
|
|
550
|
+
Examples:
|
|
551
|
+
Basic usage to save a JSON artifact:
|
|
552
|
+
```python
|
|
553
|
+
await context.artifacts().save_json({"foo": "bar", "count": 42})
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Saving with custom metadata and logical filename:
|
|
557
|
+
```python
|
|
558
|
+
await context.artifacts().save_json(
|
|
559
|
+
{"results": [1, 2, 3]},
|
|
560
|
+
name="results.json",
|
|
561
|
+
labels={"experiment": "A1"},
|
|
562
|
+
metrics={"accuracy": 0.98},
|
|
563
|
+
pin=True
|
|
564
|
+
)
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
payload: The JSON-serializable dictionary to be saved as an artifact.
|
|
569
|
+
suggested_uri: Optional logical URI for the artifact. If not provided,
|
|
570
|
+
the `name` will be used if available.
|
|
571
|
+
name: Optional logical filename for the artifact.
|
|
572
|
+
kind: The artifact kind, defaults to `"json"`.
|
|
573
|
+
labels: Optional dictionary of string labels for categorization.
|
|
574
|
+
metrics: Optional dictionary of numeric metrics for tracking.
|
|
575
|
+
pin: If True, pins the artifact for retention.
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
Artifact: The fully persisted `Artifact` object containing metadata and storage reference.
|
|
579
|
+
"""
|
|
580
|
+
staged = await self.stage_path(".json")
|
|
581
|
+
|
|
582
|
+
def _write() -> str:
|
|
583
|
+
p = Path(staged)
|
|
584
|
+
import json
|
|
585
|
+
|
|
586
|
+
p.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
587
|
+
return str(p)
|
|
588
|
+
|
|
589
|
+
staged = await asyncio.to_thread(_write)
|
|
590
|
+
|
|
591
|
+
if name and not suggested_uri:
|
|
592
|
+
suggested_uri = name
|
|
593
|
+
|
|
594
|
+
return await self.save_file(
|
|
595
|
+
path=staged,
|
|
596
|
+
kind=kind,
|
|
597
|
+
labels=labels,
|
|
598
|
+
metrics=metrics,
|
|
599
|
+
suggested_uri=suggested_uri,
|
|
600
|
+
name=name,
|
|
601
|
+
pin=pin,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# ---------- streaming APIs ----------
|
|
113
605
|
@asynccontextmanager
|
|
114
|
-
async def writer(
|
|
115
|
-
|
|
116
|
-
|
|
606
|
+
async def writer(
|
|
607
|
+
self,
|
|
608
|
+
*,
|
|
609
|
+
kind: str,
|
|
610
|
+
planned_ext: str | None = None,
|
|
611
|
+
pin: bool = False,
|
|
612
|
+
) -> AsyncIterator[Any]:
|
|
613
|
+
"""
|
|
614
|
+
Async context manager for streaming artifact writes.
|
|
615
|
+
|
|
616
|
+
This method yields a writer object that supports:
|
|
617
|
+
|
|
618
|
+
- `writer.write(bytes)` for streaming data
|
|
619
|
+
- `writer.add_labels(...)` to attach metadata
|
|
620
|
+
- `writer.add_metrics(...)` to record metrics
|
|
621
|
+
|
|
622
|
+
After the context exits, the writer's artifact is finalized and recorded in the index.
|
|
623
|
+
Accessed via `context.artifacts().writer(...)`.
|
|
624
|
+
|
|
625
|
+
Examples:
|
|
626
|
+
Basic usage to stream a file artifact:
|
|
627
|
+
```python
|
|
628
|
+
async with context.artifacts().writer(kind="binary") as w:
|
|
629
|
+
await w.write(b"some data")
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
Streaming with custom file extension and pinning:
|
|
633
|
+
```python
|
|
634
|
+
async with context.artifacts().writer(
|
|
635
|
+
kind="log",
|
|
636
|
+
planned_ext=".log",
|
|
637
|
+
pin=True
|
|
638
|
+
) as w:
|
|
639
|
+
await w.write(b'Log entry 1\\n')
|
|
640
|
+
w.add_labels({"source": 'app'})
|
|
641
|
+
w.add_metrics({"lines": 1})
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
kind: The artifact type (e.g., "binary", "log", "text").
|
|
646
|
+
planned_ext: Optional file extension for the staged artifact (e.g., ".txt").
|
|
647
|
+
pin: If True, pins the artifact for retention.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
AsyncIterator[Any]: Yields a writer object for streaming data and metadata.
|
|
651
|
+
"""
|
|
652
|
+
# 1) Delegate to the store's async context manager
|
|
653
|
+
async with self.store.open_writer(
|
|
117
654
|
kind=kind,
|
|
118
655
|
run_id=self.run_id,
|
|
119
656
|
graph_id=self.graph_id,
|
|
@@ -122,97 +659,530 @@ class ArtifactFacade:
|
|
|
122
659
|
tool_version=self.tool_version,
|
|
123
660
|
planned_ext=planned_ext,
|
|
124
661
|
pin=pin,
|
|
125
|
-
)
|
|
126
|
-
|
|
662
|
+
) as w:
|
|
663
|
+
# 2) Yield to user code (they write() and add_labels/add_metrics)
|
|
127
664
|
yield w
|
|
128
|
-
|
|
665
|
+
|
|
666
|
+
# 3) At this point, store.open_writer has fully exited and has set w.artifact
|
|
667
|
+
a = getattr(w, "artifact", None) or getattr(w, "_artifact", None)
|
|
668
|
+
|
|
129
669
|
if a:
|
|
130
|
-
await self.
|
|
131
|
-
await self.index.record_occurrence(a)
|
|
132
|
-
self.last_artifact = a
|
|
670
|
+
await self._record(a)
|
|
133
671
|
else:
|
|
134
672
|
self.last_artifact = None
|
|
135
673
|
|
|
136
|
-
|
|
137
|
-
|
|
674
|
+
# ---------- load by artifact ID ----------
|
|
675
|
+
async def get_by_id(self, artifact_id: str) -> Artifact | None:
|
|
676
|
+
"""
|
|
677
|
+
Retrieve a single artifact by its unique identifier.
|
|
138
678
|
|
|
139
|
-
|
|
140
|
-
a
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await self.index.record_occurrence(a)
|
|
151
|
-
self.last_artifact = a
|
|
152
|
-
return a
|
|
679
|
+
This asynchronous method queries the configured artifact index for the specified
|
|
680
|
+
`artifact_id`. If the index is not set up, a `RuntimeError` is raised. The method
|
|
681
|
+
is typically accessed via `context.artifacts().get_by_id(...)`.
|
|
682
|
+
|
|
683
|
+
Examples:
|
|
684
|
+
Fetching an artifact by ID:
|
|
685
|
+
```python
|
|
686
|
+
artifact = await context.artifacts().get_by_id("artifact_123")
|
|
687
|
+
if artifact:
|
|
688
|
+
print(artifact.name)
|
|
689
|
+
```
|
|
153
690
|
|
|
154
|
-
|
|
155
|
-
|
|
691
|
+
Args:
|
|
692
|
+
artifact_id: The unique string identifier of the artifact to retrieve.
|
|
156
693
|
|
|
694
|
+
Returns:
|
|
695
|
+
Artifact | None: The matching `Artifact` object if found, otherwise `None`.
|
|
696
|
+
"""
|
|
697
|
+
if self.index is None:
|
|
698
|
+
raise RuntimeError("Artifact index is not configured on this facade")
|
|
699
|
+
return await self.index.get(artifact_id)
|
|
700
|
+
|
|
701
|
+
async def load_bytes_by_id(self, artifact_id: str) -> bytes:
|
|
702
|
+
"""
|
|
703
|
+
Load raw bytes for a file-like artifact by its unique identifier.
|
|
704
|
+
|
|
705
|
+
This asynchronous method retrieves the artifact metadata from the index using
|
|
706
|
+
the provided `artifact_id`, then loads the underlying bytes from the artifact store.
|
|
707
|
+
It is accessed via `context.artifacts().load_bytes_by_id(...)`.
|
|
708
|
+
|
|
709
|
+
Examples:
|
|
710
|
+
Basic usage to load bytes for an artifact:
|
|
711
|
+
```python
|
|
712
|
+
data = await context.artifacts().load_bytes_by_id("artifact_123")
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Handling missing artifacts:
|
|
716
|
+
```python
|
|
717
|
+
try:
|
|
718
|
+
data = await context.artifacts().load_bytes_by_id("artifact_456")
|
|
719
|
+
except FileNotFoundError:
|
|
720
|
+
print("Artifact not found.")
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
artifact_id: The unique string identifier of the artifact to retrieve.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
bytes: The raw byte content of the artifact.
|
|
728
|
+
|
|
729
|
+
Raises:
|
|
730
|
+
FileNotFoundError: If the artifact is not found or missing a URI.
|
|
731
|
+
"""
|
|
732
|
+
art = await self.get_by_id(artifact_id)
|
|
733
|
+
if art is None or not art.uri:
|
|
734
|
+
raise FileNotFoundError(f"Artifact {artifact_id} not found or missing uri")
|
|
735
|
+
return await self.store.load_artifact_bytes(art.uri)
|
|
736
|
+
|
|
737
|
+
async def load_text_by_id(
|
|
738
|
+
self,
|
|
739
|
+
artifact_id: str,
|
|
740
|
+
*,
|
|
741
|
+
encoding: str = "utf-8",
|
|
742
|
+
errors: str = "strict",
|
|
743
|
+
) -> str:
|
|
744
|
+
"""
|
|
745
|
+
Load the text content of an artifact by its unique identifier.
|
|
746
|
+
|
|
747
|
+
This asynchronous method retrieves the raw bytes for the specified `artifact_id`
|
|
748
|
+
and decodes them into a string using the provided encoding. It is accessed via
|
|
749
|
+
`context.artifacts().load_text_by_id(...)`.
|
|
750
|
+
|
|
751
|
+
Examples:
|
|
752
|
+
Basic usage to load text from an artifact:
|
|
753
|
+
```python
|
|
754
|
+
text = await context.artifacts().load_text_by_id("artifact_123")
|
|
755
|
+
print(text)
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Loading with custom encoding and error handling:
|
|
759
|
+
```python
|
|
760
|
+
text = await context.artifacts().load_text_by_id(
|
|
761
|
+
"artifact_456",
|
|
762
|
+
encoding="utf-16",
|
|
763
|
+
errors="ignore"
|
|
764
|
+
)
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
artifact_id: The unique string identifier of the artifact to retrieve.
|
|
769
|
+
encoding: The text encoding to use for decoding bytes (default: `"utf-8"`).
|
|
770
|
+
errors: Error handling strategy for decoding (default: `"strict"`).
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
str: The decoded text content of the artifact.
|
|
774
|
+
|
|
775
|
+
Raises:
|
|
776
|
+
FileNotFoundError: If the artifact is not found or missing a URI.
|
|
777
|
+
"""
|
|
778
|
+
data = await self.load_bytes_by_id(artifact_id)
|
|
779
|
+
return data.decode(encoding, errors=errors)
|
|
780
|
+
|
|
781
|
+
async def load_json_by_id(
|
|
782
|
+
self,
|
|
783
|
+
artifact_id: str,
|
|
784
|
+
*,
|
|
785
|
+
encoding: str = "utf-8",
|
|
786
|
+
errors: str = "strict",
|
|
787
|
+
) -> Any:
|
|
788
|
+
"""
|
|
789
|
+
Load and parse a JSON artifact by its unique identifier.
|
|
790
|
+
|
|
791
|
+
This asynchronous method retrieves the raw text content for the specified
|
|
792
|
+
`artifact_id`, decodes it using the provided encoding, and parses it as JSON.
|
|
793
|
+
It is accessed via `context.artifacts().load_json_by_id(...)`.
|
|
794
|
+
|
|
795
|
+
Examples:
|
|
796
|
+
Basic usage to load a JSON artifact:
|
|
797
|
+
```python
|
|
798
|
+
data = await context.artifacts().load_json_by_id("artifact_123")
|
|
799
|
+
print(data["foo"])
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
Loading with custom encoding and error handling:
|
|
803
|
+
```python
|
|
804
|
+
data = await context.artifacts().load_json_by_id(
|
|
805
|
+
"artifact_456",
|
|
806
|
+
encoding="utf-16",
|
|
807
|
+
errors="ignore"
|
|
808
|
+
)
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
artifact_id: The unique string identifier of the artifact to retrieve.
|
|
813
|
+
encoding: The text encoding to use for decoding bytes (default: `"utf-8"`).
|
|
814
|
+
errors: Error handling strategy for decoding (default: `"strict"`).
|
|
815
|
+
|
|
816
|
+
Returns:
|
|
817
|
+
Any: The parsed JSON object from the artifact.
|
|
818
|
+
|
|
819
|
+
Raises:
|
|
820
|
+
FileNotFoundError: If the artifact is not found or missing a URI.
|
|
821
|
+
json.JSONDecodeError: If the artifact content is not valid JSON.
|
|
822
|
+
"""
|
|
823
|
+
text = await self.load_text_by_id(artifact_id, encoding=encoding, errors=errors)
|
|
824
|
+
return json.loads(text)
|
|
825
|
+
|
|
826
|
+
async def as_local_file_by_id(
|
|
827
|
+
self,
|
|
828
|
+
artifact_id: str,
|
|
829
|
+
*,
|
|
830
|
+
must_exist: bool = True,
|
|
831
|
+
) -> str:
|
|
832
|
+
art = await self.get_by_id(artifact_id)
|
|
833
|
+
if art is None or not art.uri:
|
|
834
|
+
raise FileNotFoundError(f"Artifact {artifact_id} not found or missing uri")
|
|
835
|
+
return await self.as_local_file(art, must_exist=must_exist)
|
|
836
|
+
|
|
837
|
+
async def as_local_dir_by_id(
|
|
838
|
+
self,
|
|
839
|
+
artifact_id: str,
|
|
840
|
+
*,
|
|
841
|
+
must_exist: bool = True,
|
|
842
|
+
) -> str:
|
|
843
|
+
art = await self.get_by_id(artifact_id)
|
|
844
|
+
if art is None or not art.uri:
|
|
845
|
+
raise FileNotFoundError(f"Artifact {artifact_id} not found or missing uri")
|
|
846
|
+
return await self.as_local_dir(art, must_exist=must_exist)
|
|
847
|
+
|
|
848
|
+
# ---------- load APIs ----------
|
|
157
849
|
async def load_bytes(self, uri: str) -> bytes:
|
|
850
|
+
"""
|
|
851
|
+
Load raw bytes from a file or URI in a backend-agnostic way.
|
|
852
|
+
|
|
853
|
+
This method retrieves the byte content from the specified `uri`, supporting both
|
|
854
|
+
local files and remote storage backends. It is accessed via `context.artifacts().load_bytes(...)`.
|
|
855
|
+
|
|
856
|
+
Examples:
|
|
857
|
+
Basic usage to load bytes from a local file:
|
|
858
|
+
```python
|
|
859
|
+
data = await context.artifacts().load_bytes("file:///tmp/model.bin")
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
Loading bytes from an S3 URI:
|
|
863
|
+
```python
|
|
864
|
+
data = await context.artifacts().load_bytes("s3://bucket/data.bin")
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
uri: The URI or path of the file to load. Supports local files and remote storage backends.
|
|
869
|
+
|
|
870
|
+
Returns:
|
|
871
|
+
bytes: The raw byte content of the file or artifact.
|
|
872
|
+
"""
|
|
158
873
|
return await self.store.load_bytes(uri)
|
|
159
874
|
|
|
160
|
-
async def load_text(
|
|
161
|
-
|
|
162
|
-
|
|
875
|
+
async def load_text(
|
|
876
|
+
self,
|
|
877
|
+
uri: str,
|
|
878
|
+
*,
|
|
879
|
+
encoding: str = "utf-8",
|
|
880
|
+
errors: str = "strict",
|
|
881
|
+
) -> str:
|
|
882
|
+
"""
|
|
883
|
+
Load the text content from a file or URI in a backend-agnostic way.
|
|
884
|
+
|
|
885
|
+
This method retrieves the raw bytes from the specified `uri`, decodes them into a string
|
|
886
|
+
using the provided encoding, and returns the text. It is accessed via `context.artifacts().load_text(...)`.
|
|
887
|
+
|
|
888
|
+
Examples:
|
|
889
|
+
Basic usage to load text from a local file:
|
|
890
|
+
```python
|
|
891
|
+
text = await context.artifacts().load_text("file:///tmp/output.txt")
|
|
892
|
+
print(text)
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
Loading text from an S3 URI with custom encoding:
|
|
896
|
+
```python
|
|
897
|
+
text = await context.artifacts().load_text(
|
|
898
|
+
"s3://bucket/data.txt",
|
|
899
|
+
encoding="utf-16"
|
|
900
|
+
)
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
Args:
|
|
904
|
+
uri: The URI or path of the file to load. Supports local files and remote storage backends.
|
|
905
|
+
encoding: The text encoding to use for decoding bytes (default: `"utf-8"`).
|
|
906
|
+
errors: Error handling strategy for decoding (default: `"strict"`).
|
|
907
|
+
|
|
908
|
+
Returns:
|
|
909
|
+
str: The decoded text content of the file or artifact.
|
|
910
|
+
"""
|
|
911
|
+
return await self.store.load_text(uri, encoding=encoding, errors=errors)
|
|
912
|
+
|
|
913
|
+
async def load_json(
|
|
914
|
+
self,
|
|
915
|
+
uri: str,
|
|
916
|
+
*,
|
|
917
|
+
encoding: str = "utf-8",
|
|
918
|
+
errors: str = "strict",
|
|
919
|
+
) -> Any:
|
|
920
|
+
"""
|
|
921
|
+
Load and parse a JSON file from the specified URI.
|
|
922
|
+
|
|
923
|
+
This asynchronous method retrieves the file contents as text, then parses
|
|
924
|
+
the text into a Python object using the standard `json` library. It is
|
|
925
|
+
typically accessed via `context.artifacts().load_json(...)`.
|
|
926
|
+
|
|
927
|
+
Examples:
|
|
928
|
+
Basic usage to load a JSON file:
|
|
929
|
+
```python
|
|
930
|
+
data = await context.artifacts().load_json("file:///path/to/data.json")
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
Specifying a custom encoding:
|
|
934
|
+
```python
|
|
935
|
+
data = await context.artifacts().load_json(
|
|
936
|
+
"file:///path/to/data.json",
|
|
937
|
+
encoding="utf-16"
|
|
938
|
+
)
|
|
939
|
+
```
|
|
163
940
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
941
|
+
Args:
|
|
942
|
+
uri: The URI of the JSON file to load. Supports local and remote paths.
|
|
943
|
+
encoding: The text encoding to use when reading the file (default: "utf-8").
|
|
944
|
+
errors: The error handling scheme for decoding (default: "strict").
|
|
945
|
+
|
|
946
|
+
Returns:
|
|
947
|
+
Any: The parsed Python object loaded from the JSON file.
|
|
948
|
+
"""
|
|
949
|
+
text = await self.load_text(uri, encoding=encoding, errors=errors)
|
|
950
|
+
return json.loads(text)
|
|
167
951
|
|
|
168
952
|
async def load_artifact(self, uri: str) -> Any:
|
|
953
|
+
"""Compatibility helper: returns bytes or directory path depending on implementation."""
|
|
169
954
|
return await self.store.load_artifact(uri)
|
|
170
955
|
|
|
171
956
|
async def load_artifact_bytes(self, uri: str) -> bytes:
|
|
172
957
|
return await self.store.load_artifact_bytes(uri)
|
|
173
958
|
|
|
174
|
-
|
|
175
|
-
async def list(self, *, scope: Scope = "run") -> builtins.list[Artifact]:
|
|
959
|
+
async def load_artifact_dir(self, uri: str) -> str:
|
|
176
960
|
"""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
- "graph": filter by (run_id, graph_id)
|
|
181
|
-
- "run": filter by (run_id) [default]
|
|
182
|
-
- "all": no implicit filters (dangerous; use sparingly)
|
|
961
|
+
Backend-agnostic: ensure a directory artifact is available as a local dir path.
|
|
962
|
+
|
|
963
|
+
FS backend can just return its CAS dir; S3 backend might download to a temp dir.
|
|
183
964
|
"""
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
965
|
+
return await self.store.load_artifact_dir(uri)
|
|
966
|
+
|
|
967
|
+
# ---------- as local helpers ----------
|
|
968
|
+
async def as_local_dir(
|
|
969
|
+
self,
|
|
970
|
+
artifact_or_uri: str | Path | Artifact,
|
|
971
|
+
*,
|
|
972
|
+
must_exist: bool = True,
|
|
973
|
+
) -> str:
|
|
974
|
+
"""
|
|
975
|
+
Ensure an artifact representing a directory is available as a local path.
|
|
976
|
+
|
|
977
|
+
This method provides a backend-agnostic way to access directory artifacts as local filesystem paths.
|
|
978
|
+
For local filesystems, it returns the underlying CAS directory. For remote backends (e.g., S3),
|
|
979
|
+
it downloads the directory contents to a staging location and returns the path.
|
|
980
|
+
|
|
981
|
+
Examples:
|
|
982
|
+
Basic usage to access a local directory artifact:
|
|
983
|
+
```python
|
|
984
|
+
local_dir = await context.artifacts().as_local_dir("file:///tmp/output_dir")
|
|
985
|
+
print(local_dir)
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
Handling missing directories:
|
|
989
|
+
```python
|
|
990
|
+
try:
|
|
991
|
+
local_dir = await context.artifacts().as_local_dir("s3://bucket/data_dir")
|
|
992
|
+
except FileNotFoundError:
|
|
993
|
+
print("Directory not found.")
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
Args:
|
|
997
|
+
artifact_or_uri: The artifact object, URI string, or Path representing the directory.
|
|
998
|
+
must_exist: If True, raises FileNotFoundError if the local path does not exist.
|
|
999
|
+
|
|
1000
|
+
Returns:
|
|
1001
|
+
str: The resolved local filesystem path to the directory artifact.
|
|
1002
|
+
|
|
1003
|
+
Raises:
|
|
1004
|
+
FileNotFoundError: If the resolved local directory does not exist and `must_exist` is True.
|
|
1005
|
+
"""
|
|
1006
|
+
uri = artifact_or_uri.uri if isinstance(artifact_or_uri, Artifact) else str(artifact_or_uri)
|
|
1007
|
+
path = await self.store.load_artifact_dir(uri)
|
|
1008
|
+
if must_exist and not Path(path).exists():
|
|
1009
|
+
raise FileNotFoundError(f"Local path for artifact dir not found: {path}")
|
|
1010
|
+
return str(Path(path).resolve())
|
|
1011
|
+
|
|
1012
|
+
async def as_local_file(
|
|
1013
|
+
self,
|
|
1014
|
+
artifact_or_uri: str | Path | Artifact,
|
|
1015
|
+
*,
|
|
1016
|
+
must_exist: bool = True,
|
|
1017
|
+
) -> str:
|
|
1018
|
+
"""
|
|
1019
|
+
This method transparently handles local and remote artifact URIs, downloading remote files
|
|
1020
|
+
to a staging location if necessary. It is typically accessed via `context.artifacts().as_local_file(...)`.
|
|
1021
|
+
|
|
1022
|
+
Examples:
|
|
1023
|
+
Using a local file path:
|
|
1024
|
+
```python
|
|
1025
|
+
local_path = await context.artifacts().as_local_file("/tmp/data.csv")
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
Using an S3 URI:
|
|
1029
|
+
```python
|
|
1030
|
+
local_path = await context.artifacts().as_local_file("s3://bucket/key.csv")
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
Using an Artifact object:
|
|
1034
|
+
```python
|
|
1035
|
+
local_path = await context.artifacts().as_local_file(artifact)
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
artifact_or_uri: The artifact to resolve, which may be a string URI, Path, or Artifact object.
|
|
1040
|
+
must_exist: If True, raises FileNotFoundError if the file does not exist or is not a file.
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
str: The absolute path to the local file containing the artifact's data.
|
|
1044
|
+
"""
|
|
1045
|
+
uri = artifact_or_uri.uri if isinstance(artifact_or_uri, Artifact) else str(artifact_or_uri)
|
|
1046
|
+
u = urlparse(uri)
|
|
1047
|
+
|
|
1048
|
+
# local fs
|
|
1049
|
+
if not u.scheme or u.scheme.lower() == "file":
|
|
1050
|
+
path = _from_uri_or_path(uri).resolve()
|
|
1051
|
+
if must_exist and not Path(path).exists():
|
|
1052
|
+
raise FileNotFoundError(f"Local path for artifact file not found: {path}")
|
|
1053
|
+
if must_exist and not Path(path).is_file():
|
|
1054
|
+
raise FileNotFoundError(f"Local path for artifact file is not a file: {path}")
|
|
1055
|
+
return path
|
|
1056
|
+
|
|
1057
|
+
# Non-FS backend: download to staging
|
|
1058
|
+
data = await self.store.load_artifact_bytes(uri)
|
|
1059
|
+
staged = await self.store.plan_staging_path(".bin")
|
|
1060
|
+
|
|
1061
|
+
def _write():
|
|
1062
|
+
p = Path(staged)
|
|
1063
|
+
p.write_bytes(data)
|
|
1064
|
+
return str(p.resolve())
|
|
1065
|
+
|
|
1066
|
+
path = await asyncio.to_thread(_write)
|
|
1067
|
+
return path
|
|
1068
|
+
|
|
1069
|
+
# ---------- indexing helpers ----------
|
|
1070
|
+
async def list(self, *, view: ArtifactView = "run") -> list[Artifact]:
|
|
1071
|
+
"""
|
|
1072
|
+
List artifacts scoped to the current run, graph, or node.
|
|
1073
|
+
|
|
1074
|
+
This method provides a quick way to enumerate artifacts associated with the current
|
|
1075
|
+
execution context. The `view` parameter controls the scope of the listing:
|
|
1076
|
+
|
|
1077
|
+
- `"node"`: artifacts for the current run, graph, and node
|
|
1078
|
+
- `"graph"`: artifacts for the current run and graph
|
|
1079
|
+
- `"run"`: artifacts for the current run (default)
|
|
1080
|
+
- `"all"`: all artifacts (tenant-scoped if applicable)
|
|
1081
|
+
|
|
1082
|
+
Examples:
|
|
1083
|
+
List all artifacts for the current run:
|
|
1084
|
+
```python
|
|
1085
|
+
artifacts = await context.artifacts().list()
|
|
1086
|
+
for a in artifacts:
|
|
1087
|
+
print(a.artifact_id, a.kind)
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
List artifacts for the current node:
|
|
1091
|
+
```python
|
|
1092
|
+
node_artifacts = await context.artifacts().list(view="node")
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
List all tenant-visible artifacts:
|
|
1096
|
+
```python
|
|
1097
|
+
all_artifacts = await context.artifacts().list(view="all")
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
Args:
|
|
1101
|
+
view: The scope for listing artifacts. Must be one of:
|
|
1102
|
+
`"node"`, `"graph"`, `"run"`, or `"all"`.
|
|
1103
|
+
|
|
1104
|
+
Returns:
|
|
1105
|
+
list[Artifact]: A list of `Artifact` objects matching the specified scope.
|
|
1106
|
+
"""
|
|
1107
|
+
if view == "all":
|
|
1108
|
+
# still tenant-scoped
|
|
1109
|
+
labels = self._tenant_labels_for_search()
|
|
1110
|
+
return await self.index.search(labels=labels or None)
|
|
1111
|
+
labels = self._view_labels(view)
|
|
1112
|
+
return await self.index.search(labels=labels or None)
|
|
197
1113
|
|
|
198
1114
|
async def search(
|
|
199
1115
|
self,
|
|
200
1116
|
*,
|
|
201
1117
|
kind: str | None = None,
|
|
202
|
-
labels: dict[str,
|
|
1118
|
+
labels: dict[str, str] | None = None,
|
|
203
1119
|
metric: str | None = None,
|
|
204
1120
|
mode: Literal["max", "min"] | None = None,
|
|
205
|
-
|
|
206
|
-
extra_scope_labels: dict[str,
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
1121
|
+
view: ArtifactView = "run",
|
|
1122
|
+
extra_scope_labels: dict[str, str] | None = None,
|
|
1123
|
+
limit: int | None = None,
|
|
1124
|
+
) -> list[Artifact]:
|
|
1125
|
+
"""
|
|
1126
|
+
Search for artifacts with flexible scoping and filtering.
|
|
1127
|
+
|
|
1128
|
+
This method allows you to query artifacts by type, labels, metrics, and other
|
|
1129
|
+
criteria. It automatically applies view-based scoping and merges any additional
|
|
1130
|
+
scope labels provided. The search is dispatched to the underlying index.
|
|
1131
|
+
|
|
1132
|
+
Examples:
|
|
1133
|
+
Basic usage to find all artifacts of a given kind:
|
|
1134
|
+
```python
|
|
1135
|
+
results = await context.artifacts().search(kind="model")
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
Searching with specific labels and metric optimization:
|
|
1139
|
+
```python
|
|
1140
|
+
results = await context.artifacts().search(
|
|
1141
|
+
kind="dataset",
|
|
1142
|
+
labels={"domain": "finance"},
|
|
1143
|
+
metric="accuracy",
|
|
1144
|
+
mode="max",
|
|
1145
|
+
limit=10,
|
|
1146
|
+
)
|
|
1147
|
+
```
|
|
1148
|
+
Extending scope with extra labels:
|
|
1149
|
+
```python
|
|
1150
|
+
results = await context.artifacts().search(
|
|
1151
|
+
extra_scope_labels={"project": "alpha"}
|
|
1152
|
+
)
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
Args:
|
|
1156
|
+
kind: The type of artifact to search for (e.g., "model", "dataset").
|
|
1157
|
+
labels: Dictionary of label key-value pairs to filter artifacts.
|
|
1158
|
+
metric: Name of a metric to optimize (e.g., "accuracy").
|
|
1159
|
+
mode: Optimization mode for the metric, either "max" or "min".
|
|
1160
|
+
view: The artifact view context, which determines default scoping.
|
|
1161
|
+
extra_scope_labels: Additional labels to further scope the search.
|
|
1162
|
+
limit: Maximum number of results to return.
|
|
1163
|
+
|
|
1164
|
+
Returns:
|
|
1165
|
+
list[Artifact]: A list of matching `Artifact` objects.
|
|
1166
|
+
|
|
1167
|
+
Notes:
|
|
1168
|
+
- The `view` parameter controls the base scoping of the search. Additional labels provided
|
|
1169
|
+
in `extra_scope_labels` are merged on top of the view-based labels.
|
|
1170
|
+
- If both `labels` and `extra_scope_labels` are provided, they are combined for filtering.
|
|
1171
|
+
|
|
1172
|
+
"""
|
|
1173
|
+
|
|
1174
|
+
eff_labels: dict[str, str] = dict(labels or {})
|
|
1175
|
+
eff_labels.update(self._view_labels(view))
|
|
212
1176
|
if extra_scope_labels:
|
|
213
1177
|
eff_labels.update(extra_scope_labels)
|
|
214
|
-
|
|
215
|
-
return await self.index.search(
|
|
1178
|
+
|
|
1179
|
+
return await self.index.search(
|
|
1180
|
+
kind=kind,
|
|
1181
|
+
labels=eff_labels or None,
|
|
1182
|
+
metric=metric,
|
|
1183
|
+
mode=mode,
|
|
1184
|
+
limit=limit,
|
|
1185
|
+
)
|
|
216
1186
|
|
|
217
1187
|
async def best(
|
|
218
1188
|
self,
|
|
@@ -220,20 +1190,102 @@ class ArtifactFacade:
|
|
|
220
1190
|
kind: str,
|
|
221
1191
|
metric: str,
|
|
222
1192
|
mode: Literal["max", "min"],
|
|
223
|
-
|
|
224
|
-
filters: dict[str,
|
|
1193
|
+
view: ArtifactView = "run",
|
|
1194
|
+
filters: dict[str, str] | None = None,
|
|
225
1195
|
) -> Artifact | None:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
1196
|
+
"""
|
|
1197
|
+
Retrieve the best artifact by optimizing a specified metric.
|
|
1198
|
+
|
|
1199
|
+
This method searches for artifacts of a given kind and returns the one that
|
|
1200
|
+
maximizes or minimizes the specified metric, scoped by the provided view and filters.
|
|
1201
|
+
It is accessed via `context.artifacts().best(...)`.
|
|
1202
|
+
|
|
1203
|
+
Examples:
|
|
1204
|
+
Find the best model by accuracy for the current run:
|
|
1205
|
+
```python
|
|
1206
|
+
best_model = await context.artifacts().best(
|
|
1207
|
+
kind="model",
|
|
1208
|
+
metric="accuracy",
|
|
1209
|
+
mode="max"
|
|
1210
|
+
)
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
Find the lowest-loss dataset for the current graph:
|
|
1214
|
+
```python
|
|
1215
|
+
best_dataset = await context.artifacts().best(
|
|
1216
|
+
kind="dataset",
|
|
1217
|
+
metric="loss",
|
|
1218
|
+
mode="min",
|
|
1219
|
+
view="graph"
|
|
1220
|
+
)
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
Apply additional label filters:
|
|
1224
|
+
```python
|
|
1225
|
+
best_artifact = await context.artifacts().best(
|
|
1226
|
+
kind="model",
|
|
1227
|
+
metric="f1_score",
|
|
1228
|
+
mode="max",
|
|
1229
|
+
filters={"domain": "finance"}
|
|
1230
|
+
)
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
Args:
|
|
1234
|
+
kind: The type of artifact to search for (e.g., "model", "dataset").
|
|
1235
|
+
metric: The metric name to optimize (e.g., "accuracy", "loss").
|
|
1236
|
+
mode: Optimization mode, either `"max"` for highest or `"min"` for lowest value.
|
|
1237
|
+
view: The artifact view context, which determines default scoping.
|
|
1238
|
+
Must be one of `"node"`, `"graph"`, `"run"`, or `"all"`.
|
|
1239
|
+
filters: Additional label filters to further restrict the search.
|
|
1240
|
+
|
|
1241
|
+
Returns:
|
|
1242
|
+
Artifact | None: The best matching `Artifact` object, or `None` if no match is found.
|
|
1243
|
+
"""
|
|
1244
|
+
eff_filters: dict[str, str] = dict(filters or {})
|
|
1245
|
+
eff_filters.update(self._view_labels(view))
|
|
1246
|
+
|
|
229
1247
|
return await self.index.best(
|
|
230
|
-
kind=kind,
|
|
1248
|
+
kind=kind,
|
|
1249
|
+
metric=metric,
|
|
1250
|
+
mode=mode,
|
|
1251
|
+
filters=eff_filters or None,
|
|
231
1252
|
)
|
|
232
1253
|
|
|
233
1254
|
async def pin(self, artifact_id: str, pinned: bool = True) -> None:
|
|
234
|
-
|
|
1255
|
+
"""
|
|
1256
|
+
Mark or unmark an artifact as pinned for retention.
|
|
1257
|
+
|
|
1258
|
+
This asynchronous method updates the `pinned` status of the specified artifact
|
|
1259
|
+
in the artifact index. Pinning an artifact ensures it is retained and not subject
|
|
1260
|
+
to automatic cleanup. It is accessed via `context.artifacts().pin(...)`.
|
|
1261
|
+
|
|
1262
|
+
Examples:
|
|
1263
|
+
Pin an artifact for retention:
|
|
1264
|
+
```python
|
|
1265
|
+
await context.artifacts().pin("artifact_123", pinned=True)
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
Unpin an artifact to allow cleanup:
|
|
1269
|
+
```python
|
|
1270
|
+
await context.artifacts().pin("artifact_456", pinned=False)
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
Args:
|
|
1274
|
+
artifact_id: The unique string identifier of the artifact to update.
|
|
1275
|
+
pinned: Boolean indicating whether to pin (`True`) or unpin (`False`) the artifact.
|
|
1276
|
+
|
|
1277
|
+
Returns:
|
|
1278
|
+
None
|
|
1279
|
+
"""
|
|
1280
|
+
await self.index.pin(artifact_id, pinned=pinned)
|
|
1281
|
+
|
|
1282
|
+
# ---------- internal helpers ----------
|
|
1283
|
+
async def _record_simple(self, a: Artifact) -> None:
|
|
1284
|
+
"""Record artifact in index and occurrence log."""
|
|
1285
|
+
await self.index.upsert(a)
|
|
1286
|
+
await self.index.record_occurrence(a)
|
|
1287
|
+
self.last_artifact = a
|
|
235
1288
|
|
|
236
|
-
# -------- internal helpers --------
|
|
237
1289
|
def _scope_labels(self, scope: Scope) -> dict[str, Any]:
|
|
238
1290
|
if scope == "node":
|
|
239
1291
|
return {"run_id": self.run_id, "graph_id": self.graph_id, "node_id": self.node_id}
|
|
@@ -243,41 +1295,96 @@ class ArtifactFacade:
|
|
|
243
1295
|
return {"run_id": self.run_id}
|
|
244
1296
|
return {} # "all"
|
|
245
1297
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
1298
|
+
# ---------- deprecated / compatibility ----------
|
|
1299
|
+
async def stage(self, ext: str = "") -> str:
|
|
1300
|
+
"""DEPRECATED: use stage_path()."""
|
|
1301
|
+
return await self.stage_path(ext=ext)
|
|
249
1302
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
1303
|
+
async def ingest(
|
|
1304
|
+
self,
|
|
1305
|
+
staged_path: str,
|
|
1306
|
+
*,
|
|
1307
|
+
kind: str,
|
|
1308
|
+
labels=None,
|
|
1309
|
+
metrics=None,
|
|
1310
|
+
suggested_uri: str | None = None,
|
|
1311
|
+
pin: bool = False,
|
|
1312
|
+
): # DEPRECATED: use ingest_file()
|
|
1313
|
+
return await self.ingest_file(
|
|
1314
|
+
staged_path,
|
|
1315
|
+
kind=kind,
|
|
1316
|
+
labels=labels,
|
|
1317
|
+
metrics=metrics,
|
|
1318
|
+
suggested_uri=suggested_uri,
|
|
1319
|
+
pin=pin,
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
async def save(
|
|
1323
|
+
self,
|
|
1324
|
+
path: str,
|
|
1325
|
+
*,
|
|
1326
|
+
kind: str,
|
|
1327
|
+
labels=None,
|
|
1328
|
+
metrics=None,
|
|
1329
|
+
suggested_uri: str | None = None,
|
|
1330
|
+
pin: bool = False,
|
|
1331
|
+
): # DEPRECATED: use save_file()
|
|
1332
|
+
return await self.save_file(
|
|
1333
|
+
path,
|
|
1334
|
+
kind=kind,
|
|
1335
|
+
labels=labels,
|
|
1336
|
+
metrics=metrics,
|
|
1337
|
+
suggested_uri=suggested_uri,
|
|
1338
|
+
pin=pin,
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
async def tmp_path(self, suffix: str = "") -> str: # DEPRECATED: use stage_path()
|
|
1342
|
+
return await self.stage_path(suffix)
|
|
1343
|
+
|
|
1344
|
+
# FS-only, legacy helpers — prefer as_local_dir/as_local_file for new code
|
|
1345
|
+
def to_local_path(
|
|
1346
|
+
self,
|
|
1347
|
+
uri_or_path: str | Path | Artifact,
|
|
1348
|
+
*,
|
|
1349
|
+
must_exist: bool = True,
|
|
1350
|
+
) -> str:
|
|
256
1351
|
"""
|
|
257
|
-
|
|
1352
|
+
DEPRECATED (FS-only):
|
|
258
1353
|
|
|
1354
|
+
This assumes file:// or plain local paths; will not work correctly with s3://.
|
|
1355
|
+
Use `await as_local_dir(...)` or `await as_local_file(...)` instead.
|
|
1356
|
+
"""
|
|
1357
|
+
s = uri_or_path.uri if isinstance(uri_or_path, Artifact) else str(uri_or_path)
|
|
259
1358
|
p = _from_uri_or_path(s).resolve()
|
|
260
1359
|
|
|
261
|
-
# If not a file:// (e.g., s3://, http://), _from_uri_or_path returns Path(s);
|
|
262
|
-
# detect that and either pass through or raise for clarity.
|
|
263
1360
|
u = urlparse(s)
|
|
264
1361
|
if "://" in s and (u.scheme or "").lower() != "file":
|
|
265
|
-
#
|
|
266
|
-
return s
|
|
1362
|
+
# Non-FS backend – just return the URI string
|
|
1363
|
+
return s
|
|
267
1364
|
|
|
268
1365
|
if must_exist and not p.exists():
|
|
269
1366
|
raise FileNotFoundError(f"Local path not found: {p}")
|
|
270
1367
|
return str(p)
|
|
271
1368
|
|
|
272
|
-
def to_local_file(
|
|
273
|
-
|
|
1369
|
+
def to_local_file(
|
|
1370
|
+
self,
|
|
1371
|
+
uri_or_path: str | Path | Artifact,
|
|
1372
|
+
*,
|
|
1373
|
+
must_exist: bool = True,
|
|
1374
|
+
) -> str:
|
|
1375
|
+
"""DEPRECATED: FS-only; use `await as_local_file(...)` instead."""
|
|
274
1376
|
p = Path(self.to_local_path(uri_or_path, must_exist=must_exist))
|
|
275
1377
|
if must_exist and not p.is_file():
|
|
276
1378
|
raise IsADirectoryError(f"Expected file, got directory: {p}")
|
|
277
1379
|
return str(p)
|
|
278
1380
|
|
|
279
|
-
def to_local_dir(
|
|
280
|
-
|
|
1381
|
+
def to_local_dir(
|
|
1382
|
+
self,
|
|
1383
|
+
uri_or_path: str | Path | Artifact,
|
|
1384
|
+
*,
|
|
1385
|
+
must_exist: bool = True,
|
|
1386
|
+
) -> str:
|
|
1387
|
+
"""DEPRECATED: FS-only; use `await as_local_dir(...)` instead."""
|
|
281
1388
|
p = Path(self.to_local_path(uri_or_path, must_exist=must_exist))
|
|
282
1389
|
if must_exist and not p.is_dir():
|
|
283
1390
|
raise NotADirectoryError(f"Expected directory, got file: {p}")
|