Skip to content
Closed
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ The SDK automatically handles all dependency packaging for Data Cloud deployment

**No need to worry about platform compatibility** - the SDK handles this automatically through the Docker-based packaging process.

## files directory

```
.
├── payload
│ ├── config.json
│ ├── entrypoint.py
├── files
│ ├── data.csv
```

## py-files directory

Your Python dependencies can be packaged as .py files, .zip archives (containing multiple .py files or a Python package structure), or .egg files.
Expand All @@ -124,6 +135,7 @@ Your Python dependencies can be packaged as .py files, .zip archives (containing
Your entry point script will define logic using the `Client` object which wraps data access layers.

You should only need the following methods:
* `find_file_path(file_name)` - Returns a file path
* `read_dlo(name)` – Read from a Data Lake Object by name
* `read_dmo(name)` – Read from a Data Model Object by name
* `write_to_dlo(name, spark_dataframe, write_mode)` – Write to a Data Model Object by name with a Spark dataframe
Expand Down Expand Up @@ -197,6 +209,7 @@ Argument:
Options:
- `--config-file TEXT`: Path to configuration file
- `--dependencies TEXT`: Additional dependencies (can be specified multiple times)
- `--profile TEXT`: Credential profile name (default: "default")


#### `datacustomcode zip`
Expand Down
12 changes: 12 additions & 0 deletions src/datacustomcode/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@
from pyspark.sql import SparkSession

from datacustomcode.config import SparkConfig, config
from datacustomcode.file.path.default import DefaultFindFilePath
from datacustomcode.io.reader.base import BaseDataCloudReader

if TYPE_CHECKING:
from pathlib import Path

from pyspark.sql import DataFrame as PySparkDataFrame

from datacustomcode.io.reader.base import BaseDataCloudReader
Expand Down Expand Up @@ -100,18 +103,21 @@ class Client:
writing, we print to the console instead of writing to Data Cloud.

Args:
finder: Find a file path
reader: A custom reader to use for reading Data Cloud objects.
writer: A custom writer to use for writing Data Cloud objects.

Example:
>>> client = Client()
>>> file_path = client.find_file_path("data.csv")
>>> dlo = client.read_dlo("my_dlo")
>>> client.write_to_dmo("my_dmo", dlo)
"""

_instance: ClassVar[Optional[Client]] = None
_reader: BaseDataCloudReader
_writer: BaseDataCloudWriter
_file: DefaultFindFilePath
_data_layer_history: dict[DataCloudObjectType, set[str]]

def __new__(
Expand Down Expand Up @@ -154,6 +160,7 @@ def __new__(
writer_init = writer
cls._instance._reader = reader_init
cls._instance._writer = writer_init
cls._instance._file = DefaultFindFilePath()
cls._instance._data_layer_history = {
DataCloudObjectType.DLO: set(),
DataCloudObjectType.DMO: set(),
Expand Down Expand Up @@ -212,6 +219,11 @@ def write_to_dmo(
self._validate_data_layer_history_does_not_contain(DataCloudObjectType.DLO)
return self._writer.write_to_dmo(name, dataframe, write_mode, **kwargs)

def find_file_path(self, file_name: str) -> Path:
"""Read a file from the local file system."""

return self._file.find_file_path(file_name)

def _validate_data_layer_history_does_not_contain(
self, data_cloud_object_type: DataCloudObjectType
) -> None:
Expand Down
14 changes: 14 additions & 0 deletions src/datacustomcode/file/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright (c) 2025, Salesforce, Inc.
# SPDX-License-Identifier: Apache-2
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
19 changes: 19 additions & 0 deletions src/datacustomcode/file/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright (c) 2025, Salesforce, Inc.
# SPDX-License-Identifier: Apache-2
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations


class BaseDataAccessLayer:
"""Base class for data access layer implementations."""
220 changes: 220 additions & 0 deletions tests/test_credentials_profile_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""
Integration tests for credentials profile functionality.

This module tests the complete flow of using different credentials profiles
with the DataCloud Custom Code Python SDK components.
"""

from __future__ import annotations

import os
from unittest.mock import MagicMock, patch

import pytest

from datacustomcode.config import config
from datacustomcode.credentials import Credentials
from datacustomcode.io.reader.query_api import QueryAPIDataCloudReader
from datacustomcode.io.writer.print import PrintDataCloudWriter


class TestCredentialsProfileIntegration:
"""Test integration of credentials profile functionality across components."""

def test_query_api_reader_with_custom_profile(self):
"""Test QueryAPIDataCloudReader uses custom credentials profile."""
mock_spark = MagicMock()

with patch("datacustomcode.credentials.Credentials.from_available") as mock_from_available:
# Mock credentials for custom profile
mock_credentials = MagicMock()
mock_credentials.login_url = "https://custom.salesforce.com"
mock_credentials.username = "[email protected]"
mock_credentials.password = "custom_password"
mock_credentials.client_id = "custom_client_id"
mock_credentials.client_secret = "custom_secret"
mock_from_available.return_value = mock_credentials

# Mock the SalesforceCDPConnection
with patch("datacustomcode.io.reader.query_api.SalesforceCDPConnection") as mock_conn_class:
mock_conn = MagicMock()
mock_conn_class.return_value = mock_conn

# Test with custom profile
reader = QueryAPIDataCloudReader(mock_spark, credentials_profile="custom_profile")

# Verify the correct profile was used
mock_from_available.assert_called_with(profile="custom_profile")

# Verify the connection was created with the custom credentials
mock_conn_class.assert_called_once_with(
"https://custom.salesforce.com",
"[email protected]",
"custom_password",
"custom_client_id",
"custom_secret",
)

def test_print_writer_with_custom_profile(self):
"""Test PrintDataCloudWriter uses custom credentials profile."""
mock_spark = MagicMock()

with patch("datacustomcode.credentials.Credentials.from_available") as mock_from_available:
# Mock credentials for custom profile
mock_credentials = MagicMock()
mock_credentials.login_url = "https://custom.salesforce.com"
mock_credentials.username = "[email protected]"
mock_credentials.password = "custom_password"
mock_credentials.client_id = "custom_client_id"
mock_credentials.client_secret = "custom_secret"
mock_from_available.return_value = mock_credentials

# Mock the SalesforceCDPConnection
with patch("datacustomcode.io.reader.query_api.SalesforceCDPConnection") as mock_conn_class:
mock_conn = MagicMock()
mock_conn_class.return_value = mock_conn

# Test with custom profile
writer = PrintDataCloudWriter(mock_spark, credentials_profile="custom_profile")

# Verify the correct profile was used
mock_from_available.assert_called_with(profile="custom_profile")

# Verify the writer has the reader with custom credentials
assert writer.reader is not None
assert isinstance(writer.reader, QueryAPIDataCloudReader)

def test_config_override_with_environment_variable(self):
"""Test that environment variable overrides config credentials profile."""
# Set environment variable
os.environ["SFDC_CREDENTIALS_PROFILE"] = "env_profile"

try:
# Simulate what happens in entrypoint.py
credentials_profile = os.environ.get("SFDC_CREDENTIALS_PROFILE", "default")
assert credentials_profile == "env_profile"

# Update both reader and writer configs
if config.reader_config and hasattr(config.reader_config, 'options'):
config.reader_config.options["credentials_profile"] = credentials_profile

if config.writer_config and hasattr(config.writer_config, 'options'):
config.writer_config.options["credentials_profile"] = credentials_profile

# Verify the configs were updated
assert config.reader_config.options["credentials_profile"] == "env_profile"
assert config.writer_config.options["credentials_profile"] == "env_profile"

finally:
# Clean up
del os.environ["SFDC_CREDENTIALS_PROFILE"]

def test_config_override_programmatically(self):
"""Test programmatic override of credentials profile."""
custom_profile = "programmatic_profile"

# Update both reader and writer configs programmatically
if config.reader_config and hasattr(config.reader_config, 'options'):
config.reader_config.options["credentials_profile"] = custom_profile

if config.writer_config and hasattr(config.writer_config, 'options'):
config.writer_config.options["credentials_profile"] = custom_profile

# Verify the configs were updated
assert config.reader_config.options["credentials_profile"] == custom_profile
assert config.writer_config.options["credentials_profile"] == custom_profile

def test_default_profile_behavior(self):
"""Test that default profile is used when no override is specified."""
# Reset to default values
if config.reader_config and hasattr(config.reader_config, 'options'):
config.reader_config.options["credentials_profile"] = "default"

if config.writer_config and hasattr(config.writer_config, 'options'):
config.writer_config.options["credentials_profile"] = "default"

# Verify default values
assert config.reader_config.options["credentials_profile"] == "default"
assert config.writer_config.options["credentials_profile"] == "default"

def test_credentials_profile_consistency(self):
"""Test that reader and writer use the same credentials profile."""
mock_spark = MagicMock()
test_profile = "consistent_profile"

with patch("datacustomcode.credentials.Credentials.from_available") as mock_from_available:
# Mock credentials
mock_credentials = MagicMock()
mock_credentials.login_url = "https://consistent.salesforce.com"
mock_credentials.username = "[email protected]"
mock_credentials.password = "consistent_password"
mock_credentials.client_id = "consistent_client_id"
mock_credentials.client_secret = "consistent_secret"
mock_from_available.return_value = mock_credentials

# Mock the SalesforceCDPConnection
with patch("datacustomcode.io.reader.query_api.SalesforceCDPConnection") as mock_conn_class:
mock_conn = MagicMock()
mock_conn_class.return_value = mock_conn

# Create reader and writer with same profile
reader = QueryAPIDataCloudReader(mock_spark, credentials_profile=test_profile)
writer = PrintDataCloudWriter(mock_spark, credentials_profile=test_profile)

# Verify both used the same profile
assert mock_from_available.call_count == 2
for call in mock_from_available.call_args_list:
assert call[1]["profile"] == test_profile

# Verify both have the same credentials
assert reader._conn is not None
assert writer.reader._conn is not None

def test_multiple_profiles_isolation(self):
"""Test that different profiles are properly isolated."""
mock_spark = MagicMock()

with patch("datacustomcode.credentials.Credentials.from_available") as mock_from_available:
# Mock different credentials for different profiles
def mock_credentials_side_effect(profile="default"):
mock_creds = MagicMock()
if profile == "profile1":
mock_creds.login_url = "https://profile1.salesforce.com"
mock_creds.username = "[email protected]"
elif profile == "profile2":
mock_creds.login_url = "https://profile2.salesforce.com"
mock_creds.username = "[email protected]"
else: # default
mock_creds.login_url = "https://default.salesforce.com"
mock_creds.username = "[email protected]"

mock_creds.password = f"{profile}_password"
mock_creds.client_id = f"{profile}_client_id"
mock_creds.client_secret = f"{profile}_secret"
return mock_creds

mock_from_available.side_effect = mock_credentials_side_effect

# Mock the SalesforceCDPConnection
with patch("datacustomcode.io.reader.query_api.SalesforceCDPConnection") as mock_conn_class:
mock_conn = MagicMock()
mock_conn_class.return_value = mock_conn

# Create readers with different profiles
reader1 = QueryAPIDataCloudReader(mock_spark, credentials_profile="profile1")
reader2 = QueryAPIDataCloudReader(mock_spark, credentials_profile="profile2")
reader_default = QueryAPIDataCloudReader(mock_spark, credentials_profile="default")

# Verify each reader used the correct profile
calls = mock_from_available.call_args_list
assert len(calls) == 3

# Check that each call used the correct profile
profiles_used = [call[1]["profile"] for call in calls]
assert "profile1" in profiles_used
assert "profile2" in profiles_used
assert "default" in profiles_used




Loading
Loading