Skip to content

Commit 54d334c

Browse files
author
Adam Weeks
committed
feat: initialize BYODS Python SDK
0 parents  commit 54d334c

13 files changed

Lines changed: 1020 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Test
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.8", "3.11"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
- run: python -m pip install --upgrade pip
21+
- run: python -m pip install -e ".[aws]" pytest ruff
22+
- run: ruff check .
23+
- run: python -m pytest -q

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
token-config.json
2+
__pycache__/
3+
*.py[cod]
4+
.pytest_cache/
5+
.ruff_cache/
6+
build/
7+
dist/
8+
*.egg-info/
9+
.venv/
10+
venv/

LICENSE

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
CISCO SAMPLE CODE LICENSE
2+
Version 1.1
3+
Copyright (c) 2018 Cisco and/or its affiliates
4+
5+
These terms govern this Cisco Systems, Inc. ("Cisco"), example or demo
6+
source code and its associated documentation (together, the "Sample
7+
Code"). By downloading, copying, modifying, compiling, or redistributing
8+
the Sample Code, you accept and agree to be bound by the following terms
9+
and conditions (the "License"). If you are accepting the License on
10+
behalf of an entity, you represent that you have the authority to do so
11+
(either you or the entity, "you"). Sample Code is not supported by Cisco
12+
TAC and is not tested for quality or performance. This is your only
13+
license to the Sample Code and all rights not expressly granted are
14+
reserved.
15+
16+
1. LICENSE GRANT: Subject to the terms and conditions of this License,
17+
Cisco hereby grants to you a perpetual, worldwide, non-exclusive, non-
18+
transferable, non-sublicensable, royalty-free license to copy and
19+
modify the Sample Code in source code form, and compile and
20+
redistribute the Sample Code in binary/object code or other executable
21+
forms, in whole or in part, solely for use with Cisco products and
22+
services. For interpreted languages like Java and Python, the
23+
executable form of the software may include source code and
24+
compilation is not required.
25+
26+
2. CONDITIONS: You shall not use the Sample Code independent of, or to
27+
replicate or compete with, a Cisco product or service. Cisco products
28+
and services are licensed under their own separate terms and you shall
29+
not use the Sample Code in any way that violates or is inconsistent
30+
with those terms (for more information, please visit:
31+
www.cisco.com/go/terms).
32+
33+
3. OWNERSHIP: Cisco retains sole and exclusive ownership of the Sample
34+
Code, including all intellectual property rights therein, except with
35+
respect to any third-party material that may be used in or by the
36+
Sample Code. Any such third-party material is licensed under its own
37+
separate terms (such as an open source license) and all use must be in
38+
full accordance with the applicable license. This License does not
39+
grant you permission to use any trade names, trademarks, service
40+
marks, or product names of Cisco. If you provide any feedback to Cisco
41+
regarding the Sample Code, you agree that Cisco, its partners, and its
42+
customers shall be free to use and incorporate such feedback into the
43+
Sample Code, and Cisco products and services, for any purpose, and
44+
without restriction, payment, or additional consideration of any kind.
45+
If you initiate or participate in any litigation against Cisco, its
46+
partners, or its customers (including cross-claims and counter-claims)
47+
alleging that the Sample Code and/or its use infringe any patent,
48+
copyright, or other intellectual property right, then all rights
49+
granted to you under this License shall terminate immediately without
50+
notice.
51+
52+
4. LIMITATION OF LIABILITY: CISCO SHALL HAVE NO LIABILITY IN CONNECTION
53+
WITH OR RELATING TO THIS LICENSE OR USE OF THE SAMPLE CODE, FOR
54+
DAMAGES OF ANY KIND, INCLUDING BUT NOT LIMITED TO DIRECT, INCIDENTAL,
55+
AND CONSEQUENTIAL DAMAGES, OR FOR ANY LOSS OF USE, DATA, INFORMATION,
56+
PROFITS, BUSINESS, OR GOODWILL, HOWEVER CAUSED, EVEN IF ADVISED OF THE
57+
POSSIBILITY OF SUCH DAMAGES.
58+
59+
5. DISCLAIMER OF WARRANTY: SAMPLE CODE IS INTENDED FOR EXAMPLE PURPOSES
60+
ONLY AND IS PROVIDED BY CISCO "AS IS" WITH ALL FAULTS AND WITHOUT
61+
WARRANTY OR SUPPORT OF ANY KIND. TO THE MAXIMUM EXTENT PERMITTED BY
62+
LAW, ALL EXPRESS AND IMPLIED CONDITIONS, REPRESENTATIONS, AND
63+
WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTY OR
64+
CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-
65+
INFRINGEMENT, SATISFACTORY QUALITY, NON-INTERFERENCE, AND ACCURACY,
66+
ARE HEREBY EXCLUDED AND EXPRESSLY DISCLAIMED BY CISCO. CISCO DOES NOT
67+
WARRANT THAT THE SAMPLE CODE IS SUITABLE FOR PRODUCTION OR COMMERCIAL
68+
USE, WILL OPERATE PROPERLY, IS ACCURATE OR COMPLETE, OR IS WITHOUT
69+
ERROR OR DEFECT.
70+
71+
6. GENERAL: This License shall be governed by and interpreted in
72+
accordance with the laws of the State of California, excluding its
73+
conflict of laws provisions. You agree to comply with all applicable
74+
United States export laws, rules, and regulations. If any provision of
75+
this License is judged illegal, invalid, or otherwise unenforceable,
76+
that provision shall be severed and the rest of the License shall
77+
remain in full force and effect. No failure by Cisco to enforce any of
78+
its rights related to the Sample Code or to a breach of this License
79+
in a particular situation will act as a waiver of such rights. In the
80+
event of any inconsistencies with any other terms, this License shall
81+
take precedence.

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Webex BYODS Python SDK
2+
3+
Reusable Python utilities for managing Webex Bring Your Own Data Source (BYODS)
4+
data sources. The SDK supports data source registration, retrieval, updates,
5+
schema discovery, JWT claim inspection, service-app authentication, and data
6+
source token extension.
7+
8+
This repository is the source of truth for the SDK. The companion CLI project
9+
uses this package as a consumer.
10+
11+
## Install
12+
13+
Until the package is published, install it directly from a local checkout:
14+
15+
```bash
16+
python3 -m pip install -e .
17+
```
18+
19+
Install AWS Secrets Manager support when running in Lambda or supplying a
20+
secret name:
21+
22+
```bash
23+
python3 -m pip install -e ".[aws]"
24+
```
25+
26+
## Configure authentication
27+
28+
Copy the safe template and provide service-app credentials plus a personal
29+
token that can request service-app tokens:
30+
31+
```bash
32+
cp token-config.json.template token-config.json
33+
```
34+
35+
Do not commit `token-config.json`.
36+
37+
## Use the SDK
38+
39+
```python
40+
from webex_byods import TokenManager, WebexDataSourceManager
41+
42+
token_manager = TokenManager(config_path="token-config.json")
43+
client = WebexDataSourceManager(
44+
token_manager.get_service_app_token(), token_manager=token_manager
45+
)
46+
47+
data_sources = client.list_all_data_sources()
48+
schemas = client.get_data_source_schemas()
49+
```
50+
51+
BYODS API operations preserve a dictionary result contract containing
52+
`success`, `data` or `error`, and `status_code`. If a data-source API request
53+
returns HTTP 401, the client obtains a fresh service-app token and retries the
54+
request once.
55+
56+
To rotate a data source nonce and obtain a fresh JWS token:
57+
58+
```python
59+
result = token_manager.extend_data_source_token(
60+
data_source_id="data-source-id",
61+
token_lifetime_minutes=1440,
62+
)
63+
```
64+
65+
## Development
66+
67+
```bash
68+
python3 -m pip install -e ".[aws]"
69+
python3 -m pip install pytest ruff
70+
python3 -m pytest -q
71+
ruff check .
72+
```
73+
74+
## License
75+
76+
[Cisco Sample Code License](LICENSE)

pyproject.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[build-system]
2+
requires = ["setuptools>=61"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "webex-byods-sdk"
7+
version = "0.1.0"
8+
description = "Reusable Webex Bring Your Own Data Source management utilities"
9+
readme = "README.md"
10+
requires-python = ">=3.8"
11+
dependencies = [
12+
"requests>=2.31.0",
13+
"PyJWT>=2.8.0",
14+
]
15+
16+
[project.optional-dependencies]
17+
aws = ["boto3>=1.28.0"]
18+
dev = ["pytest>=7", "ruff>=0.5"]
19+
20+
[project.urls]
21+
Repository = "https://github.com/WebexCommunity/webex-python-byods-sdk"
22+
Issues = "https://github.com/WebexCommunity/webex-python-byods-sdk/issues"
23+
24+
[tool.setuptools]
25+
packages = ["webex_byods"]
26+
27+
[tool.pytest.ini_options]
28+
testpaths = ["tests"]

tests/test_auth.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from unittest.mock import Mock, patch
2+
3+
import jwt
4+
5+
from webex_byods import TokenManager
6+
7+
8+
@patch("webex_byods.auth.uuid.uuid4", return_value="new-nonce")
9+
@patch("webex_byods.auth.requests.put")
10+
@patch("webex_byods.auth.requests.get")
11+
def test_extend_data_source_token_updates_nonce_and_preserves_claim_values(
12+
get, put, uuid4
13+
):
14+
jws_token = jwt.encode(
15+
{
16+
"aud": "audience-from-token",
17+
"sub": "subject-from-token",
18+
"com.cisco.datasource.schema.uuid": "schema-from-token",
19+
},
20+
"unused",
21+
algorithm="HS256",
22+
)
23+
get_response = Mock(status_code=200)
24+
get_response.json.return_value = {
25+
"jwsToken": jws_token,
26+
"url": "https://gateway.example.com/data",
27+
"status": "active",
28+
}
29+
get.return_value = get_response
30+
put_response = Mock(status_code=200)
31+
put_response.json.return_value = {"tokenExpiryTime": "2026-07-10T00:00:00Z"}
32+
put.return_value = put_response
33+
manager = TokenManager(config_path="unused.json")
34+
manager.get_service_app_token = Mock(return_value="service-token")
35+
36+
result = manager.extend_data_source_token("source-1", 60)
37+
38+
assert result == {
39+
"success": True,
40+
"data": {"tokenExpiryTime": "2026-07-10T00:00:00Z"},
41+
"nonce_updated": "new-nonce",
42+
"token_lifetime_minutes": 60,
43+
"token_expiry": "2026-07-10T00:00:00Z",
44+
"message": "Data source token extended successfully. New expiry: 2026-07-10T00:00:00Z",
45+
}
46+
put.assert_called_once_with(
47+
"https://webexapis.com/v1/dataSources/source-1",
48+
headers={
49+
"Authorization": "Bearer service-token",
50+
"Content-Type": "application/json",
51+
},
52+
json={
53+
"audience": "audience-from-token",
54+
"nonce": "new-nonce",
55+
"schemaId": "schema-from-token",
56+
"subject": "subject-from-token",
57+
"url": "https://gateway.example.com/data",
58+
"tokenLifetimeMinutes": 60,
59+
"status": "active",
60+
},
61+
)
62+
63+
64+
def test_extend_data_source_token_rejects_lifetime_over_limit():
65+
result = TokenManager(config_path="unused.json").extend_data_source_token(
66+
"source-1", 1441
67+
)
68+
69+
assert result == {
70+
"success": False,
71+
"error": "Token lifetime cannot exceed 1440 minutes (24 hours). Requested: 1441 minutes",
72+
}

tests/test_client.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from unittest.mock import Mock, call, patch
2+
3+
from webex_byods import WebexDataSourceManager
4+
5+
6+
@patch("webex_byods.client.requests.request")
7+
def test_list_data_sources_returns_existing_result_shape(request):
8+
response = Mock(status_code=200)
9+
response.json.return_value = {"items": [{"id": "source-1"}]}
10+
request.return_value = response
11+
12+
result = WebexDataSourceManager("token").list_all_data_sources()
13+
14+
assert result == {
15+
"success": True,
16+
"data": {"items": [{"id": "source-1"}]},
17+
"status_code": 200,
18+
}
19+
request.assert_called_once_with(
20+
"GET",
21+
"https://webexapis.com/v1/dataSources",
22+
headers={
23+
"Authorization": "Bearer token",
24+
"Accept": "application/json",
25+
"Content-Type": "application/json",
26+
},
27+
timeout=30,
28+
)
29+
30+
31+
@patch("webex_byods.client.requests.request")
32+
def test_request_retries_once_after_token_refresh(request):
33+
expired_response = Mock(status_code=401)
34+
refreshed_response = Mock(status_code=200)
35+
refreshed_response.json.return_value = {"items": []}
36+
request.side_effect = [expired_response, refreshed_response]
37+
token_manager = Mock()
38+
token_manager.get_service_app_token.return_value = "new-token"
39+
40+
result = WebexDataSourceManager("old-token", token_manager=token_manager).list_all_data_sources()
41+
42+
assert result["success"] is True
43+
token_manager.get_service_app_token.assert_called_once_with(force_refresh=True)
44+
assert request.call_args_list == [
45+
call(
46+
"GET",
47+
"https://webexapis.com/v1/dataSources",
48+
headers={
49+
"Authorization": "Bearer old-token",
50+
"Accept": "application/json",
51+
"Content-Type": "application/json",
52+
},
53+
timeout=30,
54+
),
55+
call(
56+
"GET",
57+
"https://webexapis.com/v1/dataSources",
58+
headers={
59+
"Authorization": "Bearer new-token",
60+
"Accept": "application/json",
61+
"Content-Type": "application/json",
62+
},
63+
timeout=30,
64+
),
65+
]
66+
67+
68+
@patch("webex_byods.client.requests.request")
69+
def test_schema_cache_is_loaded_once(request):
70+
response = Mock(status_code=200)
71+
response.json.return_value = {
72+
"items": [{"id": "schema-1", "serviceType": "Contact Center"}]
73+
}
74+
request.return_value = response
75+
manager = WebexDataSourceManager("token")
76+
77+
assert manager.get_schema_display_name("schema-1") == "Contact Center (schema-1...)"
78+
assert manager.get_available_schemas() == [
79+
{"id": "schema-1", "serviceType": "Contact Center"}
80+
]
81+
assert request.call_count == 1

0 commit comments

Comments
 (0)