Skip to content

Commit 87829c3

Browse files
authored
Add handling for global parameters (#15)
## Changes <!-- Summary of your changes that are easy to understand. Add screenshots when necessary --> Add handler methods to resolve global parameters from ADF resources. Parameter is controlled by the user via prompting. The choice is applied to an entire ADF resource during conversion. Users can choose to: - Inject resolved parameter values directly into the code or configuration during conversion - Create bundle variables and parameterize code or configuration with bundle variable references ### Linked issues <!-- DOC: Link issue with a keyword: close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved. See https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword --> N/A ### Tests <!-- How is this tested? Please see the checklist below and also describe any other relevant tests --> - [x] manually tested - [x] added unit tests - [ ] added integration tests
1 parent 8670cd2 commit 87829c3

19 files changed

Lines changed: 811 additions & 29 deletions

File tree

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ ExecuteDataFlow, SqlServerStoredProcedure, AzureFunction, WebHook, Custom, Execu
113113
- Leaf types return Activity only, control-flow returns (Activity, TranslationContext)
114114
- Use `parse_expression()` for ADF expression translation, return None for unsupported
115115

116+
### Naming, docstrings, and comments
117+
118+
- Spell names out: use unabbreviated variable, parameter, function, and class names
119+
(`parameter_values` not `params`, `whole_reference` not `whole`). Short loop indices and
120+
regex match binders (`match`, `item`) are fine.
121+
- Write docstrings in plain, conversational language aimed at both users and maintainers.
122+
Say what the function does and why in everyday terms; skip jargon and marketing tone.
123+
- Prefer self-documenting code over inline comments. Reserve comments for the non-obvious
124+
*why* (a workaround, a spec quirk, a subtle ordering constraint) -- not for restating what
125+
the code already says. Delete comments that narrate self-evident lines.
126+
116127
## Adding a New Deterministic Translator
117128

118129
1. Add IR dataclass to `src/flowx/models/ir.py`

docs/content/docs/options.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,30 @@ The following are required to consolidate into a single ingestion pipeline:
8383
- The number of metadata rows or objects must be less than 250
8484
</Callout>
8585

86+
## global_parameter_resolution
87+
88+
Controls how global parameters (`@pipeline().globalParameters.X`) are translated. This option is
89+
applied uniformly to every pipeline in the input resource.
90+
91+
| Value | Default | Behavior |
92+
|-------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
93+
| `literal` | True | Writes each global variable into the translated configuration or notebook code. |
94+
| `bundle_variable` | False | Uses a `${var.X}` reference and declares the global variable as a [DAB bundle variable](https://docs.databricks.com/aws/en/dev-tools/bundles/variables). |
95+
96+
Under `bundle_variable`, each referenced global becomes a variable in `databricks.yml` with its
97+
factory value as the default, so a deploy with no overrides reproduces the original ADF behavior.
98+
You then change the value per target or at deploy time
99+
(`databricks bundle deploy -t <target> --var "X=<value>"`). Globals referenced inside generated
100+
notebook code are wired through the task's `base_parameters` so `${var.X}` still resolves at
101+
runtime.
102+
103+
<Callout type="warn" title="Sensitive values ship in plaintext">
104+
The default values for global parameters are written into `databricks.yml` as plain text. For sensitive
105+
values (e.g. tokens, connection strings, URLs with embedded SAS signatures), clear the default and
106+
supply the value at deploy time using a `--var` argument, a per-target override, or a secret scope instead
107+
of committing sensitive values to the bundle. See `SETUP.md` for a full list of converted global variables.
108+
</Callout>
109+
86110
## Discover complexity report
87111

88112
The `discover` phase emits `<output_dir>/metadata/profile_report.csv` (default `./flowx_output/metadata/profile_report.csv`), one row per pipeline with the following columns:

skills/flowx-convert/SKILL.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,22 @@ Execute the translation engine on all deterministic activities:
9595
"$PY" -m flowx.translator.engine \
9696
--source-dir <adf_source_dir> \
9797
--output-dir <output_dir> \
98-
[--pipeline <pipeline_name>]
98+
[--pipeline <pipeline_name>] \
99+
[--global-parameter-resolution literal|bundle_variable]
99100
```
100101

101102
Where:
102103
- `<adf_source_dir>` is the original ADF JSON directory (the same `--source-dir` used by discover)
103104
- `<output_dir>` is the **shared migration output directory** (default: `./flowx_output`) — the
104105
same one discover used
105106
- `<pipeline_name>` (optional) — when provided, translates only the named pipeline. **Always pass `--pipeline` when the user has specified a specific pipeline to migrate**, matching the value passed to the discover phase.
107+
- `--global-parameter-resolution` (optional, default `literal`) — how `@pipeline().globalParameters.X`
108+
references resolve, applied to every pipeline. `literal` bakes the factory value in as a literal;
109+
`bundle_variable` emits `${var.X}` and declares the global as a DAB bundle variable whose default is
110+
the factory value, so it can be set at deploy time (`--var X=…` or a per-target override) instead of
111+
being hard-coded into pipeline/activity bodies. Globals referenced inside generated notebook code are
112+
bridged through the task's `base_parameters` so `${var.X}` still resolves. See SETUP.md for the list
113+
of hoisted variables and a plaintext-secret caveat.
106114

107115
The translation report and intermediate IR are written to the **transient** `<output_dir>/.work/`
108116
folder (`translation_report.json`, per-pipeline IR, `gaps.json`). These are consumed by the steps

src/flowx/adapter/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
INPUT_ADF_RESOURCE_URL: Final[str] = "adf_resource_url"
3232
INPUT_OUTPUT_DIR: Final[str] = "output_dir"
3333
INPUT_INVENTORY_PATH: Final[str] = "inventory_path"
34+
INPUT_GLOBAL_PARAMETER_RESOLUTION: Final[str] = "global_parameter_resolution"
3435
INPUT_TRANSLATION_REPORT_PATH: Final[str] = "translation_report_path"
3536
INPUT_OUTPUT_BUNDLE_PATH: Final[str] = "output_bundle_path"
3637
INPUT_CATALOG: Final[str] = "catalog"

src/flowx/adapter/session.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
INPUT_BUNDLE_NAME,
2020
INPUT_CATALOG,
2121
INPUT_DATABRICKS_PROFILE,
22+
INPUT_GLOBAL_PARAMETER_RESOLUTION,
2223
INPUT_INSTALL_DASHBOARD,
2324
INPUT_INVENTORY_PATH,
2425
INPUT_OUTPUT_BUNDLE_PATH,
@@ -297,6 +298,17 @@ def _collect_motif_consolidations(self) -> dict[str, MotifConsolidate]:
297298
default="./flowx_output",
298299
required=False,
299300
),
301+
MigrationInputOption(
302+
option_id=INPUT_GLOBAL_PARAMETER_RESOLUTION,
303+
prompt="How should factory global parameters be resolved?",
304+
description=(
305+
"Applies to every pipeline. 'literal' bakes each @pipeline().globalParameters.X value in as a "
306+
"literal; 'bundle_variable' emits ${var.X} and declares the global as a DAB bundle variable with "
307+
"the factory value as its default, so it can be changed at deploy time."
308+
),
309+
default="literal",
310+
required=False,
311+
),
300312
)
301313

302314
_PACKAGE_OPTIONS: tuple[MigrationInputOption, ...] = (

src/flowx/bundler/dab_writer.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ def write_bundle(
121121

122122
pipeline_resources = _collect_pipeline_resources(workflow)
123123
pipeline_variable_declarations = _build_pipeline_variable_declarations(pipeline_resources, catalog, schema)
124+
hoisted_global_variables = _collect_hoisted_global_variables(workflow)
125+
extra_variable_declarations = {**pipeline_variable_declarations, **hoisted_global_variables}
124126

125127
# 1. Write databricks.yml. When any task runs on classic compute, spark_version / node_type_id
126128
# defaults come from the ADF linked-service configs; when every task is serverless, they're omitted.
@@ -133,7 +135,7 @@ def write_bundle(
133135
spark_version=inferred_spark_version,
134136
node_type_id=inferred_node_type_id,
135137
include_cluster_variables=bundle_uses_classic_cluster,
136-
extra_variables=pipeline_variable_declarations,
138+
extra_variables=extra_variable_declarations,
137139
)
138140
databricks_yml_path.write_text(
139141
yaml.dump(
@@ -156,7 +158,8 @@ def write_bundle(
156158
resources_dir = output_dir / "resources"
157159
resources_dir.mkdir(parents=True, exist_ok=True)
158160
job_yml_path = resources_dir / f"{resource_key}.yml"
159-
job_resource = _build_job_resource(workflow, resource_key)
161+
hoisted_global_names = set(hoisted_global_variables)
162+
job_resource = _build_job_resource(workflow, resource_key, hoisted_globals=hoisted_global_names)
160163
job_yml_path.write_text(
161164
yaml.dump(
162165
job_resource, default_flow_style=False, sort_keys=False, allow_unicode=True, Dumper=_BundleYamlDumper
@@ -170,7 +173,9 @@ def write_bundle(
170173
for inner in workflow.inner_workflows:
171174
inner_key = normalize_task_key(inner.name)
172175
inner_yml_path = resources_dir / f"{inner_key}.yml"
173-
inner_resource = _build_job_resource(inner, inner_key, extra_notebooks_for_augment=workflow.notebooks)
176+
inner_resource = _build_job_resource(
177+
inner, inner_key, extra_notebooks_for_augment=workflow.notebooks, hoisted_globals=hoisted_global_names
178+
)
174179
inner_yml_path.write_text(
175180
yaml.dump(
176181
inner_resource,
@@ -289,6 +294,7 @@ def write_bundle(
289294
manual_schedule_time_of_day=manual_schedule_time_of_day_configs,
290295
manual_credentials=manual_credential_configs,
291296
neutralized_conditions=list(_neutralized_conditions),
297+
hoisted_global_variables=hoisted_global_variables,
292298
)
293299
setup_path = output_dir / "SETUP.md"
294300
setup_path.write_text(render_setup_md(prereqs, bundle_name=effective_name), encoding="utf-8")
@@ -797,6 +803,26 @@ def _collect_variable_references(value: Any) -> set[str]:
797803
return refs
798804

799805

806+
def _collect_hoisted_global_variables(workflow: PreparedWorkflow) -> dict[str, Any]:
807+
"""Returns the bundle-variable declarations for hoisted factory globals.
808+
809+
Merges ``bundle_variables`` from *workflow* and every inner workflow so a
810+
global referenced only inside a nested (run_job_task) workflow is still
811+
declared in the root ``variables:`` block.
812+
813+
Args:
814+
workflow: The prepared workflow being written.
815+
816+
Returns:
817+
Mapping of variable name to its DAB declaration dict.
818+
"""
819+
declarations: dict[str, Any] = {}
820+
for inner in workflow.inner_workflows:
821+
declarations.update(inner.bundle_variables)
822+
declarations.update(workflow.bundle_variables)
823+
return declarations
824+
825+
800826
def _build_pipeline_variable_declarations(
801827
pipeline_resources: list[dict[str, Any]],
802828
catalog: str,
@@ -1204,13 +1230,20 @@ def _collect_all_task_keys(tasks: list[dict[str, Any]]) -> set[str]:
12041230
return keys
12051231

12061232

1207-
def _augment_base_parameters(tasks: list[dict[str, Any]], notebooks: list[DabNotebook]) -> None:
1233+
def _augment_base_parameters(
1234+
tasks: list[dict[str, Any]], notebooks: list[DabNotebook], hoisted_globals: set[str] | None = None
1235+
) -> None:
12081236
"""Ensure every widget a notebook reads is declared in its base_parameters.
12091237
12101238
Args:
12111239
tasks: Top-level task dicts (mutated in place).
12121240
notebooks: Generated notebooks to scan.
1241+
hoisted_globals: Names of factory globals hoisted to bundle variables.
1242+
A widget matching one of these binds to ``${var.NAME}`` so the
1243+
deploy-time bundle variable flows into the notebook; other widgets
1244+
default to an empty string as before.
12131245
"""
1246+
hoisted = hoisted_globals or set()
12141247
notebook_by_relpath = {notebook.relative_path: notebook for notebook in notebooks}
12151248

12161249
def visit(task: dict[str, Any]) -> None:
@@ -1223,7 +1256,8 @@ def visit(task: dict[str, Any]) -> None:
12231256
widgets = set(_WIDGET_REFERENCE.findall(notebook.content))
12241257
base_parameters = notebook_task.setdefault("base_parameters", {})
12251258
for widget_name in sorted(widgets):
1226-
base_parameters.setdefault(widget_name, "")
1259+
fallback = "${var." + widget_name + "}" if widget_name in hoisted else ""
1260+
base_parameters.setdefault(widget_name, fallback)
12271261
for_each = task.get("for_each_task")
12281262
if for_each and isinstance(for_each.get("task"), dict):
12291263
visit(for_each["task"])
@@ -1238,6 +1272,7 @@ def _build_job_resource(
12381272
*,
12391273
attach_clusters: bool = True,
12401274
extra_notebooks_for_augment: list[DabNotebook] | None = None,
1275+
hoisted_globals: set[str] | None = None,
12411276
) -> dict[str, Any]:
12421277
"""Builds a job resource dict for a single workflow.
12431278
@@ -1256,7 +1291,7 @@ def _build_job_resource(
12561291
# For inner jobs (run_job_task), notebooks live in the parent workflow's list — pass them in so widget
12571292
# auto-augment can still find the bound notebook and populate base_parameters.
12581293
augment_scope = list(workflow.notebooks) + list(extra_notebooks_for_augment or [])
1259-
_augment_base_parameters(workflow.tasks, augment_scope)
1294+
_augment_base_parameters(workflow.tasks, augment_scope, hoisted_globals)
12601295
# Task values don't cross run_job_task boundaries; such a reference resolves to an empty string at
12611296
# runtime, so emit it now for SETUP.md §4. C-43: a blanked condition operand is always-true, so record
12621297
# each neutralised condition for the SETUP.md re-wiring section.
@@ -1463,6 +1498,7 @@ def pipeline_dict_to_ir(pipeline_dict: dict[str, Any]) -> tuple[Pipeline, list[d
14631498
parameters=parameters or None,
14641499
translation_configuration=_reconstruct_configuration(pipeline_dict.get("translation_configuration")),
14651500
schedule=pipeline_dict.get("schedule"),
1501+
bundle_variables=pipeline_dict.get("bundle_variables") or {},
14661502
)
14671503
return pipeline, parameters
14681504

src/flowx/bundler/prereqs_writer.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ class Prereqs:
131131
# C-43 (CF5-001 / CF5-002): condition_task operands blanked because they referenced a task in another
132132
# job ({task_key, field, original_ref}); a blanked operand is always-true, so the user must re-wire it.
133133
neutralized_conditions: list[dict[str, str]] = field(default_factory=list)
134+
hoisted_global_variables: dict[str, dict[str, Any]] = field(default_factory=dict)
134135

135136
def is_empty(self) -> bool:
136137
"""Return ``True`` when nothing needs to happen before ``bundle run``."""
@@ -150,6 +151,7 @@ def is_empty(self) -> bool:
150151
and not self.manual_schedule_time_of_day
151152
and not self.manual_credentials
152153
and not self.neutralized_conditions
154+
and not self.hoisted_global_variables
153155
)
154156

155157

@@ -361,6 +363,7 @@ def build_prereqs(
361363
manual_schedule_time_of_day: list[dict[str, Any]] | None = None,
362364
manual_credentials: list[dict[str, Any]] | None = None,
363365
neutralized_conditions: list[dict[str, str]] | None = None,
366+
hoisted_global_variables: dict[str, dict[str, Any]] | None = None,
364367
) -> Prereqs:
365368
"""Assemble a :class:`Prereqs` from the bundle's generated artifacts.
366369
@@ -408,6 +411,7 @@ def build_prereqs(
408411
manual_schedule_time_of_day=list(manual_schedule_time_of_day or []),
409412
manual_credentials=list(manual_credentials or []),
410413
neutralized_conditions=list(neutralized_conditions or []),
414+
hoisted_global_variables=dict(hoisted_global_variables or {}),
411415
)
412416

413417

@@ -476,6 +480,29 @@ def render_setup_md(prereqs: Prereqs, *, bundle_name: str) -> str:
476480
)
477481
lines.append("")
478482

483+
if prereqs.hoisted_global_variables:
484+
lines.append("## Factory global parameters (bundle variables)")
485+
lines.append("")
486+
lines.append(
487+
"These ADF factory global parameters were hoisted to DAB bundle variables "
488+
"(`global_parameter_resolution=bundle_variable`). Each is declared in `databricks.yml` "
489+
"with its factory value as the default, so a deploy with no overrides reproduces the "
490+
"original behaviour. Override per target in `databricks.yml` or at deploy time:"
491+
)
492+
lines.append("")
493+
lines.append("```bash")
494+
for name in sorted(prereqs.hoisted_global_variables):
495+
lines.append(f'databricks bundle deploy -t <target> --var "{name}=<value>"')
496+
lines.append("```")
497+
lines.append("")
498+
lines.append(
499+
"> **Security note:** the factory-value defaults are stored in plaintext in "
500+
"`databricks.yml`. For any sensitive value (tokens, connection strings, URLs with "
501+
"embedded SAS signatures), clear the default and supply it at deploy time via `--var`, "
502+
"a per-target override, or a secret scope instead of committing it to the bundle."
503+
)
504+
lines.append("")
505+
479506
if prereqs.missing_notebooks:
480507
lines.append("## Notebooks to author")
481508
lines.append("")

src/flowx/models/ir.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,8 @@ class Pipeline:
532532
tasks: Ordered list of translated activities.
533533
tags: System and user-defined tags.
534534
not_translatable: Entries describing properties that could not be translated.
535+
bundle_variables: DAB bundle-variable declarations (name -> ``{"description", "default"}``)
536+
for factory globals hoisted under the ``bundle_variable`` resolution policy.
535537
"""
536538

537539
name: str
@@ -541,6 +543,7 @@ class Pipeline:
541543
tags: dict[str, str] = field(default_factory=dict)
542544
not_translatable: list[dict[str, Any]] = field(default_factory=list)
543545
translation_configuration: TranslationConfiguration | None = None
546+
bundle_variables: dict[str, dict[str, Any]] = field(default_factory=dict)
544547

545548

546549
@dataclass(frozen=True, slots=True)
@@ -562,6 +565,7 @@ class TranslationContext:
562565
variable_default_literals: MappingProxyType[str, str] = field(default_factory=lambda: MappingProxyType({}))
563566
global_parameters: MappingProxyType[str, Any] = field(default_factory=lambda: MappingProxyType({}))
564567
linked_service_parameters: MappingProxyType[str, Any] = field(default_factory=lambda: MappingProxyType({}))
568+
global_parameter_resolution: str = "literal"
565569

566570
def with_activity(self, name: str, activity: Activity) -> TranslationContext:
567571
"""Return a new context with *activity* added to the cache.
@@ -582,6 +586,7 @@ def with_activity(self, name: str, activity: Activity) -> TranslationContext:
582586
variable_default_literals=self.variable_default_literals,
583587
global_parameters=self.global_parameters,
584588
linked_service_parameters=self.linked_service_parameters,
589+
global_parameter_resolution=self.global_parameter_resolution,
585590
)
586591

587592
def get_activity(self, activity_name: str) -> Activity | None:
@@ -627,6 +632,7 @@ def with_variable(
627632
variable_default_literals=self.variable_default_literals,
628633
global_parameters=self.global_parameters,
629634
linked_service_parameters=self.linked_service_parameters,
635+
global_parameter_resolution=self.global_parameter_resolution,
630636
)
631637

632638
def with_variable_types(
@@ -661,6 +667,7 @@ def with_variable_types(
661667
variable_default_literals=MappingProxyType({**self.variable_default_literals, **(default_literals or {})}),
662668
global_parameters=self.global_parameters,
663669
linked_service_parameters=self.linked_service_parameters,
670+
global_parameter_resolution=self.global_parameter_resolution,
664671
)
665672

666673
def get_variable_task_key(self, variable_name: str) -> str | None:
@@ -698,6 +705,7 @@ def with_linked_service_parameters(self, params: dict[str, Any]) -> TranslationC
698705
variable_default_literals=self.variable_default_literals,
699706
global_parameters=self.global_parameters,
700707
linked_service_parameters=MappingProxyType(dict(params)),
708+
global_parameter_resolution=self.global_parameter_resolution,
701709
)
702710

703711
def get_global_parameter(self, name: str) -> Any:

0 commit comments

Comments
 (0)