splime 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {splime-0.4.2/src/splime.egg-info → splime-0.4.3}/PKG-INFO +1 -1
  2. {splime-0.4.2 → splime-0.4.3}/pyproject.toml +1 -1
  3. {splime-0.4.2 → splime-0.4.3}/src/spl/_client.py +247 -52
  4. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/cli.py +66 -2
  5. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/doctor.py +80 -0
  6. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/heartbeat_service.py +45 -10
  7. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/remote_client.py +9 -2
  8. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/repositories/library.py +6 -0
  9. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/repositories/object.py +233 -2
  10. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/repositories/run.py +4 -0
  11. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/repositories/server_connection.py +424 -60
  12. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/repositories/sync_event.py +57 -4
  13. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/_helpers.py +28 -14
  14. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/artifacts.py +15 -4
  15. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/diagnostics.py +4 -3
  16. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/libraries.py +17 -10
  17. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/objects.py +32 -12
  18. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/remote.py +5 -5
  19. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/runs.py +15 -3
  20. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/server_connections.py +29 -14
  21. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/runtime_dependencies.py +4 -0
  22. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/server.py +668 -74
  23. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/server_connection.py +73 -15
  24. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/store.py +63 -2
  25. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon_client.py +300 -10
  26. {splime-0.4.2 → splime-0.4.3/src/splime.egg-info}/PKG-INFO +1 -1
  27. {splime-0.4.2 → splime-0.4.3}/LICENSE +0 -0
  28. {splime-0.4.2 → splime-0.4.3}/NOTICE +0 -0
  29. {splime-0.4.2 → splime-0.4.3}/README.md +0 -0
  30. {splime-0.4.2 → splime-0.4.3}/setup.cfg +0 -0
  31. {splime-0.4.2 → splime-0.4.3}/src/spl/__init__.py +0 -0
  32. {splime-0.4.2 → splime-0.4.3}/src/spl/_deprecate.py +0 -0
  33. {splime-0.4.2 → splime-0.4.3}/src/spl/_http.py +0 -0
  34. {splime-0.4.2 → splime-0.4.3}/src/spl/_views.py +0 -0
  35. {splime-0.4.2 → splime-0.4.3}/src/spl/client.py +0 -0
  36. {splime-0.4.2 → splime-0.4.3}/src/spl/core/__init__.py +0 -0
  37. {splime-0.4.2 → splime-0.4.3}/src/spl/core/_common.py +0 -0
  38. {splime-0.4.2 → splime-0.4.3}/src/spl/core/adapter_compat.py +0 -0
  39. {splime-0.4.2 → splime-0.4.3}/src/spl/core/common.py +0 -0
  40. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/__init__.py +0 -0
  41. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/adapter.py +0 -0
  42. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/artifact.py +0 -0
  43. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/control.py +0 -0
  44. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/distribution.py +0 -0
  45. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/function.py +0 -0
  46. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/local_function.py +0 -0
  47. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/misc.py +0 -0
  48. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/module.py +0 -0
  49. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/node.py +0 -0
  50. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/node_function.py +0 -0
  51. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/node_remote.py +0 -0
  52. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/pipeline.py +0 -0
  53. {splime-0.4.2 → splime-0.4.3}/src/spl/core/entities/scalar.py +0 -0
  54. {splime-0.4.2 → splime-0.4.3}/src/spl/core/fingerprint.py +0 -0
  55. {splime-0.4.2 → splime-0.4.3}/src/spl/core/ir/__init__.py +0 -0
  56. {splime-0.4.2 → splime-0.4.3}/src/spl/core/ir/common.py +0 -0
  57. {splime-0.4.2 → splime-0.4.3}/src/spl/core/ir/parse.py +0 -0
  58. {splime-0.4.2 → splime-0.4.3}/src/spl/core/ir/unparse.py +0 -0
  59. {splime-0.4.2 → splime-0.4.3}/src/spl/core/ir/utils.py +0 -0
  60. {splime-0.4.2 → splime-0.4.3}/src/spl/core/manifest.py +0 -0
  61. {splime-0.4.2 → splime-0.4.3}/src/spl/core/node_runtime.py +0 -0
  62. {splime-0.4.2 → splime-0.4.3}/src/spl/core/resume.py +0 -0
  63. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/__init__.py +0 -0
  64. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/__main__.py +0 -0
  65. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/canonical.py +0 -0
  66. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/client.py +0 -0
  67. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/docker_environment.py +0 -0
  68. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/docker_pool.py +0 -0
  69. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/environment.py +0 -0
  70. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/environment_base.py +0 -0
  71. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/interpreter_visibility.py +0 -0
  72. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/metadata.py +0 -0
  73. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/repositories/__init__.py +0 -0
  74. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/repositories/env.py +0 -0
  75. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/__init__.py +0 -0
  76. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/routes/envs.py +0 -0
  77. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/run_progress.py +0 -0
  78. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/runtime_backend.py +0 -0
  79. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/runtime_config.py +0 -0
  80. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/secret_store.py +0 -0
  81. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/services/__init__.py +0 -0
  82. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/services/sync.py +0 -0
  83. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/signature.py +0 -0
  84. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/spl_free_generator.py +0 -0
  85. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/spl_free_runner.py +0 -0
  86. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/storage_base.py +0 -0
  87. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/worker.py +0 -0
  88. {splime-0.4.2 → splime-0.4.3}/src/spl/daemon/worker_runtime_marker.py +0 -0
  89. {splime-0.4.2 → splime-0.4.3}/src/spl/pipeline_widget.py +0 -0
  90. {splime-0.4.2 → splime-0.4.3}/src/spl/py.typed +0 -0
  91. {splime-0.4.2 → splime-0.4.3}/src/spl/server_client.py +0 -0
  92. {splime-0.4.2 → splime-0.4.3}/src/splime.egg-info/SOURCES.txt +0 -0
  93. {splime-0.4.2 → splime-0.4.3}/src/splime.egg-info/dependency_links.txt +0 -0
  94. {splime-0.4.2 → splime-0.4.3}/src/splime.egg-info/entry_points.txt +0 -0
  95. {splime-0.4.2 → splime-0.4.3}/src/splime.egg-info/requires.txt +0 -0
  96. {splime-0.4.2 → splime-0.4.3}/src/splime.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splime
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Reuse Python functions across projects without rewriting or redeploying them.
5
5
  Author-email: Yastrebov Kirill <yastrebovks@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splime"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
 
9
9
  description = "Reuse Python functions across projects without rewriting or redeploying them."
10
10
  readme = "README.md"
@@ -27,6 +27,7 @@ from pathlib import Path
27
27
  from typing import Any, Literal, cast, overload
28
28
 
29
29
  from spl._views import (
30
+ ActionReceiptView,
30
31
  ConnectionStatusView,
31
32
  DecompositionView,
32
33
  EnvTableView,
@@ -62,6 +63,11 @@ RunSource = Literal["auto", "local"]
62
63
  RunIdNamespace = Literal["local", "daemon", "unknown"]
63
64
  ProgressOption = bool | RunStateCallback
64
65
  _NO_SERVER_CONNECTION_MESSAGE = "active server connection is not found"
66
+ _SERVER_UNREACHABLE_CODE = "central_server_unreachable"
67
+ _SERVER_UNREACHABLE_MESSAGES = (
68
+ "central SPL daemon server is not reachable",
69
+ "central SPL server is not reachable",
70
+ )
65
71
  _LIBRARY_DELETE_UNSUPPORTED_MESSAGE = (
66
72
  "Deleting central-server libraries is not supported by the SPL server API. "
67
73
  "Use the Console archive action to hide a library, or remove individual "
@@ -84,6 +90,24 @@ def _is_missing_server_connection(exc: Exception) -> bool:
84
90
  return isinstance(exc, ClientError) and _NO_SERVER_CONNECTION_MESSAGE in message
85
91
 
86
92
 
93
+ def _is_server_unreachable(exc: Exception) -> bool:
94
+ """Return whether the daemon says the central server cannot be reached."""
95
+
96
+ if not isinstance(exc, ClientError):
97
+ return False
98
+ if getattr(exc, "code", None) == _SERVER_UNREACHABLE_CODE:
99
+ return True
100
+ payload = getattr(exc, "payload", None)
101
+ if isinstance(payload, Mapping) and payload.get("code") == _SERVER_UNREACHABLE_CODE:
102
+ return True
103
+ message = str(exc)
104
+ return _SERVER_UNREACHABLE_CODE in message or any(text in message for text in _SERVER_UNREACHABLE_MESSAGES)
105
+
106
+
107
+ def _is_server_unavailable(exc: Exception) -> bool:
108
+ return _is_missing_server_connection(exc) or _is_server_unreachable(exc)
109
+
110
+
87
111
  def _is_404_error(exc: Exception) -> bool:
88
112
  return isinstance(exc, ClientError) and str(exc).startswith("404:")
89
113
 
@@ -126,6 +150,39 @@ def _with_local_cache_warning(payload: Mapping[str, Any]) -> dict[str, Any]:
126
150
  return {**dict(payload), "warning": _LOCAL_CACHE_ONLY_WARNING}
127
151
 
128
152
 
153
+ def _server_connection_mapping(state: Mapping[str, Any]) -> Mapping[str, Any]:
154
+ connection = state.get("connection") or state.get("remote_connection") or {}
155
+ return connection if isinstance(connection, Mapping) else {}
156
+
157
+
158
+ def _state_is_server_unreachable(state: Mapping[str, Any]) -> bool:
159
+ if state.get("code") == _SERVER_UNREACHABLE_CODE:
160
+ return True
161
+ return bool(state.get("offline")) and bool(_server_connection_mapping(state))
162
+
163
+
164
+ def _state_has_server_connection(state: Mapping[str, Any]) -> bool:
165
+ if _state_is_server_unreachable(state):
166
+ return False
167
+ connected = state.get("connected")
168
+ if connected is not None:
169
+ return bool(connected)
170
+ return _server_connection_mapping(state).get("status") == "connected"
171
+
172
+
173
+ def _normalize_server_connection_state(state: Mapping[str, Any]) -> dict[str, Any]:
174
+ normalized = dict(state)
175
+ normalized.setdefault("connected", bool(normalized.get("server_url")))
176
+ if _state_is_server_unreachable(normalized):
177
+ normalized["connected"] = False
178
+ normalized["offline"] = True
179
+ elif not normalized.get("connected"):
180
+ normalized.setdefault("offline", bool(_server_connection_mapping(normalized)))
181
+ else:
182
+ normalized.setdefault("offline", False)
183
+ return normalized
184
+
185
+
129
186
  def _progress_callback(progress: ProgressOption) -> RunStateCallback | None:
130
187
  """Turn a ``progress`` option into a wait-loop state callback.
131
188
 
@@ -205,6 +262,7 @@ class _BareServerRef:
205
262
 
206
263
 
207
264
  _CATALOG_HEADERS = ("name", "kind", "version", "library", "inputs")
265
+ _CATALOG_HEADERS_WITH_OWNER = ("name", "kind", "version", "owner", "library", "inputs")
208
266
 
209
267
 
210
268
  def _catalog_rows(
@@ -237,6 +295,7 @@ def _catalog_rows(
237
295
  "name": str(record.get("display_name") or record.get("name") or ""),
238
296
  "kind": str(record.get("kind") or ""),
239
297
  "version": str(version_value or ""),
298
+ "owner": str(record.get("owner_id") or ""),
240
299
  "library": str(library_name or ""),
241
300
  "inputs": str(len(record.get("inputs") or [])),
242
301
  }
@@ -244,20 +303,26 @@ def _catalog_rows(
244
303
  return rows
245
304
 
246
305
 
306
+ def _catalog_headers(rows: list[dict[str, str]]) -> tuple[str, ...]:
307
+ owners = {row.get("owner") or "" for row in rows}
308
+ owners.discard("")
309
+ return _CATALOG_HEADERS_WITH_OWNER if len(owners) > 1 else _CATALOG_HEADERS
310
+
311
+
247
312
  def _rows_to_text(rows: list[dict[str, str]], title: str) -> str:
248
313
  if not rows:
249
314
  return f"{title}: (empty)"
250
- widths = {header: max(len(header), *(len(row[header]) for row in rows)) for header in _CATALOG_HEADERS}
251
- head = " ".join(header.ljust(widths[header]) for header in _CATALOG_HEADERS)
252
- body = "\n".join(" ".join(row[header].ljust(widths[header]) for header in _CATALOG_HEADERS) for row in rows)
315
+ headers = _catalog_headers(rows)
316
+ widths = {header: max(len(header), *(len(row[header]) for row in rows)) for header in headers}
317
+ head = " ".join(header.ljust(widths[header]) for header in headers)
318
+ body = "\n".join(" ".join(row[header].ljust(widths[header]) for header in headers) for row in rows)
253
319
  return f"{title} ({len(rows)}):\n{head}\n{body}"
254
320
 
255
321
 
256
322
  def _rows_to_html(rows: list[dict[str, str]], title: str) -> str:
257
- head = "".join(f"<th style='text-align:left'>{escape(header)}</th>" for header in _CATALOG_HEADERS)
258
- body = "".join(
259
- "<tr>" + "".join(f"<td>{escape(row[header])}</td>" for header in _CATALOG_HEADERS) + "</tr>" for row in rows
260
- )
323
+ headers = _catalog_headers(rows)
324
+ head = "".join(f"<th style='text-align:left'>{escape(header)}</th>" for header in headers)
325
+ body = "".join("<tr>" + "".join(f"<td>{escape(row[header])}</td>" for header in headers) + "</tr>" for row in rows)
261
326
  return (
262
327
  f"<div><b>{escape(title)}</b> ({len(rows)})"
263
328
  f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>"
@@ -579,7 +644,7 @@ class RemoteRun:
579
644
  poll_interval: float = 0.25,
580
645
  timeout_seconds: float | None = None,
581
646
  progress: ProgressOption = True,
582
- ) -> dict[str, Any]:
647
+ ) -> RunRecordView:
583
648
  """Wait until the run succeeds or fails, then return final state.
584
649
 
585
650
  ``progress`` controls feedback during slow phases (first-run
@@ -965,9 +1030,10 @@ class SPLClient:
965
1030
  except Exception as exc:
966
1031
  if _is_missing_server_connection(exc):
967
1032
  return ConnectionStatusView({"connected": False, "offline": False, "connection": None})
1033
+ if _is_server_unreachable(exc):
1034
+ return ConnectionStatusView({"connected": False, "offline": True, "connection": None})
968
1035
  raise
969
- state.setdefault("connected", bool(state.get("server_url")))
970
- return ConnectionStatusView(state)
1036
+ return ConnectionStatusView(_normalize_server_connection_state(state))
971
1037
 
972
1038
  def machines(self) -> dict[str, Any]:
973
1039
  """Return the user's machines, or an empty listing when not connected.
@@ -983,13 +1049,13 @@ class SPLClient:
983
1049
  try:
984
1050
  self._daemon.server_connection()
985
1051
  except Exception as exc:
986
- if not _is_missing_server_connection(exc):
1052
+ if not _is_server_unavailable(exc):
987
1053
  raise
988
1054
  return MachineListView({"current_machine_id": None, "machines": []})
989
1055
  try:
990
1056
  machines = self._daemon.server_machines()
991
1057
  except Exception as exc:
992
- if not _is_missing_server_connection(exc):
1058
+ if not _is_server_unavailable(exc):
993
1059
  raise
994
1060
  return MachineListView({"current_machine_id": None, "machines": []})
995
1061
  return MachineListView(machines)
@@ -999,7 +1065,13 @@ class SPLClient:
999
1065
 
1000
1066
  if not self._has_server_connection():
1001
1067
  return LibraryListView([])
1002
- return LibraryListView(self._daemon.server_libraries(include_accessible=include_accessible))
1068
+ try:
1069
+ libraries = self._daemon.server_libraries(include_accessible=include_accessible)
1070
+ except Exception as exc:
1071
+ if not _is_server_unavailable(exc):
1072
+ raise
1073
+ libraries = []
1074
+ return LibraryListView(libraries)
1003
1075
 
1004
1076
  # The flat library aliases (create_library, get_library, update_library,
1005
1077
  # delete_library, grant_library, revoke_library_grant, add_reference,
@@ -1196,6 +1268,7 @@ class SPLClient:
1196
1268
  accessible server catalog when connected and local-only when offline.
1197
1269
  """
1198
1270
 
1271
+ requested_scope = scope
1199
1272
  if scope == "auto":
1200
1273
  scope = "server" if owner is not None or library is not None or self._has_server_connection() else "local"
1201
1274
  if scope == "local":
@@ -1205,25 +1278,34 @@ class SPLClient:
1205
1278
  if scope == "server":
1206
1279
  if owner is None and library is None and not self._has_server_connection():
1207
1280
  return ObjectList([], title=object_scope_title("server"))
1208
- return _wrap_objects(
1209
- self._daemon.server_objects(
1281
+ try:
1282
+ server_objects = self._daemon.server_objects(
1210
1283
  owner_id=owner,
1211
1284
  library=library,
1212
1285
  compact=compact,
1213
- ),
1214
- title=object_scope_title("server"),
1215
- )
1286
+ )
1287
+ except Exception as exc:
1288
+ if not _is_server_unavailable(exc):
1289
+ raise
1290
+ if requested_scope == "auto" and owner is None and library is None:
1291
+ return _wrap_objects(self._daemon.list_objects(compact=compact), title=object_scope_title("local"))
1292
+ server_objects = []
1293
+ return _wrap_objects(server_objects, title=object_scope_title("server"))
1216
1294
  if scope == "all":
1217
1295
  local_objects = self._daemon.list_objects(compact=compact)
1218
- server_objects = (
1219
- []
1220
- if owner is None and library is None and not self._has_server_connection()
1221
- else self._daemon.server_objects(
1222
- owner_id=owner,
1223
- library=library,
1224
- compact=compact,
1225
- )
1226
- )
1296
+ if owner is None and library is None and not self._has_server_connection():
1297
+ server_objects = []
1298
+ else:
1299
+ try:
1300
+ server_objects = self._daemon.server_objects(
1301
+ owner_id=owner,
1302
+ library=library,
1303
+ compact=compact,
1304
+ )
1305
+ except Exception as exc:
1306
+ if not _is_server_unavailable(exc):
1307
+ raise
1308
+ server_objects = []
1227
1309
  return ObjectCatalog(
1228
1310
  {
1229
1311
  "local": local_objects,
@@ -1292,19 +1374,82 @@ class SPLClient:
1292
1374
  "stale mirrors pruned",
1293
1375
  )
1294
1376
 
1377
+ def pull(
1378
+ self,
1379
+ name: str,
1380
+ *,
1381
+ owner: str | None = None,
1382
+ library: str | None = None,
1383
+ version: int | None = None,
1384
+ all_versions: bool = False,
1385
+ ) -> ActionReceiptView:
1386
+ """Mirror one accessible server object into the local daemon cache.
1387
+
1388
+ A pulled server-origin mirror can be inspected and called while the
1389
+ daemon is offline. The method requires an active server connection for
1390
+ the download itself; repeat pulls are idempotent and report unchanged
1391
+ versions under ``skipped``.
1392
+ """
1393
+
1394
+ if not self._has_server_connection():
1395
+ raise ClientError(self._pull_requires_server_connection_message())
1396
+ resolved_name = name
1397
+ owner_id = owner
1398
+ resolved_library = library
1399
+ if owner is None and library is None:
1400
+ ref = self._resolve_bare_server_ref(name, example_method="pull")
1401
+ resolved_name = ref.name
1402
+ owner_id = ref.owner_id
1403
+ resolved_library = ref.library
1404
+ return wrap_action(
1405
+ self._daemon.pull_server_object(
1406
+ resolved_name,
1407
+ owner_id=owner_id,
1408
+ library=resolved_library,
1409
+ version=version,
1410
+ all_versions=all_versions,
1411
+ ),
1412
+ "object pulled",
1413
+ )
1414
+
1415
+ def pull_all(
1416
+ self,
1417
+ *,
1418
+ owner: str | None = None,
1419
+ library: str | None = None,
1420
+ all_versions: bool = False,
1421
+ dry_run: bool = False,
1422
+ ) -> ActionReceiptView:
1423
+ """Mirror the visible server catalog into the local daemon cache.
1424
+
1425
+ The visible catalog can be large. Start with ``dry_run=True`` and
1426
+ narrow the batch with ``owner=`` or ``library=`` before downloading a
1427
+ whole catalog. ``dry_run`` returns the same receipt shape without
1428
+ writing local mirror rows or YAML files.
1429
+ """
1430
+
1431
+ if not self._has_server_connection():
1432
+ raise ClientError(self._pull_all_requires_server_connection_message())
1433
+ return wrap_action(
1434
+ self._daemon.pull_all_server_objects(
1435
+ owner_id=owner,
1436
+ library=library,
1437
+ all_versions=all_versions,
1438
+ dry_run=dry_run,
1439
+ ),
1440
+ "server catalog pull plan" if dry_run else "server catalog pulled",
1441
+ )
1442
+
1295
1443
  def _has_server_connection(self) -> bool:
1296
1444
  if self.server_connection is not None:
1297
- return bool(self.server_connection.get("connected"))
1445
+ return _state_has_server_connection(self.server_connection)
1298
1446
  try:
1299
1447
  state = self._daemon.server_connection()
1300
1448
  except Exception as exc:
1301
- if _is_missing_server_connection(exc):
1449
+ if _is_server_unavailable(exc):
1302
1450
  return False
1303
1451
  raise
1304
- if bool(state.get("connected")):
1305
- return True
1306
- connection = state.get("connection") or state.get("remote_connection") or {}
1307
- return connection.get("status") == "connected"
1452
+ return _state_has_server_connection(state)
1308
1453
 
1309
1454
  def _server_connection_label(self) -> str:
1310
1455
  state = self.server_connection
@@ -1323,7 +1468,39 @@ class SPLClient:
1323
1468
  return str(value)
1324
1469
  return "the active server connection"
1325
1470
 
1326
- def _resolve_bare_server_ref(self, name: str) -> _BareServerRef | None:
1471
+ @staticmethod
1472
+ def _offline_bare_server_ref_message(name: str) -> str:
1473
+ return (
1474
+ "404: {!r} is not registered locally and the daemon has no server connection; "
1475
+ "reconnect with client.connect_server(...) to search the server catalog, or "
1476
+ "client.pull(...) the object next time you are online."
1477
+ ).format(name)
1478
+
1479
+ @staticmethod
1480
+ def _unreachable_bare_server_ref_message(name: str) -> str:
1481
+ return (
1482
+ "404: {!r} is not registered locally; server unreachable; "
1483
+ "mirror it with client.pull(...) when online, or reconnect with "
1484
+ "client.connect_server(...) to search the server catalog."
1485
+ ).format(name)
1486
+
1487
+ @staticmethod
1488
+ def _pull_requires_server_connection_message() -> str:
1489
+ return (
1490
+ "404: pull requires a server connection; the daemon has no server connection. "
1491
+ "Reconnect with client.connect_server(...) to search the server catalog, "
1492
+ "then retry client.pull(...)."
1493
+ )
1494
+
1495
+ @staticmethod
1496
+ def _pull_all_requires_server_connection_message() -> str:
1497
+ return (
1498
+ "404: pull_all requires a server connection; the daemon has no server connection. "
1499
+ "Reconnect with client.connect_server(...) to search the server catalog, "
1500
+ "then retry client.pull_all(...)."
1501
+ )
1502
+
1503
+ def _resolve_bare_server_ref(self, name: str, *, example_method: str = "signature") -> _BareServerRef:
1327
1504
  """Resolve a bare object name through the visible server catalog after a local 404.
1328
1505
 
1329
1506
  This is intentionally catalog-driven: no default library is assumed.
@@ -1331,12 +1508,30 @@ class SPLClient:
1331
1508
  ``owner=``/``library=`` explicitly.
1332
1509
  """
1333
1510
 
1334
- if not self._has_server_connection():
1335
- return None
1511
+ try:
1512
+ connection_state = (
1513
+ self.server_connection if self.server_connection is not None else self._daemon.server_connection()
1514
+ )
1515
+ except Exception as exc:
1516
+ if _is_server_unreachable(exc):
1517
+ raise ClientError(self._unreachable_bare_server_ref_message(name)) from exc
1518
+ if _is_missing_server_connection(exc):
1519
+ raise ClientError(self._offline_bare_server_ref_message(name)) from exc
1520
+ raise
1521
+ if not _state_has_server_connection(connection_state):
1522
+ if _state_is_server_unreachable(connection_state):
1523
+ raise ClientError(self._unreachable_bare_server_ref_message(name))
1524
+ raise ClientError(self._offline_bare_server_ref_message(name))
1525
+ try:
1526
+ server_records = self._daemon.server_objects(compact=True)
1527
+ except Exception as exc:
1528
+ if _is_server_unreachable(exc):
1529
+ raise ClientError(self._unreachable_bare_server_ref_message(name)) from exc
1530
+ if _is_missing_server_connection(exc):
1531
+ raise ClientError(self._offline_bare_server_ref_message(name)) from exc
1532
+ raise
1336
1533
  candidates = [
1337
- record
1338
- for record in self._daemon.server_objects(compact=True)
1339
- if isinstance(record, Mapping) and name in _server_record_names(record)
1534
+ record for record in server_records if isinstance(record, Mapping) and name in _server_record_names(record)
1340
1535
  ]
1341
1536
  if len(candidates) == 1:
1342
1537
  record = candidates[0]
@@ -1351,7 +1546,7 @@ class SPLClient:
1351
1546
  raise ClientError(
1352
1547
  "{!r} is not registered locally; found on the server in: {}. "
1353
1548
  "Pass library=... and owner=... to disambiguate, for example "
1354
- "client.signature({!r}, library='...').".format(name, choices, name)
1549
+ "client.{}({!r}, library='...').".format(name, choices, example_method, name)
1355
1550
  )
1356
1551
  raise ClientError(
1357
1552
  "{!r} is not registered locally; no accessible server object named {!r} (connected as {}). "
@@ -1384,8 +1579,6 @@ class SPLClient:
1384
1579
  if owner is not None or library is not None or not _local_object_missing(exc):
1385
1580
  raise
1386
1581
  ref = self._resolve_bare_server_ref(name)
1387
- if ref is None:
1388
- raise
1389
1582
  signature = dict(
1390
1583
  self._daemon.signature(
1391
1584
  ref.name,
@@ -1537,8 +1730,6 @@ class SPLClient:
1537
1730
  if not _local_object_missing(exc):
1538
1731
  raise
1539
1732
  ref = self._resolve_bare_server_ref(str(name))
1540
- if ref is None:
1541
- raise
1542
1733
  response = self._remote_decomposition_response(
1543
1734
  {
1544
1735
  "name": ref.name,
@@ -1626,8 +1817,6 @@ class SPLClient:
1626
1817
  if not _local_object_missing(exc):
1627
1818
  raise
1628
1819
  ref = self._resolve_bare_server_ref(pipeline)
1629
- if ref is None:
1630
- raise
1631
1820
  response = self._remote_decomposition_response(
1632
1821
  {
1633
1822
  "name": ref.name,
@@ -1717,12 +1906,18 @@ class SPLClient:
1717
1906
  )
1718
1907
  freshness_line = None
1719
1908
  if function is None and "resolved_from_server" not in signature and self._has_server_connection():
1720
- freshness_line = _server_freshness_line(
1721
- signature,
1722
- self._daemon.server_objects(compact=True),
1723
- owner=owner,
1724
- library=library,
1725
- )
1909
+ try:
1910
+ server_records = self._daemon.server_objects(compact=True)
1911
+ except Exception as exc:
1912
+ if not _is_server_unavailable(exc):
1913
+ raise
1914
+ else:
1915
+ freshness_line = _server_freshness_line(
1916
+ signature,
1917
+ server_records,
1918
+ owner=owner,
1919
+ library=library,
1920
+ )
1726
1921
  display_name = signature.get("display_name") or signature["name"]
1727
1922
  lines = [(f"{display_name} v{signature['version']} ({signature['kind']})")]
1728
1923
  server_resolution = _server_resolution_label(signature.get("resolved_from_server"))
@@ -205,6 +205,25 @@ def build_parser() -> argparse.ArgumentParser:
205
205
  "server-connections",
206
206
  help="list stored central-server connection attempts",
207
207
  )
208
+ subparsers.add_parser(
209
+ "connections-list",
210
+ help="list stored central-server connection attempts",
211
+ )
212
+ connections_prune = subparsers.add_parser(
213
+ "connections-prune",
214
+ help="prune stale stored central-server connection attempts",
215
+ )
216
+ connections_prune.add_argument(
217
+ "--older-than-days",
218
+ type=int,
219
+ default=30,
220
+ help="also prune stale non-current rows older than this many days",
221
+ )
222
+ connections_prune.add_argument(
223
+ "--dry-run",
224
+ action="store_true",
225
+ help="show stale rows without deleting them",
226
+ )
208
227
  subparsers.add_parser(
209
228
  "server-machines",
210
229
  help="list machines visible to the connected user",
@@ -369,6 +388,15 @@ def build_parser() -> argparse.ArgumentParser:
369
388
  )
370
389
  object_versions.add_argument("name_or_id")
371
390
 
391
+ pull = subparsers.add_parser("pull", help="mirror server objects into the local daemon cache")
392
+ pull.add_argument("name", nargs="?")
393
+ pull.add_argument("--all", dest="all_objects", action="store_true")
394
+ pull.add_argument("--owner", default=None)
395
+ pull.add_argument("--library", default=None)
396
+ pull.add_argument("--version", type=int, default=None)
397
+ pull.add_argument("--all-versions", action="store_true")
398
+ pull.add_argument("--dry-run", action="store_true")
399
+
372
400
  run = subparsers.add_parser("run", help="start an object run")
373
401
  run.add_argument("object")
374
402
  run.add_argument("--args", default="[]", help="JSON list of positional args")
@@ -467,7 +495,8 @@ def build_parser() -> argparse.ArgumentParser:
467
495
  def main(argv: list[str] | None = None) -> int:
468
496
  """Execute the CLI command."""
469
497
 
470
- args = build_parser().parse_args(argv)
498
+ parser = build_parser()
499
+ args = parser.parse_args(argv)
471
500
 
472
501
  if args.command == "serve":
473
502
  try:
@@ -523,8 +552,15 @@ def main(argv: list[str] | None = None) -> int:
523
552
  print_json(client.disconnect_server())
524
553
  elif args.command == "server-connection":
525
554
  print_json(client.server_connection())
526
- elif args.command == "server-connections":
555
+ elif args.command in {"server-connections", "connections-list"}:
527
556
  print_json(client.server_connections())
557
+ elif args.command == "connections-prune":
558
+ print_json(
559
+ client.prune_server_connections(
560
+ older_than_days=args.older_than_days,
561
+ dry_run=args.dry_run,
562
+ )
563
+ )
528
564
  elif args.command == "server-machines":
529
565
  print_json(client.server_machines())
530
566
  elif args.command == "env-add":
@@ -601,6 +637,34 @@ def main(argv: list[str] | None = None) -> int:
601
637
  print_json(client.outputs(args.name_or_id, version=args.version))
602
638
  elif args.command == "object-versions":
603
639
  print_json(client.object_versions(args.name_or_id))
640
+ elif args.command == "pull":
641
+ if args.all_objects:
642
+ if args.name is not None:
643
+ parser.error("pull --all does not accept a name")
644
+ if args.version is not None:
645
+ parser.error("pull --all does not accept --version")
646
+ print_json(
647
+ client.pull_all_server_objects(
648
+ owner_id=args.owner,
649
+ library=args.library,
650
+ all_versions=args.all_versions,
651
+ dry_run=args.dry_run,
652
+ )
653
+ )
654
+ else:
655
+ if args.name is None:
656
+ parser.error("pull requires a name unless --all is set")
657
+ if args.dry_run:
658
+ parser.error("pull --dry-run requires --all")
659
+ print_json(
660
+ client.pull_server_object(
661
+ args.name,
662
+ owner_id=args.owner,
663
+ library=args.library,
664
+ version=args.version,
665
+ all_versions=args.all_versions,
666
+ )
667
+ )
604
668
  elif args.command == "run":
605
669
  positional_args = parse_json_arg(args.args, list)
606
670
  keyword_args = parse_json_arg(args.kwargs, dict)