Skip to content

Commit f74be4b

Browse files
authored
add support for creating a project from a template (#116)
1 parent 558c5fe commit f74be4b

File tree

7 files changed

+111
-5
lines changed

7 files changed

+111
-5
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.0.32] 2025-10-28
11+
12+
- Add `Client.create_project_from_template()` method to create a new project from a template
13+
- Add `Project.create_from_template()` method to create a new project from a template
14+
1015
## [1.0.31] 2025-10-14
1116

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

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

148-
[Unreleased]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.31...HEAD
153+
[Unreleased]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.32...HEAD
154+
[1.0.32]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.31...v1.0.32
149155
[1.0.31]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.30...v1.0.31
150156
[1.0.30]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.29...v1.0.30
151157
[1.0.29]: https://github.com/cleanlab/cleanlab-codex/compare/v1.0.28...v1.0.29

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ classifiers = [
2626
]
2727
dependencies = [
2828
"cleanlab-tlm~=1.1,>=1.1.14",
29-
"codex-sdk==0.1.0a30",
29+
"codex-sdk==0.1.0a31",
3030
"pydantic>=2.0.0, <3",
3131
]
3232

src/cleanlab_codex/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# SPDX-License-Identifier: MIT
2-
__version__ = "1.0.31"
2+
__version__ = "1.0.32"

src/cleanlab_codex/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ def create_project(self, name: str, description: Optional[str] = None) -> Projec
6868

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

71+
def create_project_from_template(
72+
self,
73+
template_project_id: str,
74+
name: str | None = None,
75+
description: str | None = None,
76+
) -> Project:
77+
"""Create a new project from a template. Project will be created in the organization the client is using.
78+
79+
Args:
80+
template_project_id (str): The ID of the template project to create the project from.
81+
name (str, optional): Optional name for the project. If not provided, the name will be the same as the template project.
82+
description (str, optional): Optional description for the project. If not provided, the description will be the same as the template project.
83+
84+
Returns:
85+
Project: The created project.
86+
"""
87+
return Project.create_from_template(self._client, self._organization_id, template_project_id, name, description)
88+
7189
def list_organizations(self) -> list[Organization]:
7290
"""List the organizations the authenticated user is a member of.
7391

src/cleanlab_codex/project.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,36 @@ def create(
118118

119119
return Project(sdk_client, project_id, verify_existence=False)
120120

121+
@classmethod
122+
def create_from_template(
123+
cls,
124+
sdk_client: _Codex,
125+
organization_id: str,
126+
template_project_id: str,
127+
name: str | None = None,
128+
description: str | None = None,
129+
) -> Project:
130+
"""Create a new project from a template.
131+
132+
Args:
133+
sdk_client (Codex): The Codex SDK client to use to create the project. This client must be authenticated with a user-level API key.
134+
organization_id (str): The ID of the organization to create the project in.
135+
template_project_id (str): The ID of the template project to create the project from.
136+
name (str, optional): Optional name for the project. If not provided, the name will be the same as the template project.
137+
description (str, optional): Optional description for the project. If not provided, the description will be the same as the template project.
138+
139+
Returns:
140+
Project: The created project.
141+
"""
142+
project_id = sdk_client.projects.create_from_template(
143+
organization_id=organization_id,
144+
template_project_id=template_project_id,
145+
name=name,
146+
description=description,
147+
extra_headers=_AnalyticsMetadata().to_headers(),
148+
).id
149+
return Project(sdk_client, project_id, verify_existence=False)
150+
121151
def create_access_key(
122152
self,
123153
name: str,

tests/test_client.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from codex import AuthenticationError
99
from codex.types.project_return_schema import Config as ProjectReturnConfig
1010
from codex.types.project_return_schema import ProjectReturnSchema
11-
from codex.types.users.myself.user_organizations_schema import Organization as SDKOrganization
11+
from codex.types.users.myself.user_organizations_schema import (
12+
Organization as SDKOrganization,
13+
)
1214
from codex.types.users.myself.user_organizations_schema import UserOrganizationsSchema
1315

1416
from cleanlab_codex.client import Client
@@ -22,6 +24,7 @@
2224
FAKE_PROJECT_DESCRIPTION = "Test Description"
2325
DEFAULT_PROJECT_CONFIG = ProjectConfig()
2426
DUMMY_API_KEY = "GP0FzPfA7wYy5L64luII2YaRT2JoSXkae7WEo7dH6Bw"
27+
FAKE_TEMPLATE_PROJECT_ID = str(uuid.uuid4())
2528

2629

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

4346

44-
def test_client_uses_specified_organization(mock_client_from_api_key: MagicMock) -> None:
47+
def test_client_uses_specified_organization(
48+
mock_client_from_api_key: MagicMock,
49+
) -> None:
4550
"""Test that client uses specified organization ID"""
4651
specified_org_id = "specified-org-id"
4752
client = Client(DUMMY_API_KEY, organization_id=specified_org_id)
@@ -63,6 +68,7 @@ def test_create_project_without_description(
6368
organization_id=FAKE_ORGANIZATION_ID,
6469
updated_at=datetime.now(),
6570
description=None,
71+
is_template=False,
6672
)
6773
client = Client(DUMMY_API_KEY, organization_id=FAKE_ORGANIZATION_ID)
6874
project = client.create_project(FAKE_PROJECT_NAME) # no description
@@ -126,6 +132,7 @@ def test_create_project(mock_client_from_api_key: MagicMock, default_headers: di
126132
organization_id=FAKE_ORGANIZATION_ID,
127133
updated_at=datetime.now(),
128134
description=FAKE_PROJECT_DESCRIPTION,
135+
is_template=False,
129136
)
130137
mock_client_from_api_key.organization_id = FAKE_ORGANIZATION_ID
131138
codex = Client(DUMMY_API_KEY, organization_id=FAKE_ORGANIZATION_ID)
@@ -151,10 +158,36 @@ def test_get_project(mock_client_from_api_key: MagicMock) -> None:
151158
organization_id=FAKE_ORGANIZATION_ID,
152159
updated_at=datetime.now(),
153160
description=FAKE_PROJECT_DESCRIPTION,
161+
is_template=False,
154162
)
155163

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

159167
assert mock_client_from_api_key.projects.retrieve.call_count == 1
160168
assert mock_client_from_api_key.projects.retrieve.call_args[0][0] == FAKE_PROJECT_ID
169+
170+
171+
def test_create_project_from_template(mock_client_from_api_key: MagicMock, default_headers: dict[str, str]) -> None:
172+
mock_client_from_api_key.projects.create_from_template.return_value = ProjectReturnSchema(
173+
id=FAKE_PROJECT_ID,
174+
config=ProjectReturnConfig(),
175+
created_at=datetime.now(),
176+
created_by_user_id=FAKE_USER_ID,
177+
name=FAKE_PROJECT_NAME,
178+
organization_id=FAKE_ORGANIZATION_ID,
179+
updated_at=datetime.now(),
180+
description=FAKE_PROJECT_DESCRIPTION,
181+
is_template=False,
182+
)
183+
mock_client_from_api_key.organization_id = FAKE_ORGANIZATION_ID
184+
codex = Client(DUMMY_API_KEY, organization_id=FAKE_ORGANIZATION_ID)
185+
project = codex.create_project_from_template(FAKE_TEMPLATE_PROJECT_ID, FAKE_PROJECT_NAME, FAKE_PROJECT_DESCRIPTION)
186+
mock_client_from_api_key.projects.create_from_template.assert_called_once_with(
187+
organization_id=FAKE_ORGANIZATION_ID,
188+
template_project_id=FAKE_TEMPLATE_PROJECT_ID,
189+
name=FAKE_PROJECT_NAME,
190+
description=FAKE_PROJECT_DESCRIPTION,
191+
extra_headers=default_headers,
192+
)
193+
assert project.id == FAKE_PROJECT_ID

tests/test_project.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
DEFAULT_PROJECT_CONFIG = Config()
2424
DUMMY_ACCESS_KEY = "sk-1-EMOh6UrRo7exTEbEi8_azzACAEdtNiib2LLa1IGo6kA"
2525
FAKE_LOG_ID = str(uuid.uuid4())
26+
FAKE_TEMPLATE_PROJECT_ID = str(uuid.uuid4())
2627

2728

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

220221

222+
def test_create_project_from_template(mock_client_from_api_key: MagicMock, default_headers: dict[str, str]) -> None:
223+
mock_client_from_api_key.projects.create_from_template.return_value.id = FAKE_PROJECT_ID
224+
mock_client_from_api_key.organization_id = FAKE_ORGANIZATION_ID
225+
project = Project.create_from_template(
226+
mock_client_from_api_key,
227+
FAKE_ORGANIZATION_ID,
228+
FAKE_TEMPLATE_PROJECT_ID,
229+
)
230+
assert project.id == FAKE_PROJECT_ID
231+
mock_client_from_api_key.projects.create_from_template.assert_called_once_with(
232+
organization_id=FAKE_ORGANIZATION_ID,
233+
template_project_id=FAKE_TEMPLATE_PROJECT_ID,
234+
name=None,
235+
description=None,
236+
extra_headers=default_headers,
237+
)
238+
239+
221240
def test_create_access_key(mock_client_from_api_key: MagicMock, default_headers: dict[str, str]) -> None:
222241
project = Project(mock_client_from_api_key, FAKE_PROJECT_ID)
223242
access_key_name = "Test Access Key"

0 commit comments

Comments
 (0)