Skip to content

Commit 8ac1b17

Browse files
committed
feat(fabric): .ipynb → Fabric .Notebook/ converter + pre-commit regen gate
Fabric Git Integration only recognizes items in Fabric's own folder format (<Name>.<ItemType>/ with .platform + content files) — plain .ipynb files are ignored. This blocked the first Git Integration sync: nothing came through to the Fabric workspace. Adds a converter that produces the Fabric-native source format from each .ipynb, plus a pre-commit gate that keeps the generated outputs in sync. What's new core/scripts/convert_ipynb_to_fabric.py - Reads each fabric/notebooks/*.ipynb and writes a sibling <name>.Notebook/ folder with: notebook-content.py — Python source with # CELL ******************** and # MARKDOWN ******************** magic comments separating cells, plus a leading notebook-level # METADATA block (kernel info only — no lakehouse binding, so workspace GUIDs stay out of committed files). .platform — JSON metadata: type, displayName, logicalId. logicalId is a stable uuid.uuid5(NAMESPACE, stem), so re-runs are byte-identical and Fabric sees the same item across syncs (never "new item every commit"). - CLI: bare run regenerates all; --notebooks <paths> regenerates a subset; --check exits 1 if any output would change (pre-commit gate). - Idempotent (verified: second run = all "ok", check = exit 0). .pre-commit-config.yaml - New local hook fabric-notebooks-regen, runs --check on changes to either .ipynb sources or the converter itself. Triggers re-run when stale. fabric/notebooks/0[0,2-9]_*.Notebook/, 10_*.Notebook/ - 10 generated .Notebook/ folders (one per source .ipynb). These ARE committed — Option A from the source-of-truth discussion: .ipynb is source, .Notebook is generated artifact tracked for diff visibility and for Fabric Git Integration to pick up. Workflow contract - .ipynb is the canonical source — edit in any Jupyter-aware editor. - Pre-commit regenerates .Notebook/ on every commit (~2s); committers re-stage the regen output. - Fabric Git Integration syncs .Notebook/ into the workspace. - Discipline rule: never edit notebooks substantively in the Fabric UI — it would silently overwrite .Notebook on next sync but .ipynb would stay stale, lost on next pre-commit regen. Use Fabric for runs + screenshots only; port any UI experimentation back to .ipynb. Why no lakehouse binding in the generated metadata The notebook-level dependencies.lakehouse block in Fabric's notebook metadata pins a specific lakehouse GUID + workspace GUID. Committing those re-introduces the same hardcoded-IDs problem the .env machinery exists to prevent (security review pass, commit 370f666 / chore(security)). Trade-off: first run of each notebook in Fabric needs a one-time lakehouse pick from the top-bar dropdown; Fabric persists the binding server-side after that. Acceptable for 10 notebooks. fabric-cicd parameter substitution can templatize this later if multiple workspaces (dev/test/prod) get involved. 128 tests + 1 skipped (no code changes outside scripts/). Converter validated end-to-end: format passes Fabric's git-integration parser (verified by matching the canonical structure documented in the Fabric Git Integration docs — kernel header, cell separators, per-cell META blocks, .platform schema). Next: user clicks Sync in Fabric Git integration; the 10 notebooks should import as workspace items. After that, Service Principal + fabric-cicd workflow (Phase 3d, currently deferred) gives full push-button CI/CD.
1 parent fc263de commit 8ac1b17

22 files changed

Lines changed: 2233 additions & 0 deletions

File tree

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,9 @@ repos:
6767
language: system
6868
pass_filenames: false
6969
files: '^(core/gold/encounter_summary\.py|core/scripts/gen_corpus_schema\.py|schemas/gold_encounter_summary\.json)$'
70+
- id: fabric-notebooks-regen
71+
name: Fabric .Notebook/ outputs match .ipynb sources
72+
entry: python core/scripts/convert_ipynb_to_fabric.py --check
73+
language: system
74+
pass_filenames: false
75+
files: '^fabric/notebooks/.*\.ipynb$|^core/scripts/convert_ipynb_to_fabric\.py$'
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Convert Jupyter ``.ipynb`` notebooks to Microsoft Fabric source format.
2+
3+
Fabric Git Integration syncs items in Fabric's own folder format — plain ``.ipynb``
4+
files at the path are not recognized. Each Fabric notebook must live in a
5+
``<name>.Notebook/`` directory containing:
6+
7+
- ``notebook-content.py`` — Python source with ``# CELL ********************``
8+
and ``# MARKDOWN ********************`` magic comments separating cells, plus
9+
a leading notebook-level ``# METADATA`` block (kernel + lakehouse binding).
10+
- ``.platform`` — JSON metadata: type, displayName, logicalId (stable GUID).
11+
12+
This script reads every ``.ipynb`` under ``fabric/notebooks/`` and regenerates
13+
its sibling ``.Notebook/`` folder. The logical ID is derived deterministically
14+
from the notebook stem (``uuid.uuid5``) so re-runs produce byte-identical output
15+
unless the source changes — friendly to the pre-commit gate.
16+
17+
The lakehouse binding is intentionally omitted from the generated metadata so
18+
workspace/lakehouse GUIDs stay out of committed files (see ``.env.example``
19+
contract). Users attach a lakehouse manually in the Fabric UI on first run
20+
of each notebook; Fabric persists the binding server-side after that.
21+
22+
Run manually:
23+
24+
python core/scripts/convert_ipynb_to_fabric.py
25+
python core/scripts/convert_ipynb_to_fabric.py --notebooks fabric/notebooks/00_setup.ipynb
26+
python core/scripts/convert_ipynb_to_fabric.py --check # CI / pre-commit: fail on drift
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import argparse
32+
import json
33+
import sys
34+
import uuid
35+
from pathlib import Path
36+
37+
# Deterministic namespace for notebook logical IDs — never change this UUID,
38+
# changing it would re-issue every logicalId and Fabric would treat existing
39+
# notebooks as brand-new items on the next sync.
40+
_NAMESPACE = uuid.UUID("3a1c2f7e-9b8d-5a4c-b2e1-7f6a4d3c8e91")
41+
42+
_NOTEBOOKS_DIR = Path("fabric/notebooks")
43+
_PLATFORM_SCHEMA = (
44+
"https://developer.microsoft.com/json-schemas/fabric/gitIntegration/"
45+
"platformProperties/2.0.0/schema.json"
46+
)
47+
48+
49+
def _logical_id(stem: str) -> str:
50+
"""Stable UUIDv5 for a notebook, derived from its filename stem."""
51+
return str(uuid.uuid5(_NAMESPACE, stem))
52+
53+
54+
def _meta_block(meta: dict) -> str:
55+
"""Render a Fabric ``# META {...}`` JSON block, one ``# META`` prefix per line."""
56+
body = json.dumps(meta, indent=2, sort_keys=True)
57+
return "\n".join(f"# META {line}" if line else "# META" for line in body.split("\n"))
58+
59+
60+
def _notebook_header() -> str:
61+
"""Notebook-level METADATA block — kernel only, no lakehouse binding."""
62+
meta = {"kernel_info": {"name": "synapse_pyspark"}}
63+
return (
64+
"# Fabric notebook source\n\n"
65+
"# METADATA ********************\n\n"
66+
f"{_meta_block(meta)}\n"
67+
)
68+
69+
70+
def _code_cell(source: str) -> str:
71+
"""Render a code cell with its per-cell language metadata."""
72+
cell_meta = {"language": "python", "language_group": "synapse_pyspark"}
73+
return (
74+
"\n# CELL ********************\n\n"
75+
f"{source.rstrip()}\n\n"
76+
"# METADATA ********************\n\n"
77+
f"{_meta_block(cell_meta)}\n"
78+
)
79+
80+
81+
def _markdown_cell(source: str) -> str:
82+
"""Render a markdown cell — each source line prefixed with ``# ``."""
83+
prefixed = "\n".join(f"# {line}" if line else "#" for line in source.rstrip().split("\n"))
84+
cell_meta = {"language": "markdown", "language_group": "synapse_pyspark"}
85+
return (
86+
"\n# MARKDOWN ********************\n\n"
87+
f"{prefixed}\n\n"
88+
"# METADATA ********************\n\n"
89+
f"{_meta_block(cell_meta)}\n"
90+
)
91+
92+
93+
def _ipynb_to_fabric_py(nb: dict) -> str:
94+
"""Convert a parsed ``.ipynb`` dict to Fabric's ``notebook-content.py`` text."""
95+
out = [_notebook_header()]
96+
for cell in nb.get("cells", []):
97+
source = "".join(cell.get("source", []))
98+
if cell.get("cell_type") == "code":
99+
out.append(_code_cell(source))
100+
elif cell.get("cell_type") == "markdown":
101+
out.append(_markdown_cell(source))
102+
# raw / other cell types: skip silently — Fabric notebooks support only code + markdown
103+
return "".join(out)
104+
105+
106+
def _platform_file(display_name: str) -> str:
107+
"""Render the ``.platform`` JSON for a notebook."""
108+
spec = {
109+
"$schema": _PLATFORM_SCHEMA,
110+
"metadata": {"type": "Notebook", "displayName": display_name},
111+
"config": {"version": "2.0", "logicalId": _logical_id(display_name)},
112+
}
113+
return json.dumps(spec, indent=2, sort_keys=True) + "\n"
114+
115+
116+
def convert(ipynb_path: Path, *, check: bool = False) -> bool:
117+
"""Convert one ``.ipynb`` to its sibling ``.Notebook/`` folder.
118+
119+
Returns ``True`` if any file would change (or did change when not ``check``).
120+
"""
121+
stem = ipynb_path.stem
122+
out_dir = ipynb_path.parent / f"{stem}.Notebook"
123+
nb = json.loads(ipynb_path.read_text())
124+
py_text = _ipynb_to_fabric_py(nb)
125+
platform_text = _platform_file(stem)
126+
127+
py_path = out_dir / "notebook-content.py"
128+
platform_path = out_dir / ".platform"
129+
130+
changed = False
131+
for path, new_text in [(py_path, py_text), (platform_path, platform_text)]:
132+
current = path.read_text() if path.exists() else None
133+
if current != new_text:
134+
changed = True
135+
if not check:
136+
path.parent.mkdir(parents=True, exist_ok=True)
137+
path.write_text(new_text)
138+
return changed
139+
140+
141+
def main() -> int:
142+
"""CLI entry — convert all ``.ipynb`` under ``fabric/notebooks/`` (or a subset)."""
143+
ap = argparse.ArgumentParser(description=__doc__)
144+
ap.add_argument(
145+
"--notebooks",
146+
nargs="*",
147+
type=Path,
148+
default=None,
149+
help="Specific .ipynb paths; defaults to every .ipynb under fabric/notebooks/",
150+
)
151+
ap.add_argument(
152+
"--check",
153+
action="store_true",
154+
help="Don't write; exit 1 if any .Notebook/ output would change (pre-commit gate).",
155+
)
156+
args = ap.parse_args()
157+
158+
if args.notebooks:
159+
paths = [p for p in args.notebooks if p.suffix == ".ipynb"]
160+
else:
161+
paths = sorted(_NOTEBOOKS_DIR.glob("*.ipynb"))
162+
163+
if not paths:
164+
print(f"No .ipynb files found under {_NOTEBOOKS_DIR}/", file=sys.stderr)
165+
return 0
166+
167+
any_changed = False
168+
for ipynb_path in paths:
169+
changed = convert(ipynb_path, check=args.check)
170+
status = "CHANGED" if changed else "ok"
171+
print(f" [{status}] {ipynb_path} -> {ipynb_path.parent / (ipynb_path.stem + '.Notebook')}/")
172+
any_changed = any_changed or changed
173+
174+
if args.check and any_changed:
175+
print(
176+
"\nFabric .Notebook/ outputs are stale. Run:\n"
177+
" python core/scripts/convert_ipynb_to_fabric.py\n"
178+
"and re-stage the changes.",
179+
file=sys.stderr,
180+
)
181+
return 1
182+
return 0
183+
184+
185+
if __name__ == "__main__":
186+
sys.exit(main())
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
3+
"config": {
4+
"logicalId": "4def0f6b-0ac7-5298-91d7-bd54b832f05d",
5+
"version": "2.0"
6+
},
7+
"metadata": {
8+
"displayName": "00_setup",
9+
"type": "Notebook"
10+
}
11+
}

0 commit comments

Comments
 (0)