Skip to content

Commit c97f0b7

Browse files
authored
fix: Andres comments (#9)
* docs: nits * feat: custom base url
1 parent 9a98f9c commit c97f0b7

File tree

9 files changed

+250
-20
lines changed

9 files changed

+250
-20
lines changed

examples/available_tools.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
3. Using multiple patterns for cross-vertical functionality
99
4. Filtering by specific operations
1010
5. Combining multiple operation patterns
11-
12-
TODO: experimental - get_available_tools(account_id="your_account_id")
11+
6. TODO: get_account_tools(account_id="your_account_id")
1312
1413
```bash
1514
uv run examples/available_tools.py

examples/custom_base_url.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
Example demonstrating how to use a custom base URL with StackOne tools.
3+
4+
This is useful for:
5+
1. Testing against development APIs
6+
2. Working with self-hosted StackOne instances
7+
8+
Usage:
9+
10+
```bash
11+
uv run examples/custom_base_url.py
12+
```
13+
"""
14+
15+
from stackone_ai.toolset import StackOneToolSet
16+
17+
18+
def custom_base_url():
19+
"""
20+
Default base URL
21+
"""
22+
default_toolset = StackOneToolSet()
23+
hris_tools = default_toolset.get_tools(filter_pattern="hris_*")
24+
25+
assert len(hris_tools) > 0
26+
assert hris_tools[0]._execute_config.url.startswith("https://api.stackone.com")
27+
28+
"""
29+
Custom base URL
30+
"""
31+
dev_toolset = StackOneToolSet(base_url="https://api.example-dev.com")
32+
dev_hris_tools = dev_toolset.get_tools(filter_pattern="hris_*")
33+
34+
"""
35+
Note this uses the same tools but substitutes the base URL
36+
"""
37+
assert len(dev_hris_tools) > 0
38+
assert dev_hris_tools[0]._execute_config.url.startswith("https://api.example-dev.com")
39+
40+
41+
if __name__ == "__main__":
42+
custom_base_url()

examples/file_uploads.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@
2424
"""
2525
# Resume content
2626
27-
This is a sample resume content that will be uploaded to StackOne.
28-
27+
This is a sample resume content that will be uploaded using the `hris_upload_employee_document` tool.
2928
"""
3029

3130
resume_content = """

examples/index.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,38 @@
11
"""
2-
StackOne AI provides a unified interface for accessing various SaaS tools through AI-friendly APIs.
2+
StackOne AI SDK provides an AI-friendly interface for accessing various SaaS tools through the StackOne Unified API.
3+
4+
This SDK is available on [PyPI](https://pypi.org/project/stackone-ai/) for python projects. There is a node version in the works.
35
46
# Installation
57
68
```bash
7-
# Using pip
8-
pip install stackone-ai
9-
109
# Using uv
1110
uv add stackone-ai
11+
12+
# Using pip
13+
pip install stackone-ai
1214
```
1315
1416
# How to use these docs
1517
1618
All examples are complete and runnable.
17-
We use [uv](https://docs.astral.sh/uv/getting-started/installation/) for python dependency management.
19+
We use [uv](https://docs.astral.sh/uv/getting-started/installation/) for easy python dependency management.
20+
21+
Install uv:
1822
19-
To run this example, install the dependencies (one-time setup) and run the script:
23+
```bash
24+
curl -LsSf https://astral.sh/uv/install.sh | sh
25+
```
26+
To run this example, clone the repo, install the dependencies (one-time setup) and run the script:
2027
2128
```bash
29+
git clone https://github.com/stackoneHQ/stackone-ai-python.git
30+
cd stackone-ai-python
31+
32+
# Install dependencies
2233
uv sync --all-extras
34+
35+
# Run the example
2336
uv run examples/index.py
2437
```
2538

examples/langchain_integration.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ def langchain_integration() -> None:
3838

3939
result = model_with_tools.invoke(f"Can you get me information about employee with ID: {employee_id}?")
4040

41-
if result.tool_calls:
42-
for tool_call in result.tool_calls:
43-
tool = tools.get_tool(tool_call["name"])
44-
if tool:
45-
result = tool.execute(tool_call["args"])
46-
assert result is not None
47-
assert result.get("data") is not None
41+
assert result.tool_calls is not None
42+
for tool_call in result.tool_calls:
43+
tool = tools.get_tool(tool_call["name"])
44+
if tool:
45+
result = tool.execute(tool_call["args"])
46+
assert result is not None
47+
assert result.get("data") is not None
4848

4949

5050
if __name__ == "__main__":

stackone_ai/specs/parser.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66

77

88
class OpenAPIParser:
9-
def __init__(self, spec_path: Path):
9+
def __init__(self, spec_path: Path, base_url: str | None = None):
1010
self.spec_path = spec_path
1111
with open(spec_path) as f:
1212
self.spec = json.load(f)
1313
# Get base URL from servers array or default to stackone API
1414
servers = self.spec.get("servers", [{"url": "https://api.stackone.com"}])
15-
self.base_url = servers[0]["url"] if isinstance(servers, list) else "https://api.stackone.com"
15+
default_url = servers[0]["url"] if isinstance(servers, list) else "https://api.stackone.com"
16+
# Use provided base_url if available, otherwise use the default from the spec
17+
self.base_url = base_url or default_url
1618

1719
def _is_file_type(self, schema: dict[str, Any]) -> bool:
1820
"""Check if a schema represents a file upload."""

stackone_ai/toolset.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ def __init__(
3636
self,
3737
api_key: str | None = None,
3838
account_id: str | None = None,
39+
base_url: str | None = None,
3940
) -> None:
4041
"""Initialize StackOne tools with authentication
4142
4243
Args:
4344
api_key: Optional API key. If not provided, will try to get from STACKONE_API_KEY env var
4445
account_id: Optional account ID. If not provided, will try to get from STACKONE_ACCOUNT_ID env var
46+
base_url: Optional base URL override for API requests. If not provided, uses the URL from the OAS
4547
4648
Raises:
4749
ToolsetConfigError: If no API key is provided or found in environment
@@ -54,6 +56,7 @@ def __init__(
5456
)
5557
self.api_key: str = api_key_value
5658
self.account_id = account_id or os.getenv("STACKONE_ACCOUNT_ID")
59+
self.base_url = base_url
5760

5861
def _parse_parameters(self, parameters: list[dict[str, Any]]) -> dict[str, dict[str, str]]:
5962
"""Parse OpenAPI parameters into tool properties
@@ -133,7 +136,7 @@ def get_tools(
133136

134137
# Load all available specs
135138
for spec_file in OAS_DIR.glob("*.json"):
136-
parser = OpenAPIParser(spec_file)
139+
parser = OpenAPIParser(spec_file, base_url=self.base_url)
137140
tool_definitions = parser.parse_tools()
138141

139142
# Create tools and filter if pattern is provided

tests/test_parser.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,3 +699,33 @@ def test_form_data_without_files(temp_spec_file: Path) -> None:
699699

700700
# Check body type
701701
assert tool.execute.body_type == "form"
702+
703+
704+
def test_parser_with_base_url_override(tmp_path: Path, sample_openapi_spec: dict[str, Any]) -> None:
705+
"""Test that the parser uses the provided base_url instead of the one from the spec."""
706+
# Write the spec to a temporary file
707+
spec_file = tmp_path / "test_spec.json"
708+
with open(spec_file, "w") as f:
709+
json.dump(sample_openapi_spec, f)
710+
711+
# Create parser with default base_url
712+
default_parser = OpenAPIParser(spec_file)
713+
assert default_parser.base_url == "https://api.test.com"
714+
715+
# Create parser with development base_url
716+
dev_parser = OpenAPIParser(spec_file, base_url="https://api.example-dev.com")
717+
assert dev_parser.base_url == "https://api.example-dev.com"
718+
719+
# Create parser with experimental base_url
720+
exp_parser = OpenAPIParser(spec_file, base_url="https://api.example-exp.com")
721+
assert exp_parser.base_url == "https://api.example-exp.com"
722+
723+
# Verify the base_url is used in the tool definitions for development environment
724+
dev_tools = dev_parser.parse_tools()
725+
assert dev_tools["get_employee"].execute.url.startswith("https://api.example-dev.com")
726+
assert not dev_tools["get_employee"].execute.url.startswith("https://api.test.com")
727+
728+
# Verify the base_url is used in the tool definitions for experimental environment
729+
exp_tools = exp_parser.parse_tools()
730+
assert exp_tools["get_employee"].execute.url.startswith("https://api.example-exp.com")
731+
assert not exp_tools["get_employee"].execute.url.startswith("https://api.test.com")

tests/test_toolset.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,145 @@ def test_empty_filter_result():
8585
toolset = StackOneToolSet(api_key="test_key")
8686
tools = toolset.get_tools(filter_pattern="unknown_*")
8787
assert len(tools) == 0
88+
89+
90+
def test_toolset_with_base_url():
91+
"""Test StackOneToolSet with a custom base_url"""
92+
mock_spec_content = {
93+
"paths": {
94+
"/employee/{id}": {
95+
"get": {
96+
"operationId": "hris_get_employee",
97+
"summary": "Get employee details",
98+
"parameters": [
99+
{
100+
"in": "path",
101+
"name": "id",
102+
"schema": {"type": "string"},
103+
"description": "Employee ID",
104+
}
105+
],
106+
}
107+
}
108+
}
109+
}
110+
111+
# Create mock tool definition with default URL
112+
mock_tool_def = ToolDefinition(
113+
description="Get employee details",
114+
parameters=ToolParameters(
115+
type="object",
116+
properties={
117+
"id": {
118+
"type": "string",
119+
"description": "Employee ID",
120+
}
121+
},
122+
),
123+
execute=ExecuteConfig(
124+
method="GET",
125+
url="https://api.stackone.com/employee/{id}",
126+
name="hris_get_employee",
127+
headers={},
128+
parameter_locations={"id": "path"},
129+
),
130+
)
131+
132+
# Create mock tool definition with development URL
133+
mock_tool_def_dev = ToolDefinition(
134+
description="Get employee details",
135+
parameters=ToolParameters(
136+
type="object",
137+
properties={
138+
"id": {
139+
"type": "string",
140+
"description": "Employee ID",
141+
}
142+
},
143+
),
144+
execute=ExecuteConfig(
145+
method="GET",
146+
url="https://api.example-dev.com/employee/{id}",
147+
name="hris_get_employee",
148+
headers={},
149+
parameter_locations={"id": "path"},
150+
),
151+
)
152+
153+
# Create mock tool definition with experimental URL
154+
mock_tool_def_exp = ToolDefinition(
155+
description="Get employee details",
156+
parameters=ToolParameters(
157+
type="object",
158+
properties={
159+
"id": {
160+
"type": "string",
161+
"description": "Employee ID",
162+
}
163+
},
164+
),
165+
execute=ExecuteConfig(
166+
method="GET",
167+
url="https://api.example-exp.com/employee/{id}",
168+
name="hris_get_employee",
169+
headers={},
170+
parameter_locations={"id": "path"},
171+
),
172+
)
173+
174+
# Mock the OpenAPIParser and file operations
175+
with (
176+
patch("stackone_ai.toolset.OAS_DIR") as mock_dir,
177+
patch("stackone_ai.toolset.OpenAPIParser") as mock_parser_class,
178+
):
179+
# Setup mocks
180+
mock_path = MagicMock()
181+
mock_path.exists.return_value = True
182+
mock_dir.__truediv__.return_value = mock_path
183+
mock_dir.glob.return_value = [mock_path]
184+
185+
# Setup parser mock for default URL
186+
mock_parser = MagicMock()
187+
mock_parser.spec = mock_spec_content
188+
mock_parser.parse_tools.return_value = {"hris_get_employee": mock_tool_def}
189+
190+
# Setup parser mock for development URL
191+
mock_parser_dev = MagicMock()
192+
mock_parser_dev.spec = mock_spec_content
193+
mock_parser_dev.parse_tools.return_value = {"hris_get_employee": mock_tool_def_dev}
194+
195+
# Setup parser mock for experimental URL
196+
mock_parser_exp = MagicMock()
197+
mock_parser_exp.spec = mock_spec_content
198+
mock_parser_exp.parse_tools.return_value = {"hris_get_employee": mock_tool_def_exp}
199+
200+
# Configure the mock parser class to return different instances based on base_url
201+
def get_parser(spec_path, base_url=None):
202+
if base_url == "https://api.example-dev.com":
203+
return mock_parser_dev
204+
elif base_url == "https://api.example-exp.com":
205+
return mock_parser_exp
206+
return mock_parser
207+
208+
mock_parser_class.side_effect = get_parser
209+
210+
# Test with default URL
211+
toolset = StackOneToolSet(api_key="test_key")
212+
tools = toolset.get_tools(filter_pattern="hris_*")
213+
tool = tools.get_tool("hris_get_employee")
214+
assert tool is not None
215+
assert tool._execute_config.url == "https://api.stackone.com/employee/{id}"
216+
217+
# Test with development URL
218+
toolset_dev = StackOneToolSet(api_key="test_key", base_url="https://api.example-dev.com")
219+
tools_dev = toolset_dev.get_tools(filter_pattern="hris_*")
220+
tool_dev = tools_dev.get_tool("hris_get_employee")
221+
assert tool_dev is not None
222+
assert tool_dev._execute_config.url == "https://api.example-dev.com/employee/{id}"
223+
224+
# Test with experimental URL
225+
toolset_exp = StackOneToolSet(api_key="test_key", base_url="https://api.example-exp.com")
226+
tools_exp = toolset_exp.get_tools(filter_pattern="hris_*")
227+
tool_exp = tools_exp.get_tool("hris_get_employee")
228+
assert tool_exp is not None
229+
assert tool_exp._execute_config.url == "https://api.example-exp.com/employee/{id}"

0 commit comments

Comments
 (0)