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.
Files changed (267) hide show
  1. aethergraph/__init__.py +4 -10
  2. aethergraph/__main__.py +293 -0
  3. aethergraph/api/v1/__init__.py +0 -0
  4. aethergraph/api/v1/agents.py +46 -0
  5. aethergraph/api/v1/apps.py +70 -0
  6. aethergraph/api/v1/artifacts.py +415 -0
  7. aethergraph/api/v1/channels.py +89 -0
  8. aethergraph/api/v1/deps.py +168 -0
  9. aethergraph/api/v1/graphs.py +259 -0
  10. aethergraph/api/v1/identity.py +25 -0
  11. aethergraph/api/v1/memory.py +353 -0
  12. aethergraph/api/v1/misc.py +47 -0
  13. aethergraph/api/v1/pagination.py +29 -0
  14. aethergraph/api/v1/runs.py +568 -0
  15. aethergraph/api/v1/schemas.py +535 -0
  16. aethergraph/api/v1/session.py +323 -0
  17. aethergraph/api/v1/stats.py +201 -0
  18. aethergraph/api/v1/viz.py +152 -0
  19. aethergraph/config/config.py +22 -0
  20. aethergraph/config/loader.py +3 -2
  21. aethergraph/config/storage.py +209 -0
  22. aethergraph/contracts/__init__.py +0 -0
  23. aethergraph/contracts/services/__init__.py +0 -0
  24. aethergraph/contracts/services/artifacts.py +27 -14
  25. aethergraph/contracts/services/memory.py +45 -17
  26. aethergraph/contracts/services/metering.py +129 -0
  27. aethergraph/contracts/services/runs.py +50 -0
  28. aethergraph/contracts/services/sessions.py +87 -0
  29. aethergraph/contracts/services/state_stores.py +3 -0
  30. aethergraph/contracts/services/viz.py +44 -0
  31. aethergraph/contracts/storage/artifact_index.py +88 -0
  32. aethergraph/contracts/storage/artifact_store.py +99 -0
  33. aethergraph/contracts/storage/async_kv.py +34 -0
  34. aethergraph/contracts/storage/blob_store.py +50 -0
  35. aethergraph/contracts/storage/doc_store.py +35 -0
  36. aethergraph/contracts/storage/event_log.py +31 -0
  37. aethergraph/contracts/storage/vector_index.py +48 -0
  38. aethergraph/core/__init__.py +0 -0
  39. aethergraph/core/execution/forward_scheduler.py +13 -2
  40. aethergraph/core/execution/global_scheduler.py +21 -15
  41. aethergraph/core/execution/step_forward.py +10 -1
  42. aethergraph/core/graph/__init__.py +0 -0
  43. aethergraph/core/graph/graph_builder.py +8 -4
  44. aethergraph/core/graph/graph_fn.py +156 -15
  45. aethergraph/core/graph/graph_spec.py +8 -0
  46. aethergraph/core/graph/graphify.py +146 -27
  47. aethergraph/core/graph/node_spec.py +0 -2
  48. aethergraph/core/graph/node_state.py +3 -0
  49. aethergraph/core/graph/task_graph.py +39 -1
  50. aethergraph/core/runtime/__init__.py +0 -0
  51. aethergraph/core/runtime/ad_hoc_context.py +64 -4
  52. aethergraph/core/runtime/base_service.py +28 -4
  53. aethergraph/core/runtime/execution_context.py +13 -15
  54. aethergraph/core/runtime/graph_runner.py +222 -37
  55. aethergraph/core/runtime/node_context.py +510 -6
  56. aethergraph/core/runtime/node_services.py +12 -5
  57. aethergraph/core/runtime/recovery.py +15 -1
  58. aethergraph/core/runtime/run_manager.py +783 -0
  59. aethergraph/core/runtime/run_manager_local.py +204 -0
  60. aethergraph/core/runtime/run_registration.py +2 -2
  61. aethergraph/core/runtime/run_types.py +89 -0
  62. aethergraph/core/runtime/runtime_env.py +136 -7
  63. aethergraph/core/runtime/runtime_metering.py +71 -0
  64. aethergraph/core/runtime/runtime_registry.py +36 -13
  65. aethergraph/core/runtime/runtime_services.py +194 -6
  66. aethergraph/core/tools/builtins/toolset.py +1 -1
  67. aethergraph/core/tools/toolkit.py +5 -0
  68. aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
  69. aethergraph/plugins/agents/default_chat_agent.py +171 -0
  70. aethergraph/plugins/agents/shared.py +81 -0
  71. aethergraph/plugins/channel/adapters/webui.py +112 -112
  72. aethergraph/plugins/channel/routes/webui_routes.py +367 -102
  73. aethergraph/plugins/channel/utils/slack_utils.py +115 -59
  74. aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
  75. aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
  76. aethergraph/runtime/__init__.py +15 -0
  77. aethergraph/server/app_factory.py +190 -34
  78. aethergraph/server/clients/channel_client.py +202 -0
  79. aethergraph/server/http/channel_http_routes.py +116 -0
  80. aethergraph/server/http/channel_ws_routers.py +45 -0
  81. aethergraph/server/loading.py +117 -0
  82. aethergraph/server/server.py +131 -0
  83. aethergraph/server/server_state.py +240 -0
  84. aethergraph/server/start.py +227 -66
  85. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  86. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  87. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  88. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  89. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  90. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  91. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  92. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  93. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  94. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  95. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  96. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  97. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  98. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  99. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  100. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  101. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  102. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  103. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  104. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  105. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  106. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  107. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  108. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  109. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  110. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  111. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  112. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  113. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  114. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  115. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  116. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  117. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  118. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  119. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  120. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  121. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  122. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  123. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  124. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  125. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  126. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  127. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  128. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  129. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  130. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  131. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  132. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  133. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  134. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  135. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  136. aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  137. aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  138. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  139. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  140. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  141. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  142. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  143. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  144. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
  145. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
  146. aethergraph/server/ui_static/index.html +15 -0
  147. aethergraph/server/ui_static/logo.png +0 -0
  148. aethergraph/services/artifacts/__init__.py +0 -0
  149. aethergraph/services/artifacts/facade.py +1239 -132
  150. aethergraph/services/auth/{dev.py → authn.py} +0 -8
  151. aethergraph/services/auth/authz.py +100 -0
  152. aethergraph/services/channel/__init__.py +0 -0
  153. aethergraph/services/channel/channel_bus.py +19 -1
  154. aethergraph/services/channel/factory.py +13 -1
  155. aethergraph/services/channel/ingress.py +311 -0
  156. aethergraph/services/channel/queue_adapter.py +75 -0
  157. aethergraph/services/channel/session.py +502 -19
  158. aethergraph/services/container/default_container.py +122 -43
  159. aethergraph/services/continuations/continuation.py +6 -0
  160. aethergraph/services/continuations/stores/fs_store.py +19 -0
  161. aethergraph/services/eventhub/event_hub.py +76 -0
  162. aethergraph/services/kv/__init__.py +0 -0
  163. aethergraph/services/kv/ephemeral.py +244 -0
  164. aethergraph/services/llm/__init__.py +0 -0
  165. aethergraph/services/llm/generic_client copy.py +691 -0
  166. aethergraph/services/llm/generic_client.py +1288 -187
  167. aethergraph/services/llm/providers.py +3 -1
  168. aethergraph/services/llm/types.py +47 -0
  169. aethergraph/services/llm/utils.py +284 -0
  170. aethergraph/services/logger/std.py +3 -0
  171. aethergraph/services/mcp/__init__.py +9 -0
  172. aethergraph/services/mcp/http_client.py +38 -0
  173. aethergraph/services/mcp/service.py +225 -1
  174. aethergraph/services/mcp/stdio_client.py +41 -6
  175. aethergraph/services/mcp/ws_client.py +44 -2
  176. aethergraph/services/memory/__init__.py +0 -0
  177. aethergraph/services/memory/distillers/llm_long_term.py +234 -0
  178. aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
  179. aethergraph/services/memory/distillers/long_term.py +225 -0
  180. aethergraph/services/memory/facade/__init__.py +3 -0
  181. aethergraph/services/memory/facade/chat.py +440 -0
  182. aethergraph/services/memory/facade/core.py +447 -0
  183. aethergraph/services/memory/facade/distillation.py +424 -0
  184. aethergraph/services/memory/facade/rag.py +410 -0
  185. aethergraph/services/memory/facade/results.py +315 -0
  186. aethergraph/services/memory/facade/retrieval.py +139 -0
  187. aethergraph/services/memory/facade/types.py +77 -0
  188. aethergraph/services/memory/facade/utils.py +43 -0
  189. aethergraph/services/memory/facade_dep.py +1539 -0
  190. aethergraph/services/memory/factory.py +9 -3
  191. aethergraph/services/memory/utils.py +10 -0
  192. aethergraph/services/metering/eventlog_metering.py +470 -0
  193. aethergraph/services/metering/noop.py +25 -4
  194. aethergraph/services/rag/__init__.py +0 -0
  195. aethergraph/services/rag/facade.py +279 -23
  196. aethergraph/services/rag/index_factory.py +2 -2
  197. aethergraph/services/rag/node_rag.py +317 -0
  198. aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
  199. aethergraph/services/registry/__init__.py +0 -0
  200. aethergraph/services/registry/agent_app_meta.py +419 -0
  201. aethergraph/services/registry/registry_key.py +1 -1
  202. aethergraph/services/registry/unified_registry.py +74 -6
  203. aethergraph/services/scope/scope.py +159 -0
  204. aethergraph/services/scope/scope_factory.py +164 -0
  205. aethergraph/services/state_stores/serialize.py +5 -0
  206. aethergraph/services/state_stores/utils.py +2 -1
  207. aethergraph/services/viz/__init__.py +0 -0
  208. aethergraph/services/viz/facade.py +413 -0
  209. aethergraph/services/viz/viz_service.py +69 -0
  210. aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
  211. aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
  212. aethergraph/storage/artifacts/cas_store.py +422 -0
  213. aethergraph/storage/artifacts/fs_cas.py +18 -0
  214. aethergraph/storage/artifacts/s3_cas.py +14 -0
  215. aethergraph/storage/artifacts/utils.py +124 -0
  216. aethergraph/storage/blob/fs_blob.py +86 -0
  217. aethergraph/storage/blob/s3_blob.py +115 -0
  218. aethergraph/storage/continuation_store/fs_cont.py +283 -0
  219. aethergraph/storage/continuation_store/inmem_cont.py +146 -0
  220. aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
  221. aethergraph/storage/docstore/fs_doc.py +63 -0
  222. aethergraph/storage/docstore/sqlite_doc.py +31 -0
  223. aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
  224. aethergraph/storage/eventlog/fs_event.py +136 -0
  225. aethergraph/storage/eventlog/sqlite_event.py +47 -0
  226. aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
  227. aethergraph/storage/factory.py +432 -0
  228. aethergraph/storage/fs_utils.py +28 -0
  229. aethergraph/storage/graph_state_store/state_store.py +64 -0
  230. aethergraph/storage/kv/inmem_kv.py +103 -0
  231. aethergraph/storage/kv/layered_kv.py +52 -0
  232. aethergraph/storage/kv/sqlite_kv.py +39 -0
  233. aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
  234. aethergraph/storage/memory/event_persist.py +68 -0
  235. aethergraph/storage/memory/fs_persist.py +118 -0
  236. aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
  237. aethergraph/{services → storage}/memory/indices.py +31 -7
  238. aethergraph/storage/metering/meter_event.py +55 -0
  239. aethergraph/storage/runs/doc_store.py +280 -0
  240. aethergraph/storage/runs/inmen_store.py +82 -0
  241. aethergraph/storage/runs/sqlite_run_store.py +403 -0
  242. aethergraph/storage/sessions/doc_store.py +183 -0
  243. aethergraph/storage/sessions/inmem_store.py +110 -0
  244. aethergraph/storage/sessions/sqlite_session_store.py +399 -0
  245. aethergraph/storage/vector_index/chroma_index.py +138 -0
  246. aethergraph/storage/vector_index/faiss_index.py +179 -0
  247. aethergraph/storage/vector_index/sqlite_index.py +187 -0
  248. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/METADATA +138 -31
  249. aethergraph-0.1.0a2.dist-info/RECORD +356 -0
  250. aethergraph-0.1.0a2.dist-info/entry_points.txt +3 -0
  251. aethergraph/services/artifacts/factory.py +0 -35
  252. aethergraph/services/artifacts/fs_store.py +0 -656
  253. aethergraph/services/artifacts/jsonl_index.py +0 -123
  254. aethergraph/services/artifacts/sqlite_index.py +0 -209
  255. aethergraph/services/memory/distillers/episode.py +0 -116
  256. aethergraph/services/memory/distillers/rolling.py +0 -74
  257. aethergraph/services/memory/facade.py +0 -633
  258. aethergraph/services/memory/persist_fs.py +0 -40
  259. aethergraph/services/rag/index/base.py +0 -27
  260. aethergraph/services/rag/index/faiss_index.py +0 -121
  261. aethergraph/services/rag/index/sqlite_index.py +0 -134
  262. aethergraph-0.1.0a1.dist-info/RECORD +0 -182
  263. aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
  264. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/WHEEL +0 -0
  265. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/LICENSE +0 -0
  266. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/NOTICE +0 -0
  267. {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 builtins
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
- Artifact,
11
- AsyncArtifactIndex,
12
- AsyncArtifactStore,
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
- from .paths import _from_uri_or_path
16
-
17
- Scope = Literal["node", "run", "graph", "all"]
20
+ ArtifactView = Literal["node", "graph", "run", "all"]
18
21
 
19
22
 
20
23
  class ArtifactFacade:
21
- """Facade for artifact storage and indexing operations within a specific context.
22
- Provides async methods to stage, ingest, save, and write artifacts with automatic indexing.
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
- self.run_id, self.graph_id, self.node_id = run_id, graph_id, node_id
37
- self.tool_name, self.tool_version = tool_name, tool_version
38
- self.store, self.index = store, index
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
- async def stage(self, ext: str = "") -> str:
42
- return await self.store.plan_staging_path(ext)
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
- async def ingest(
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.index.upsert(a)
68
- await self.index.record_occurrence(a)
317
+ await self._record(a)
69
318
  return a
70
319
 
71
- async def save(
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.index.upsert(a)
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(self, payload: str, *, suggested_uri: str | None = None):
100
- a = await self.store.save_text(payload=payload, suggested_uri=suggested_uri)
101
- await self.index.upsert(a)
102
- await self.index.record_occurrence(a)
103
- self.last_artifact = a
104
- return a
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
- async def save_json(self, payload: dict, *, suggested_uri: str | None = None):
107
- a = await self.store.save_json(payload=payload, suggested_uri=suggested_uri)
108
- await self.index.upsert(a)
109
- await self.index.record_occurrence(a)
110
- self.last_artifact = a
111
- return a
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(self, *, kind: str, planned_ext: str | None = None, pin: bool = False):
115
- # Use the store's (sync) contextmanager via async wrapper; user writes bytes
116
- cm = await self.store.open_writer(
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
- with cm as w:
662
+ ) as w:
663
+ # 2) Yield to user code (they write() and add_labels/add_metrics)
127
664
  yield w
128
- a = getattr(w, "_artifact", None)
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.index.upsert(a)
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
- async def stage_dir(self, suffix: str = "") -> str:
137
- return await self.store.plan_staging_dir(suffix)
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
- async def ingest_dir(self, staged_dir: str, **kw):
140
- a = await self.store.ingest_directory(
141
- staged_dir=staged_dir,
142
- run_id=self.run_id,
143
- graph_id=self.graph_id,
144
- node_id=self.node_id,
145
- tool_name=self.tool_name,
146
- tool_version=self.tool_version,
147
- **kw,
148
- )
149
- await self.index.upsert(a)
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
- async def tmp_path(self, suffix: str = "") -> str:
155
- return await self.store.plan_staging_path(suffix)
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(self, uri: str, *, encoding: str = "utf-8", errors: str = "strict") -> str:
161
- data = await self.store.load_text(uri)
162
- return data
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
- async def load_json(self, uri: str, *, encoding: str = "utf-8", errors: str = "strict") -> Any:
165
- data = await self.store.load_json(uri, encoding=encoding, errors=errors)
166
- return data
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
- # ------- indexing pass-throughs with scoping -------
175
- async def list(self, *, scope: Scope = "run") -> builtins.list[Artifact]:
959
+ async def load_artifact_dir(self, uri: str) -> str:
176
960
  """
177
- Quick listing scoped to current run/graph/node by default.
178
- scope:
179
- - "node": filter by (run_id, graph_id, node_id)
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
- if scope == "node":
185
- arts = await self.index.search(
186
- labels={"graph_id": self.graph_id, "node_id": self.node_id}
187
- )
188
- return [a for a in arts if a.run_id == self.run_id]
189
- if scope == "graph":
190
- arts = await self.index.search(labels={"graph_id": self.graph_id})
191
- return [a for a in arts if a.run_id == self.run_id]
192
- if scope == "run":
193
- return await self.index.list_for_run(self.run_id)
194
- if scope == "all":
195
- return await self.index.search()
196
- return await self.index.search(labels=self._scope_labels(scope))
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, Any] | None = None,
1118
+ labels: dict[str, str] | None = None,
203
1119
  metric: str | None = None,
204
1120
  mode: Literal["max", "min"] | None = None,
205
- scope: Scope = "run",
206
- extra_scope_labels: dict[str, Any] | None = None,
207
- ) -> builtins.list[Artifact]:
208
- """Pass-through search with automatic scoping."""
209
- eff_labels = dict(labels or {})
210
- if scope in ("node", "graph", "project"):
211
- eff_labels.update(self._scope_labels(scope))
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
- # Delegate heavy lifting to the index
215
- return await self.index.search(kind=kind, labels=eff_labels, metric=metric, mode=mode)
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
- scope: Scope = "run",
224
- filters: dict[str, Any] | None = None,
1193
+ view: ArtifactView = "run",
1194
+ filters: dict[str, str] | None = None,
225
1195
  ) -> Artifact | None:
226
- eff_filters = dict(filters or {})
227
- if scope in ("node", "graph", "project"):
228
- eff_filters.update(self._scope_labels(scope))
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, metric=metric, mode=mode, filters=eff_filters or None
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
- await self.index.pin(artifact_id, pinned)
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
- def _project_id(self) -> str | None:
247
- # This function is no longer used, but kept for possible future use.
248
- return getattr(self, "project_id", None)
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
- # ---------- convenience: URI -> local path (FS only) ----------
251
- def to_local_path(self, uri_or_path: str | Path | Artifact, *, must_exist: bool = True) -> str:
252
- """
253
- Return an absolute native path string if input is a file:// URI or local path.
254
- If given an Artifact, uses artifact.uri.
255
- If the scheme is not file://, returns the string form unchanged (or raise in strict mode).
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
- s = uri_or_path.uri or "" if isinstance(uri_or_path, Artifact) else str(uri_or_path)
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
- # Not a filesystem artifact; caller likely needs a downloader
266
- return s # or: raise ValueError("Not a local filesystem URI")
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(self, uri_or_path: str | Path | Artifact, *, must_exist: bool = True) -> str:
273
- """Same as to_local_path but asserts it's a file (not a dir)."""
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(self, uri_or_path: str | Path | Artifact, *, must_exist: bool = True) -> str:
280
- """Same as to_local_path but asserts it's a directory."""
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}")