Skip to content

Commit 5baa65a

Browse files
committed
streaming source from config
1 parent 2c3d285 commit 5baa65a

5 files changed

Lines changed: 152 additions & 24 deletions

File tree

src/datacustomcode/client.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from __future__ import annotations
1616

1717
from enum import Enum
18-
import os
1918
from typing import (
2019
TYPE_CHECKING,
2120
Any,
@@ -52,18 +51,22 @@
5251
from datacustomcode.spark.base import BaseSparkSessionProvider
5352

5453

55-
_STREAMING_SOURCE_ENV = "BYOC_STREAMING_SOURCE_NAME"
56-
57-
5854
def _streaming_source_name() -> str:
59-
"""Return the runtime streaming source name.
55+
"""Return the streaming transform's read-source name.
56+
57+
Resolved from ``config.streaming_source``, which ``run_entrypoint``
58+
populates from config.json's ``permissions.read`` entry.
6059
6160
Raises:
62-
RuntimeError: If ``BYOC_STREAMING_SOURCE_NAME`` is not set
61+
RuntimeError: If no ``streaming_source`` has been configured (e.g. the
62+
transform's config.json has no ``permissions.read`` entry).
6363
"""
64-
source = os.environ.get(_STREAMING_SOURCE_ENV)
64+
source = config.streaming_source
6565
if not source:
66-
raise RuntimeError(f"{_STREAMING_SOURCE_ENV} is not set.")
66+
raise RuntimeError(
67+
"No streaming source configured. A streaming transform must declare "
68+
"its read source in config.json under 'permissions.read'."
69+
)
6770
return source
6871

6972

@@ -583,7 +586,7 @@ def write_dlo_deltas(
583586
"""Write a streaming DataFrame of deltas to a DLO in Data Cloud.
584587
585588
Starts a streaming query that writes each micro-batch to the
586-
target DLO and returns the ``StreamingQuery`` handle; the caller
589+
target DLO and returns the ``StreamingQuery`` handle; the caller
587590
typically calls ``query.awaitTermination()``.
588591
589592
Args:

src/datacustomcode/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ class ClientConfig(BaseConfig):
8989
spark_provider_config: Union[
9090
SparkProviderConfig[BaseSparkSessionProvider], None
9191
] = None
92+
# Source object name for a streaming (DELTA_SYNC) transform, populated by
93+
# ``run_entrypoint`` from config.json's ``permissions.read``
94+
streaming_source: Union[str, None] = None
9295

9396
def update(self, other: ClientConfig) -> ClientConfig:
9497
"""Merge this ClientConfig with another, respecting force flags.
@@ -116,6 +119,8 @@ def merge(
116119
self.spark_provider_config = merge(
117120
self.spark_provider_config, other.spark_provider_config
118121
)
122+
if other.streaming_source is not None:
123+
self.streaming_source = other.streaming_source
119124
return self
120125

121126

src/datacustomcode/run.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ def _set_config_option(config_obj, key: str, value: Optional[str]) -> None:
4242
config_obj.options[key] = value
4343

4444

45+
def _read_source_from_permissions(config_json: dict) -> Optional[str]:
46+
"""Return the read-source name from config.json ``permissions.read``.
47+
"""
48+
permissions = config_json.get("permissions")
49+
if not isinstance(permissions, dict):
50+
return None
51+
read = permissions.get("read")
52+
if not isinstance(read, dict):
53+
return None
54+
for layer in ("dlo", "dmo"):
55+
names = read.get(layer)
56+
if names:
57+
return names[0]
58+
return None
59+
60+
4561
def _update_config_options(profile: Optional[str], sf_cli_org: Optional[str]):
4662
if sf_cli_org:
4763
config_key = "sf_cli_org"
@@ -125,6 +141,8 @@ def run_entrypoint(
125141
_set_config_option(config.reader_config, "dataspace", dataspace)
126142
_set_config_option(config.writer_config, "dataspace", dataspace)
127143

144+
config.streaming_source = _read_source_from_permissions(config_json)
145+
128146
_update_config_options(profile, sf_cli_org)
129147

130148
for dependency in dependencies:

tests/test_client.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import os
43
from unittest.mock import MagicMock, patch
54

65
from pyspark.sql import DataFrame, SparkSession
@@ -84,13 +83,17 @@ def mock_config(mock_spark):
8483
@pytest.fixture
8584
def reset_client():
8685
"""Reset the client singletons (and the shared Spark session) between tests."""
86+
from datacustomcode.client import config as client_config
87+
8788
Client._instance = None
8889
StreamingClient._instance = None
8990
_BaseClient._shared_spark = None
91+
client_config.streaming_source = None
9092
yield
9193
Client._instance = None
9294
StreamingClient._instance = None
9395
_BaseClient._shared_spark = None
96+
client_config.streaming_source = None
9497

9598

9699
class TestClient:
@@ -298,29 +301,29 @@ def test_read_dlo_deltas(self, reset_client, mock_spark):
298301
reader.read_dlo_deltas.return_value = mock_df
299302

300303
client = StreamingClient(reader=reader, writer=writer)
301-
# The streaming source is resolved by the runtime and recorded from the
302-
# env var it sets; the caller passes no name.
303-
with patch.dict(
304-
"os.environ", {"BYOC_STREAMING_SOURCE_NAME": "Account_std__dll"}
305-
):
304+
305+
with patch("datacustomcode.client.config") as mock_config:
306+
mock_config.streaming_source = "Account_std__dll"
306307
result = client.read_dlo_deltas()
307308

308309
reader.read_dlo_deltas.assert_called_once_with()
309310
assert result is mock_df
310311
assert "Account_std__dll" in client._data_layer_history[DataCloudObjectType.DLO]
311312

312-
def test_read_dlo_deltas_without_source_env_raises(self, reset_client, mock_spark):
313-
"""Delta reads require the runtime source env var; absence fails fast."""
313+
def test_read_dlo_deltas_without_configured_source_raises(
314+
self, reset_client, mock_spark
315+
):
316+
"""Delta reads require a configured streaming source; absence fails fast."""
314317
reader = MagicMock(spec=BaseDataCloudReader)
315318
writer = MagicMock(spec=BaseDataCloudWriter)
316319

317320
client = StreamingClient(reader=reader, writer=writer)
318-
with patch.dict("os.environ", {}, clear=False):
319-
os.environ.pop("BYOC_STREAMING_SOURCE_NAME", None)
321+
with patch("datacustomcode.client.config") as mock_config:
322+
mock_config.streaming_source = None
320323
with pytest.raises(RuntimeError) as exc_info:
321324
client.read_dlo_deltas()
322325

323-
assert "BYOC_STREAMING_SOURCE_NAME" in str(exc_info.value)
326+
assert "permissions.read" in str(exc_info.value)
324327
reader.read_dlo_deltas.assert_not_called()
325328

326329
def test_read_dmo_deltas(self, reset_client, mock_spark):
@@ -330,9 +333,8 @@ def test_read_dmo_deltas(self, reset_client, mock_spark):
330333
reader.read_dmo_deltas.return_value = mock_df
331334

332335
client = StreamingClient(reader=reader, writer=writer)
333-
with patch.dict(
334-
"os.environ", {"BYOC_STREAMING_SOURCE_NAME": "Account_model__dlm"}
335-
):
336+
with patch("datacustomcode.client.config") as mock_config:
337+
mock_config.streaming_source = "Account_model__dlm"
336338
result = client.read_dmo_deltas()
337339

338340
reader.read_dmo_deltas.assert_called_once_with()
@@ -384,7 +386,8 @@ def test_streaming_read_write_flow(self, reset_client, mock_spark):
384386

385387
client = StreamingClient(reader=reader, writer=writer)
386388

387-
with patch.dict("os.environ", {"BYOC_STREAMING_SOURCE_NAME": "source_dll"}):
389+
with patch("datacustomcode.client.config") as mock_config:
390+
mock_config.streaming_source = "source_dll"
388391
df = client.read_dlo_deltas()
389392
client.write_dlo_deltas("target_dll", df)
390393

tests/test_run.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,3 +488,102 @@ def test_run_entrypoint_empty_dataspace_value(self):
488488
os.unlink(entrypoint_file)
489489
if os.path.exists(config_json_path):
490490
os.unlink(config_json_path)
491+
492+
493+
class TestReadSourceFromPermissions:
494+
"""`_read_source_from_permissions` extracts the streaming read source from
495+
config.json's `permissions.read`."""
496+
497+
def test_returns_single_dlo(self):
498+
from datacustomcode.run import _read_source_from_permissions
499+
500+
config_json = {"permissions": {"read": {"dlo": ["Account_std__dll"]}}}
501+
assert _read_source_from_permissions(config_json) == "Account_std__dll"
502+
503+
def test_returns_single_dmo(self):
504+
from datacustomcode.run import _read_source_from_permissions
505+
506+
config_json = {"permissions": {"read": {"dmo": ["Account_model__dlm"]}}}
507+
assert _read_source_from_permissions(config_json) == "Account_model__dlm"
508+
509+
def test_dlo_preferred_when_both_present(self):
510+
from datacustomcode.run import _read_source_from_permissions
511+
512+
config_json = {
513+
"permissions": {"read": {"dlo": ["the_dll"], "dmo": ["the_dlm"]}}
514+
}
515+
assert _read_source_from_permissions(config_json) == "the_dll"
516+
517+
def test_returns_first_of_multiple(self):
518+
from datacustomcode.run import _read_source_from_permissions
519+
520+
config_json = {"permissions": {"read": {"dlo": ["first__dll", "second__dll"]}}}
521+
assert _read_source_from_permissions(config_json) == "first__dll"
522+
523+
@pytest.mark.parametrize(
524+
"config_json",
525+
[
526+
{},
527+
{"permissions": None},
528+
{"permissions": {}},
529+
{"permissions": {"read": None}},
530+
{"permissions": {"read": {}}},
531+
{"permissions": {"read": {"dlo": []}}},
532+
],
533+
)
534+
def test_returns_none_when_absent_or_empty(self, config_json):
535+
from datacustomcode.run import _read_source_from_permissions
536+
537+
assert _read_source_from_permissions(config_json) is None
538+
539+
540+
class TestStreamingSourceScenarios:
541+
"""`run_entrypoint` populates `config.streaming_source` from config.json."""
542+
543+
def _run_capturing_streaming_source(self, config_json_body):
544+
"""Run an entrypoint that records config.streaming_source and return it."""
545+
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as temp:
546+
entrypoint_content = textwrap.dedent(
547+
"""
548+
from datacustomcode.config import config
549+
with open("streaming_source_output.txt", "w") as f:
550+
f.write(f"streaming_source: {config.streaming_source}")
551+
"""
552+
)
553+
temp.write(entrypoint_content.encode("utf-8"))
554+
entrypoint_file = temp.name
555+
556+
entrypoint_dir = os.path.dirname(entrypoint_file)
557+
config_json_path = os.path.join(entrypoint_dir, "config.json")
558+
with open(config_json_path, "w") as f:
559+
json.dump(config_json_body, f)
560+
561+
try:
562+
run_entrypoint(
563+
entrypoint=entrypoint_file,
564+
config_file=None,
565+
dependencies=[],
566+
profile="default",
567+
)
568+
with open("streaming_source_output.txt", "r") as f:
569+
return f.read()
570+
finally:
571+
if os.path.exists(entrypoint_file):
572+
os.unlink(entrypoint_file)
573+
if os.path.exists(config_json_path):
574+
os.unlink(config_json_path)
575+
if os.path.exists("streaming_source_output.txt"):
576+
os.unlink("streaming_source_output.txt")
577+
578+
def test_streaming_source_set_from_permissions_read(self):
579+
content = self._run_capturing_streaming_source(
580+
{
581+
"dataspace": "default",
582+
"permissions": {"read": {"dlo": ["Account_std__dll"]}},
583+
}
584+
)
585+
assert "streaming_source: Account_std__dll" in content
586+
587+
def test_streaming_source_none_for_batch_without_read(self):
588+
content = self._run_capturing_streaming_source({"dataspace": "default"})
589+
assert "streaming_source: None" in content

0 commit comments

Comments
 (0)