Skip to content

Commit f9643fd

Browse files
S0okJuhalucinor
authored andcommitted
feat: Add region tools (#28)
* feat(keystone): Add delete_region tool(#14) - Add delete_region tool - Updated tests are passing * feat(keystone): Add delete_region tool(#14) - Add delete_region tool - Updated tests are passing * feat(keystone): Add update_region tool(#14) - Add update_region tool - Updated tests are passing * fix(keystone): Change delete_region return type(#21) - Change delete region return type: str -> None * feat(keystone): Add get_region tool(#14) - Add get_region tool - Updated tests are passing * feat(keystone): Add get_region tool(#14) - Register tool * feat(keystone): Move Region response to /response(#14) * fix(keystone): Delete unnecessary comments(#14) - Delete unnecessary comments: It could be None. - Change delete_region return value comments: None
1 parent 255966a commit f9643fd

File tree

3 files changed

+201
-14
lines changed

3 files changed

+201
-14
lines changed

src/openstack_mcp_server/tools/keystone_tools.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
from .base import get_openstack_conn
2+
from .response.keystone import Region
23
from fastmcp import FastMCP
3-
from pydantic import BaseModel
4-
5-
6-
# NOTE: In openstacksdk, all of the fields are optional.
7-
# In this case, we are only using description field as optional.
8-
class Region(BaseModel):
9-
id: str
10-
description: str | None = None
114

125

136
class KeystoneTools:
@@ -21,7 +14,10 @@ def register_tools(self, mcp: FastMCP):
2114
"""
2215

2316
mcp.tool()(self.get_regions)
17+
mcp.tool()(self.get_region)
2418
mcp.tool()(self.create_region)
19+
mcp.tool()(self.delete_region)
20+
mcp.tool()(self.update_region)
2521

2622
def get_regions(self) -> list[Region]:
2723
"""
@@ -39,12 +35,26 @@ def get_regions(self) -> list[Region]:
3935

4036
return region_list
4137

42-
def create_region(self, id: str, description: str | None = None) -> Region:
38+
def get_region(self, id: str) -> Region:
39+
"""
40+
Get a region.
41+
42+
:param id: The ID of the region. (required)
43+
44+
:return: The Region object.
45+
"""
46+
conn = get_openstack_conn()
47+
48+
region = conn.identity.get_region(region=id)
49+
50+
return Region(id=region.id, description=region.description)
51+
52+
def create_region(self, id: str, description: str = "") -> Region:
4353
"""
4454
Create a new region.
4555
46-
:param id: The ID of the region.
47-
:param description: The description of the region. It can be None.
56+
:param id: The ID of the region. (required)
57+
:param description: The description of the region. (optional)
4858
4959
:return: The created Region object.
5060
"""
@@ -53,3 +63,37 @@ def create_region(self, id: str, description: str | None = None) -> Region:
5363
region = conn.identity.create_region(id=id, description=description)
5464

5565
return Region(id=region.id, description=region.description)
66+
67+
def delete_region(self, id: str) -> None:
68+
"""
69+
Delete a region.
70+
71+
:param id: The ID of the region. (required)
72+
73+
:return: None
74+
"""
75+
conn = get_openstack_conn()
76+
77+
# ignore_missing is set to False to raise an exception if the region does not exist.
78+
conn.identity.delete_region(region=id, ignore_missing=False)
79+
80+
return None
81+
82+
def update_region(self, id: str, description: str = "") -> Region:
83+
"""
84+
Update a region.
85+
86+
:param id: The ID of the region. (required)
87+
:param description: The string description of the region. (optional)
88+
89+
:return: The updated Region object.
90+
"""
91+
conn = get_openstack_conn()
92+
93+
updated_region = conn.identity.update_region(
94+
region=id, description=description
95+
)
96+
97+
return Region(
98+
id=updated_region.id, description=updated_region.description
99+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from pydantic import BaseModel
2+
3+
4+
# NOTE: In openstacksdk, all of the fields are optional.
5+
# In this case, we are only using description field as optional.
6+
class Region(BaseModel):
7+
id: str
8+
description: str = ""

tests/tools/test_keystone_tools.py

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def test_create_region_without_description(
9494
# Create mock region object
9595
mock_region = Mock()
9696
mock_region.id = "RegionOne"
97-
mock_region.description = None
97+
mock_region.description = ""
9898

9999
# Configure mock region.create_region()
100100
mock_conn.identity.create_region.return_value = mock_region
@@ -132,6 +132,141 @@ def test_create_region_invalid_id_format(
132132
)
133133

134134
# Verify mock calls
135-
mock_conn.identity.create_region.assert_called_once_with(
136-
id=1, description="Region One description"
135+
mock_conn.identity.create_region.assert_called_once_with(id=1, description="Region One description")
136+
137+
def test_delete_region_success(self, mock_get_openstack_conn_keystone):
138+
"""Test deleting a keystone region successfully."""
139+
mock_conn = mock_get_openstack_conn_keystone
140+
141+
# Test delete_region()
142+
keystone_tools = self.get_keystone_tools()
143+
result = keystone_tools.delete_region(id="RegionOne")
144+
145+
# Verify results
146+
assert result == None
147+
148+
# Verify mock calls
149+
mock_conn.identity.delete_region.assert_called_once_with(region="RegionOne", ignore_missing=False)
150+
151+
def test_delete_region_not_found(self, mock_get_openstack_conn_keystone):
152+
"""Test deleting a keystone region that does not exist."""
153+
mock_conn = mock_get_openstack_conn_keystone
154+
155+
# Configure mock to raise NotFoundException
156+
mock_conn.identity.delete_region.side_effect = exceptions.NotFoundException(
157+
"Region 'RegionOne' not found"
137158
)
159+
160+
# Test delete_region()
161+
keystone_tools = self.get_keystone_tools()
162+
163+
# Verify exception is raised
164+
with pytest.raises(exceptions.NotFoundException, match="Region 'RegionOne' not found"):
165+
keystone_tools.delete_region(id="RegionOne")
166+
167+
# Verify mock calls
168+
mock_conn.identity.delete_region.assert_called_once_with(region="RegionOne", ignore_missing=False)
169+
170+
def test_update_region_success(self, mock_get_openstack_conn_keystone):
171+
"""Test updating a keystone region successfully."""
172+
mock_conn = mock_get_openstack_conn_keystone
173+
174+
# Create mock region object
175+
mock_region = Mock()
176+
mock_region.id = "RegionOne"
177+
mock_region.description = "Region One description"
178+
179+
# Configure mock region.update_region()
180+
mock_conn.identity.update_region.return_value = mock_region
181+
182+
# Test update_region()
183+
keystone_tools = self.get_keystone_tools()
184+
result = keystone_tools.update_region(id="RegionOne", description="Region One description")
185+
186+
# Verify results
187+
assert result == Region(id="RegionOne", description="Region One description")
188+
189+
# Verify mock calls
190+
mock_conn.identity.update_region.assert_called_once_with(region="RegionOne", description="Region One description")
191+
192+
def test_update_region_without_description(self, mock_get_openstack_conn_keystone):
193+
"""Test updating a keystone region without a description."""
194+
mock_conn = mock_get_openstack_conn_keystone
195+
196+
# Create mock region object
197+
mock_region = Mock()
198+
mock_region.id = "RegionOne"
199+
mock_region.description = ""
200+
201+
# Configure mock region.update_region()
202+
mock_conn.identity.update_region.return_value = mock_region
203+
204+
# Test update_region()
205+
keystone_tools = self.get_keystone_tools()
206+
result = keystone_tools.update_region(id="RegionOne")
207+
208+
# Verify results
209+
assert result == Region(id="RegionOne")
210+
211+
# Verify mock calls
212+
mock_conn.identity.update_region.assert_called_once_with(region="RegionOne", description="")
213+
214+
def test_update_region_invalid_id_format(self, mock_get_openstack_conn_keystone):
215+
"""Test updating a keystone region with an invalid ID format."""
216+
mock_conn = mock_get_openstack_conn_keystone
217+
218+
# Configure mock region.update_region() to raise an exception
219+
mock_conn.identity.update_region.side_effect = exceptions.BadRequestException(
220+
"Invalid input for field 'id': Expected string, got integer"
221+
)
222+
223+
# Test update_region()
224+
keystone_tools = self.get_keystone_tools()
225+
226+
# Verify exception is raised
227+
with pytest.raises(exceptions.BadRequestException, match="Invalid input for field 'id': Expected string, got integer"):
228+
keystone_tools.update_region(id=1, description="Region One description")
229+
230+
# Verify mock calls
231+
mock_conn.identity.update_region.assert_called_once_with(region=1, description="Region One description")
232+
233+
def test_get_region_success(self, mock_get_openstack_conn_keystone):
234+
"""Test getting a keystone region successfully."""
235+
mock_conn = mock_get_openstack_conn_keystone
236+
237+
# Create mock region object
238+
mock_region = Mock()
239+
mock_region.id = "RegionOne"
240+
mock_region.description = "Region One description"
241+
242+
# Configure mock region.get_region()
243+
mock_conn.identity.get_region.return_value = mock_region
244+
245+
# Test get_region()
246+
keystone_tools = self.get_keystone_tools()
247+
result = keystone_tools.get_region(id="RegionOne")
248+
249+
# Verify results
250+
assert result == Region(id="RegionOne", description="Region One description")
251+
252+
# Verify mock calls
253+
mock_conn.identity.get_region.assert_called_once_with(region="RegionOne")
254+
255+
def test_get_region_not_found(self, mock_get_openstack_conn_keystone):
256+
"""Test getting a keystone region that does not exist."""
257+
mock_conn = mock_get_openstack_conn_keystone
258+
259+
# Configure mock to raise NotFoundException
260+
mock_conn.identity.get_region.side_effect = exceptions.NotFoundException(
261+
"Region 'RegionOne' not found"
262+
)
263+
264+
# Test get_region()
265+
keystone_tools = self.get_keystone_tools()
266+
267+
# Verify exception is raised
268+
with pytest.raises(exceptions.NotFoundException, match="Region 'RegionOne' not found"):
269+
keystone_tools.get_region(id="RegionOne")
270+
271+
# Verify mock calls
272+
mock_conn.identity.get_region.assert_called_once_with(region="RegionOne")

0 commit comments

Comments
 (0)