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
2 changes: 1 addition & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ jobs:
- name: Run All Tests
run: make test-client-python
env:
OPEN_API_REF: f9709139a3693f6624efda12a001e242c5d506b6
OPEN_API_REF: c0b62b28b14d0d164d37a1f6bf19dc9d39e5769b

- name: Check for SDK changes
run: |
Expand Down
7 changes: 6 additions & 1 deletion config/clients/python/CHANGELOG.md.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD)

### [{{packageVersion}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.9.4...{{packageVersion}}) (2025-07-09)
### [{{packageVersion}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.9.5...{{packageVersion}}) (2025-09-15)

- fix: reuse ssl context in the sync client (#222) - thanks @wadells!
- feat: add OAuth2 scopes parameter support to CredentialConfiguration (#213) - thanks @SoulPancake

### [v0.9.5](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.9.4...v0.9.5) (2025-07-09)

- fix: aiohttp.ClientResponse.data should be awaited (#197) - thanks @cmbernard333

Expand Down
2 changes: 1 addition & 1 deletion config/clients/python/config.overrides.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"sdkId": "python",
"gitRepoId": "python-sdk",
"packageName": "openfga_sdk",
"packageVersion": "0.9.5",
"packageVersion": "0.9.6",
"packageDescription": "Python SDK for OpenFGA",
"packageDetailedDescription": "This is an autogenerated python SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api).",
"fossaComplianceNoticeId": "2f8a8629-b46c-435e-b8cd-1174a674fb4b",
Expand Down
215 changes: 214 additions & 1 deletion config/clients/python/template/test/sync/rest_test.py.mustache
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{{>partial_header}}

import json
from unittest.mock import MagicMock
import ssl

from unittest.mock import MagicMock, patch

import pytest

Expand Down Expand Up @@ -520,3 +522,214 @@ def test_stream_exception_in_chunks():
# Exception is logged, we yield nothing
assert results == []
mock_pool_manager.request.assert_called_once()


# Tests for SSL Context Reuse (fix for OpenSSL 3.0+ performance issues)
@patch("ssl.create_default_context")
@patch("urllib3.PoolManager")
def test_ssl_context_created_with_ca_cert(mock_pool_manager, mock_create_context):
"""Test that SSL context is created with CA certificate file."""
mock_ssl_context = MagicMock()
mock_create_context.return_value = mock_ssl_context

mock_config = MagicMock()
mock_config.ssl_ca_cert = "/path/to/ca.pem"
mock_config.cert_file = None
mock_config.key_file = None
mock_config.verify_ssl = True
mock_config.connection_pool_maxsize = 4
mock_config.timeout_millisec = 5000
mock_config.proxy = None

RESTClientObject(configuration=mock_config)

# Verify SSL context was created with CA file
mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem")

# Verify SSL context was passed to PoolManager
mock_pool_manager.assert_called_once()
call_kwargs = mock_pool_manager.call_args[1]
assert call_kwargs["ssl_context"] == mock_ssl_context


@patch("ssl.create_default_context")
@patch("urllib3.PoolManager")
def test_ssl_context_loads_client_certificate(mock_pool_manager, mock_create_context):
"""Test that SSL context loads client certificate and key when provided."""
mock_ssl_context = MagicMock()
mock_create_context.return_value = mock_ssl_context

mock_config = MagicMock()
mock_config.ssl_ca_cert = None
mock_config.cert_file = "/path/to/client.pem"
mock_config.key_file = "/path/to/client.key"
mock_config.verify_ssl = True
mock_config.connection_pool_maxsize = 4
mock_config.timeout_millisec = 5000
mock_config.proxy = None

RESTClientObject(configuration=mock_config)

# Verify SSL context was created
mock_create_context.assert_called_once_with(cafile=None)

# Verify client certificate was loaded
mock_ssl_context.load_cert_chain.assert_called_once_with(
"/path/to/client.pem", keyfile="/path/to/client.key"
)

# Verify SSL context was passed to PoolManager
mock_pool_manager.assert_called_once()
call_kwargs = mock_pool_manager.call_args[1]
assert call_kwargs["ssl_context"] == mock_ssl_context


@patch("ssl.create_default_context")
@patch("urllib3.PoolManager")
def test_ssl_context_disables_verification_when_verify_ssl_false(
mock_pool_manager, mock_create_context
):
"""Test that SSL context disables verification when verify_ssl=False."""
mock_ssl_context = MagicMock()
mock_create_context.return_value = mock_ssl_context

mock_config = MagicMock()
mock_config.ssl_ca_cert = None
mock_config.cert_file = None
mock_config.key_file = None
mock_config.verify_ssl = False
mock_config.connection_pool_maxsize = 4
mock_config.timeout_millisec = 5000
mock_config.proxy = None

RESTClientObject(configuration=mock_config)

# Verify SSL context was created
mock_create_context.assert_called_once_with(cafile=None)

# Verify SSL verification was disabled
assert mock_ssl_context.check_hostname is False
assert mock_ssl_context.verify_mode == ssl.CERT_NONE

# Verify SSL context was passed to PoolManager
mock_pool_manager.assert_called_once()
call_kwargs = mock_pool_manager.call_args[1]
assert call_kwargs["ssl_context"] == mock_ssl_context


@patch("ssl.create_default_context")
@patch("urllib3.ProxyManager")
def test_ssl_context_used_with_proxy_manager(mock_proxy_manager, mock_create_context):
"""Test that SSL context is passed to ProxyManager when proxy is configured."""
mock_ssl_context = MagicMock()
mock_create_context.return_value = mock_ssl_context

mock_config = MagicMock()
mock_config.ssl_ca_cert = "/path/to/ca.pem"
mock_config.cert_file = "/path/to/client.pem"
mock_config.key_file = "/path/to/client.key"
mock_config.verify_ssl = True
mock_config.connection_pool_maxsize = 4
mock_config.timeout_millisec = 5000
mock_config.proxy = "http://proxy:8080"
mock_config.proxy_headers = {"Proxy-Auth": "token"}

RESTClientObject(configuration=mock_config)

# Verify SSL context was created with CA file
mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem")

# Verify client certificate was loaded
mock_ssl_context.load_cert_chain.assert_called_once_with(
"/path/to/client.pem", keyfile="/path/to/client.key"
)

# Verify SSL context was passed to ProxyManager
mock_proxy_manager.assert_called_once()
call_kwargs = mock_proxy_manager.call_args[1]
assert call_kwargs["ssl_context"] == mock_ssl_context
assert call_kwargs["proxy_url"] == "http://proxy:8080"
assert call_kwargs["proxy_headers"] == {"Proxy-Auth": "token"}


@patch("ssl.create_default_context")
@patch("urllib3.PoolManager")
def test_ssl_context_reuse_performance_optimization(
mock_pool_manager, mock_create_context
):
"""Test that SSL context creation is called only once per client instance."""
mock_ssl_context = MagicMock()
mock_create_context.return_value = mock_ssl_context

mock_config = MagicMock()
mock_config.ssl_ca_cert = "/path/to/ca.pem"
mock_config.cert_file = None
mock_config.key_file = None
mock_config.verify_ssl = True
mock_config.connection_pool_maxsize = 4
mock_config.timeout_millisec = 5000
mock_config.proxy = None

# Create client instance
client = RESTClientObject(configuration=mock_config)

# Verify SSL context was created exactly once
mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem")

# Verify the same SSL context instance is reused
mock_pool_manager.assert_called_once()
call_kwargs = mock_pool_manager.call_args[1]
assert call_kwargs["ssl_context"] is mock_ssl_context

# Verify context was not created again during subsequent operations
mock_create_context.reset_mock()

# Build a request (this should not trigger SSL context creation)
client.build_request("GET", "https://example.com")

# SSL context should not be created again
mock_create_context.assert_not_called()


@patch("ssl.create_default_context")
@patch("urllib3.PoolManager")
def test_ssl_context_with_all_ssl_options(mock_pool_manager, mock_create_context):
"""Test SSL context creation with all SSL configuration options set."""
mock_ssl_context = MagicMock()
mock_create_context.return_value = mock_ssl_context

mock_config = MagicMock()
mock_config.ssl_ca_cert = "/path/to/ca.pem"
mock_config.cert_file = "/path/to/client.pem"
mock_config.key_file = "/path/to/client.key"
mock_config.verify_ssl = True
mock_config.connection_pool_maxsize = 8
mock_config.timeout_millisec = 10000
mock_config.proxy = None

RESTClientObject(configuration=mock_config)

# Verify SSL context was created with CA file
mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem")

# Verify client certificate was loaded
mock_ssl_context.load_cert_chain.assert_called_once_with(
"/path/to/client.pem", keyfile="/path/to/client.key"
)

# Verify SSL verification settings were NOT modified (verify_ssl=True)
# check_hostname and verify_mode should remain at their default secure values
assert (
not hasattr(mock_ssl_context, "check_hostname")
or mock_ssl_context.check_hostname
)
assert (
not hasattr(mock_ssl_context, "verify_mode")
or mock_ssl_context.verify_mode != ssl.CERT_NONE
)

# Verify SSL context was passed to PoolManager
mock_pool_manager.assert_called_once()
call_kwargs = mock_pool_manager.call_args[1]
assert call_kwargs["ssl_context"] == mock_ssl_context
assert call_kwargs["maxsize"] == 8
Loading