src/: Source code following the src-layout patternsrc/sap_cloud_sdk/: Main package namespacetests/: Test code mirroring the source structure- Private implementation modules use underscore prefix (e.g.,
_s3.py,_models.py) - Keep internal packages organized with clear separation of concerns
- Create shared utilities in
core/for reusable patterns across modules - Avoid unnecessary nesting - keep structure as flat as possible while maintaining clarity
- Follow PEP 8 naming conventions consistently
- Use descriptive names for variables and functions, avoid single-letter names except for loop counters
- Private methods and attributes use leading underscore (e.g.,
_validate_input) - Use descriptive file names that reflect functionality (e.g.,
_s3.pyfor S3 implementation)
- Use dataclasses for configuration and data models with proper type annotations
- Separate public API from internal implementation using underscore prefixes
- Keep imports clean - users should import from top-level module packages
- Use dependency injection where appropriate rather than global state
- Separate concerns into dedicated files (models, exceptions, implementations)
- Design for composition over inheritance
- Use type annotations throughout the codebase (functions, methods, class attributes)
- Leverage
typingmodule for complex types (Union, Optional, List, Dict, etc.) - Use Protocol classes for defining interfaces/contracts
- Enable strict type checking with ty in development
- Use
py.typedmarker files to indicate typed packages - Use
TypedDictfor structured dictionaries
- Create domain-specific exception hierarchies inheriting from built-in exceptions
- Use
raise ... from efor exception chaining to preserve stack traces - Don't expose internal implementation details in error messages
- Validate inputs early and provide clear validation error messages
- Use context managers (
withstatements) for resource management when applicable - Consider async variants for I/O-bound operations where it adds value
- Always clean up resources properly (file handles, network connections, etc.)
- Follow PEP 8 style guidelines
- Prefer composition and dependency injection over global state
- Use descriptive variable names and avoid abbreviations
- Use
pytestas the testing framework - Organize tests to mirror source code structure
- Use descriptive test names that explain what is being tested, following the
test_<functionality>_<condition>_<expected_result>pattern - For integration tests, follow the Integration Test Guidelines below
Integration tests live under tests/<module>/integration/ and hit real external services. Follow the patterns established in objectstore and destination.
tests/<module>/integration/
__init__.py
conftest.py # fixtures and environment setup
test_<module>_bdd.py # BDD step definitions
<module>.feature # Gherkin scenarios (one or more)
- Use a session-scoped fixture to load
.env_integration_testsand validate required env vars — callpytest.skip(...)(notpytest.fail) when vars are missing, so CI doesn't report an error on environments where the service isn't configured. - Create service clients in session-scoped fixtures so they are shared across the full test run.
- Add a session-scoped
autouse=Truefixture that cleans up any leftover test data before and after the session. - Add per-test cleanup via a
cleanup_*fixture or anautouse=Truescenario-level fixture that deletes all resources registered during the scenario. - Provide a
failure_simulationfixture that creates clients configured with unreachable endpoints or invalid credentials — use explicitconfig=injection rather than patching env vars. - Register the
integrationpytest marker inpytest_configureand apply it automatically to all tests underintegration/inpytest_collection_modifyitems.
@pytest.fixture(scope="session")
def integration_env() -> dict:
env_file = Path(__file__).parents[3] / ".env_integration_tests"
if env_file.exists():
load_dotenv(env_file)
required = ["CLOUD_SDK_CFG_<MODULE>_DEFAULT_URL", ...]
missing = [v for v in required if not os.getenv(v)]
if missing:
pytest.skip(f"Missing env vars: {missing}")
return {v: os.environ[v] for v in required}
@pytest.fixture(scope="session")
def my_client(integration_env):
try:
return create_client()
except Exception as e:
pytest.fail(f"Failed to create client: {e}")- Wire scenarios from feature files with
scenarios("my_module.feature")at the top. - Maintain a
ScenarioContextdataclass to carry state between steps — don't use module-level variables. - Provide a
contextfixture that returns a freshScenarioContextper scenario. - Wrap every action (
when/giventhat calls the SDK) in atry/exceptthat stores the error oncontext.last_errororcontext.operation_errorrather than letting it propagate — this letsthensteps assert on both success and failure paths cleanly. - Prefix cleanup operations with an idempotent delete before create to avoid 409 conflicts on re-runs.
- Name test objects with a timestamped prefix (e.g.
sdk-python-integration-tests/test-<timestamp>-) to namespace them and enable bulk cleanup.
class ScenarioContext:
def __init__(self):
self.result = None
self.last_error: Optional[Exception] = None
self.cleanup_items: list = []
@pytest.fixture
def context():
return ScenarioContext()
@when("I perform the operation")
def perform_operation(context, my_client):
try:
context.result = my_client.do_something()
context.last_error = None
except Exception as e:
context.last_error = e
@then("the operation should succeed")
def operation_succeeds(context):
assert context.last_error is None, f"Unexpected error: {context.last_error}"
assert context.result is not None- Happy path for each public operation (create, read, update, delete, list)
- Not-found and permission-denied error paths using the
failure_simulationfixture - Optional/multi-tenant scenarios — guard with
pytest.skipwhen the required env var is absent rather than failing - Concurrent operations where the module supports them
- Internal implementation details — only test through the public
create_client()API - Retry logic or credential rotation — those belong in unit tests with mocks
See Integration Tests for environment setup and how to run tests locally.
- Use docstrings for all public functions, classes, and modules
- Follow Google or NumPy docstring style consistently
- Include type information in docstrings when not obvious from annotations
- Provide usage examples in docstrings for complex functionality
- Document parameters, return values, and raised exceptions
- Keep dependencies minimal - avoid heavy frameworks when simple solutions suffice
- Use the standard
loggingmodule - Create module-level loggers using
logger = logging.getLogger(__name__) - Use appropriate log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
- Don't log sensitive information (credentials, personal data)
- Use structured logging with consistent formatting
- Log important state changes and error conditions
- Use generators and iterators for memory efficiency with large datasets
- Use appropriate data structures for the use case
- All SDK modules should include telemetry instrumentation for observability
- Use the centralized
@record_metricsdecorator fromcore/telemetry
- Add module constant to
core/telemetry/module.py - Add operation constants to
core/telemetry/operation.py - Apply decorator to client methods:ß
from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics @record_metrics(Module.MY_MODULE, Operation.MY_OPERATION) def my_operation(self): # implementation
- For modules called by other SDK modules (e.g., auditlog), add
_telemetry_sourceparameter:def __init__(self, transport, _telemetry_source: Optional[Module] = None): self._telemetry_source = _telemetry_source
- When creating the client from another module, pass the source:
self._audit_client = create_auditlog_client(_telemetry_source=Module.OBJECTSTORE)
- The decorator automatically reads
_telemetry_sourcefromselfto track call origin
- Never log or expose sensitive information (passwords, tokens, etc.)
- Validate all inputs, especially from external sources
- Follow principle of least privilege in code design
BTP service bindings can rotate at runtime. Long-lived clients holding stale credentials will fail with auth errors once the old secrets expire. Every SDK module that reads credentials from mounts or env vars must support rotation.
Proactive — check ConfigFactory.has_changed() before every credential use. If the mtime of the secret directory changed, re-read the binding and discard any cached tokens or session objects.
Reactive — on a credential-rejection error (e.g. HTTP 401 for OAuth modules, S3 InvalidAccessKeyId/SignatureDoesNotMatch for object store), refresh credentials and retry the operation once.
Token providers and auth classes must accept either a fixed config object or a callable (factory) returning the config:
if callable(config) and not isinstance(config, MyConfig):
self._config_factory: Callable[[], MyConfig] = config
self._config = config()
else:
self._config_factory = lambda: config # static; no rotation tracking
self._config = configBefore serving a cached token, call _refresh_if_rotated():
def _refresh_if_rotated(self) -> None:
has_changed = getattr(self._config_factory, "has_changed", None)
if callable(has_changed) and has_changed():
self._config = self._config_factory()
self._cached_token = None # discard stale token
# rebuild session/client if neededCredentials are baked into the client at construction time, so the client itself must be rebuilt on rotation. Wrap every public operation in _execute_with_retry:
_CREDENTIAL_ERROR_CODES = frozenset({"InvalidAccessKeyId", "SignatureDoesNotMatch"})
def _execute_with_retry(self, fn):
self._refresh_if_rotated()
try:
return fn()
except S3Error as e:
if e.code in _CREDENTIAL_ERROR_CODES:
with self._lock:
self._creds = self._config_factory()
self._client = self._create_client()
return fn()
raiseUse a threading.Lock when rebuilding the client to avoid races under concurrent calls.
- Auto-detection path (no explicit
config=): pass aConfigFactoryinstance directly to the token provider / auth class.ConfigFactorycarrieshas_changed()automatically. - Explicit
config=path: wrap in a static lambda to preserve the factory interface without rotation tracking.
def create_client(*, instance=None, config=None):
if config is not None:
credentials = lambda: config # static, no rotation
else:
credentials = _make_config_factory(instance)
return MyClient(credentials)ConfigFactory[C] (from sap_cloud_sdk.core.secret_resolver) reads bindings on every __call__() and tracks secret directory mtime via has_changed(). To use it, the binding dataclass must:
- Have all fields defaulting to
""sobinding_cls()can be called with no args. - Implement
validate()raising on missing required fields.
Every module that supports rotation must have tests for:
- Proactive:
has_changed()returnsTrue→ token/session/client is rebuilt before the next operation. - Reactive (key-based clients): credential-rejection error on first call → retried once with fresh credentials.
- No rebuild:
has_changed()returnsFalse→ existing session/client reused. - Static config: plain config object (no
has_changed) → no error, no rotation check.