Skip to content

Commit 2ed33d3

Browse files
Mirroring commit 0036bba05bd7741a14ad7527fa89622f3626eb6b from internal
1 parent ec40ed3 commit 2ed33d3

File tree

5 files changed

+187
-0
lines changed

5 files changed

+187
-0
lines changed

dagster_open_platform/lib/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from dagster_open_platform.lib.executable_component import (
2+
ExecutableComponent as ExecutableComponent,
3+
)
4+
from dagster_open_platform.lib.schedule import ScheduleComponent as ScheduleComponent
5+
from dagster_open_platform.lib.sling.cloud_product_ingest import (
6+
ProdDbReplicationsComponent as ProdDbReplicationsComponent,
7+
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import importlib
2+
import inspect
3+
from typing import Annotated, Callable, Literal, Optional
4+
5+
import dagster as dg
6+
from dagster.components import (
7+
Component,
8+
ComponentLoadContext,
9+
Model,
10+
ResolutionContext,
11+
Resolvable,
12+
ResolvedAssetSpec,
13+
Resolver,
14+
)
15+
from dagster_shared import check
16+
from typing_extensions import TypeAlias
17+
18+
19+
class DailyPartitionDefinitionModel(Resolvable, Model):
20+
type: Literal["daily"] = "daily"
21+
start_date: str
22+
end_offset: int = 0
23+
24+
25+
def resolve_partition_definition(
26+
context: ResolutionContext, model: DailyPartitionDefinitionModel
27+
) -> dg.PartitionsDefinition:
28+
return dg.DailyPartitionsDefinition(
29+
start_date=model.start_date,
30+
end_offset=model.end_offset,
31+
)
32+
33+
34+
ResolvedPartitionDefinition: TypeAlias = Annotated[
35+
dg.DailyPartitionsDefinition,
36+
Resolver(
37+
resolve_partition_definition,
38+
model_field_type=DailyPartitionDefinitionModel,
39+
can_inject=True,
40+
),
41+
]
42+
43+
44+
def resolve_callable(context: ResolutionContext, model: str) -> Callable:
45+
module_path, fn_name = model.rsplit(".", 1)
46+
module = importlib.import_module(module_path)
47+
return getattr(module, fn_name)
48+
49+
50+
ResolvableCallable: TypeAlias = Annotated[
51+
Callable, Resolver(resolve_callable, model_field_type=str)
52+
]
53+
54+
55+
def get_resources_from_callable(func: Callable) -> list[str]:
56+
sig = inspect.signature(func)
57+
return [param.name for param in sig.parameters.values() if param.name != "context"]
58+
59+
60+
class ExecutableComponent(Component, Resolvable, Model):
61+
"""Executable Component represents an executable node in the asset graph.
62+
63+
It is comprised of an execute_fn, which is can be specified as a fully
64+
resolved symbol reference in yaml. This makes it a plain ole' Python function
65+
that does the execution within the asset graph.
66+
67+
You can pass an arbitrary number of assets or asset checks to the component.
68+
69+
With this structure this component replaces @asset, @multi_asset, @asset_check, and @multi_asset_check.
70+
which can all be expressed as a single ExecutableComponent.
71+
"""
72+
73+
# inferred from the function name if not provided
74+
name: Optional[str] = None
75+
partitions_def: Optional[ResolvedPartitionDefinition] = None
76+
assets: Optional[list[ResolvedAssetSpec]] = None
77+
execute_fn: ResolvableCallable
78+
79+
def get_resource_keys(self) -> set[str]:
80+
return set(get_resources_from_callable(self.execute_fn))
81+
82+
def build_defs(self, context: ComponentLoadContext) -> dg.Definitions:
83+
required_resource_keys = self.get_resource_keys()
84+
85+
check.invariant(len(self.assets or []) > 0, "assets is required for now")
86+
87+
@dg.multi_asset(
88+
name=self.name or self.execute_fn.__name__,
89+
specs=self.assets,
90+
partitions_def=self.partitions_def,
91+
required_resource_keys=required_resource_keys,
92+
)
93+
def _assets_def(context: dg.AssetExecutionContext, **kwargs):
94+
rd = context.resources.original_resource_dict
95+
to_pass = {k: v for k, v in rd.items() if k in required_resource_keys}
96+
check.invariant(set(to_pass.keys()) == required_resource_keys, "Resource keys mismatch")
97+
return self.execute_fn(context, **to_pass)
98+
99+
return dg.Definitions(assets=[_assets_def])
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from pathlib import Path
2+
from unittest.mock import MagicMock, patch
3+
4+
import pandas as pd
5+
from dagster import AssetsDefinition, DailyPartitionsDefinition, Definitions, materialize
6+
from dagster.components.core.context import use_component_load_context
7+
from dagster_open_platform.utils.components_test import build_component_and_defs
8+
from dagster_open_platform_tests.utils import component_context
9+
10+
11+
def test_basic_execute():
12+
# need to import here to avoid double import
13+
from dagster_open_platform.lib.executable_component import ExecutableComponent
14+
15+
ctx = component_context(Path("gong/component"))
16+
17+
with use_component_load_context(ctx):
18+
component, defs = build_component_and_defs(ctx)
19+
20+
import dagster_open_platform.lib.executable_component
21+
22+
assert type(component).__name__ == "ExecutableComponent"
23+
assert type(component) is dagster_open_platform.lib.executable_component.ExecutableComponent
24+
assert isinstance(component, ExecutableComponent), f"Component is {type(component)}"
25+
26+
assert component.get_resource_keys() == {"claude", "snowflake", "slack_gong"}
27+
28+
defs = Definitions.merge(
29+
defs,
30+
Definitions(
31+
resources=dict(
32+
snowflake=MagicMock(),
33+
claude=MagicMock(),
34+
slack_gong=MagicMock(),
35+
)
36+
),
37+
)
38+
39+
assets_def = defs.get_assets_def("gong_calls_transcript_ai")
40+
assert isinstance(assets_def, AssetsDefinition)
41+
42+
assert isinstance(assets_def.partitions_def, DailyPartitionsDefinition)
43+
44+
mock_df = pd.DataFrame(
45+
{
46+
"CONVERSATION_ID": ["123", "456"],
47+
"CALL_ANALYSIS_CONTEXT": [
48+
"Call between John and Jane discussing product features",
49+
"Call between Mike and Sarah about pricing",
50+
],
51+
}
52+
)
53+
54+
with patch(
55+
"dagster_open_platform.defs.gong.component.gong_calls_transcript_ai.get_completed_calls_with_transcripts",
56+
return_value=mock_df,
57+
):
58+
with patch(
59+
"dagster_open_platform.defs.gong.component.gong_calls_transcript_ai.create_call_analysis_context",
60+
return_value=pd.DataFrame(),
61+
):
62+
result = materialize(
63+
[assets_def],
64+
partition_key="2025-04-14",
65+
)
66+
67+
assert result.success

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ dev = [
5454
]
5555

5656
tests = [
57+
"mock>=5.2.0",
5758
"pytest",
5859
"responses",
5960
"syrupy",

uv.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)