Source code for lucidlink.workspace

"""
Workspace context after successful authentication.
"""

import logging
import threading
import warnings
from typing import Dict, List, Optional

from .exceptions import FilespaceAlreadyLinkedError
from .filespace import Filespace
from .filespace_models import FilespaceInfo, SyncMode

logger = logging.getLogger(__name__)

# Wire discriminator of filespace-internal-error notifications on the native notification
# channel — must match ToString at the C ABI gate in Sdk/Api/lucid_api_daemon.cpp.
_NOTIFICATION_FILESPACE_INTERNAL_ERROR = "filespace_internal_error"


[docs] class Workspace: """ Workspace context after successful authentication. Provides access to filespace operations within the authenticated workspace. Returned by ``Daemon.authenticate()``. Multiple filespaces can be linked concurrently — each ``link_filespace()`` call returns an independent ``Filespace`` that stays usable until it is unlinked. Example: .. code-block:: python credentials = ServiceAccountCredentials(token) client.login(credentials) workspace = client.get_workspace(client.list_workspaces()[0].id) print(workspace.id, workspace.name) """ def __init__(self, native_daemon, workspace_id: str, workspace_name: str, notification_watcher=None): """ Initialize workspace context. Args: native_daemon: Native runtime wrapper (internal use) workspace_id: Workspace ID workspace_name: Workspace name notification_watcher: The client's daemon-notification watcher; this workspace subscribes itself as its handler (internal use) Note: This constructor is called internally by ``Client.login()``. Users should not construct ``Workspace`` objects directly. :meta private-args: native_daemon """ self._native_daemon = native_daemon self._id = workspace_id self._name = workspace_name self._linked_filespaces: Dict[str, Filespace] = {} # filespace id -> Filespace self._aliases: Dict[str, str] = {} # name-or-id used at link time -> filespace id # Single lock for every read or write of `_linked_filespaces` / # `_aliases` — link/unlink/stop/iteration. The GIL atomises single # bytecodes but not multi-step patterns (the comprehension reassignment # in `_on_filespace_unlinked`, the find-then-link check in # `link_filespace`, dict iteration while another thread mutates), so # those need explicit serialisation. RLock because `stop()` holds the # lock while calling `filespace.unlink()`, which re-enters # `_on_filespace_unlinked` on the same thread. self._link_lock = threading.RLock() if notification_watcher is not None: notification_watcher.set_handler(self._on_daemon_notification) @property def id(self) -> str: """Get the workspace ID.""" return self._id @property def name(self) -> str: """Get the workspace name.""" return self._name @property def linked_filespaces(self) -> List[Filespace]: """Get the currently linked ``Filespace`` objects.""" with self._link_lock: return [fs for fs in self._linked_filespaces.values() if fs.is_linked]
[docs] def list_filespaces(self) -> List[FilespaceInfo]: """ List all filespaces in this workspace. Returns: List of FilespaceInfo objects with id, name, and created timestamp. Raises: ConnectionError: If LucidLink services are unreachable AuthenticationError: If access token expired RuntimeError: If the client is not running Example: .. code-block:: python filespaces = workspace.list_filespaces() for fs in filespaces: print(f"{fs.name}") """ return [ FilespaceInfo(id=d["id"], name=d["name"], created=d["created"]) for d in self._native_daemon.list_filespaces(self.id) ]
def _on_daemon_notification(self, notification: Dict) -> None: """Apply one daemon push notification (arrives on the client's watcher thread): a filespace internal error unlinks the filespace locally.""" if notification.get("type") != _NOTIFICATION_FILESPACE_INTERNAL_ERROR: return filespace_id = notification.get("filespace_id", "") logger.warning("Filespace %s: %s", filespace_id, notification.get("reason", "")) filespace = self._get_linked_by_id(filespace_id) if filespace is not None: filespace._mark_unlinked() def _get_linked_by_id(self, filespace_id: str) -> Optional[Filespace]: """Thread-safe registry lookup by canonical filespace id — no alias logic and no liveness filter (the notification handler wants the entry even though it is about to flip ``is_linked``).""" with self._link_lock: return self._linked_filespaces.get(filespace_id) def _find_linked(self, identifier: str) -> Optional[Filespace]: """Resolve an identifier (link-time name or filespace id) to a live link. Caller must hold ``_link_lock``. """ fs_id = self._aliases.get(identifier) if fs_id is None and identifier in self._linked_filespaces: fs_id = identifier if fs_id is None: return None filespace = self._linked_filespaces.get(fs_id) if filespace is not None and filespace.is_linked: return filespace return None def _recover_alias_miss( self, identifier: str, error: FilespaceAlreadyLinkedError ) -> Optional[Filespace]: """Map an already-linked error back to the live Filespace and register the new alias. Returns None (caller re-raises) if the daemon's id is absent or no longer tracked — a state we can't safely recover from. Caller must hold ``_link_lock``. """ fs_id = error.filespace_id if not fs_id: return None filespace = self._linked_filespaces.get(fs_id) if filespace is None or not filespace.is_linked: return None self._aliases[identifier] = fs_id return filespace def _on_filespace_unlinked(self, filespace_id: str) -> None: """Callback from Filespace.unlink — drop the entry and any aliases that point at this id so a long-running service doesn't accumulate zombies.""" with self._link_lock: self._linked_filespaces.pop(filespace_id, None) self._aliases = {k: v for k, v in self._aliases.items() if v != filespace_id}
[docs] def stop(self) -> None: """Stop workspace — unlinks all linked filespaces. If a filespace's ``sync_mode`` is ``SYNC_ALL``, ``sync_all()`` is called before it is unlinked. Per-filespace unlink errors are swallowed so one failing link never blocks the others. Safe to call multiple times. """ # `filespace.unlink()` re-enters `_on_filespace_unlinked` on the same # thread (the lock is an RLock so the re-entry is OK). Holding the lock # across the iteration also stops a concurrent `link_filespace` from # publishing a new entry that would survive the clear below. with self._link_lock: for filespace in list(self._linked_filespaces.values()): try: filespace.unlink() except Exception: pass self._linked_filespaces.clear() self._aliases.clear()
def __repr__(self) -> str: return f"Workspace(id='{self.id}', name='{self.name}')"