Skip to content

Commit 00d0604

Browse files
authored
feat(config): sparse fork-safe local config override with deep-merge and caller-owned override deprecation (#78)
Introduces src/config_resolver.py as the single source of truth for framework config loading. load_framework_config() loads the authoritative default from config/default/<name> and, when present, deep-merges a partial override file from src/local/config/<name> on top — so users only need to specify the keys they want to change. - src/config_resolver.py: new module with load_framework_config, resolve_framework_config_dir, load_framework_global_config, and the deprecated resolve_framework_config_path shim (kept until v1.0.0) - src/constants.py: add FrameworkPaths.LOCAL_CONFIG_PATH - src/logger.py: load_framework_logger_config now delegates to the resolver; fix factory name mismatch (create_logger → get_logger) - src/dlt_pipeline_builder.py: replace manual config path construction with config_resolver calls - src/dataflow_spec_builder/spec_mapper.py: use resolve_framework_config_dir for mapping path - src/logger.py: simplify and clarify logger config loading and overall code readability - src/utility.py: extract config resolution functions into config_resolver; keep deep_merge here - src/local/config/logger.json + README.md: sample overlay and usage documentation - Deprecate config/override/ with a runtime DeprecationWarning - docs: rewrite feature_framework_configuration.rst; update feature_logging.rst for new config path; add ADR 0006
1 parent 66851d8 commit 00d0604

12 files changed

Lines changed: 549 additions & 158 deletions

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v0.14.0
1+
v0.15.0

docs/decisions/0003-pluggable-logger-architecture.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ Introduce a **factory-based pluggable logger** configured entirely via
4343

4444
**Resolution sequence** (`src/logger.py`, `resolve_pipeline_logger`):
4545

46-
1. Load `logger.json` from the framework bundle (`src/config/override/`) and the
47-
pipeline bundle (`pipeline_configs/`); deep-merge per precedence rules
46+
1. Load `logger.json` from the framework bundle (`src/local/config/` overlay on top of
47+
`src/config/default/`, or `src/config/override/` if still present — deprecated v0.13.0)
48+
and the pipeline bundle (`pipeline_configs/`); deep-merge per precedence rules
4849
(framework wins by default; `allow_pipeline_logger_override: true` inverts).
4950
2. If `enabled` is `false`, return the default `lakeflowframework` stdout logger.
5051
3. If `library` is set and not importable, fall back to the default logger with a
@@ -53,8 +54,9 @@ Introduce a **factory-based pluggable logger** configured entirely via
5354
the returned object exposes `debug`, `info`, `warning`, `error`, `critical`,
5455
and `exception`.
5556
5. On any failure (import error, factory error, invalid return type), fall back to
56-
the default logger with a warning — the pipeline never fails due to logging
57-
misconfiguration.
57+
the default logger, emit a `CustomLoggerConfigWarning` (Python `warnings` — visible
58+
in stderr and catchable with `pytest.warns`), and log an `ERROR` record — the
59+
pipeline never fails due to logging misconfiguration.
5860
6. Apply `mirror_to_stdout` and `CompositeLogger` wrapping (see ADR-0004).
5961

6062
**Resolved log level injection:** before calling the factory the framework
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# ADR-0006: `src/local/config/` sparse overlay replaces `config/override/`
2+
3+
**Date:** 2026-05-17
4+
**Status:** Accepted
5+
**PR:** feature/local-config-resolver-and-override (v0.15.0)
6+
7+
---
8+
9+
## Context
10+
11+
The original framework config mechanism offered a binary choice: use
12+
`config/default/` (shipped defaults) **or** replace it entirely with
13+
`config/override/` (full-tree copy). This had three significant problems:
14+
15+
1. **Whole-tree requirement.** A customer who wanted to change a single key in
16+
`global.json` had to copy the entire `config/default/` tree into
17+
`config/override/` and keep it in sync across framework upgrades. A missed
18+
sync would silently hide new default values.
19+
20+
2. **Coupling across config files.** The original resolver treated the presence
21+
of any non-hidden file in `config/override/` as a signal to use that tree
22+
exclusively for _all_ framework config reads. Adding a `logger.json` override
23+
inadvertently activated the override tree for global config, requiring the
24+
override tree to be complete.
25+
26+
3. **Override logic scattered inside the loader.** The loader (`load_framework_config`)
27+
was responsible for detecting whether `config/override/` was active, emitting
28+
deprecation warnings, and loading the correct file. This mixed concerns and made
29+
the detection logic duplicated at every call site (especially for multi-file
30+
resolution like `GLOBAL_CONFIG`).
31+
32+
The `src/local/` fork-safe area introduced in ADR-0001 provides a natural home
33+
for customer customisations. Config overrides should follow the same pattern.
34+
35+
## Decision
36+
37+
Introduce `src/local/config/` as a **sparse overlay directory** whose files are
38+
**deep-merged on top of their `src/config/default/` equivalents** at runtime.
39+
40+
### Separation of concerns
41+
42+
Override detection and deprecation warnings are the **caller's responsibility**.
43+
`load_framework_config` is a pure loader with no override logic:
44+
45+
- **`DLTPipelineBuilder._load_framework_global_config`** checks
46+
`config/override/` for global config files, emits the `DeprecationWarning` if
47+
active, stores the resolved root in `self._active_config_path`, and passes it
48+
to `load_framework_config`. `self._active_config_path` is reused by
49+
`_setup_operational_metadata` so the override detection only runs once per
50+
pipeline initialisation.
51+
52+
- **`load_framework_logger_config`** independently checks
53+
`config/override/logger.json`, emits its own `DeprecationWarning` if active,
54+
and passes the resolved root to `load_framework_config`. Logger config is
55+
detected separately from global config because a customer may have one but
56+
not the other.
57+
58+
### `load_framework_config` API
59+
60+
```python
61+
load_framework_config(
62+
name: str | Sequence[str],
63+
framework_path: str,
64+
config_path: str = FrameworkPaths.CONFIG_PATH, # caller-resolved active root
65+
fail_on_not_exists: bool = True,
66+
) -> Dict
67+
```
68+
69+
Behaviour:
70+
71+
1. When `name` is a **sequence** (e.g. `FrameworkPaths.GLOBAL_CONFIG =
72+
("global.json", "global.yaml", "global.yml")`):
73+
- Find all matching files inside `config_path`.
74+
- Raise `ValueError` if more than one match is found.
75+
- Raise `FileNotFoundError` (or return `{}` if `fail_on_not_exists=False`)
76+
if no match is found.
77+
- Resolve to the single matching filename and proceed.
78+
2. Load the file from `os.path.join(framework_path, config_path, name)`.
79+
3. If `src/local/config/<name>` exists, **deep-merge** it on top:
80+
- Dict values merged recursively; overlay wins on conflicts.
81+
- Non-dict values and lists replaced wholesale.
82+
- Keys absent from the overlay retain their default values.
83+
4. Return the merged dict.
84+
85+
No override detection. No warnings. Callers pass the already-resolved `config_path`.
86+
87+
### `resolve_framework_config_dir` (directory-based config)
88+
89+
For directory-based config (e.g. `dataflow_spec_mapping/`):
90+
91+
1. If `src/local/config/<subdir>/` exists, return it (local wins entirely).
92+
2. Otherwise return `src/config/default/<subdir>/`.
93+
94+
### `resolve_framework_config_path` (legacy shim)
95+
96+
Kept until v1.0.0 for any external callers. Emits a `DeprecationWarning` when
97+
`config/override/` is active. Framework internals no longer call this.
98+
99+
### Deprecation timeline
100+
101+
| Version | Action |
102+
|---------|--------|
103+
| v0.13.0 | `config/override/` deprecated; `DeprecationWarning` added |
104+
| v0.15.0 | `src/local/config/` introduced; all framework call sites migrated to `load_framework_config`; override detection moved to callers |
105+
| v1.0.0 | `config/override/` support and `resolve_framework_config_path` removed |
106+
107+
## Consequences
108+
109+
- A customer who wants to change one key in `global.json` creates a one-field
110+
`src/local/config/global.json` — no need to maintain a full copy.
111+
- Framework upgrades that add new default keys automatically appear in the
112+
merged config; only explicitly overridden keys are affected.
113+
- `config/override/` still works during the deprecation window; a
114+
`DeprecationWarning` is emitted by the caller (not deep inside the loader),
115+
giving an accurate stack frame in the pipeline logs.
116+
- The multi-file resolution check (preventing duplicate `global.json` and
117+
`global.yaml` coexisting) now lives inside `load_framework_config` when a
118+
sequence is passed — consistent with the pipeline bundle's equivalent check in
119+
`_load_pipeline_bundle_global_config_file`.
120+
- The `logger.json` override (pluggable logger, ADR-0003) now lives in
121+
`src/local/config/logger.json` rather than `config/override/`.
122+
- Substitutions and secrets files (which include workspace-environment prefixes)
123+
are not yet covered by `load_framework_config` and remain on the legacy path
124+
until a follow-up PR addresses the workspace-env prefix complexity.

docs/source/feature_framework_configuration.rst

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,25 @@ Framework configuration
1111
* - **Databricks Docs:**
1212
- NA
1313

14-
Framework-level settings (global JSON/YAML, substitutions, secrets, spec mappings, operational metadata) live under **one** active directory. The framework chooses between a **default** tree and an optional **override** tree; everything else reads paths relative to that choice.
14+
Framework-level settings (global JSON/YAML, substitutions, secrets, spec
15+
mappings, operational metadata) live under ``src/config/default/``. Individual
16+
values can be overridden per-deployment using sparse files in
17+
``src/local/config/`` — only the keys you want to change are needed.
1518

1619
Configuration
1720
-------------
1821

1922
| **Scope: Global (framework bundle)**
20-
| **Default:** ``./config/default/`` (for example ``src/config/default/`` when the framework root is ``src``).
21-
| **Override:** ``./config/override/`` (for example ``src/config/override/``). Optional; see **Override** below.
23+
| **Default:** ``src/config/default/`` — authoritative, always read.
24+
| **Override:** ``src/local/config/`` — sparse override; deep-merged on top of defaults.
2225
23-
Under the active directory you normally have:
26+
Under ``src/config/default/`` you normally have:
2427

2528
* exactly one global file: ``global.json``, ``global.yaml``, or ``global.yml``
2629
* a ``dataflow_spec_mapping/`` directory (see :doc:`feature_versioning_dataflow_spec`)
2730
* optional per-target substitution and secrets files (see :doc:`feature_substitutions`, :doc:`feature_secrets`)
2831
* optional ``operational_metadata_<layer>.json`` (see :doc:`feature_operational_metadata`)
32+
* optional ``logger.json`` (see :doc:`feature_logging`)
2933

3034
Mandatory
3135
---------
@@ -57,13 +61,51 @@ Inside the global file, all top-level keys are optional. Common ones:
5761
* - ``override_max_workers`` / ``pipeline_builder_disable_threading``
5862
- :doc:`feature_builder_parallelization`
5963

60-
Override
61-
--------
64+
Local override (``src/local/config/``)
65+
---------------------------------------
66+
67+
Place sparse JSON/YAML files in ``src/local/config/`` to override individual
68+
keys without copying the entire default file. The framework **deep-merges** the
69+
overlay on top of the defaults at runtime:
70+
71+
* Dict values are merged recursively — only the keys present in the overlay
72+
are changed.
73+
* Non-dict values and lists are replaced wholesale.
74+
* Keys not present in the overlay retain their default values.
75+
76+
Example — change one global setting without touching the rest of ``global.json``:
77+
78+
.. code-block:: json
79+
80+
{
81+
"dataflow_spec_version": "0.0.3"
82+
}
83+
84+
Save this as ``src/local/config/global.json``. All other keys from
85+
``src/config/default/global.json`` are kept unchanged.
86+
87+
For **directory-based config** (e.g. ``dataflow_spec_mapping/``), place the
88+
entire override directory in ``src/local/config/`` — the local directory takes
89+
full precedence over the default.
90+
91+
See ``src/local/config/README.md`` in the framework bundle for the full list of
92+
supported files and migration instructions.
93+
94+
.. deprecated:: v0.14.0
95+
96+
``config/override/``
97+
^^^^^^^^^^^^^^^^^^^^
98+
99+
The ``config/override/`` mechanism (whole-tree replacement) is deprecated as
100+
of v0.14.0 and will be removed in v1.0.0. Migrate to ``src/local/config/``
101+
sparse files instead.
62102

63-
* If ``./config/override/`` has **no** non-hidden files (only names starting with ``.``, such as ``.gitkeep``), the framework uses ``./config/default/``.
64-
* If it has **any** non-hidden file or folder, the framework uses ``./config/override/`` instead—but then that directory must already contain **both** a valid global file and a ``dataflow_spec_mapping/`` directory. Otherwise startup fails with a message to copy the full layout from ``./config/default/``.
65-
* If **neither** directory has non-hidden content, startup fails: add configuration under ``./config/default/``.
103+
**Migration steps:**
66104

67-
.. tip::
105+
1. Identify which keys in your ``config/override/`` files differ from
106+
``config/default/``.
107+
2. Create sparse files in ``src/local/config/`` containing only those keys.
108+
3. Remove all files from ``config/override/`` (leave the ``.gitkeep``).
68109

69-
Leave ``config/override`` empty until you can mirror the whole ``config/default`` tree.
110+
A ``DeprecationWarning`` is emitted at pipeline startup when
111+
``config/override/`` contains non-hidden files.

docs/source/feature_logging.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,11 @@ Create ``src/local/libraries/structured_stdout_logger.py``:
374374
"""Factory called by the framework as factory(dbutils, spark, **factory_args)."""
375375
return StructuredStdoutLogger(level=level, logger_name=logger_name)
376376
377+
**Step 2 — Enable the logger via** ``src/local/config/logger.json``
378+
379+
Add or update ``src/local/config/logger.json`` in the framework bundle
380+
(a sparse file is sufficient — only the keys you want to set are needed):
381+
377382
**Step 2 — Enable the logger via** ``config/override/logger.json``
378383

379384
Add or update ``src/config/override/logger.json`` in the framework bundle
@@ -602,6 +607,3 @@ Structured stdout logger output (JSON, via ``structured_stdout_logger``):
602607
{"timestamp": "2025-02-06T04:05:46.161000+00:00", "level": "INFO", "logger": "lakeflowframework", "message": "Initializing Pipeline..."}
603608
{"timestamp": "2025-02-06T04:05:48.254000+00:00", "level": "INFO", "logger": "lakeflowframework", "message": "Creating Flow: flow_name"}
604609
{"timestamp": "2025-02-06T04:06:26.527000+00:00", "level": "ERROR", "logger": "lakeflowframework", "message": "Failed to process Data Flow Spec: schema mismatch", "exc_info": "Traceback (most recent call last):\n ..."}
605-
606-
607-

0 commit comments

Comments
 (0)