From d9c2b1ceb46562738eb2fb2541ad3f8bcd1241e9 Mon Sep 17 00:00:00 2001 From: thiagobomfin-galileo Date: Fri, 13 Mar 2026 13:26:16 -0300 Subject: [PATCH 1/4] Moving project.py to root, away from __future__ --- poetry.lock | 2 +- pyproject.toml | 2 + src/galileo/__future__/__init__.py | 12 +- src/galileo/__future__/collaborator.py | 349 +-------- src/galileo/__future__/configuration.py | 2 +- src/galileo/__future__/dataset.py | 4 +- src/galileo/__future__/experiment.py | 10 +- src/galileo/__future__/integration.py | 4 +- src/galileo/__future__/log_stream.py | 6 +- src/galileo/__future__/metric.py | 4 +- src/galileo/__future__/project.py | 784 +------------------- src/galileo/__future__/prompt.py | 4 +- src/galileo/__future__/provider.py | 4 +- src/galileo/__future__/shared/base.py | 153 +--- src/galileo/__future__/shared/column.py | 2 +- src/galileo/__future__/shared/exceptions.py | 121 +-- src/galileo/__init__.py | 12 + src/galileo/collaborator.py | 348 +++++++++ src/galileo/decorator.py | 2 +- src/galileo/project.py | 783 +++++++++++++++++++ src/galileo/projects.py | 9 + src/galileo/shared/__init__.py | 1 + src/galileo/shared/base.py | 152 ++++ src/galileo/shared/exceptions.py | 98 +++ src/galileo/utils/validations.py | 2 +- tests/future/test_experiment.py | 2 +- tests/future/test_project.py | 28 +- tests/test_api_headers.py | 2 +- tests/test_backward_compat_future.py | 76 ++ 29 files changed, 1559 insertions(+), 1419 deletions(-) create mode 100644 src/galileo/collaborator.py create mode 100644 src/galileo/project.py create mode 100644 src/galileo/shared/__init__.py create mode 100644 src/galileo/shared/base.py create mode 100644 src/galileo/shared/exceptions.py create mode 100644 tests/test_backward_compat_future.py diff --git a/poetry.lock b/poetry.lock index 31e1aa167..cf2edc89e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6734,4 +6734,4 @@ otel = ["opentelemetry-api", "opentelemetry-exporter-otlp", "opentelemetry-sdk"] [metadata] lock-version = "2.1" python-versions = "^3.10,<3.15" -content-hash = "146221ede62dbf88520ed81db3c86f273a42967912812e64d58520891d15a8ea" +content-hash = "7d8e5a5b58868fccb94040f531d414efbee1f6e4638790dbbd5239ec349d22a7" diff --git a/pyproject.toml b/pyproject.toml index 74dcfb13c..7f69d74ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ starlette = { version = ">=0.27.0", optional = true } backoff = "^2.2.1" crewai = { version = ">=0.152.0,<2.0.0", optional = true, python = ">=3.10,<3.14" } tqdm = { version = ">=4.0.0" } +typing-extensions = { version = ">=4.5.0" } opentelemetry-sdk = { version = "^1.38.0", optional = true } opentelemetry-api = { version = "^1.38.0", optional = true } opentelemetry-exporter-otlp = { version = "^1.38.0", optional = true } @@ -238,6 +239,7 @@ ignore = [ "src/galileo/utils/datasets.py" = ["PLC0415"] "src/galileo/utils/serialization.py" = ["PLC0415"] # Optional dependency conditional imports "src/galileo/handlers/crewai/handler.py" = ["PLC0415"] # Version-specific conditional imports +"src/galileo/project.py" = ["PLC0415"] # Bottom-of-file circular import avoidance [tool.ruff.lint.isort] known-first-party = ["galileo_core"] diff --git a/src/galileo/__future__/__init__.py b/src/galileo/__future__/__init__.py index ff75c3592..d601c980d 100644 --- a/src/galileo/__future__/__init__.py +++ b/src/galileo/__future__/__init__.py @@ -4,7 +4,8 @@ This package provides the next-generation object-centric API for Galileo. """ -from galileo.__future__.collaborator import Collaborator, CollaboratorRole +from __future__ import annotations + from galileo.__future__.configuration import Configuration from galileo.__future__.dataset import Dataset from galileo.__future__.experiment import Experiment @@ -12,9 +13,12 @@ from galileo.__future__.log_stream import LogStream from galileo.__future__.metric import CodeMetric, GalileoMetric, LlmMetric, LocalMetric, Metric from galileo.__future__.model import Model -from galileo.__future__.project import Project from galileo.__future__.prompt import Prompt -from galileo.__future__.shared.exceptions import ( +from galileo.collaborator import Collaborator, CollaboratorRole +from galileo.project import Project +from galileo.schema.message import Message +from galileo.search import RecordType +from galileo.shared.exceptions import ( APIError, ConfigurationError, GalileoFutureError, @@ -22,8 +26,6 @@ ResourceNotFoundError, ValidationError, ) -from galileo.schema.message import Message -from galileo.search import RecordType from galileo.utils.log_config import enable_console_logging from galileo_core.schemas.logging.llm import MessageRole from galileo_core.schemas.logging.step import StepType diff --git a/src/galileo/__future__/collaborator.py b/src/galileo/__future__/collaborator.py index c5594077a..339e42600 100644 --- a/src/galileo/__future__/collaborator.py +++ b/src/galileo/__future__/collaborator.py @@ -1,348 +1,5 @@ -from __future__ import annotations +"""Re-export from galileo.collaborator — will be deprecated once all __future__ modules are migrated.""" -import logging -from datetime import datetime -from typing import Any +from galileo.collaborator import Collaborator, CollaboratorRole -from galileo.projects import Projects - -# Re-export CollaboratorRole from the auto-generated models -from galileo.resources.models.collaborator_role import CollaboratorRole - -logger = logging.getLogger(__name__) - - -class Collaborator: - """ - Immutable representation of a user's collaboration on a project. - - A Collaborator binds a user to a project with a specific role and permissions. - Collaborators are contextual - the same user can have different roles on - different projects. - - Collaborator attributes are immutable. To modify the role, use the update() - method which returns a new Collaborator instance. To remove access, use remove(). - - Attributes - ---------- - id (str): The unique identifier of this collaboration (UserProject ID). - user_id (str): The ID of the user who has access to the project. - project_id (str): The ID of the project this collaboration belongs to. - role (CollaboratorRole): The role assigned to the user (OWNER, EDITOR, VIEWER, ANNOTATOR). - created_at (datetime): When this collaboration was created. - email (str | None): The user's email address. - first_name (str | None): The user's first name. - last_name (str | None): The user's last name. - permissions (list | None): Actions the API caller can perform on this collaborator - record (e.g., "update" or "delete" this collaboration). None indicates no - permissions data is available (either omitted by the API or explicitly empty). - - Examples - -------- - # Get collaborators for a project - project = Project.get(name="My Project") - collaborators = project.collaborators - - for collab in collaborators: - print(f"{collab.email} - {collab.role}") - - # Check if a specific user has access - viewer = next((c for c in collaborators if c.email == "viewer@example.com"), None) - if viewer: - print(f"User has {viewer.role} access") - - # Filter by role using the CollaboratorRole enum - from galileo.__future__ import CollaboratorRole - editors = [c for c in collaborators if c.role == CollaboratorRole.EDITOR] - - # Update a collaborator's role directly on the object - updated_collab = collab.update(role=CollaboratorRole.EDITOR) - - # Remove a collaborator - collab.remove() - """ - - _id: str - _user_id: str - _project_id: str - _role: CollaboratorRole - _created_at: datetime | None - _email: str | None - _first_name: str | None - _last_name: str | None - _permissions: list[Any] | None - - def __init__( - self, - *, - id: str, - user_id: str, - project_id: str, - role: CollaboratorRole, - created_at: datetime | None = None, - email: str | None = None, - first_name: str | None = None, - last_name: str | None = None, - permissions: list[Any] | None = None, - ) -> None: - """ - Initialize a Collaborator instance. - - This constructor is typically called internally by Project methods. - Users should not create Collaborator instances directly. - - Args: - id: The collaboration record ID. - user_id: The user's ID. - project_id: The project's ID. - role: The collaboration role (CollaboratorRole enum). - created_at: When the collaboration was created. - email: The user's email address. - first_name: The user's first name. - last_name: The user's last name. - permissions: Actions the API caller can perform on this record. None indicates - no permissions data is available (either omitted or explicitly empty). - """ - # Use object.__setattr__ to bypass __setattr__ immutability - object.__setattr__(self, "_id", id) - object.__setattr__(self, "_user_id", user_id) - object.__setattr__(self, "_project_id", project_id) - object.__setattr__(self, "_role", role) - object.__setattr__(self, "_created_at", created_at) - object.__setattr__(self, "_email", email) - object.__setattr__(self, "_first_name", first_name) - object.__setattr__(self, "_last_name", last_name) - object.__setattr__(self, "_permissions", permissions) - - @property - def id(self) -> str: - """Get the collaboration record ID.""" - return self._id - - @property - def user_id(self) -> str: - """Get the user ID.""" - return self._user_id - - @property - def project_id(self) -> str: - """Get the project ID.""" - return self._project_id - - @property - def role(self) -> CollaboratorRole: - """Get the collaboration role.""" - return self._role - - @property - def created_at(self) -> datetime | None: - """Get the creation timestamp.""" - return self._created_at - - @property - def email(self) -> str | None: - """Get the user's email address.""" - return self._email - - @property - def first_name(self) -> str | None: - """Get the user's first name.""" - return self._first_name - - @property - def last_name(self) -> str | None: - """Get the user's last name.""" - return self._last_name - - @property - def permissions(self) -> list[Any] | None: - """Get the permissions for this collaboration. - - Returns None if no permissions data is available (either omitted by - the API or explicitly empty). A non-empty list indicates specific - actions the API caller can perform on this collaborator record. - """ - return self._permissions - - def __str__(self) -> str: - """String representation of the collaborator.""" - return f"Collaborator(email='{self._email}', role={self._role})" - - def __repr__(self) -> str: - """Detailed string representation of the collaborator.""" - return f"Collaborator(id='{self._id}', user_id='{self._user_id}', email='{self._email}', role={self._role})" - - def __setattr__(self, name: str, value: Any) -> None: - """ - Prevent modification of collaborator attributes (immutability). - - Raises - ------ - AttributeError: Always, since collaborators are immutable. - """ - raise AttributeError( - f"Collaborator objects are immutable. Cannot set attribute '{name}'. " - f"Use collab.update(role=...) to change the role." - ) - - def __delattr__(self, name: str) -> None: - """ - Prevent deletion of collaborator attributes (immutability). - - Raises - ------ - AttributeError: Always, since collaborators are immutable. - """ - raise AttributeError(f"Collaborator objects are immutable. Cannot delete attribute '{name}'.") - - def __eq__(self, other: object) -> bool: - """ - Check equality based on user_id and project_id. - - Args: - other: Object to compare with. - - Returns - ------- - bool: True if collaborators represent the same user-project binding. - """ - if not isinstance(other, Collaborator): - return False - return self._user_id == other._user_id and self._project_id == other._project_id - - def __hash__(self) -> int: - """ - Hash based on user_id and project_id. - - Returns - ------- - int: Hash value. - """ - return hash((self._user_id, self._project_id)) - - def to_dict(self) -> dict[str, Any]: - """ - Convert collaborator to dictionary representation. - - Returns - ------- - dict: Dictionary with collaborator properties. - """ - return { - "id": self._id, - "user_id": self._user_id, - "project_id": self._project_id, - "role": self._role.value, # Convert enum to string value - "created_at": self._created_at.isoformat() if self._created_at else None, - "email": self._email, - "first_name": self._first_name, - "last_name": self._last_name, - "permissions": self._permissions, - } - - def update(self, role: CollaboratorRole) -> Collaborator: - """ - Update this collaborator's role. - - Creates a new Collaborator instance with the updated role. The original - instance remains unchanged (immutability is preserved). - - Args: - role: The new role to assign (CollaboratorRole enum). - - Returns - ------- - Collaborator: A new Collaborator instance with the updated role. - - Examples - -------- - # Update a collaborator's role - collab = project.collaborators[0] - updated_collab = collab.update(role=CollaboratorRole.EDITOR) - print(f"New role: {updated_collab.role}") - """ - logger.info( - f"Collaborator.update: project_id='{self._project_id}', user_id='{self._user_id}', role='{role}' - started" - ) - - try: - projects_service = Projects() - api_collaborator = projects_service.update_user_project_collaborator( - project_id=self._project_id, user_id=self._user_id, role=role - ) - - updated = Collaborator._from_api_response(api_collaborator, project_id=self._project_id) - logger.info(f"Collaborator.update: project_id='{self._project_id}', user_id='{self._user_id}' - completed") - return updated - - except Exception as e: - logger.error( - f"Collaborator.update: project_id='{self._project_id}', user_id='{self._user_id}' - failed: {e}" - ) - raise - - def remove(self) -> None: - """ - Remove this collaborator from the project. - - Revokes the user's access to the project. After calling this method, - the collaborator object should no longer be used. - - Examples - -------- - # Remove a collaborator - collab = project.collaborators[0] - collab.remove() - """ - logger.info(f"Collaborator.remove: project_id='{self._project_id}', user_id='{self._user_id}' - started") - - try: - projects_service = Projects() - projects_service.unshare_project_with_user(project_id=self._project_id, user_id=self._user_id) - logger.info(f"Collaborator.remove: project_id='{self._project_id}', user_id='{self._user_id}' - completed") - - except Exception as e: - logger.error( - f"Collaborator.remove: project_id='{self._project_id}', user_id='{self._user_id}' - failed: {e}" - ) - raise - - @classmethod - def _from_api_response(cls, response: Any, project_id: str) -> Collaborator: - """ - Factory method to create a Collaborator from an API response. - - Args: - response: The UserCollaborator response from the API. - project_id: The project ID this collaborator belongs to. - - Returns - ------- - Collaborator: A new Collaborator instance. - """ - # Handle permissions - convert to list of dicts if present and non-empty. - # Note: The API model normalizes missing permissions to an empty list, so we cannot - # distinguish between "field omitted" and "explicitly empty". Both result in None here. - permissions = None - if hasattr(response, "permissions") and response.permissions: - permissions = [ - {"action": p.action, "allowed": p.allowed, "message": p.message} if hasattr(p, "action") else p - for p in response.permissions - ] - - # Handle role - it should already be a CollaboratorRole enum from the API - role = response.role if hasattr(response, "role") else CollaboratorRole.VIEWER - if isinstance(role, str): - role = CollaboratorRole(role) - - return cls( - id=str(response.id), - user_id=str(response.user_id), - project_id=project_id, - role=role, - created_at=response.created_at if hasattr(response, "created_at") else None, - email=response.email if hasattr(response, "email") else None, - first_name=response.first_name if hasattr(response, "first_name") else None, - last_name=response.last_name if hasattr(response, "last_name") else None, - permissions=permissions, - ) +__all__ = ["Collaborator", "CollaboratorRole"] diff --git a/src/galileo/__future__/configuration.py b/src/galileo/__future__/configuration.py index c58eb47a5..b024d388b 100644 --- a/src/galileo/__future__/configuration.py +++ b/src/galileo/__future__/configuration.py @@ -5,9 +5,9 @@ from pathlib import Path from typing import Any, Callable, Optional -from galileo.__future__.shared.exceptions import ConfigurationError from galileo.config import GalileoPythonConfig from galileo.constants import DEFAULT_CONSOLE_URL +from galileo.shared.exceptions import ConfigurationError from galileo.utils.log_config import enable_console_logging as _enable_console_logging from galileo.utils.log_config import get_logger diff --git a/src/galileo/__future__/dataset.py b/src/galileo/__future__/dataset.py index 8398f3982..abf6704d9 100644 --- a/src/galileo/__future__/dataset.py +++ b/src/galileo/__future__/dataset.py @@ -4,12 +4,12 @@ from datetime import datetime from typing import Any -from galileo.__future__.shared.base import StateManagementMixin, SyncState -from galileo.__future__.shared.exceptions import ValidationError from galileo.datasets import Datasets from galileo.resources.models.dataset_content import DatasetContent from galileo.resources.models.dataset_row import DatasetRow from galileo.resources.types import Unset +from galileo.shared.base import StateManagementMixin, SyncState +from galileo.shared.exceptions import ValidationError logger = logging.getLogger(__name__) diff --git a/src/galileo/__future__/experiment.py b/src/galileo/__future__/experiment.py index 923eed9be..90a2365d7 100644 --- a/src/galileo/__future__/experiment.py +++ b/src/galileo/__future__/experiment.py @@ -5,8 +5,6 @@ from collections.abc import Iterator from typing import TYPE_CHECKING, Any -from galileo.__future__.shared.base import StateManagementMixin, SyncState -from galileo.__future__.shared.exceptions import ValidationError from galileo.__future__.shared.experiment_result import ExperimentRunResult, ExperimentStatusInfo from galileo.__future__.shared.query_result import QueryResult from galileo.config import GalileoPythonConfig @@ -41,6 +39,8 @@ from galileo.schema.filters import FilterType from galileo.schema.metrics import GalileoMetrics, LocalMetricConfig, Metric from galileo.search import RecordType, Search +from galileo.shared.base import StateManagementMixin, SyncState +from galileo.shared.exceptions import ValidationError # TODO: get_records_for_dataset needed for function-based experiments # from galileo.utils.datasets import get_records_for_dataset, load_dataset_and_records @@ -51,9 +51,9 @@ if TYPE_CHECKING: from galileo.__future__.dataset import Dataset - from galileo.__future__.project import Project from galileo.__future__.prompt import Prompt from galileo.__future__.shared.column import ColumnCollection + from galileo.project import Project _logger = get_logger(__name__) @@ -1440,7 +1440,7 @@ def _get_columns(self, api_func: Any, error_msg: str) -> LogRecordsAvailableColu def project(self) -> Project | None: """Get the project this experiment belongs to.""" # Local import to avoid circular dependency - from galileo.__future__.project import Project + from galileo.project import Project return Project.get(id=self.project_id) @@ -1849,6 +1849,6 @@ def trace_columns(self) -> ColumnCollection: # Import at end to avoid circular import (dataset.py, prompt.py, project.py import Experiment) from galileo.__future__.dataset import Dataset # noqa: E402 from galileo.__future__.model import Model # noqa: E402 -from galileo.__future__.project import Project # noqa: E402 from galileo.__future__.prompt import Prompt # noqa: E402 from galileo.__future__.shared.column import Column, ColumnCollection # noqa: E402 +from galileo.project import Project # noqa: E402 diff --git a/src/galileo/__future__/integration.py b/src/galileo/__future__/integration.py index 2c31b48b2..577ae9ca2 100644 --- a/src/galileo/__future__/integration.py +++ b/src/galileo/__future__/integration.py @@ -4,8 +4,6 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, cast -from galileo.__future__.shared.base import StateManagementMixin, SyncState -from galileo.__future__.shared.exceptions import APIError, ValidationError from galileo.__future__.shared.utils import classproperty from galileo.config import GalileoPythonConfig from galileo.resources.api.integrations import ( @@ -15,6 +13,8 @@ from galileo.resources.models.integration_db import IntegrationDB from galileo.resources.models.integration_name import IntegrationName from galileo.resources.types import Unset +from galileo.shared.base import StateManagementMixin, SyncState +from galileo.shared.exceptions import APIError, ValidationError from galileo.utils.exceptions import APIException if TYPE_CHECKING: diff --git a/src/galileo/__future__/log_stream.py b/src/galileo/__future__/log_stream.py index 6b1c4e26c..49032a32e 100644 --- a/src/galileo/__future__/log_stream.py +++ b/src/galileo/__future__/log_stream.py @@ -7,8 +7,6 @@ from typing import TYPE_CHECKING, Any from galileo import galileo_context -from galileo.__future__.shared.base import StateManagementMixin, SyncState -from galileo.__future__.shared.exceptions import ValidationError from galileo.__future__.shared.query_result import QueryResult from galileo.config import GalileoPythonConfig from galileo.export import ExportClient @@ -26,6 +24,8 @@ from galileo.schema.filters import FilterType from galileo.schema.metrics import GalileoMetrics, LocalMetricConfig, Metric from galileo.search import RecordType, Search +from galileo.shared.base import StateManagementMixin, SyncState +from galileo.shared.exceptions import ValidationError if TYPE_CHECKING: from galileo.__future__.shared.column import ColumnCollection @@ -950,9 +950,9 @@ def trace_columns(self) -> ColumnCollection: # Import at end to avoid circular import (project.py imports LogStream) -from galileo.__future__.project import Project # noqa: E402 from galileo.__future__.shared.column import Column, ColumnCollection # noqa: E402 from galileo.config import GalileoConfig # noqa: E402 +from galileo.project import Project # noqa: E402 from galileo.resources.api.run_scorer_settings import ( # noqa: E402 get_settings_projects_project_id_runs_run_id_scorer_settings_get, ) diff --git a/src/galileo/__future__/metric.py b/src/galileo/__future__/metric.py index 430c21955..ee1e63b27 100644 --- a/src/galileo/__future__/metric.py +++ b/src/galileo/__future__/metric.py @@ -13,8 +13,6 @@ from galileo.__future__.model import Model from galileo.__future__.configuration import Configuration -from galileo.__future__.shared.base import StateManagementMixin, SyncState -from galileo.__future__.shared.exceptions import ValidationError from galileo.config import GalileoPythonConfig from galileo.metrics import Metrics from galileo.resources.api.data import ( @@ -36,6 +34,8 @@ from galileo.schema.metrics import GalileoMetrics, LocalMetricConfig from galileo.schema.metrics import Metric as LegacyMetric from galileo.scorers import Scorers +from galileo.shared.base import StateManagementMixin, SyncState +from galileo.shared.exceptions import ValidationError from galileo_core.schemas.logging.span import Span from galileo_core.schemas.logging.step import StepType from galileo_core.schemas.logging.trace import Trace diff --git a/src/galileo/__future__/project.py b/src/galileo/__future__/project.py index 2f919b5fd..58381d4c2 100644 --- a/src/galileo/__future__/project.py +++ b/src/galileo/__future__/project.py @@ -1,783 +1,5 @@ -from __future__ import annotations +"""Re-export from galileo.project — will be deprecated once all __future__ modules are migrated.""" -import builtins -import logging -from datetime import datetime -from typing import TYPE_CHECKING, Any +from galileo.project import Project -from galileo.__future__.collaborator import Collaborator, CollaboratorRole -from galileo.__future__.shared.base import StateManagementMixin, SyncState -from galileo.__future__.shared.exceptions import APIError, ValidationError -from galileo.projects import Projects - -if TYPE_CHECKING: - from galileo.__future__.dataset import Dataset - from galileo.__future__.experiment import Experiment - from galileo.__future__.log_stream import LogStream - from galileo.__future__.prompt import Prompt - -logger = logging.getLogger(__name__) - - -class Project(StateManagementMixin): - """ - Object-centric interface for Galileo projects. - - This class provides an intuitive way to work with Galileo projects, - encapsulating project management operations and providing seamless - integration with log stream management. - - Attributes - ---------- - created_at (datetime.datetime): When the project was created. - created_by (str): The user who created the project. - id (str): The unique project identifier. - updated_at (datetime.datetime): When the project was last updated. - bookmark (Union[Unset, bool]): Whether the project is bookmarked. - name (Union[None, Unset, str]): The project name. - permissions (Union[Unset, list]): Project permissions. - type (Union[None, ProjectType, Unset]): The project type. - - Examples - -------- - # Create a new project locally, then persist - project = Project(name="My AI Project").create() - - # Get an existing project - project = Project.get(name="My AI Project") - - # List all projects - projects = Project.list() - - # Create a log stream for the project - log_stream = project.create_log_stream(name="Production Logs") - - # List log streams for the project - log_streams = project.list_log_streams() - - # Access related resources via properties - for log_stream in project.logstreams: - print(log_stream.name) - - for experiment in project.experiments: - print(experiment.name) - - for dataset in project.datasets: - print(dataset.name) - - for prompt in project.prompts: - print(prompt.name) - - # Or use the explicit list methods - datasets = project.list_datasets() - prompts = project.list_prompts() - - # Manage collaborators - for collab in project.collaborators: - print(f"{collab.email}: {collab.role}") - - # Add a collaborator - project.add_collaborator(user_id="user-123", role=CollaboratorRole.EDITOR) - - # Update a collaborator's role - project.update_collaborator(user_id="user-123", role=CollaboratorRole.VIEWER) - - # Remove a collaborator - project.remove_collaborator(user_id="user-123") - - # Delete a project (WARNING: cannot be undone!) - old_project = Project.get(name="Old Project") - old_project.delete() - """ - - # Type annotations for instance attributes - id: str | None - name: str | None - created_at: datetime | None - created_by: str | None - updated_at: datetime | None - bookmark: bool | None - permissions: list[Any] | None - type: Any | None - - def __str__(self) -> str: - """String representation of the project.""" - return f"Project(name='{self.name}', id='{self.id}')" - - def __repr__(self) -> str: - """Detailed string representation of the project.""" - return f"Project(name='{self.name}', id='{self.id}', created_at='{self.created_at}')" - - def __init__(self, name: str | None = None) -> None: - """ - Initialize a Project instance locally. - - Creates a local project object that exists only in memory until .create() - is called to persist it to the API. - - Args: - name (Optional[str]): The name of the project to create. - - Raises - ------ - ValidationError: If name is not provided. - """ - super().__init__() - if name is None: - raise ValidationError( - "'name' must be provided to create a project. Use Project.get() to retrieve an existing project." - ) - - # Initialize attributes locally - self.name = name - self.id = None - self.created_at = None - self.created_by = None - self.updated_at = None - self.bookmark = None - self.permissions = None - self.type = None - - # Set initial state - self._set_state(SyncState.LOCAL_ONLY) - - @classmethod - def _create_empty(cls) -> Project: - """Internal constructor bypassing __init__ for API hydration.""" - instance = cls.__new__(cls) - super(Project, instance).__init__() - return instance - - @classmethod - def _from_api_response(cls, retrieved_project: Any) -> Project: - """ - Factory method to create a Project instance from an API response. - - Args: - retrieved_project: The project data retrieved from the API. - - Returns - ------- - Project: A new Project instance populated with the API data. - """ - instance = cls._create_empty() - instance.created_at = retrieved_project.created_at - instance.created_by = retrieved_project.created_by - instance.id = retrieved_project.id - instance.updated_at = retrieved_project.updated_at - instance.bookmark = retrieved_project.bookmark - instance.name = retrieved_project.name - instance.permissions = retrieved_project.permissions - instance.type = retrieved_project.type - # Set state to synced since we just retrieved from API - instance._set_state(SyncState.SYNCED) - return instance - - def create(self) -> Project: - """ - Persist this project to the API. - - Returns - ------- - Project: This project instance with updated attributes from the API. - - Raises - ------ - Exception: If the API call fails. - - Examples - -------- - project = Project(name="My AI Project").create() - assert project.is_synced() - """ - if self.name is None: - raise ValueError("Project name is not set. Cannot create project without a name.") - try: - logger.info(f"Project.create: name='{self.name}' - started") - projects_service = Projects() - created_project = projects_service.create(name=self.name) - - # Update attributes from response - self.created_at = created_project.created_at - self.created_by = created_project.created_by - self.id = created_project.id - self.updated_at = created_project.updated_at - self.bookmark = created_project.bookmark - self.name = created_project.name - self.permissions = created_project.permissions - self.type = created_project.type - - # Set state to synced - self._set_state(SyncState.SYNCED) - logger.info(f"Project.create: id='{self.id}' - completed") - return self - except Exception as e: - self._set_state(SyncState.FAILED_SYNC, error=e) - logger.error(f"Project.create: name='{self.name}' - failed: {e}") - raise - - @classmethod - def get(cls, *, id: str | None = None, name: str | None = None) -> Project | None: - """ - Get an existing project by ID or name. - - Args: - id (Optional[str]): The project ID. - name (Optional[str]): The project name. - - Returns - ------- - Optional[Project]: The project if found, None otherwise. - - Raises - ------ - ValidationError: If neither or both id and name are provided. - - Examples - -------- - # Get by name - project = Project.get(name="My AI Project") - - # Get by ID - project = Project.get(id="project-123") - """ - try: - projects_service = Projects() - retrieved_project = projects_service.get(id=id, name=name) - if retrieved_project is None: - return None - - return cls._from_api_response(retrieved_project) - except Exception as e: - logger.error("Project.get: id='%s' name='%s' - failed: %s", id, name, str(e)) - raise APIError("Failed to retrieve project: %s", original_error=e) from e - - @classmethod - def list(cls) -> builtins.list[Project]: - """ - List all available projects. - - Returns - ------- - List[Project]: A list of all projects. - - Examples - -------- - projects = Project.list() - for project in projects: - # Process each project - pass - """ - projects_service = Projects() - retrieved_projects = projects_service.list() - - return [cls._from_api_response(retrieved_project) for retrieved_project in retrieved_projects] - - def create_log_stream(self, name: str) -> LogStream: - """ - Create a new log stream for this project. - - Args: - name (str): The name of the log stream to create. - - Returns - ------- - LogStream: The created log stream. - - Examples - -------- - project = Project.get(name="My AI Project") - log_stream = project.create_log_stream(name="Production Logs") - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot create log stream for a local-only project.") - - # Use the LogStream pattern to avoid duplication - return LogStream(name=name, project_id=self.id).create() - - def list_log_streams(self) -> builtins.list[LogStream]: - """ - List all log streams for this project. - - Returns - ------- - List[LogStream]: A list of log streams belonging to this project. - - Examples - -------- - project = Project.get(name="My AI Project") - log_streams = project.list_log_streams() - for stream in log_streams: - # Process each log stream - pass - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot list log streams for a local-only project.") - - # Use the LogStream pattern to avoid duplication - return LogStream.list(project_id=self.id) - - def list_experiments(self) -> builtins.list[Experiment]: - """ - List all experiments for this project. - - Returns - ------- - List[Experiment]: A list of experiments belonging to this project. - - Examples - -------- - project = Project.get(name="My AI Project") - experiments = project.list_experiments() - for exp in experiments: - # Process each experiment - pass - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot list experiments for a local-only project.") - - # Use the Experiment pattern to avoid duplication - return Experiment.list(project_id=self.id) - - def list_datasets(self) -> builtins.list[Dataset]: - """ - List all datasets used in this project. - - Returns - ------- - List[Dataset]: A list of datasets used in this project. - - Examples - -------- - project = Project.get(name="My AI Project") - datasets = project.list_datasets() - for dataset in datasets: - # Process each dataset - pass - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot list datasets for a local-only project.") - - # Use the Dataset pattern to avoid duplication - return Dataset.list(project_id=self.id) - - def list_prompts(self) -> builtins.list[Prompt]: - """ - List all prompts used in this project. - - Returns - ------- - List[Prompt]: A list of prompts used in this project. - - Examples - -------- - project = Project.get(name="My AI Project") - prompts = project.list_prompts() - for prompt in prompts: - # Process each prompt - pass - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot list prompts for a local-only project.") - - # Use the Prompt pattern to avoid duplication - return Prompt.list(project_id=self.id) - - @property - def logstreams(self) -> builtins.list[LogStream]: - """ - Property to access log streams for this project. - - This is a read-only property that returns the current list of log streams. - To create new log streams, use create_log_stream(). - - Returns - ------- - List[LogStream]: A list of log streams belonging to this project. - - Examples - -------- - project = Project.get(name="My AI Project") - for stream in project.logstreams: - print(stream.name) - """ - return self.list_log_streams() - - @property - def experiments(self) -> builtins.list[Experiment]: - """ - Property to access experiments for this project. - - This is a read-only property that returns the current list of experiments. - - Returns - ------- - List[Experiment]: A list of experiments belonging to this project. - - Examples - -------- - project = Project.get(name="My AI Project") - for exp in project.experiments: - print(exp.name) - """ - return self.list_experiments() - - @property - def datasets(self) -> builtins.list[Dataset]: - """ - Property to access datasets used in this project. - - This is a read-only property that returns the datasets associated with this project. - - Returns - ------- - List[Dataset]: A list of datasets used in this project. - - Examples - -------- - project = Project.get(name="My AI Project") - for dataset in project.datasets: - print(dataset.name) - """ - return self.list_datasets() - - @property - def prompts(self) -> builtins.list[Prompt]: - """ - Property to access prompts used in this project. - - This is a read-only property that returns the prompts associated with this project. - - Returns - ------- - List[Prompt]: A list of prompts used in this project. - - Examples - -------- - project = Project.get(name="My AI Project") - for prompt in project.prompts: - print(prompt.name) - """ - return self.list_prompts() - - # ------------------------------------------------------------------------- - # Collaborator Management - # ------------------------------------------------------------------------- - - def list_collaborators(self) -> builtins.list[Collaborator]: - """ - List all collaborators for this project. - - Returns a list of Collaborator objects representing users who have - access to this project, along with their roles and permissions. - - Returns - ------- - list[Collaborator]: A list of collaborators for this project. - - Raises - ------ - ValueError: If the project ID is not set. - - Examples - -------- - project = Project.get(name="My AI Project") - collaborators = project.list_collaborators() - for collab in collaborators: - print(f"{collab.email}: {collab.role}") - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot list collaborators for a local-only project.") - - logger.info(f"Project.list_collaborators: project_id='{self.id}' - started") - - projects_service = Projects() - api_collaborators = projects_service.list_user_project_collaborators(project_id=self.id) - - collaborators = [Collaborator._from_api_response(c, project_id=self.id) for c in api_collaborators] - - logger.info(f"Project.list_collaborators: project_id='{self.id}' - found {len(collaborators)} collaborator(s)") - return collaborators - - @property - def collaborators(self) -> builtins.list[Collaborator]: - """ - Property to access collaborators for this project. - - Returns users who have access to this project. Each Collaborator object - has update() and remove() methods for modifying access. You can also - use add_collaborator() on the project to add new collaborators. - - .. note:: - **This property makes an API call on every access and is not cached.** - - Returns - ------- - list[Collaborator]: A list of collaborators for this project. - - Examples - -------- - project = Project.get(name="My AI Project") - for collab in project.collaborators: - print(f"{collab.email}: {collab.role}") - - # Filter by role - editors = [c for c in project.collaborators if c.role == CollaboratorRole.EDITOR] - - # Update or remove directly on the collaborator object - collab.update(role=CollaboratorRole.EDITOR) - collab.remove() - """ - return self.list_collaborators() - - def add_collaborator(self, user_id: str, role: CollaboratorRole = CollaboratorRole.VIEWER) -> Collaborator: - """ - Add a collaborator to this project. - - Shares the project with a user, granting them access with the specified role. - - Args: - user_id: The ID of the user to add as a collaborator. - role: The role to assign. One of CollaboratorRole.OWNER, EDITOR, VIEWER, or ANNOTATOR. - Defaults to VIEWER. - - Returns - ------- - Collaborator: The created collaborator object. - - Raises - ------ - ValueError: If the project ID is not set. - APIError: If the user is already a collaborator or the API call fails. - - Examples - -------- - project = Project.get(name="My AI Project") - - # Add a viewer (default role) - collab = project.add_collaborator(user_id="user-123") - - # Add an editor - collab = project.add_collaborator( - user_id="user-456", - role=CollaboratorRole.EDITOR - ) - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot add collaborator to a local-only project.") - - logger.info(f"Project.add_collaborator: project_id='{self.id}', user_id='{user_id}', role='{role}' - started") - - try: - projects_service = Projects() - api_collaborator = projects_service.share_project_with_user(project_id=self.id, user_id=user_id, role=role) - - collaborator = Collaborator._from_api_response(api_collaborator, project_id=self.id) - logger.info(f"Project.add_collaborator: project_id='{self.id}', user_id='{user_id}' - completed") - return collaborator - - except Exception as e: - logger.error(f"Project.add_collaborator: project_id='{self.id}', user_id='{user_id}' - failed: {e}") - raise APIError(f"Failed to add collaborator: {e}") from e - - def update_collaborator(self, user_id: str, role: CollaboratorRole) -> Collaborator: - """ - Update a collaborator's role on this project. - - Changes the role of an existing collaborator. The user must already - have access to the project. - - Args: - user_id: The ID of the user whose role to update. - role: The new role to assign. One of CollaboratorRole.OWNER, EDITOR, VIEWER, or ANNOTATOR. - - Returns - ------- - Collaborator: The updated collaborator object. - - Raises - ------ - ValueError: If the project ID is not set. - APIError: If the user is not a collaborator or the API call fails. - - Examples - -------- - project = Project.get(name="My AI Project") - - # Promote a viewer to editor - updated = project.update_collaborator( - user_id="user-123", - role=CollaboratorRole.EDITOR - ) - print(f"Updated role: {updated.role}") - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot update collaborator on a local-only project.") - - logger.info( - f"Project.update_collaborator: project_id='{self.id}', user_id='{user_id}', role='{role}' - started" - ) - - try: - projects_service = Projects() - api_collaborator = projects_service.update_user_project_collaborator( - project_id=self.id, user_id=user_id, role=role - ) - - collaborator = Collaborator._from_api_response(api_collaborator, project_id=self.id) - logger.info(f"Project.update_collaborator: project_id='{self.id}', user_id='{user_id}' - completed") - return collaborator - - except Exception as e: - logger.error(f"Project.update_collaborator: project_id='{self.id}', user_id='{user_id}' - failed: {e}") - raise APIError(f"Failed to update collaborator: {e}") from e - - def remove_collaborator(self, user_id: str) -> None: - """ - Remove a collaborator from this project. - - Revokes a user's access to this project. The user will no longer be able - to view or interact with the project. - - Args: - user_id: The ID of the user to remove. - - Raises - ------ - ValueError: If the project ID is not set. - APIError: If the user is not a collaborator or the API call fails. - - Examples - -------- - project = Project.get(name="My AI Project") - - # Remove a user's access - project.remove_collaborator(user_id="user-123") - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot remove collaborator from a local-only project.") - - logger.info(f"Project.remove_collaborator: project_id='{self.id}', user_id='{user_id}' - started") - - try: - projects_service = Projects() - projects_service.unshare_project_with_user(project_id=self.id, user_id=user_id) - logger.info(f"Project.remove_collaborator: project_id='{self.id}', user_id='{user_id}' - completed") - - except Exception as e: - logger.error(f"Project.remove_collaborator: project_id='{self.id}', user_id='{user_id}' - failed: {e}") - raise APIError(f"Failed to remove collaborator: {e}") from e - - def refresh(self) -> None: - """ - Refresh this project's state from the API. - - Updates all attributes with the latest values from the remote API - and sets the state to SYNCED. - - Raises - ------ - Exception: If the API call fails or the project no longer exists. - - Examples - -------- - project.refresh() - assert project.is_synced() - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot refresh a local-only project.") - try: - logger.debug(f"Project.refresh: id='{self.id}' - started") - projects_service = Projects() - retrieved_project = projects_service.get(id=self.id) - - if retrieved_project is None: - raise ValueError(f"Project with id '{self.id}' no longer exists") - - # Update all attributes from response - self.created_at = retrieved_project.created_at - self.created_by = retrieved_project.created_by - self.id = retrieved_project.id - self.updated_at = retrieved_project.updated_at - self.bookmark = retrieved_project.bookmark - self.name = retrieved_project.name - self.permissions = retrieved_project.permissions - self.type = retrieved_project.type - - # Set state to synced - self._set_state(SyncState.SYNCED) - logger.debug(f"Project.refresh: id='{self.id}' - completed") - except Exception as e: - self._set_state(SyncState.FAILED_SYNC, error=e) - logger.error(f"Project.refresh: id='{self.id}' - failed: {e}") - raise - - def delete(self) -> None: - """ - Delete this project. - - This is a destructive operation that permanently removes the project - and all associated data (experiments, log streams, datasets, traces, etc.) - from the API. - - WARNING: This operation cannot be undone! - - After successful deletion, the object state is set to DELETED. The local - object still exists in memory but no longer represents a remote resource. - - Raises - ------ - ValueError: If the project ID is not set. - Exception: If the API call fails. - - Examples - -------- - # Delete a project - project = Project.get(name="Old Project") - project.delete() - assert project.is_deleted() - - # After deletion, the project no longer exists remotely - # The local object is marked as DELETED - print(project.sync_state) # SyncState.DELETED - """ - if self.id is None: - raise ValueError("Project ID is not set. Cannot delete a local-only project.") - - try: - logger.info(f"Project.delete: name='{self.name}' id='{self.id}' - started") - projects_service = Projects() - projects_service.delete_project(id=self.id) - # Set state to deleted after successful deletion - self._set_state(SyncState.DELETED) - logger.info(f"Project.delete: name='{self.name}' id='{self.id}' - completed") - except Exception as e: - self._set_state(SyncState.FAILED_SYNC, error=e) - logger.error(f"Project.delete: name='{self.name}' id='{self.id}' - failed: {e}") - raise - - def save(self) -> Project: - """ - Save changes to this project. - - This method is a placeholder for future functionality to update - project properties. - - Returns - ------- - Project: This project instance. - - Raises - ------ - NotImplementedError: This functionality is not yet implemented. - """ - raise NotImplementedError( - "Project updates are not yet implemented. Use specific methods to modify project state." - ) - - -# Import at end to avoid circular import (log_stream.py imports Project) -from galileo.__future__.dataset import Dataset # noqa: E402 -from galileo.__future__.experiment import Experiment # noqa: E402 -from galileo.__future__.log_stream import LogStream # noqa: E402 -from galileo.__future__.prompt import Prompt # noqa: E402 +__all__ = ["Project"] diff --git a/src/galileo/__future__/prompt.py b/src/galileo/__future__/prompt.py index c0272851d..93baf2ee3 100644 --- a/src/galileo/__future__/prompt.py +++ b/src/galileo/__future__/prompt.py @@ -6,8 +6,6 @@ from datetime import datetime from typing import TYPE_CHECKING, Any -from galileo.__future__.shared.base import StateManagementMixin, SyncState -from galileo.__future__.shared.exceptions import ValidationError from galileo.config import GalileoPythonConfig from galileo.projects import Projects from galileo.prompts import GlobalPromptTemplates @@ -24,6 +22,8 @@ ) from galileo.resources.types import Unset from galileo.schema.message import Message +from galileo.shared.base import StateManagementMixin, SyncState +from galileo.shared.exceptions import ValidationError from galileo.utils.env_helpers import _get_project_from_env, _get_project_id_from_env from galileo_core.schemas.logging.llm import MessageRole diff --git a/src/galileo/__future__/provider.py b/src/galileo/__future__/provider.py index deb1b8fff..45cf6d6ba 100644 --- a/src/galileo/__future__/provider.py +++ b/src/galileo/__future__/provider.py @@ -5,8 +5,6 @@ from datetime import datetime from typing import Any -from galileo.__future__.shared.base import StateManagementMixin, SyncState -from galileo.__future__.shared.exceptions import APIError, IntegrationNotConfiguredError, ValidationError from galileo.config import GalileoPythonConfig from galileo.resources.api.integrations import ( create_or_update_integration_integrations_anthropic_put, @@ -26,6 +24,8 @@ OpenAIIntegrationCreate, ) from galileo.resources.types import Unset +from galileo.shared.base import StateManagementMixin, SyncState +from galileo.shared.exceptions import APIError, IntegrationNotConfiguredError, ValidationError from galileo.utils.exceptions import APIException logger = logging.getLogger(__name__) diff --git a/src/galileo/__future__/shared/base.py b/src/galileo/__future__/shared/base.py index f302a7a0e..ba09212f6 100644 --- a/src/galileo/__future__/shared/base.py +++ b/src/galileo/__future__/shared/base.py @@ -1,152 +1,5 @@ -"""Base classes for lifecycle and state management.""" +"""Re-export from galileo.shared.base — will be deprecated once all __future__ modules are migrated.""" -from __future__ import annotations +from galileo.shared.base import StateManagementMixin, SyncState -import logging -from abc import ABC, abstractmethod -from enum import Enum - -logger = logging.getLogger(__name__) - - -class SyncState(Enum): - """ - Enumeration of possible synchronization states for business objects. - - Attributes - ---------- - LOCAL_ONLY: Object exists only in memory, not yet persisted remotely. - SYNCED: Local and remote states match. - DIRTY: Local changes exist that have not been saved. - FAILED_SYNC: Last attempt to sync failed. - DELETED: Object was deleted remotely, local object still exists. - """ - - LOCAL_ONLY = "local_only" - SYNCED = "synced" - DIRTY = "dirty" - FAILED_SYNC = "failed_sync" - DELETED = "deleted" - - -class StateManagementMixin(ABC): - """ - Base mixin for business objects providing lifecycle state management. - - This mixin provides state tracking and helper methods for objects that - need to synchronize between local and remote (API) states. - - Attributes - ---------- - _sync_state: Current synchronization state of the object. - _last_error: Last error encountered during synchronization (optional). - """ - - def __init__(self) -> None: - """Initialize the mixin with default state.""" - self._sync_state: SyncState = SyncState.LOCAL_ONLY - self._last_error: Exception | None = None - - @property - def sync_state(self) -> SyncState: - """ - Get the current synchronization state. - - Returns - ------- - SyncState: The current state of the object. - """ - return self._sync_state - - def _set_state(self, state: SyncState, error: Exception | None = None) -> None: - """ - Set the synchronization state and optionally store an error. - - Args: - state: The new synchronization state. - error: Optional exception that caused the state transition. - """ - old_state = self._sync_state - self._sync_state = state - self._last_error = error - - # Log state transitions at DEBUG level - obj_name = self.__class__.__name__ - obj_id = getattr(self, "id", "unknown") - obj_name_attr = getattr(self, "name", "unknown") - - if error: - logger.debug( - f"{obj_name} state transition: {old_state.value} → {state.value} " - f"(id={obj_id}, name={obj_name_attr}, error={str(error)[:100]})" - ) - else: - logger.debug( - f"{obj_name} state transition: {old_state.value} → {state.value} (id={obj_id}, name={obj_name_attr})" - ) - - def is_synced(self) -> bool: - """ - Check if the object is synchronized with the remote state. - - Returns - ------- - bool: True if the object is synced, False otherwise. - """ - return self._sync_state == SyncState.SYNCED - - def is_dirty(self) -> bool: - """ - Check if the object has unsaved local changes. - - Returns - ------- - bool: True if the object has unsaved changes, False otherwise. - """ - return self._sync_state == SyncState.DIRTY - - def is_local_only(self) -> bool: - """ - Check if the object exists only locally. - - Returns - ------- - bool: True if the object has not been persisted, False otherwise. - """ - return self._sync_state == SyncState.LOCAL_ONLY - - def has_failed(self) -> bool: - """ - Check if the last synchronization attempt failed. - - Returns - ------- - bool: True if the last sync failed, False otherwise. - """ - return self._sync_state == SyncState.FAILED_SYNC - - def is_deleted(self) -> bool: - """ - Check if the object has been deleted remotely. - - Returns - ------- - bool: True if the object was deleted, False otherwise. - """ - return self._sync_state == SyncState.DELETED - - @abstractmethod - def refresh(self) -> None: - """ - Refresh the object state from the remote API. - - This method should: - 1. Fetch the latest state from the API - 2. Update all local attributes - 3. Set the state to SYNCED on success - - Raises - ------ - NotImplementedError: If not implemented by subclass. - """ - raise NotImplementedError("Subclasses must implement refresh()") +__all__ = ["StateManagementMixin", "SyncState"] diff --git a/src/galileo/__future__/shared/column.py b/src/galileo/__future__/shared/column.py index 099c2171b..e0def448d 100644 --- a/src/galileo/__future__/shared/column.py +++ b/src/galileo/__future__/shared/column.py @@ -6,7 +6,6 @@ from collections.abc import Iterator, Mapping from typing import TYPE_CHECKING, Any -from galileo.__future__.shared.exceptions import ValidationError from galileo.__future__.shared.filter import boolean, date, number, text from galileo.__future__.shared.sort import sort from galileo.resources.models import ( @@ -18,6 +17,7 @@ LogRecordsTextFilter, ) from galileo.resources.types import Unset +from galileo.shared.exceptions import ValidationError if TYPE_CHECKING: from galileo.resources.models import ColumnInfo diff --git a/src/galileo/__future__/shared/exceptions.py b/src/galileo/__future__/shared/exceptions.py index 4a6e03688..d6a833ebe 100644 --- a/src/galileo/__future__/shared/exceptions.py +++ b/src/galileo/__future__/shared/exceptions.py @@ -1,98 +1,23 @@ -from typing import ClassVar, Optional - - -class GalileoFutureError(Exception): - """ - Base exception for all Galileo Future API errors. - - This exception serves as the base class for all custom exceptions - in the future API, allowing users to catch all API-related errors. - """ - - -class ConfigurationError(GalileoFutureError): - """ - Raised when there are configuration-related errors. - - This includes missing API keys, invalid URLs, or connection failures. - """ - - -class ValidationError(GalileoFutureError): - """ - Raised when input validation fails. - - This includes invalid parameter combinations, missing required fields, - or malformed input data. - """ - - -class ResourceNotFoundError(GalileoFutureError): - """ - Raised when a requested resource cannot be found. - - This includes projects, datasets, prompts, or log streams that don't exist. - """ - - -class ResourceConflictError(GalileoFutureError): - """ - Raised when there's a conflict with existing resources. - - This includes attempting to create resources with duplicate names - or conflicting operations. - """ - - -class APIError(GalileoFutureError): - """ - Raised when the underlying API returns an error. - - This wraps errors from the legacy API to provide consistent error handling. - """ - - def __init__(self, message: str, original_error: Optional[Exception] = None): - super().__init__(message) - self.original_error = original_error - - -class SyncError(GalileoFutureError): - """ - Raised when there's a state synchronization error. - - This includes failures to persist changes, conflicts during updates, - or other synchronization-related issues. - """ - - def __init__(self, message: str, sync_state: Optional[str] = None, original_error: Optional[Exception] = None): - super().__init__(message) - self.sync_state = sync_state - self.original_error = original_error - - -class IntegrationNotConfiguredError(GalileoFutureError): - """ - Raised when attempting to use an integration that is not configured. - - This error provides guidance on how to create or configure the integration. - """ - - # Integrations that have SDK create methods - _SUPPORTED_CREATE_METHODS: ClassVar[dict[str, str]] = { - "openai": "Integration.create_openai()", - "azure": "Integration.create_azure()", - "aws_bedrock": "Integration.create_bedrock()", - "anthropic": "Integration.create_anthropic()", - } - - def __init__(self, integration_name: str): - create_method = self._SUPPORTED_CREATE_METHODS.get(integration_name) - if create_method: - message = ( - f"No '{integration_name}' integration configured.\n" - f"Create one using {create_method} or configure it in the Galileo console." - ) - else: - message = f"No '{integration_name}' integration configured.\nConfigure it in the Galileo console." - super().__init__(message) - self.integration_name = integration_name +"""Re-export from galileo.shared.exceptions — will be deprecated once all __future__ modules are migrated.""" + +from galileo.shared.exceptions import ( + APIError, + ConfigurationError, + GalileoFutureError, + IntegrationNotConfiguredError, + ResourceConflictError, + ResourceNotFoundError, + SyncError, + ValidationError, +) + +__all__ = [ + "APIError", + "ConfigurationError", + "GalileoFutureError", + "IntegrationNotConfiguredError", + "ResourceConflictError", + "ResourceNotFoundError", + "SyncError", + "ValidationError", +] diff --git a/src/galileo/__init__.py b/src/galileo/__init__.py index d51ef2ead..5ca6a4247 100644 --- a/src/galileo/__init__.py +++ b/src/galileo/__init__.py @@ -1,5 +1,6 @@ """Galileo.""" +from galileo.collaborator import Collaborator, CollaboratorRole from galileo.decorator import GalileoDecorator, galileo_context, log, start_session from galileo.exceptions import ( AuthenticationError, @@ -16,6 +17,7 @@ from galileo.protect import ainvoke_protect, invoke_protect from galileo.schema.message import Message from galileo.schema.metrics import GalileoMetrics, GalileoScorers +from galileo.shared.base import SyncState from galileo.stages import ( create_protect_stage, get_protect_stage, @@ -47,12 +49,20 @@ from galileo_core.schemas.protect.ruleset import Ruleset from galileo_core.schemas.protect.stage import StageType +# isort: split +# Import Project AFTER all other galileo modules to avoid circular imports. +# project.py → __future__/dataset.py → __future__/__init__.py → experiment.py +# → experiments.py → prompts.py → `from galileo import Message` +from galileo.project import Project + __version__ = "1.50.1" __all__ = [ "AgentSpan", "AuthenticationError", "BadRequestError", + "Collaborator", + "CollaboratorRole", "ConflictError", "ExecutionStatus", "ForbiddenError", @@ -67,6 +77,7 @@ "MessageRole", "NotFoundError", "Payload", + "Project", "RateLimitError", "Request", "Response", @@ -78,6 +89,7 @@ "StageType", "StepType", "StepWithChildSpans", + "SyncState", "ToolCall", "ToolCallFunction", "ToolSpan", diff --git a/src/galileo/collaborator.py b/src/galileo/collaborator.py new file mode 100644 index 000000000..c348729a2 --- /dev/null +++ b/src/galileo/collaborator.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any + +from galileo.projects import Projects + +# Re-export CollaboratorRole from the auto-generated models +from galileo.resources.models.collaborator_role import CollaboratorRole + +logger = logging.getLogger(__name__) + + +class Collaborator: + """ + Immutable representation of a user's collaboration on a project. + + A Collaborator binds a user to a project with a specific role and permissions. + Collaborators are contextual - the same user can have different roles on + different projects. + + Collaborator attributes are immutable. To modify the role, use the update() + method which returns a new Collaborator instance. To remove access, use remove(). + + Attributes + ---------- + id (str): The unique identifier of this collaboration (UserProject ID). + user_id (str): The ID of the user who has access to the project. + project_id (str): The ID of the project this collaboration belongs to. + role (CollaboratorRole): The role assigned to the user (OWNER, EDITOR, VIEWER, ANNOTATOR). + created_at (datetime | None): When this collaboration was created. + email (str | None): The user's email address. + first_name (str | None): The user's first name. + last_name (str | None): The user's last name. + permissions (list | None): Actions the API caller can perform on this collaborator + record (e.g., "update" or "delete" this collaboration). None indicates no + permissions data is available (either omitted by the API or explicitly empty). + + Examples + -------- + # Get collaborators for a project + project = Project.get(name="My Project") + collaborators = project.collaborators + + for collab in collaborators: + print(f"{collab.email} - {collab.role}") + + # Check if a specific user has access + viewer = next((c for c in collaborators if c.email == "viewer@example.com"), None) + if viewer: + print(f"User has {viewer.role} access") + + # Filter by role using the CollaboratorRole enum + from galileo import CollaboratorRole + editors = [c for c in collaborators if c.role == CollaboratorRole.EDITOR] + + # Update a collaborator's role directly on the object + updated_collab = collab.update(role=CollaboratorRole.EDITOR) + + # Remove a collaborator + collab.remove() + """ + + _id: str + _user_id: str + _project_id: str + _role: CollaboratorRole + _created_at: datetime | None + _email: str | None + _first_name: str | None + _last_name: str | None + _permissions: list[Any] | None + + def __init__( + self, + *, + id: str, + user_id: str, + project_id: str, + role: CollaboratorRole, + created_at: datetime | None = None, + email: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + permissions: list[Any] | None = None, + ) -> None: + """ + Initialize a Collaborator instance. + + This constructor is typically called internally by Project methods. + Users should not create Collaborator instances directly. + + Args: + id: The collaboration record ID. + user_id: The user's ID. + project_id: The project's ID. + role: The collaboration role (CollaboratorRole enum). + created_at: When the collaboration was created. + email: The user's email address. + first_name: The user's first name. + last_name: The user's last name. + permissions: Actions the API caller can perform on the record. None indicates + no permissions data is available (either omitted or explicitly empty). + """ + # Use object.__setattr__ to bypass __setattr__ immutability + object.__setattr__(self, "_id", id) + object.__setattr__(self, "_user_id", user_id) + object.__setattr__(self, "_project_id", project_id) + object.__setattr__(self, "_role", role) + object.__setattr__(self, "_created_at", created_at) + object.__setattr__(self, "_email", email) + object.__setattr__(self, "_first_name", first_name) + object.__setattr__(self, "_last_name", last_name) + object.__setattr__(self, "_permissions", permissions) + + @property + def id(self) -> str: + """Get the collaboration record ID.""" + return self._id + + @property + def user_id(self) -> str: + """Get the user ID.""" + return self._user_id + + @property + def project_id(self) -> str: + """Get the project ID.""" + return self._project_id + + @property + def role(self) -> CollaboratorRole: + """Get the collaboration role.""" + return self._role + + @property + def created_at(self) -> datetime | None: + """Get the creation timestamp.""" + return self._created_at + + @property + def email(self) -> str | None: + """Get the user's email address.""" + return self._email + + @property + def first_name(self) -> str | None: + """Get the user's first name.""" + return self._first_name + + @property + def last_name(self) -> str | None: + """Get the user's last name.""" + return self._last_name + + @property + def permissions(self) -> list[Any] | None: + """Get the permissions for this collaboration. + + Returns None if no permissions data is available (either omitted by + the API or explicitly empty). A non-empty list indicates specific + actions the API caller can perform on this collaborator record. + """ + return self._permissions + + def __str__(self) -> str: + """String representation of the collaborator.""" + return f"Collaborator(email='{self._email}', role={self._role})" + + def __repr__(self) -> str: + """Detailed string representation of the collaborator.""" + return f"Collaborator(id='{self._id}', user_id='{self._user_id}', email='{self._email}', role={self._role})" + + def __setattr__(self, name: str, value: Any) -> None: + """ + Prevent modification of collaborator attributes (immutability). + + Raises + ------ + AttributeError: Always, since collaborators are immutable. + """ + raise AttributeError( + f"Collaborator objects are immutable. Cannot set attribute '{name}'. " + f"Use collab.update(role=...) to change the role." + ) + + def __delattr__(self, name: str) -> None: + """ + Prevent deletion of collaborator attributes (immutability). + + Raises + ------ + AttributeError: Always, since collaborators are immutable. + """ + raise AttributeError(f"Collaborator objects are immutable. Cannot delete attribute '{name}'.") + + def __eq__(self, other: object) -> bool: + """ + Check equality based on user_id and project_id. + + Args: + other: Object to compare with. + + Returns + ------- + bool: True if collaborators represent the same user-project binding. + """ + if not isinstance(other, Collaborator): + return False + return self._user_id == other._user_id and self._project_id == other._project_id + + def __hash__(self) -> int: + """ + Hash based on user_id and project_id. + + Returns + ------- + int: Hash value. + """ + return hash((self._user_id, self._project_id)) + + def to_dict(self) -> dict[str, Any]: + """ + Convert collaborator to dictionary representation. + + Returns + ------- + dict: Dictionary with collaborator properties. + """ + return { + "id": self._id, + "user_id": self._user_id, + "project_id": self._project_id, + "role": self._role.value, # Convert enum to string value + "created_at": self._created_at.isoformat() if self._created_at else None, + "email": self._email, + "first_name": self._first_name, + "last_name": self._last_name, + "permissions": self._permissions, + } + + def update(self, role: CollaboratorRole) -> Collaborator: + """ + Update this collaborator's role. + + Creates a new Collaborator instance with the updated role. The original + instance remains unchanged (immutability is preserved). + + Args: + role: The new role to assign (CollaboratorRole enum). + + Returns + ------- + Collaborator: A new Collaborator instance with the updated role. + + Examples + -------- + # Update a collaborator's role + collab = project.collaborators[0] + updated_collab = collab.update(role=CollaboratorRole.EDITOR) + print(f"New role: {updated_collab.role}") + """ + logger.info( + f"Collaborator.update: project_id='{self._project_id}', user_id='{self._user_id}', role='{role}' - started" + ) + + try: + projects_service = Projects() + api_collaborator = projects_service.update_user_project_collaborator( + project_id=self._project_id, user_id=self._user_id, role=role + ) + + updated = Collaborator._from_api_response(api_collaborator, project_id=self._project_id) + logger.info(f"Collaborator.update: project_id='{self._project_id}', user_id='{self._user_id}' - completed") + return updated + + except Exception as e: + logger.error( + f"Collaborator.update: project_id='{self._project_id}', user_id='{self._user_id}' - failed: {e}" + ) + raise + + def remove(self) -> None: + """ + Remove this collaborator from the project. + + Revokes the user's access to the project. After calling this method, + the collaborator object should no longer be used. + + Examples + -------- + # Remove a collaborator + collab = project.collaborators[0] + collab.remove() + """ + logger.info(f"Collaborator.remove: project_id='{self._project_id}', user_id='{self._user_id}' - started") + + try: + projects_service = Projects() + projects_service.unshare_project_with_user(project_id=self._project_id, user_id=self._user_id) + logger.info(f"Collaborator.remove: project_id='{self._project_id}', user_id='{self._user_id}' - completed") + + except Exception as e: + logger.error( + f"Collaborator.remove: project_id='{self._project_id}', user_id='{self._user_id}' - failed: {e}" + ) + raise + + @classmethod + def _from_api_response(cls, response: Any, project_id: str) -> Collaborator: + """ + Factory method to create a Collaborator from an API response. + + Args: + response: The UserCollaborator response from the API. + project_id: The project ID this collaborator belongs to. + + Returns + ------- + Collaborator: A new Collaborator instance. + """ + # Handle permissions - convert to list of dicts if present and non-empty. + # Note: The API model normalizes missing permissions to an empty list, so we cannot + # distinguish between "field omitted" and "explicitly empty". Both result in None here. + permissions = None + if hasattr(response, "permissions") and response.permissions: + permissions = [ + {"action": p.action, "allowed": p.allowed, "message": p.message} if hasattr(p, "action") else p + for p in response.permissions + ] + + # Handle role - it should already be a CollaboratorRole enum from the API + role = response.role if hasattr(response, "role") else CollaboratorRole.VIEWER + if isinstance(role, str): + role = CollaboratorRole(role) + + return cls( + id=str(response.id), + user_id=str(response.user_id), + project_id=project_id, + role=role, + created_at=response.created_at if hasattr(response, "created_at") else None, + email=response.email if hasattr(response, "email") else None, + first_name=response.first_name if hasattr(response, "first_name") else None, + last_name=response.last_name if hasattr(response, "last_name") else None, + permissions=permissions, + ) diff --git a/src/galileo/decorator.py b/src/galileo/decorator.py index 568533f5a..8775104e9 100644 --- a/src/galileo/decorator.py +++ b/src/galileo/decorator.py @@ -635,7 +635,7 @@ def _safe_prepare_call( self._prepare_call(span_type, span_params, dataset_record) return True except Exception as e: - from galileo.__future__.shared.exceptions import ConfigurationError + from galileo.shared.exceptions import ConfigurationError if isinstance(e, ConfigurationError): _logger.error("Galileo logging initialization failed: %s", e, exc_info=True) diff --git a/src/galileo/project.py b/src/galileo/project.py new file mode 100644 index 000000000..c573a5a19 --- /dev/null +++ b/src/galileo/project.py @@ -0,0 +1,783 @@ +from __future__ import annotations + +import builtins +import logging +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from galileo.collaborator import Collaborator, CollaboratorRole +from galileo.projects import Projects +from galileo.shared.base import StateManagementMixin, SyncState +from galileo.shared.exceptions import APIError, ValidationError + +if TYPE_CHECKING: + from galileo.__future__.dataset import Dataset + from galileo.__future__.experiment import Experiment + from galileo.__future__.log_stream import LogStream + from galileo.__future__.prompt import Prompt + +logger = logging.getLogger(__name__) + + +class Project(StateManagementMixin): + """ + Object-centric interface for Galileo projects. + + This class provides an intuitive way to work with Galileo projects, + encapsulating project management operations and providing seamless + integration with log stream management. + + Attributes + ---------- + created_at (datetime.datetime): When the project was created. + created_by (str): The user who created the project. + id (str): The unique project identifier. + updated_at (datetime.datetime): When the project was last updated. + bookmark (Union[Unset, bool]): Whether the project is bookmarked. + name (Union[None, Unset, str]): The project name. + permissions (Union[Unset, list]): Project permissions. + type (Union[None, ProjectType, Unset]): The project type. + + Examples + -------- + # Create a new project locally, then persist + project = Project(name="My AI Project").create() + + # Get an existing project + project = Project.get(name="My AI Project") + + # List all projects + projects = Project.list() + + # Create a log stream for the project + log_stream = project.create_log_stream(name="Production Logs") + + # List log streams for the project + log_streams = project.list_log_streams() + + # Access related resources via properties + for log_stream in project.logstreams: + print(log_stream.name) + + for experiment in project.experiments: + print(experiment.name) + + for dataset in project.datasets: + print(dataset.name) + + for prompt in project.prompts: + print(prompt.name) + + # Or use the explicit list methods + datasets = project.list_datasets() + prompts = project.list_prompts() + + # Manage collaborators + for collab in project.collaborators: + print(f"{collab.email}: {collab.role}") + + # Add a collaborator + project.add_collaborator(user_id="user-123", role=CollaboratorRole.EDITOR) + + # Update a collaborator's role + project.update_collaborator(user_id="user-123", role=CollaboratorRole.VIEWER) + + # Remove a collaborator + project.remove_collaborator(user_id="user-123") + + # Delete a project (WARNING: cannot be undone!) + old_project = Project.get(name="Old Project") + old_project.delete() + """ + + # Type annotations for instance attributes + id: str | None + name: str | None + created_at: datetime | None + created_by: str | None + updated_at: datetime | None + bookmark: bool | None + permissions: list[Any] | None + type: Any | None + + def __str__(self) -> str: + """String representation of the project.""" + return f"Project(name='{self.name}', id='{self.id}')" + + def __repr__(self) -> str: + """Detailed string representation of the project.""" + return f"Project(name='{self.name}', id='{self.id}', created_at='{self.created_at}')" + + def __init__(self, name: str | None = None) -> None: + """ + Initialize a Project instance locally. + + Creates a local project object that exists only in memory until .create() + is called to persist it to the API. + + Args: + name (Optional[str]): The name of the project to create. + + Raises + ------ + ValidationError: If name is not provided. + """ + super().__init__() + if name is None: + raise ValidationError( + "'name' must be provided to create a project. Use Project.get() to retrieve an existing project." + ) + + # Initialize attributes locally + self.name = name + self.id = None + self.created_at = None + self.created_by = None + self.updated_at = None + self.bookmark = None + self.permissions = None + self.type = None + + # Set initial state + self._set_state(SyncState.LOCAL_ONLY) + + @classmethod + def _create_empty(cls) -> Project: + """Internal constructor bypassing __init__ for API hydration.""" + instance = cls.__new__(cls) + super(Project, instance).__init__() + return instance + + @classmethod + def _from_api_response(cls, retrieved_project: Any) -> Project: + """ + Factory method to create a Project instance from an API response. + + Args: + retrieved_project: The project data retrieved from the API. + + Returns + ------- + Project: A new Project instance populated with the API data. + """ + instance = cls._create_empty() + instance.created_at = retrieved_project.created_at + instance.created_by = retrieved_project.created_by + instance.id = retrieved_project.id + instance.updated_at = retrieved_project.updated_at + instance.bookmark = retrieved_project.bookmark + instance.name = retrieved_project.name + instance.permissions = retrieved_project.permissions + instance.type = retrieved_project.type + # Set state to synced since we just retrieved from API + instance._set_state(SyncState.SYNCED) + return instance + + def create(self) -> Project: + """ + Persist this project to the API. + + Returns + ------- + Project: This project instance with updated attributes from the API. + + Raises + ------ + Exception: If the API call fails. + + Examples + -------- + project = Project(name="My AI Project").create() + assert project.is_synced() + """ + if self.name is None: + raise ValueError("Project name is not set. Cannot create project without a name.") + try: + logger.info(f"Project.create: name='{self.name}' - started") + projects_service = Projects() + created_project = projects_service.create(name=self.name) + + # Update attributes from response + self.created_at = created_project.created_at + self.created_by = created_project.created_by + self.id = created_project.id + self.updated_at = created_project.updated_at + self.bookmark = created_project.bookmark + self.name = created_project.name + self.permissions = created_project.permissions + self.type = created_project.type + + # Set state to synced + self._set_state(SyncState.SYNCED) + logger.info(f"Project.create: id='{self.id}' - completed") + return self + except Exception as e: + self._set_state(SyncState.FAILED_SYNC, error=e) + logger.error(f"Project.create: name='{self.name}' - failed: {e}") + raise + + @classmethod + def get(cls, *, id: str | None = None, name: str | None = None) -> Project | None: + """ + Get an existing project by ID or name. + + Args: + id (Optional[str]): The project ID. + name (Optional[str]): The project name. + + Returns + ------- + Optional[Project]: The project if found, None otherwise. + + Raises + ------ + ValidationError: If neither or both id and name are provided. + + Examples + -------- + # Get by name + project = Project.get(name="My AI Project") + + # Get by ID + project = Project.get(id="project-123") + """ + try: + projects_service = Projects() + retrieved_project = projects_service.get(id=id, name=name) + if retrieved_project is None: + return None + + return cls._from_api_response(retrieved_project) + except Exception as e: + logger.error("Project.get: id='%s' name='%s' - failed: %s", id, name, str(e)) + raise APIError("Failed to retrieve project: %s", original_error=e) from e + + @classmethod + def list(cls) -> builtins.list[Project]: + """ + List all available projects. + + Returns + ------- + List[Project]: A list of all projects. + + Examples + -------- + projects = Project.list() + for project in projects: + # Process each project + pass + """ + projects_service = Projects() + retrieved_projects = projects_service.list() + + return [cls._from_api_response(retrieved_project) for retrieved_project in retrieved_projects] + + def create_log_stream(self, name: str) -> LogStream: + """ + Create a new log stream for this project. + + Args: + name (str): The name of the log stream to create. + + Returns + ------- + LogStream: The created log stream. + + Examples + -------- + project = Project.get(name="My AI Project") + log_stream = project.create_log_stream(name="Production Logs") + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot create log stream for a local-only project.") + + # Use the LogStream pattern to avoid duplication + return LogStream(name=name, project_id=self.id).create() + + def list_log_streams(self) -> builtins.list[LogStream]: + """ + List all log streams for this project. + + Returns + ------- + List[LogStream]: A list of log streams belonging to this project. + + Examples + -------- + project = Project.get(name="My AI Project") + log_streams = project.list_log_streams() + for stream in log_streams: + # Process each log stream + pass + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot list log streams for a local-only project.") + + # Use the LogStream pattern to avoid duplication + return LogStream.list(project_id=self.id) + + def list_experiments(self) -> builtins.list[Experiment]: + """ + List all experiments for this project. + + Returns + ------- + List[Experiment]: A list of experiments belonging to this project. + + Examples + -------- + project = Project.get(name="My AI Project") + experiments = project.list_experiments() + for exp in experiments: + # Process each experiment + pass + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot list experiments for a local-only project.") + + # Use the Experiment pattern to avoid duplication + return Experiment.list(project_id=self.id) + + def list_datasets(self) -> builtins.list[Dataset]: + """ + List all datasets used in this project. + + Returns + ------- + List[Dataset]: A list of datasets used in this project. + + Examples + -------- + project = Project.get(name="My AI Project") + datasets = project.list_datasets() + for dataset in datasets: + # Process each dataset + pass + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot list datasets for a local-only project.") + + # Use the Dataset pattern to avoid duplication + return Dataset.list(project_id=self.id) + + def list_prompts(self) -> builtins.list[Prompt]: + """ + List all prompts used in this project. + + Returns + ------- + List[Prompt]: A list of prompts used in this project. + + Examples + -------- + project = Project.get(name="My AI Project") + prompts = project.list_prompts() + for prompt in prompts: + # Process each prompt + pass + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot list prompts for a local-only project.") + + # Use the Prompt pattern to avoid duplication + return Prompt.list(project_id=self.id) + + @property + def logstreams(self) -> builtins.list[LogStream]: + """ + Property to access log streams for this project. + + This is a read-only property that returns the current list of log streams. + To create new log streams, use create_log_stream(). + + Returns + ------- + List[LogStream]: A list of log streams belonging to this project. + + Examples + -------- + project = Project.get(name="My AI Project") + for stream in project.logstreams: + print(stream.name) + """ + return self.list_log_streams() + + @property + def experiments(self) -> builtins.list[Experiment]: + """ + Property to access experiments for this project. + + This is a read-only property that returns the current list of experiments. + + Returns + ------- + List[Experiment]: A list of experiments belonging to this project. + + Examples + -------- + project = Project.get(name="My AI Project") + for exp in project.experiments: + print(exp.name) + """ + return self.list_experiments() + + @property + def datasets(self) -> builtins.list[Dataset]: + """ + Property to access datasets used in this project. + + This is a read-only property that returns the datasets associated with this project. + + Returns + ------- + List[Dataset]: A list of datasets used in this project. + + Examples + -------- + project = Project.get(name="My AI Project") + for dataset in project.datasets: + print(dataset.name) + """ + return self.list_datasets() + + @property + def prompts(self) -> builtins.list[Prompt]: + """ + Property to access prompts used in this project. + + This is a read-only property that returns the prompts associated with this project. + + Returns + ------- + List[Prompt]: A list of prompts used in this project. + + Examples + -------- + project = Project.get(name="My AI Project") + for prompt in project.prompts: + print(prompt.name) + """ + return self.list_prompts() + + # ------------------------------------------------------------------------- + # Collaborator Management + # ------------------------------------------------------------------------- + + def list_collaborators(self) -> builtins.list[Collaborator]: + """ + List all collaborators for this project. + + Returns a list of Collaborator objects representing users who have + access to this project, along with their roles and permissions. + + Returns + ------- + list[Collaborator]: A list of collaborators for this project. + + Raises + ------ + ValueError: If the project ID is not set. + + Examples + -------- + project = Project.get(name="My AI Project") + collaborators = project.list_collaborators() + for collab in collaborators: + print(f"{collab.email}: {collab.role}") + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot list collaborators for a local-only project.") + + logger.info(f"Project.list_collaborators: project_id='{self.id}' - started") + + projects_service = Projects() + api_collaborators = projects_service.list_user_project_collaborators(project_id=self.id) + + collaborators = [Collaborator._from_api_response(c, project_id=self.id) for c in api_collaborators] + + logger.info(f"Project.list_collaborators: project_id='{self.id}' - found {len(collaborators)} collaborator(s)") + return collaborators + + @property + def collaborators(self) -> builtins.list[Collaborator]: + """ + Property to access collaborators for this project. + + Returns users who have access to this project. Each Collaborator object + has update() and remove() methods for modifying access. You can also + use add_collaborator() on the project to add new collaborators. + + .. note:: + **This property makes an API call on every access and is not cached.** + + Returns + ------- + list[Collaborator]: A list of collaborators for this project. + + Examples + -------- + project = Project.get(name="My AI Project") + for collab in project.collaborators: + print(f"{collab.email}: {collab.role}") + + # Filter by role + editors = [c for c in project.collaborators if c.role == CollaboratorRole.EDITOR] + + # Update or remove directly on the collaborator object + collab.update(role=CollaboratorRole.EDITOR) + collab.remove() + """ + return self.list_collaborators() + + def add_collaborator(self, user_id: str, role: CollaboratorRole = CollaboratorRole.VIEWER) -> Collaborator: + """ + Add a collaborator to this project. + + Shares the project with a user, granting them access with the specified role. + + Args: + user_id: The ID of the user to add as a collaborator. + role: The role to assign. One of CollaboratorRole.OWNER, EDITOR, VIEWER, or ANNOTATOR. + Defaults to VIEWER. + + Returns + ------- + Collaborator: The created collaborator object. + + Raises + ------ + ValueError: If the project ID is not set. + APIError: If the user is already a collaborator or the API call fails. + + Examples + -------- + project = Project.get(name="My AI Project") + + # Add a viewer (default role) + collab = project.add_collaborator(user_id="user-123") + + # Add an editor + collab = project.add_collaborator( + user_id="user-456", + role=CollaboratorRole.EDITOR + ) + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot add collaborator to a local-only project.") + + logger.info(f"Project.add_collaborator: project_id='{self.id}', user_id='{user_id}', role='{role}' - started") + + try: + projects_service = Projects() + api_collaborator = projects_service.share_project_with_user(project_id=self.id, user_id=user_id, role=role) + + collaborator = Collaborator._from_api_response(api_collaborator, project_id=self.id) + logger.info(f"Project.add_collaborator: project_id='{self.id}', user_id='{user_id}' - completed") + return collaborator + + except Exception as e: + logger.error(f"Project.add_collaborator: project_id='{self.id}', user_id='{user_id}' - failed: {e}") + raise APIError(f"Failed to add collaborator: {e}") from e + + def update_collaborator(self, user_id: str, role: CollaboratorRole) -> Collaborator: + """ + Update a collaborator's role on this project. + + Changes the role of an existing collaborator. The user must already + have access to the project. + + Args: + user_id: The ID of the user whose role to update. + role: The new role to assign. One of CollaboratorRole.OWNER, EDITOR, VIEWER, or ANNOTATOR. + + Returns + ------- + Collaborator: The updated collaborator object. + + Raises + ------ + ValueError: If the project ID is not set. + APIError: If the user is not a collaborator or the API call fails. + + Examples + -------- + project = Project.get(name="My AI Project") + + # Promote a viewer to editor + updated = project.update_collaborator( + user_id="user-123", + role=CollaboratorRole.EDITOR + ) + print(f"Updated role: {updated.role}") + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot update collaborator on a local-only project.") + + logger.info( + f"Project.update_collaborator: project_id='{self.id}', user_id='{user_id}', role='{role}' - started" + ) + + try: + projects_service = Projects() + api_collaborator = projects_service.update_user_project_collaborator( + project_id=self.id, user_id=user_id, role=role + ) + + collaborator = Collaborator._from_api_response(api_collaborator, project_id=self.id) + logger.info(f"Project.update_collaborator: project_id='{self.id}', user_id='{user_id}' - completed") + return collaborator + + except Exception as e: + logger.error(f"Project.update_collaborator: project_id='{self.id}', user_id='{user_id}' - failed: {e}") + raise APIError(f"Failed to update collaborator: {e}") from e + + def remove_collaborator(self, user_id: str) -> None: + """ + Remove a collaborator from this project. + + Revokes a user's access to this project. The user will no longer be able + to view or interact with the project. + + Args: + user_id: The ID of the user to remove. + + Raises + ------ + ValueError: If the project ID is not set. + APIError: If the user is not a collaborator or the API call fails. + + Examples + -------- + project = Project.get(name="My AI Project") + + # Remove a user's access + project.remove_collaborator(user_id="user-123") + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot remove collaborator from a local-only project.") + + logger.info(f"Project.remove_collaborator: project_id='{self.id}', user_id='{user_id}' - started") + + try: + projects_service = Projects() + projects_service.unshare_project_with_user(project_id=self.id, user_id=user_id) + logger.info(f"Project.remove_collaborator: project_id='{self.id}', user_id='{user_id}' - completed") + + except Exception as e: + logger.error(f"Project.remove_collaborator: project_id='{self.id}', user_id='{user_id}' - failed: {e}") + raise APIError(f"Failed to remove collaborator: {e}") from e + + def refresh(self) -> None: + """ + Refresh this project's state from the API. + + Updates all attributes with the latest values from the remote API + and sets the state to SYNCED. + + Raises + ------ + Exception: If the API call fails or the project no longer exists. + + Examples + -------- + project.refresh() + assert project.is_synced() + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot refresh a local-only project.") + try: + logger.debug(f"Project.refresh: id='{self.id}' - started") + projects_service = Projects() + retrieved_project = projects_service.get(id=self.id) + + if retrieved_project is None: + raise ValueError(f"Project with id '{self.id}' no longer exists") + + # Update all attributes from response + self.created_at = retrieved_project.created_at + self.created_by = retrieved_project.created_by + self.id = retrieved_project.id + self.updated_at = retrieved_project.updated_at + self.bookmark = retrieved_project.bookmark + self.name = retrieved_project.name + self.permissions = retrieved_project.permissions + self.type = retrieved_project.type + + # Set state to synced + self._set_state(SyncState.SYNCED) + logger.debug(f"Project.refresh: id='{self.id}' - completed") + except Exception as e: + self._set_state(SyncState.FAILED_SYNC, error=e) + logger.error(f"Project.refresh: id='{self.id}' - failed: {e}") + raise + + def delete(self) -> None: + """ + Delete this project. + + This is a destructive operation that permanently removes the project + and all associated data (experiments, log streams, datasets, traces, etc.) + from the API. + + WARNING: This operation cannot be undone! + + After successful deletion, the object state is set to DELETED. The local + object still exists in memory but no longer represents a remote resource. + + Raises + ------ + ValueError: If the project ID is not set. + Exception: If the API call fails. + + Examples + -------- + # Delete a project + project = Project.get(name="Old Project") + project.delete() + assert project.is_deleted() + + # After deletion, the project no longer exists remotely + # The local object is marked as DELETED + print(project.sync_state) # SyncState.DELETED + """ + if self.id is None: + raise ValueError("Project ID is not set. Cannot delete a local-only project.") + + try: + logger.info(f"Project.delete: name='{self.name}' id='{self.id}' - started") + projects_service = Projects() + projects_service.delete_project(id=self.id) + # Set state to deleted after successful deletion + self._set_state(SyncState.DELETED) + logger.info(f"Project.delete: name='{self.name}' id='{self.id}' - completed") + except Exception as e: + self._set_state(SyncState.FAILED_SYNC, error=e) + logger.error(f"Project.delete: name='{self.name}' id='{self.id}' - failed: {e}") + raise + + def save(self) -> Project: + """ + Save changes to this project. + + This method is a placeholder for future functionality to update + project properties. + + Returns + ------- + Project: This project instance. + + Raises + ------ + NotImplementedError: This functionality is not yet implemented. + """ + raise NotImplementedError( + "Project updates are not yet implemented. Use specific methods to modify project state." + ) + + +# Import at end to avoid circular import (log_stream.py imports Project) +from galileo.__future__.dataset import Dataset # noqa: E402 +from galileo.__future__.experiment import Experiment # noqa: E402 +from galileo.__future__.log_stream import LogStream # noqa: E402 +from galileo.__future__.prompt import Prompt # noqa: E402 diff --git a/src/galileo/projects.py b/src/galileo/projects.py index 7a33688f8..0196c2ebc 100644 --- a/src/galileo/projects.py +++ b/src/galileo/projects.py @@ -3,6 +3,7 @@ from typing import Optional, Union import httpx +from typing_extensions import deprecated from galileo.config import GalileoPythonConfig from galileo.resources.api.projects import ( @@ -397,6 +398,7 @@ def delete_project(self, id: Optional[str] = None, name: Optional[str] = None) - # +@deprecated("Use galileo.project.Project.get() instead.") def get_project(*, id: Optional[str] = None, name: Optional[str] = None) -> Optional[Project]: """ Retrieves a project by id or name (exactly one of `id` or `name` must be provided). @@ -426,6 +428,7 @@ def get_project(*, id: Optional[str] = None, name: Optional[str] = None) -> Opti return Projects().get(id=id, name=name) +@deprecated("Use galileo.project.Project.list() instead.") def list_projects() -> list[Project]: """ Lists all projects. @@ -451,6 +454,7 @@ def list_projects() -> list[Project]: return Projects().list() +@deprecated("Use galileo.project.Project(name=...).create() instead.") def create_project(name: str) -> Project: """ Creates a new project. @@ -478,6 +482,7 @@ def create_project(name: str) -> Project: return Projects().create(name=name) +@deprecated("Use project.add_collaborator() instead.") def share_project_with_user( project_id: str, user_id: str, role: CollaboratorRole = CollaboratorRole.VIEWER ) -> UserCollaborator: @@ -501,6 +506,7 @@ def share_project_with_user( return Projects().share_project_with_user(project_id=project_id, user_id=user_id, role=role) +@deprecated("Use project.remove_collaborator() instead.") def unshare_project_with_user(project_id: str, user_id: str) -> None: """ Unshare a project with a user. @@ -515,6 +521,7 @@ def unshare_project_with_user(project_id: str, user_id: str) -> None: return Projects().unshare_project_with_user(project_id=project_id, user_id=user_id) +@deprecated("Use project.list_collaborators() instead.") def list_user_project_collaborators(project_id: str) -> list[UserCollaborator]: """ List all users that a project is shared with. @@ -532,6 +539,7 @@ def list_user_project_collaborators(project_id: str) -> list[UserCollaborator]: return Projects().list_user_project_collaborators(project_id=project_id) +@deprecated("Use project.update_collaborator() instead.") def update_user_project_collaborator( project_id: str, user_id: str, role: CollaboratorRole = CollaboratorRole.VIEWER ) -> UserCollaborator: @@ -555,6 +563,7 @@ def update_user_project_collaborator( return Projects().update_user_project_collaborator(project_id=project_id, user_id=user_id, role=role) +@deprecated("Use galileo.project.Project.get(name=...).delete() instead.") def delete_project(*, id: Optional[str] = None, name: Optional[str] = None) -> bool: """ Deletes a gen_ai project by ID or name (exactly one of `id` or `name` must be provided). diff --git a/src/galileo/shared/__init__.py b/src/galileo/shared/__init__.py new file mode 100644 index 000000000..0546cdc29 --- /dev/null +++ b/src/galileo/shared/__init__.py @@ -0,0 +1 @@ +"""Shared utilities for the galileo package.""" diff --git a/src/galileo/shared/base.py b/src/galileo/shared/base.py new file mode 100644 index 000000000..f302a7a0e --- /dev/null +++ b/src/galileo/shared/base.py @@ -0,0 +1,152 @@ +"""Base classes for lifecycle and state management.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from enum import Enum + +logger = logging.getLogger(__name__) + + +class SyncState(Enum): + """ + Enumeration of possible synchronization states for business objects. + + Attributes + ---------- + LOCAL_ONLY: Object exists only in memory, not yet persisted remotely. + SYNCED: Local and remote states match. + DIRTY: Local changes exist that have not been saved. + FAILED_SYNC: Last attempt to sync failed. + DELETED: Object was deleted remotely, local object still exists. + """ + + LOCAL_ONLY = "local_only" + SYNCED = "synced" + DIRTY = "dirty" + FAILED_SYNC = "failed_sync" + DELETED = "deleted" + + +class StateManagementMixin(ABC): + """ + Base mixin for business objects providing lifecycle state management. + + This mixin provides state tracking and helper methods for objects that + need to synchronize between local and remote (API) states. + + Attributes + ---------- + _sync_state: Current synchronization state of the object. + _last_error: Last error encountered during synchronization (optional). + """ + + def __init__(self) -> None: + """Initialize the mixin with default state.""" + self._sync_state: SyncState = SyncState.LOCAL_ONLY + self._last_error: Exception | None = None + + @property + def sync_state(self) -> SyncState: + """ + Get the current synchronization state. + + Returns + ------- + SyncState: The current state of the object. + """ + return self._sync_state + + def _set_state(self, state: SyncState, error: Exception | None = None) -> None: + """ + Set the synchronization state and optionally store an error. + + Args: + state: The new synchronization state. + error: Optional exception that caused the state transition. + """ + old_state = self._sync_state + self._sync_state = state + self._last_error = error + + # Log state transitions at DEBUG level + obj_name = self.__class__.__name__ + obj_id = getattr(self, "id", "unknown") + obj_name_attr = getattr(self, "name", "unknown") + + if error: + logger.debug( + f"{obj_name} state transition: {old_state.value} → {state.value} " + f"(id={obj_id}, name={obj_name_attr}, error={str(error)[:100]})" + ) + else: + logger.debug( + f"{obj_name} state transition: {old_state.value} → {state.value} (id={obj_id}, name={obj_name_attr})" + ) + + def is_synced(self) -> bool: + """ + Check if the object is synchronized with the remote state. + + Returns + ------- + bool: True if the object is synced, False otherwise. + """ + return self._sync_state == SyncState.SYNCED + + def is_dirty(self) -> bool: + """ + Check if the object has unsaved local changes. + + Returns + ------- + bool: True if the object has unsaved changes, False otherwise. + """ + return self._sync_state == SyncState.DIRTY + + def is_local_only(self) -> bool: + """ + Check if the object exists only locally. + + Returns + ------- + bool: True if the object has not been persisted, False otherwise. + """ + return self._sync_state == SyncState.LOCAL_ONLY + + def has_failed(self) -> bool: + """ + Check if the last synchronization attempt failed. + + Returns + ------- + bool: True if the last sync failed, False otherwise. + """ + return self._sync_state == SyncState.FAILED_SYNC + + def is_deleted(self) -> bool: + """ + Check if the object has been deleted remotely. + + Returns + ------- + bool: True if the object was deleted, False otherwise. + """ + return self._sync_state == SyncState.DELETED + + @abstractmethod + def refresh(self) -> None: + """ + Refresh the object state from the remote API. + + This method should: + 1. Fetch the latest state from the API + 2. Update all local attributes + 3. Set the state to SYNCED on success + + Raises + ------ + NotImplementedError: If not implemented by subclass. + """ + raise NotImplementedError("Subclasses must implement refresh()") diff --git a/src/galileo/shared/exceptions.py b/src/galileo/shared/exceptions.py new file mode 100644 index 000000000..4a6e03688 --- /dev/null +++ b/src/galileo/shared/exceptions.py @@ -0,0 +1,98 @@ +from typing import ClassVar, Optional + + +class GalileoFutureError(Exception): + """ + Base exception for all Galileo Future API errors. + + This exception serves as the base class for all custom exceptions + in the future API, allowing users to catch all API-related errors. + """ + + +class ConfigurationError(GalileoFutureError): + """ + Raised when there are configuration-related errors. + + This includes missing API keys, invalid URLs, or connection failures. + """ + + +class ValidationError(GalileoFutureError): + """ + Raised when input validation fails. + + This includes invalid parameter combinations, missing required fields, + or malformed input data. + """ + + +class ResourceNotFoundError(GalileoFutureError): + """ + Raised when a requested resource cannot be found. + + This includes projects, datasets, prompts, or log streams that don't exist. + """ + + +class ResourceConflictError(GalileoFutureError): + """ + Raised when there's a conflict with existing resources. + + This includes attempting to create resources with duplicate names + or conflicting operations. + """ + + +class APIError(GalileoFutureError): + """ + Raised when the underlying API returns an error. + + This wraps errors from the legacy API to provide consistent error handling. + """ + + def __init__(self, message: str, original_error: Optional[Exception] = None): + super().__init__(message) + self.original_error = original_error + + +class SyncError(GalileoFutureError): + """ + Raised when there's a state synchronization error. + + This includes failures to persist changes, conflicts during updates, + or other synchronization-related issues. + """ + + def __init__(self, message: str, sync_state: Optional[str] = None, original_error: Optional[Exception] = None): + super().__init__(message) + self.sync_state = sync_state + self.original_error = original_error + + +class IntegrationNotConfiguredError(GalileoFutureError): + """ + Raised when attempting to use an integration that is not configured. + + This error provides guidance on how to create or configure the integration. + """ + + # Integrations that have SDK create methods + _SUPPORTED_CREATE_METHODS: ClassVar[dict[str, str]] = { + "openai": "Integration.create_openai()", + "azure": "Integration.create_azure()", + "aws_bedrock": "Integration.create_bedrock()", + "anthropic": "Integration.create_anthropic()", + } + + def __init__(self, integration_name: str): + create_method = self._SUPPORTED_CREATE_METHODS.get(integration_name) + if create_method: + message = ( + f"No '{integration_name}' integration configured.\n" + f"Create one using {create_method} or configure it in the Galileo console." + ) + else: + message = f"No '{integration_name}' integration configured.\nConfigure it in the Galileo console." + super().__init__(message) + self.integration_name = integration_name diff --git a/src/galileo/utils/validations.py b/src/galileo/utils/validations.py index df82ddb4b..ff5cecd29 100644 --- a/src/galileo/utils/validations.py +++ b/src/galileo/utils/validations.py @@ -1,7 +1,7 @@ from functools import wraps from typing import Any, Callable, TypeVar -from galileo.__future__.shared.exceptions import ValidationError +from galileo.shared.exceptions import ValidationError R = TypeVar("R") diff --git a/tests/future/test_experiment.py b/tests/future/test_experiment.py index 4af82a50f..f244afb26 100644 --- a/tests/future/test_experiment.py +++ b/tests/future/test_experiment.py @@ -675,7 +675,7 @@ def test_query_raises_error_when_not_synced(self, reset_configuration: None) -> class TestExperimentRelationships: """Test suite for Experiment relationship properties.""" - @patch("galileo.__future__.project.Project") + @patch("galileo.project.Project") def test_project_property_returns_project( self, mock_project_class: MagicMock, diff --git a/tests/future/test_project.py b/tests/future/test_project.py index 7295a88dc..1cc9dd196 100644 --- a/tests/future/test_project.py +++ b/tests/future/test_project.py @@ -30,7 +30,7 @@ def test_init_without_name_raises_validation_error(self, reset_configuration: No class TestProjectCreate: """Test suite for Project.create() method.""" - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_create_persists_project_to_api( self, mock_projects_class: MagicMock, reset_configuration: None, mock_project: MagicMock ) -> None: @@ -45,7 +45,7 @@ def test_create_persists_project_to_api( assert project.id == mock_project.id assert project.is_synced() - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_create_handles_api_failure(self, mock_projects_class: MagicMock, reset_configuration: None) -> None: """Test create() handles API failures and sets state correctly.""" mock_service = MagicMock() @@ -64,7 +64,7 @@ class TestProjectGet: """Test suite for Project.get() class method.""" @pytest.mark.parametrize("lookup_key,lookup_value", [("name", "Test Project"), ("id", "test-project-id-123")]) - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_get_returns_project( self, mock_projects_class: MagicMock, @@ -85,7 +85,7 @@ def test_get_returns_project( assert project is not None assert project.is_synced() - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_get_returns_none_when_not_found(self, mock_projects_class: MagicMock, reset_configuration: None) -> None: """Test get() returns None when project is not found.""" mock_service = MagicMock() @@ -96,7 +96,7 @@ def test_get_returns_none_when_not_found(self, mock_projects_class: MagicMock, r assert project is None - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_get_handles_api_error(self, mock_projects_class: MagicMock, reset_configuration: None) -> None: """Test get() wraps API errors in APIError.""" mock_service = MagicMock() @@ -110,7 +110,7 @@ def test_get_handles_api_error(self, mock_projects_class: MagicMock, reset_confi class TestProjectList: """Test suite for Project.list() class method.""" - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_list_returns_all_projects(self, mock_projects_class: MagicMock, reset_configuration: None) -> None: """Test list() returns a list of synced project instances.""" mock_service = MagicMock() @@ -143,7 +143,7 @@ class TestProjectLogStreams: @patch("galileo.__future__.log_stream.LogStreams") @patch("galileo.__future__.log_stream.Projects") - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_create_log_stream( self, mock_projects_class: MagicMock, @@ -182,7 +182,7 @@ def test_create_log_stream_raises_error_for_local_only_project(self, reset_confi @patch("galileo.__future__.log_stream.Projects") @patch("galileo.__future__.log_stream.LogStreams") - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_list_log_streams( self, mock_projects_class: MagicMock, @@ -222,7 +222,7 @@ def test_list_log_streams( class TestProjectRefresh: """Test suite for Project.refresh() method.""" - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_refresh_updates_attributes_from_api( self, mock_projects_class: MagicMock, reset_configuration: None ) -> None: @@ -268,7 +268,7 @@ def test_refresh_raises_error_for_local_only_project(self, reset_configuration: with pytest.raises(ValueError, match="Project ID is not set"): project.refresh() - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_refresh_raises_error_if_project_no_longer_exists( self, mock_projects_class: MagicMock, reset_configuration: None, mock_project: MagicMock ) -> None: @@ -315,7 +315,7 @@ def test_repr_representation(self, reset_configuration: None) -> None: class TestProjectCollaborators: """Test suite for Project collaborator management methods.""" - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_add_update_remove_collaborator( self, mock_projects_class: MagicMock, @@ -341,7 +341,7 @@ def test_add_update_remove_collaborator( project.remove_collaborator(user_id=mock_collaborator.user_id) mock_service.unshare_project_with_user.assert_called_once() - @patch("galileo.__future__.project.Projects") + @patch("galileo.project.Projects") def test_collaborators_property_returns_same_as_list_method( self, mock_projects_class: MagicMock, @@ -483,7 +483,7 @@ def test_collaborator_to_dict_with_none_values( assert result["last_name"] is None assert result["permissions"] is None - @patch("galileo.__future__.collaborator.Projects") + @patch("galileo.collaborator.Projects") def test_collaborator_update_error_path( self, mock_projects_class: MagicMock, reset_configuration: None, mock_collaborator: MagicMock ) -> None: @@ -502,7 +502,7 @@ def test_collaborator_update_error_path( with pytest.raises(Exception, match="API Error"): collab.update(role=CollaboratorRole.EDITOR) - @patch("galileo.__future__.collaborator.Projects") + @patch("galileo.collaborator.Projects") def test_collaborator_remove_error_path( self, mock_projects_class: MagicMock, reset_configuration: None, mock_collaborator: MagicMock ) -> None: diff --git a/tests/test_api_headers.py b/tests/test_api_headers.py index 1b3196a26..93c260077 100644 --- a/tests/test_api_headers.py +++ b/tests/test_api_headers.py @@ -103,7 +103,7 @@ def capture_and_call(*args, **kwargs): assert header2.startswith("galileo-python/") # But they should have different method names - assert "list@galileo.__future__.project" in header1.lower() + assert "list@galileo.project" in header1.lower() assert "list_projects@galileo.projects" in header2 # Headers should be different diff --git a/tests/test_backward_compat_future.py b/tests/test_backward_compat_future.py new file mode 100644 index 000000000..4047d19c7 --- /dev/null +++ b/tests/test_backward_compat_future.py @@ -0,0 +1,76 @@ +"""Tests that all migrated symbols remain importable from galileo.__future__ and resolve to the same objects.""" + + +def test_project_is_same_class(): + from galileo.__future__ import Project as FutureProject + from galileo.project import Project as RootProject + + assert FutureProject is RootProject + + +def test_collaborator_is_same_class(): + from galileo.__future__ import Collaborator as FutureCollaborator + from galileo.collaborator import Collaborator as RootCollaborator + + assert FutureCollaborator is RootCollaborator + + +def test_collaborator_role_is_same_class(): + from galileo.__future__ import CollaboratorRole as FutureRole + from galileo.collaborator import CollaboratorRole as RootRole + + assert FutureRole is RootRole + + +def test_sync_state_is_same_class(): + from galileo.__future__.shared.base import SyncState as FutureSyncState + from galileo.shared.base import SyncState as RootSyncState + + assert FutureSyncState is RootSyncState + + +def test_state_management_mixin_is_same_class(): + from galileo.__future__.shared.base import StateManagementMixin as FutureMixin + from galileo.shared.base import StateManagementMixin as RootMixin + + assert FutureMixin is RootMixin + + +def test_exceptions_are_same_classes(): + from galileo.__future__.shared.exceptions import APIError as FutureAPIError + from galileo.__future__.shared.exceptions import ConfigurationError as FutureConfigError + from galileo.__future__.shared.exceptions import GalileoFutureError as FutureBaseError + from galileo.__future__.shared.exceptions import IntegrationNotConfiguredError as FutureIntError + from galileo.__future__.shared.exceptions import ResourceConflictError as FutureConflictError + from galileo.__future__.shared.exceptions import ResourceNotFoundError as FutureNotFoundError + from galileo.__future__.shared.exceptions import SyncError as FutureSyncError + from galileo.__future__.shared.exceptions import ValidationError as FutureValidationError + from galileo.shared.exceptions import ( + APIError, + ConfigurationError, + GalileoFutureError, + IntegrationNotConfiguredError, + ResourceConflictError, + ResourceNotFoundError, + SyncError, + ValidationError, + ) + + assert FutureAPIError is APIError + assert FutureConfigError is ConfigurationError + assert FutureBaseError is GalileoFutureError + assert FutureIntError is IntegrationNotConfiguredError + assert FutureConflictError is ResourceConflictError + assert FutureNotFoundError is ResourceNotFoundError + assert FutureSyncError is SyncError + assert FutureValidationError is ValidationError + + +def test_root_init_exports(): + """Test that the new exports are available from the galileo package root.""" + from galileo import Collaborator, CollaboratorRole, Project, SyncState + + assert Project is not None + assert Collaborator is not None + assert CollaboratorRole is not None + assert SyncState is not None From 81a553265dd2c64e570e60d84b133a27fe56574a Mon Sep 17 00:00:00 2001 From: thiagobomfin-galileo Date: Mon, 16 Mar 2026 12:55:48 -0300 Subject: [PATCH 2/4] fix(project): raise ValidationError for invalid Project.get() arguments --- src/galileo/project.py | 5 +++++ tests/future/test_project.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/galileo/project.py b/src/galileo/project.py index c573a5a19..a2dfaeb6a 100644 --- a/src/galileo/project.py +++ b/src/galileo/project.py @@ -241,6 +241,11 @@ def get(cls, *, id: str | None = None, name: str | None = None) -> Project | Non # Get by ID project = Project.get(id="project-123") """ + id = id.strip() if id else None + name = name.strip() if name else None + if (not id and not name) or (id and name): + raise ValidationError("Exactly one of 'id' or 'name' must be provided.") + try: projects_service = Projects() retrieved_project = projects_service.get(id=id, name=name) diff --git a/tests/future/test_project.py b/tests/future/test_project.py index 1cc9dd196..453d453a1 100644 --- a/tests/future/test_project.py +++ b/tests/future/test_project.py @@ -106,6 +106,24 @@ def test_get_handles_api_error(self, mock_projects_class: MagicMock, reset_confi with pytest.raises(APIError, match="Failed to retrieve project"): Project.get(name="Test Project") + def test_get_raises_validation_error_when_no_args(self, reset_configuration: None) -> None: + # Given: no id or name provided + # When/Then: ValidationError is raised (not APIError) + with pytest.raises(ValidationError, match="Exactly one of 'id' or 'name' must be provided"): + Project.get() + + def test_get_raises_validation_error_when_both_args(self, reset_configuration: None) -> None: + # Given: both id and name provided + # When/Then: ValidationError is raised (not APIError) + with pytest.raises(ValidationError, match="Exactly one of 'id' or 'name' must be provided"): + Project.get(id="proj-123", name="My Project") + + def test_get_raises_validation_error_for_whitespace_only_args(self, reset_configuration: None) -> None: + # Given: only whitespace provided for both args (strips to empty) + # When/Then: ValidationError is raised (not APIError) + with pytest.raises(ValidationError, match="Exactly one of 'id' or 'name' must be provided"): + Project.get(id=" ", name=" ") + class TestProjectList: """Test suite for Project.list() class method.""" From 2e6b4120c2f55bef788529e3624dcb65a11b0193 Mon Sep 17 00:00:00 2001 From: thiagobomfin-galileo Date: Mon, 16 Mar 2026 12:57:46 -0300 Subject: [PATCH 3/4] fix: resolve circular import issue --- src/galileo/__future__/log_stream.py | 2 +- src/galileo/__init__.py | 7 +------ src/galileo/experiments.py | 3 +-- src/galileo/prompts.py | 2 +- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/galileo/__future__/log_stream.py b/src/galileo/__future__/log_stream.py index 49032a32e..c2513b799 100644 --- a/src/galileo/__future__/log_stream.py +++ b/src/galileo/__future__/log_stream.py @@ -6,9 +6,9 @@ from datetime import datetime from typing import TYPE_CHECKING, Any -from galileo import galileo_context from galileo.__future__.shared.query_result import QueryResult from galileo.config import GalileoPythonConfig +from galileo.decorator import galileo_context from galileo.export import ExportClient from galileo.log_streams import LogStreams from galileo.projects import Projects diff --git a/src/galileo/__init__.py b/src/galileo/__init__.py index 5ca6a4247..5d116b7fb 100644 --- a/src/galileo/__init__.py +++ b/src/galileo/__init__.py @@ -14,6 +14,7 @@ ServerError, ) from galileo.logger import GalileoLogger +from galileo.project import Project from galileo.protect import ainvoke_protect, invoke_protect from galileo.schema.message import Message from galileo.schema.metrics import GalileoMetrics, GalileoScorers @@ -49,12 +50,6 @@ from galileo_core.schemas.protect.ruleset import Ruleset from galileo_core.schemas.protect.stage import StageType -# isort: split -# Import Project AFTER all other galileo modules to avoid circular imports. -# project.py → __future__/dataset.py → __future__/__init__.py → experiment.py -# → experiments.py → prompts.py → `from galileo import Message` -from galileo.project import Project - __version__ = "1.50.1" __all__ = [ diff --git a/src/galileo/experiments.py b/src/galileo/experiments.py index be242f0df..542f9f5c3 100644 --- a/src/galileo/experiments.py +++ b/src/galileo/experiments.py @@ -7,10 +7,9 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field -from galileo import galileo_context, log from galileo.config import GalileoPythonConfig from galileo.datasets import Dataset, convert_dataset_row_to_record -from galileo.decorator import galileo_dataset_context +from galileo.decorator import galileo_context, galileo_dataset_context, log from galileo.experiment_tags import upsert_experiment_tag from galileo.projects import Project, Projects from galileo.prompts import PromptTemplate diff --git a/src/galileo/prompts.py b/src/galileo/prompts.py index 38c280d79..bca9a5401 100644 --- a/src/galileo/prompts.py +++ b/src/galileo/prompts.py @@ -3,7 +3,6 @@ import warnings from typing import Optional, Union, overload -from galileo import Message from galileo.config import GalileoPythonConfig from galileo.resources.api.prompts import ( create_global_prompt_template_templates_post, @@ -30,6 +29,7 @@ UpdatePromptTemplateRequest, ) from galileo.resources.types import Unset +from galileo.schema.message import Message from galileo.utils.exceptions import APIException from galileo.utils.projects import resolve_project_id from galileo.utils.prompts import generate_unique_name From ddd1b9157f24007ac0cffc2d5badfc0844f501be Mon Sep 17 00:00:00 2001 From: thiagobomfin-galileo Date: Mon, 16 Mar 2026 13:24:52 -0300 Subject: [PATCH 4/4] fix(decorator): move ConfigurationError import to module scope --- src/galileo/decorator.py | 3 +-- src/galileo/project.py | 7 ++----- tests/future/test_project.py | 39 +++++++++++++++++++++++++++--------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/galileo/decorator.py b/src/galileo/decorator.py index 8775104e9..3a3b803dc 100644 --- a/src/galileo/decorator.py +++ b/src/galileo/decorator.py @@ -62,6 +62,7 @@ def call_llm(prompt, temperature=0.7): from galileo.schema.datasets import DatasetRecord from galileo.schema.metrics import LocalMetricConfig from galileo.schema.trace import SPAN_TYPE +from galileo.shared.exceptions import ConfigurationError from galileo.utils import _get_timestamp from galileo.utils.env_helpers import _get_mode_or_default from galileo.utils.serialization import EventSerializer, convert_time_delta_to_ns, serialize_to_str @@ -635,8 +636,6 @@ def _safe_prepare_call( self._prepare_call(span_type, span_params, dataset_record) return True except Exception as e: - from galileo.shared.exceptions import ConfigurationError - if isinstance(e, ConfigurationError): _logger.error("Galileo logging initialization failed: %s", e, exc_info=True) else: diff --git a/src/galileo/project.py b/src/galileo/project.py index a2dfaeb6a..b5c75906e 100644 --- a/src/galileo/project.py +++ b/src/galileo/project.py @@ -241,11 +241,6 @@ def get(cls, *, id: str | None = None, name: str | None = None) -> Project | Non # Get by ID project = Project.get(id="project-123") """ - id = id.strip() if id else None - name = name.strip() if name else None - if (not id and not name) or (id and name): - raise ValidationError("Exactly one of 'id' or 'name' must be provided.") - try: projects_service = Projects() retrieved_project = projects_service.get(id=id, name=name) @@ -253,6 +248,8 @@ def get(cls, *, id: str | None = None, name: str | None = None) -> Project | Non return None return cls._from_api_response(retrieved_project) + except ValueError as e: + raise ValidationError(str(e)) from e except Exception as e: logger.error("Project.get: id='%s' name='%s' - failed: %s", id, name, str(e)) raise APIError("Failed to retrieve project: %s", original_error=e) from e diff --git a/tests/future/test_project.py b/tests/future/test_project.py index 453d453a1..c9ad75ef7 100644 --- a/tests/future/test_project.py +++ b/tests/future/test_project.py @@ -106,21 +106,42 @@ def test_get_handles_api_error(self, mock_projects_class: MagicMock, reset_confi with pytest.raises(APIError, match="Failed to retrieve project"): Project.get(name="Test Project") - def test_get_raises_validation_error_when_no_args(self, reset_configuration: None) -> None: - # Given: no id or name provided - # When/Then: ValidationError is raised (not APIError) + @patch("galileo.project.Projects") + def test_get_raises_validation_error_when_no_args( + self, mock_projects_class: MagicMock, reset_configuration: None + ) -> None: + # Given: Projects.get raises ValueError for missing id and name + mock_service = MagicMock() + mock_projects_class.return_value = mock_service + mock_service.get.side_effect = ValueError("Exactly one of 'id' or 'name' must be provided.") + + # When/Then: Project.get() translates it to ValidationError (not APIError) with pytest.raises(ValidationError, match="Exactly one of 'id' or 'name' must be provided"): Project.get() - def test_get_raises_validation_error_when_both_args(self, reset_configuration: None) -> None: - # Given: both id and name provided - # When/Then: ValidationError is raised (not APIError) + @patch("galileo.project.Projects") + def test_get_raises_validation_error_when_both_args( + self, mock_projects_class: MagicMock, reset_configuration: None + ) -> None: + # Given: Projects.get raises ValueError when both id and name are supplied + mock_service = MagicMock() + mock_projects_class.return_value = mock_service + mock_service.get.side_effect = ValueError("Exactly one of 'id' or 'name' must be provided.") + + # When/Then: Project.get() translates it to ValidationError (not APIError) with pytest.raises(ValidationError, match="Exactly one of 'id' or 'name' must be provided"): Project.get(id="proj-123", name="My Project") - def test_get_raises_validation_error_for_whitespace_only_args(self, reset_configuration: None) -> None: - # Given: only whitespace provided for both args (strips to empty) - # When/Then: ValidationError is raised (not APIError) + @patch("galileo.project.Projects") + def test_get_raises_validation_error_for_whitespace_only_args( + self, mock_projects_class: MagicMock, reset_configuration: None + ) -> None: + # Given: Projects.get strips whitespace then raises ValueError (both args collapse to empty) + mock_service = MagicMock() + mock_projects_class.return_value = mock_service + mock_service.get.side_effect = ValueError("Exactly one of 'id' or 'name' must be provided.") + + # When/Then: Project.get() translates it to ValidationError (not APIError) with pytest.raises(ValidationError, match="Exactly one of 'id' or 'name' must be provided"): Project.get(id=" ", name=" ")