"""Space module for Kumiho asset management.
This module provides the :class:`Space` class, which represents hierarchical
containers for organizing items within a project. Spaces form the folder
structure of the Kumiho asset hierarchy.
Example:
Working with spaces::
import kumiho
project = kumiho.get_project("film-2024")
# Create space hierarchy
chars = project.create_space("characters")
heroes = chars.create_space("heroes")
villains = chars.create_space("villains")
# Create items in spaces
hero_model = heroes.create_item("main-hero", "model")
# Navigate space hierarchy
parent = heroes.get_parent_space() # Returns chars
children = chars.get_child_spaces() # Returns [heroes, villains]
"""
from typing import TYPE_CHECKING, Dict, List, Optional
from .base import KumihoObject
from .bundle import Bundle
from .kref import Kref
from .proto.kumiho_pb2 import SpaceResponse
from .item import Item
if TYPE_CHECKING:
from .client import _Client
from .bundle import Bundle
from .project import Project
[docs]
class Space(KumihoObject):
"""A hierarchical container for organizing items in Kumiho.
Spaces form the folder structure within a project. They can contain
other spaces (subspaces) and items, allowing you to organize assets
in a meaningful hierarchy.
Spaces are identified by their full path (e.g., "/project/characters/heroes")
and can store custom metadata.
Attributes:
path (str): The full path of the space (e.g., "/project/assets").
name (str): The name of this space (last component of path).
type (str): The type of space ("root" for project-level, "sub" for nested).
created_at (Optional[str]): ISO timestamp when the space was created.
author (str): The user ID who created the space.
metadata (Dict[str, str]): Custom metadata key-value pairs.
username (str): Display name of the creator.
Example:
Creating and navigating spaces::
import kumiho
project = kumiho.get_project("my-project")
# Create a space
assets = project.create_space("assets")
# Create subspaces
models = assets.create_space("models")
textures = assets.create_space("textures")
# Create items
chair = models.create_item("chair", "model")
# List items with filters
all_models = models.get_items()
wood_textures = textures.get_items(name_filter="wood*")
# Navigate hierarchy
parent = models.get_parent_space() # Returns assets
project = models.get_project() # Returns my-project
"""
[docs]
def __init__(self, pb_space: SpaceResponse, client: '_Client') -> None:
"""Initialize a Space from a protobuf response.
Args:
pb_space: The protobuf SpaceResponse message.
client: The client instance for making API calls.
"""
super().__init__(client)
self.path = pb_space.path
self.name = pb_space.name
self.type = pb_space.type
self.created_at = pb_space.created_at or None
self.author = pb_space.author
self.metadata = dict(pb_space.metadata)
self.username = pb_space.username
[docs]
def __repr__(self) -> str:
"""Return a string representation of the Space."""
return f"<kumiho.Space path='{self.path}'>"
[docs]
def create_space(self, name: str) -> 'Space':
"""Create a new subspace within this space.
Args:
name: The name of the new subspace.
Returns:
Space: The newly created Space object.
Example:
>>> assets = project.create_space("assets")
>>> models = assets.create_space("models")
>>> textures = assets.create_space("textures")
"""
return self._client.create_space(parent_path=self.path, space_name=name)
[docs]
def get_space(self, name: str) -> 'Space':
"""Get an existing subspace by name.
Args:
name: The name of the subspace (not full path).
Returns:
Space: The Space object.
Raises:
grpc.RpcError: If the space is not found.
Example:
>>> assets = project.get_space("assets")
>>> models = assets.get_space("models")
"""
path = f"{self.path.rstrip('/')}/{name}"
return self._client.get_space(path)
[docs]
def get_spaces(self, recursive: bool = False) -> List['Space']:
"""List child spaces under this space.
Args:
recursive: If True, include all nested spaces. If False (default),
only direct children.
Returns:
List[Space]: A list of Space objects.
Example:
>>> # Direct children only
>>> children = space.get_spaces()
>>> # All nested spaces
>>> all_spaces = space.get_spaces(recursive=True)
"""
return self._client.get_child_spaces(self.path, recursive=recursive)
[docs]
def create_item(self, item_name: str, kind: str) -> Item:
"""Create a new item within this space.
Items are versioned assets that can contain multiple artifacts.
The combination of name and kind must be unique within the space.
Args:
item_name: The name of the item (e.g., "hero-character").
kind: The kind of item (e.g., "model", "texture", "workflow").
Returns:
Item: The newly created Item object.
Example:
>>> models = project.get_space("models")
>>> chair = models.create_item("office-chair", "model")
>>> wood = textures.create_item("oak-wood", "texture")
"""
return self._client.create_item(self.path, item_name, kind)
[docs]
def create_bundle(
self,
bundle_name: str,
metadata: Optional[Dict[str, str]] = None
) -> Bundle:
"""Create a new bundle within this space.
Bundles are special items that aggregate other items.
They provide a way to group related items together and maintain
an audit trail of membership changes through revision history.
Args:
bundle_name: The name of the bundle. Must be unique within
the space.
metadata: Optional key-value metadata for the bundle.
Returns:
Bundle: The newly created Bundle object with kind "bundle".
Raises:
grpc.RpcError: If the bundle name is already taken or if there
is a connection error.
See Also:
:class:`~kumiho.bundle.Bundle`: The Bundle class.
:meth:`~kumiho.project.Project.create_bundle`: Create bundle in a project.
Example::
>>> # Create a bundle for a character bundle
>>> assets = project.get_space("assets")
>>> bundle = assets.create_bundle("character-bundle")
>>>
>>> # Add items to the bundle
>>> hero = assets.get_item("hero", "model")
>>> bundle.add_member(hero)
"""
return self._client.create_bundle(
parent_path=self.path,
bundle_name=bundle_name,
metadata=metadata
)
[docs]
def get_items(
self,
item_name_filter: str = "",
kind_filter: str = ""
) -> List[Item]:
"""List items within this space with optional filtering.
Args:
item_name_filter: Filter by item name. Supports wildcards.
kind_filter: Filter by item kind.
Returns:
List[Item]: A list of Item objects matching the filters.
Example:
>>> # All items in space
>>> items = space.get_items()
>>> # Only models
>>> models = space.get_items(kind_filter="model")
>>> # Items starting with "hero"
>>> heroes = space.get_items(item_name_filter="hero*")
"""
return self._client.get_items(self.path, item_name_filter, kind_filter)
[docs]
def get_item(self, item_name: str, kind: str) -> Item:
"""Get a specific item by name and kind.
Args:
item_name: The name of the item.
kind: The kind of the item.
Returns:
Item: The Item object.
Raises:
grpc.RpcError: If the item is not found.
Example:
>>> chair = models.get_item("office-chair", "model")
>>> revisions = chair.get_revisions()
"""
return self._client.get_item(self.path, item_name, kind)
[docs]
def get_bundle(self, bundle_name: str) -> Bundle:
"""Get a bundle by name from this space.
This is a convenience method that fetches a bundle item and returns
it as a Bundle object with bundle-specific methods like add_member(),
get_members(), etc.
Args:
bundle_name: The name of the bundle.
Returns:
Bundle: The Bundle object.
Raises:
grpc.RpcError: If the bundle is not found.
Example:
>>> bundle = space.get_bundle("character-bundle")
>>> members = bundle.get_members()
>>> for member in members:
... print(member.item_kref)
"""
# Construct the kref URI for the bundle
# Space path is like "/project/space", we need "project/space/bundle.bundle"
path_without_slash = self.path.lstrip("/")
kref_uri = f"kref://{path_without_slash}/{bundle_name}.bundle"
return self._client.get_bundle_by_kref(kref_uri)
[docs]
def set_attribute(self, key: str, value: str) -> bool:
"""Set a single metadata attribute.
This allows granular updates to metadata without replacing the entire
metadata map.
Args:
key: The attribute key to set.
value: The attribute value.
Returns:
bool: True if the attribute was set successfully.
Example:
>>> space.set_attribute("department", "modeling")
True
"""
# For spaces, the server expects the path directly in the kref uri field.
# We bypass the Kref class validation since paths don't match kref:// format.
from .proto.kumiho_pb2 import Kref as PbKref, SetAttributeRequest
req = SetAttributeRequest(kref=PbKref(uri=self.path), key=key, value=value)
resp = self._client.stub.SetAttribute(req)
if resp.success:
self.metadata[key] = value
return resp.success
[docs]
def get_attribute(self, key: str) -> Optional[str]:
"""Get a single metadata attribute.
Args:
key: The attribute key to retrieve.
Returns:
The attribute value if it exists, None otherwise.
Example:
>>> space.get_attribute("department")
"modeling"
"""
# For spaces, the server expects the path directly in the kref uri field.
# We bypass the Kref class validation since paths don't match kref:// format.
from .proto.kumiho_pb2 import Kref as PbKref, GetAttributeRequest
req = GetAttributeRequest(kref=PbKref(uri=self.path), key=key)
resp = self._client.stub.GetAttribute(req)
return resp.value if resp.exists else None
[docs]
def delete_attribute(self, key: str) -> bool:
"""Delete a single metadata attribute.
Args:
key: The attribute key to delete.
Returns:
bool: True if the attribute was deleted successfully.
Example:
>>> space.delete_attribute("old_field")
True
"""
# For spaces, the server expects the path directly in the kref uri field.
# We bypass the Kref class validation since paths don't match kref:// format.
from .proto.kumiho_pb2 import Kref as PbKref, DeleteAttributeRequest
req = DeleteAttributeRequest(kref=PbKref(uri=self.path), key=key)
resp = self._client.stub.DeleteAttribute(req)
if resp.success and key in self.metadata:
del self.metadata[key]
return resp.success
[docs]
def delete(self, force: bool = False) -> None:
"""Delete this space.
Args:
force: If True, force deletion even if the space contains
items. If False (default), deletion fails if space
is not empty.
Raises:
grpc.RpcError: If deletion fails (e.g., space not empty
and force=False).
Example:
>>> # Delete empty space
>>> empty_space.delete()
>>> # Force delete space with contents
>>> old_space.delete(force=True)
"""
self._client.delete_space(self.path, force)
[docs]
def get_parent_space(self) -> Optional['Space']:
"""Get the parent space of this space.
Returns:
Optional[Space]: The parent Space object, or None if this is
a project-level root space.
Example:
>>> heroes = project.get_space("characters/heroes")
>>> chars = heroes.get_parent_space() # Returns "characters" space
>>> root = chars.get_parent_space() # Returns None (project root)
"""
if self.path == "/":
return None
# Split path and remove the last component
parts = [p for p in self.path.split('/') if p] # Remove empty strings
if len(parts) <= 1:
return None # This is a root-level space
parent_parts = parts[:-1]
if not parent_parts:
parent_path = "/"
else:
parent_path = "/" + "/".join(parent_parts)
return self._client.get_space(parent_path)
[docs]
def get_child_spaces(self) -> List['Space']:
"""Get immediate child spaces of this space.
This is a convenience method equivalent to ``get_spaces(recursive=False)``.
Returns:
List[Space]: A list of direct child Space objects.
Example:
>>> assets = project.get_space("assets")
>>> children = assets.get_child_spaces()
>>> for child in children:
... print(child.name)
"""
return self._client.get_child_spaces(self.path)
[docs]
def get_project(self) -> 'Project':
"""Get the project that contains this space.
Returns:
Project: The parent Project object.
Example:
>>> space = kumiho.get_item("kref://my-project/assets/hero.model").get_space()
>>> project = space.get_project()
>>> print(project.name)
my-project
"""
# The project name is the first component of the path
parts = [p for p in self.path.split('/') if p]
if not parts:
raise ValueError("Root space has no project")
project_name = parts[0]
project = self._client.get_project(project_name)
if project is None:
raise ValueError(f"Project '{project_name}' not found")
return project