Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.32] 2025-10-28

- Add `Client.create_project_from_template()` method to create a new project from a template
- Add `Project.create_from_template()` method to create a new project from a template

## [1.0.31] 2025-10-14

- Add `expert_guardrail_override_explanation` and `log_id` to `ProjectValidateResponse` docstring
Expand Down Expand Up @@ -145,7 +150,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Initial release of the `cleanlab-codex` client library.

[Unreleased]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.31...HEAD
[Unreleased]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.32...HEAD
[1.0.32]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.31...v1.0.32
[1.0.31]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.30...v1.0.31
[1.0.30]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.29...v1.0.30
[1.0.29]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.28...v1.0.29
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ classifiers = [
]
dependencies = [
"cleanlab-tlm~=1.1,>=1.1.14",
"codex-sdk==0.1.0a30",
"codex-sdk==0.1.0a31",
"pydantic>=2.0.0, <3",
]

Expand Down
2 changes: 1 addition & 1 deletion src/cleanlab_codex/__about__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# SPDX-License-Identifier: MIT
__version__ = "1.0.31"
__version__ = "1.0.32"
18 changes: 18 additions & 0 deletions src/cleanlab_codex/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ def create_project(self, name: str, description: Optional[str] = None) -> Projec

return Project.create(self._client, self._organization_id, name, description)

def create_project_from_template(
self,
template_project_id: str,
name: str | None = None,
description: str | None = None,
) -> Project:
"""Create a new project from a template. Project will be created in the organization the client is using.

Args:
template_project_id (str): The ID of the template project to create the project from.
name (str, optional): Optional name for the project. If not provided, the name will be the same as the template project.
description (str, optional): Optional description for the project. If not provided, the description will be the same as the template project.

Returns:
Project: The created project.
"""
return Project.create_from_template(self._client, self._organization_id, template_project_id, name, description)

def list_organizations(self) -> list[Organization]:
"""List the organizations the authenticated user is a member of.

Expand Down
30 changes: 30 additions & 0 deletions src/cleanlab_codex/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,36 @@ def create(

return Project(sdk_client, project_id, verify_existence=False)

@classmethod
def create_from_template(
cls,
sdk_client: _Codex,
organization_id: str,
template_project_id: str,
name: str | None = None,
description: str | None = None,
) -> Project:
"""Create a new project from a template.

Args:
sdk_client (Codex): The Codex SDK client to use to create the project. This client must be authenticated with a user-level API key.
organization_id (str): The ID of the organization to create the project in.
template_project_id (str): The ID of the template project to create the project from.
name (str, optional): Optional name for the project. If not provided, the name will be the same as the template project.
description (str, optional): Optional description for the project. If not provided, the description will be the same as the template project.

Returns:
Project: The created project.
"""
project_id = sdk_client.projects.create_from_template(
organization_id=organization_id,
template_project_id=template_project_id,
name=name,
description=description,
extra_headers=_AnalyticsMetadata().to_headers(),
).id
return Project(sdk_client, project_id, verify_existence=False)

def create_access_key(
self,
name: str,
Expand Down
37 changes: 35 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from codex import AuthenticationError
from codex.types.project_return_schema import Config as ProjectReturnConfig
from codex.types.project_return_schema import ProjectReturnSchema
from codex.types.users.myself.user_organizations_schema import Organization as SDKOrganization
from codex.types.users.myself.user_organizations_schema import (
Organization as SDKOrganization,
)
from codex.types.users.myself.user_organizations_schema import UserOrganizationsSchema

from cleanlab_codex.client import Client
Expand All @@ -22,6 +24,7 @@
FAKE_PROJECT_DESCRIPTION = "Test Description"
DEFAULT_PROJECT_CONFIG = ProjectConfig()
DUMMY_API_KEY = "GP0FzPfA7wYy5L64luII2YaRT2JoSXkae7WEo7dH6Bw"
FAKE_TEMPLATE_PROJECT_ID = str(uuid.uuid4())


def test_client_uses_default_organization(mock_client_from_api_key: MagicMock) -> None:
Expand All @@ -41,7 +44,9 @@ def test_client_uses_default_organization(mock_client_from_api_key: MagicMock) -
assert client.organization_id == default_org_id


def test_client_uses_specified_organization(mock_client_from_api_key: MagicMock) -> None:
def test_client_uses_specified_organization(
mock_client_from_api_key: MagicMock,
) -> None:
"""Test that client uses specified organization ID"""
specified_org_id = "specified-org-id"
client = Client(DUMMY_API_KEY, organization_id=specified_org_id)
Expand All @@ -63,6 +68,7 @@ def test_create_project_without_description(
organization_id=FAKE_ORGANIZATION_ID,
updated_at=datetime.now(),
description=None,
is_template=False,
)
client = Client(DUMMY_API_KEY, organization_id=FAKE_ORGANIZATION_ID)
project = client.create_project(FAKE_PROJECT_NAME) # no description
Expand Down Expand Up @@ -126,6 +132,7 @@ def test_create_project(mock_client_from_api_key: MagicMock, default_headers: di
organization_id=FAKE_ORGANIZATION_ID,
updated_at=datetime.now(),
description=FAKE_PROJECT_DESCRIPTION,
is_template=False,
)
mock_client_from_api_key.organization_id = FAKE_ORGANIZATION_ID
codex = Client(DUMMY_API_KEY, organization_id=FAKE_ORGANIZATION_ID)
Expand All @@ -151,10 +158,36 @@ def test_get_project(mock_client_from_api_key: MagicMock) -> None:
organization_id=FAKE_ORGANIZATION_ID,
updated_at=datetime.now(),
description=FAKE_PROJECT_DESCRIPTION,
is_template=False,
)

project = Client(DUMMY_API_KEY, organization_id=FAKE_ORGANIZATION_ID).get_project(FAKE_PROJECT_ID)
assert project.id == FAKE_PROJECT_ID

assert mock_client_from_api_key.projects.retrieve.call_count == 1
assert mock_client_from_api_key.projects.retrieve.call_args[0][0] == FAKE_PROJECT_ID


def test_create_project_from_template(mock_client_from_api_key: MagicMock, default_headers: dict[str, str]) -> None:
mock_client_from_api_key.projects.create_from_template.return_value = ProjectReturnSchema(
id=FAKE_PROJECT_ID,
config=ProjectReturnConfig(),
created_at=datetime.now(),
created_by_user_id=FAKE_USER_ID,
name=FAKE_PROJECT_NAME,
organization_id=FAKE_ORGANIZATION_ID,
updated_at=datetime.now(),
description=FAKE_PROJECT_DESCRIPTION,
is_template=False,
)
mock_client_from_api_key.organization_id = FAKE_ORGANIZATION_ID
codex = Client(DUMMY_API_KEY, organization_id=FAKE_ORGANIZATION_ID)
project = codex.create_project_from_template(FAKE_TEMPLATE_PROJECT_ID, FAKE_PROJECT_NAME, FAKE_PROJECT_DESCRIPTION)
mock_client_from_api_key.projects.create_from_template.assert_called_once_with(
organization_id=FAKE_ORGANIZATION_ID,
template_project_id=FAKE_TEMPLATE_PROJECT_ID,
name=FAKE_PROJECT_NAME,
description=FAKE_PROJECT_DESCRIPTION,
extra_headers=default_headers,
)
assert project.id == FAKE_PROJECT_ID
19 changes: 19 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
DEFAULT_PROJECT_CONFIG = Config()
DUMMY_ACCESS_KEY = "sk-1-EMOh6UrRo7exTEbEi8_azzACAEdtNiib2LLa1IGo6kA"
FAKE_LOG_ID = str(uuid.uuid4())
FAKE_TEMPLATE_PROJECT_ID = str(uuid.uuid4())


def test_project_validate_with_dict_response(
Expand Down Expand Up @@ -218,6 +219,24 @@ def test_create_project(mock_client_from_api_key: MagicMock, default_headers: di
assert mock_client_from_api_key.projects.retrieve.call_count == 0


def test_create_project_from_template(mock_client_from_api_key: MagicMock, default_headers: dict[str, str]) -> None:
mock_client_from_api_key.projects.create_from_template.return_value.id = FAKE_PROJECT_ID
mock_client_from_api_key.organization_id = FAKE_ORGANIZATION_ID
project = Project.create_from_template(
mock_client_from_api_key,
FAKE_ORGANIZATION_ID,
FAKE_TEMPLATE_PROJECT_ID,
)
assert project.id == FAKE_PROJECT_ID
mock_client_from_api_key.projects.create_from_template.assert_called_once_with(
organization_id=FAKE_ORGANIZATION_ID,
template_project_id=FAKE_TEMPLATE_PROJECT_ID,
name=None,
description=None,
extra_headers=default_headers,
)


def test_create_access_key(mock_client_from_api_key: MagicMock, default_headers: dict[str, str]) -> None:
project = Project(mock_client_from_api_key, FAKE_PROJECT_ID)
access_key_name = "Test Access Key"
Expand Down