databricks-agent-notebooks 0.2.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/PKG-INFO +1 -1
  2. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/pyproject.toml +8 -1
  3. databricks_agent_notebooks-0.4.0/src/databricks_agent_notebooks/__init__.py +10 -0
  4. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/_constants.py +9 -0
  5. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/cli.py +120 -32
  6. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/config/frontmatter.py +11 -0
  7. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/execution/executor.py +63 -19
  8. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/execution/injection.py +130 -13
  9. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/execution/rendering.py +68 -4
  10. databricks_agent_notebooks-0.4.0/src/databricks_agent_notebooks/for_agents/README.md +497 -0
  11. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/for_agents/agent_doctor.md +57 -28
  12. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/for_agents/scala_development.md +49 -0
  13. databricks_agent_notebooks-0.4.0/src/databricks_agent_notebooks/for_agents/scripts/agent-nb-run.sh +122 -0
  14. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/connect.py +27 -2
  15. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/doctor.py +44 -0
  16. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks.egg-info/PKG-INFO +1 -1
  17. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks.egg-info/SOURCES.txt +1 -0
  18. databricks_agent_notebooks-0.2.2/src/databricks_agent_notebooks/__init__.py +0 -5
  19. databricks_agent_notebooks-0.2.2/src/databricks_agent_notebooks/for_agents/README.md +0 -289
  20. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/LICENSE +0 -0
  21. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/README.md +0 -0
  22. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/setup.cfg +0 -0
  23. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/__main__.py +0 -0
  24. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/config/__init__.py +0 -0
  25. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/execution/__init__.py +0 -0
  26. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/execution/lineage.py +0 -0
  27. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/for_agents/examples/scala/package_cells_scaffold.md +0 -0
  28. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/for_agents/examples/scala/src/api/implementation/Api.scala +0 -0
  29. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/for_agents/examples/scala/src/api/stable/Api.scala +0 -0
  30. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/for_agents/examples/smoke/python_select_one.md +0 -0
  31. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/for_agents/examples/smoke/scala_select_one.md +0 -0
  32. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/for_agents/package_cell_instructions.md +0 -0
  33. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/formats/__init__.py +0 -0
  34. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/formats/conversion.py +0 -0
  35. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/formats/dbr_source.py +0 -0
  36. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/integrations/__init__.py +0 -0
  37. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/integrations/databricks/__init__.py +0 -0
  38. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/integrations/databricks/clusters.py +0 -0
  39. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/__init__.py +0 -0
  40. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/home.py +0 -0
  41. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/inventory.py +0 -0
  42. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/kernel.py +0 -0
  43. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/launcher.py +0 -0
  44. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/manifest.py +0 -0
  45. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks/runtime/scala_connect.py +0 -0
  46. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks.egg-info/dependency_links.txt +0 -0
  47. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks.egg-info/entry_points.txt +0 -0
  48. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks.egg-info/requires.txt +0 -0
  49. {databricks_agent_notebooks-0.2.2 → databricks_agent_notebooks-0.4.0}/src/databricks_agent_notebooks.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-agent-notebooks
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Standalone notebook conversion and local runtime tooling for the databricks-agent-notebooks repository.
5
5
  Author: Swoop
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "databricks-agent-notebooks"
7
- version = "0.2.2"
7
+ version = "0.4.0"
8
8
  description = "Standalone notebook conversion and local runtime tooling for the databricks-agent-notebooks repository."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -52,6 +52,13 @@ agent-notebook = "databricks_agent_notebooks.cli:main"
52
52
  testpaths = ["tests"]
53
53
  addopts = "-q"
54
54
  pythonpath = ["src"]
55
+ markers = [
56
+ "integration: Fast integration tests (CLI smoke, no side effects)",
57
+ "slow: Slow tests (kernel downloads, package installs, real execution)",
58
+ "spark: Tests requiring local Spark execution (PySpark or Almond kernel)",
59
+ "databricks: Tests requiring live Databricks workspace credentials",
60
+ "scala: Tests requiring Almond/Scala kernel and Java",
61
+ ]
55
62
 
56
63
  [tool.setuptools]
57
64
  package-dir = { "" = "src" }
@@ -0,0 +1,10 @@
1
+ """Standalone Databricks notebook conversion and execution tooling."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("databricks-agent-notebooks")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0-dev"
9
+
10
+ __all__ = ["__version__"]
@@ -103,3 +103,12 @@ def scala_variant_for_dbr(major: int) -> ScalaVariant:
103
103
  if major >= 17:
104
104
  return SCALA_213
105
105
  return SCALA_212
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # LOCAL_SPARK — reserved profile for local PySpark / vanilla Spark execution
110
+ # ---------------------------------------------------------------------------
111
+
112
+ LOCAL_SPARK_PROFILE = "LOCAL_SPARK"
113
+ LOCAL_SPARK_DEFAULT_MASTER = "local[*]"
114
+ LOCAL_SPARK_DEFAULT_VERSION = "3.5.4"
@@ -8,6 +8,7 @@ same argparse/subparsers/dispatch-table pattern as ``libs.continuum.cli``.
8
8
  from __future__ import annotations
9
9
 
10
10
  import argparse
11
+ import re
11
12
  import shutil
12
13
  import subprocess
13
14
  import sys
@@ -15,7 +16,8 @@ import tempfile
15
16
  from importlib import resources
16
17
  from pathlib import Path
17
18
 
18
- from databricks_agent_notebooks.config.frontmatter import DatabricksConfig, merge_config
19
+ from databricks_agent_notebooks import __version__
20
+ from databricks_agent_notebooks.config.frontmatter import DatabricksConfig, is_local_spark, merge_config
19
21
  from databricks_agent_notebooks.execution.executor import RawProgressValue, emit_progress_signal, execute_notebook
20
22
  from databricks_agent_notebooks.execution.injection import inject_cells
21
23
  from databricks_agent_notebooks.execution.rendering import render
@@ -23,7 +25,14 @@ from databricks_agent_notebooks.formats.conversion import to_notebook, validate_
23
25
  from databricks_agent_notebooks.integrations.databricks.clusters import ClusterError, default_service
24
26
  from databricks_agent_notebooks.runtime.connect import ensure_cluster_runtime, ensure_serverless_runtime
25
27
  from databricks_agent_notebooks.runtime.home import resolve_runtime_home
26
- from databricks_agent_notebooks._constants import DEFAULT_SCALA_VARIANT, SCALA_212, SCALA_VARIANTS
28
+ import os
29
+
30
+ from databricks_agent_notebooks._constants import (
31
+ DEFAULT_SCALA_VARIANT,
32
+ LOCAL_SPARK_DEFAULT_VERSION,
33
+ SCALA_212,
34
+ SCALA_VARIANTS,
35
+ )
27
36
  from databricks_agent_notebooks.runtime.scala_connect import prefetch_scala_connect, resolve_scala_connect
28
37
  from databricks_agent_notebooks.runtime.doctor import Check, doctor_scala_connect_readiness, run_checks
29
38
  from databricks_agent_notebooks.runtime.inventory import doctor_installed_runtimes, list_installed_runtimes
@@ -39,6 +48,30 @@ from databricks_agent_notebooks.runtime.kernel import (
39
48
 
40
49
  import nbformat
41
50
 
51
+ _VALID_SCALA_LOCAL_MASTER_RE = re.compile(r"^local(\[(\*|\d+)(,\d+)?\])?$")
52
+
53
+
54
+ def _validate_scala_local_spark(master: str, executor_memory: str | None) -> str | None:
55
+ """Validate LOCAL_SPARK configuration for Scala notebooks.
56
+
57
+ Returns an error message string if validation fails, or None if valid.
58
+ """
59
+ if not _VALID_SCALA_LOCAL_MASTER_RE.match(master):
60
+ return (
61
+ f"error: Spark master URL '{master}' is not supported for Scala notebooks. "
62
+ "Scala LOCAL_SPARK only supports local[*], local[N], local[N,M], or local. "
63
+ "The local-cluster mode is incompatible with the Almond kernel's classloader "
64
+ "(ClassNotFoundException in executors). "
65
+ "Use local[N] for parallelism control instead."
66
+ )
67
+ if executor_memory:
68
+ return (
69
+ "error: AGENT_NOTEBOOK_LOCAL_SPARK_EXECUTOR_MEMORY is not supported for Scala notebooks. "
70
+ "In local mode there are no separate executor processes — the driver IS the executor. "
71
+ "Use AGENT_NOTEBOOK_LOCAL_SPARK_DRIVER_MEMORY to control the total JVM heap size instead."
72
+ )
73
+ return None
74
+
42
75
 
43
76
  # ---------------------------------------------------------------------------
44
77
  # Parser
@@ -75,6 +108,10 @@ def _build_parser() -> argparse.ArgumentParser:
75
108
  epilog=_agent_docs_epilog(),
76
109
  formatter_class=argparse.RawDescriptionHelpFormatter,
77
110
  )
111
+ parser.add_argument(
112
+ "--version", action="version",
113
+ version=f"%(prog)s {__version__}",
114
+ )
78
115
  subparsers = parser.add_subparsers(dest="command")
79
116
 
80
117
  # -- run --
@@ -207,9 +244,85 @@ def _cmd_run(args: argparse.Namespace) -> int:
207
244
  language = _resolve_execution_language(notebook, config)
208
245
  inject_session = not args.no_inject_session
209
246
 
247
+ # Step 2b: LOCAL_SPARK validation and branching
248
+ local_spark = is_local_spark(config)
249
+ if local_spark and config.cluster:
250
+ print("error: --profile LOCAL_SPARK and --cluster are mutually exclusive", file=sys.stderr)
251
+ return 1
252
+
210
253
  # Step 3: Resolve cluster
211
254
  cluster = None
212
- if config.cluster and inject_session:
255
+ if local_spark and inject_session:
256
+ emit_progress_signal("compute", mode=RawProgressValue("local-spark"))
257
+ # Pre-flight: verify pyspark is importable for Python LOCAL_SPARK
258
+ if language != "scala":
259
+ try:
260
+ import pyspark # noqa: F401, PLC0415
261
+ except ImportError:
262
+ print(
263
+ "error: pyspark is required for Python LOCAL_SPARK but not importable. "
264
+ "Install it with: pip install pyspark (or uv pip install pyspark)",
265
+ file=sys.stderr,
266
+ )
267
+ return 1
268
+ # Validate and set up Scala LOCAL_SPARK
269
+ if language == "scala":
270
+ local_spark_master = os.environ.get("AGENT_NOTEBOOK_LOCAL_SPARK_MASTER", "local[*]")
271
+ executor_memory = os.environ.get("AGENT_NOTEBOOK_LOCAL_SPARK_EXECUTOR_MEMORY")
272
+ validation_error = _validate_scala_local_spark(local_spark_master, executor_memory)
273
+ if validation_error:
274
+ print(validation_error, file=sys.stderr)
275
+ return 1
276
+ spark_ver = os.environ.get(
277
+ "AGENT_NOTEBOOK_LOCAL_SPARK_VERSION", LOCAL_SPARK_DEFAULT_VERSION,
278
+ )
279
+ scala_variant = SCALA_VARIANTS.get(
280
+ "2.13" if spark_ver.startswith("4.") else "2.12", SCALA_212,
281
+ )
282
+ notebook.metadata["kernelspec"] = {
283
+ "name": scala_variant.kernel_id,
284
+ "display_name": scala_variant.kernel_display_name,
285
+ "language": "scala",
286
+ }
287
+ # Spark 3.x on Java 17+ needs --add-opens for the Almond kernel JVM.
288
+ # PySpark handles this in its own launcher; for Scala we propagate via
289
+ # JDK_JAVA_OPTIONS (standard since Java 9). Unlike JAVA_TOOL_OPTIONS,
290
+ # JDK_JAVA_OPTIONS does not print "Picked up ..." to stderr.
291
+ existing_jdk = os.environ.get("JDK_JAVA_OPTIONS", "")
292
+ existing_jto = os.environ.get("JAVA_TOOL_OPTIONS", "")
293
+ if spark_ver.startswith("3.") and "--add-opens" not in existing_jdk and "--add-opens" not in existing_jto:
294
+ _opens = (
295
+ "--add-opens=java.base/java.lang=ALL-UNNAMED "
296
+ "--add-opens=java.base/java.lang.invoke=ALL-UNNAMED "
297
+ "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED "
298
+ "--add-opens=java.base/java.io=ALL-UNNAMED "
299
+ "--add-opens=java.base/java.net=ALL-UNNAMED "
300
+ "--add-opens=java.base/java.nio=ALL-UNNAMED "
301
+ "--add-opens=java.base/java.util=ALL-UNNAMED "
302
+ "--add-opens=java.base/java.util.concurrent=ALL-UNNAMED "
303
+ "--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED "
304
+ "--add-opens=java.base/sun.nio.ch=ALL-UNNAMED "
305
+ "--add-opens=java.base/sun.nio.cs=ALL-UNNAMED "
306
+ "--add-opens=java.base/sun.security.action=ALL-UNNAMED "
307
+ "--add-opens=java.base/sun.util.calendar=ALL-UNNAMED "
308
+ "--add-opens=java.security.jgss/sun.security.krb5=ALL-UNNAMED"
309
+ )
310
+ os.environ["JDK_JAVA_OPTIONS"] = f"{existing_jdk} {_opens}".strip()
311
+ # Inject driver memory as -Xmx into JDK_JAVA_OPTIONS for Scala.
312
+ # The SparkConf injection (in injection.py) generates
313
+ # spark.driver.memory but that is decorative for Scala — the JVM
314
+ # is already running by the time SparkSession.builder reads it.
315
+ existing_jdk = os.environ.get("JDK_JAVA_OPTIONS", "")
316
+ driver_memory = os.environ.get("AGENT_NOTEBOOK_LOCAL_SPARK_DRIVER_MEMORY")
317
+ if driver_memory and "-Xmx" not in existing_jdk and "-Xmx" not in existing_jto:
318
+ os.environ["JDK_JAVA_OPTIONS"] = f"{existing_jdk} -Xmx{driver_memory}".strip()
319
+ print(
320
+ f"warning: In local mode, spark.driver.memory controls the total JVM heap "
321
+ f"shared by driver and all task threads — there are no separate executor "
322
+ f"processes. -Xmx{driver_memory} has been set on the JVM.",
323
+ file=sys.stderr,
324
+ )
325
+ elif config.cluster and inject_session:
213
326
  service = default_service()
214
327
  try:
215
328
  effective_profile = config.profile or "DEFAULT"
@@ -224,7 +337,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
224
337
  emit_progress_signal("failed", error=str(exc))
225
338
  print(f"error: {exc}", file=sys.stderr)
226
339
  return 1
227
- elif not config.cluster:
340
+ elif not config.cluster and not local_spark:
228
341
  emit_progress_signal("compute", mode=RawProgressValue("serverless"))
229
342
 
230
343
  managed_python_executable = None
@@ -252,7 +365,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
252
365
  "display_name": scala_variant.kernel_display_name,
253
366
  "language": "scala",
254
367
  }
255
- elif cluster is None and inject_session and language == "python":
368
+ elif cluster is None and inject_session and not local_spark and language == "python":
256
369
  try:
257
370
  managed_runtime = ensure_serverless_runtime(
258
371
  profile=config.profile,
@@ -263,7 +376,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
263
376
  print(f"error: {exc}", file=sys.stderr)
264
377
  return 1
265
378
  managed_python_executable = managed_runtime.python_executable
266
- elif cluster is None and inject_session and language == "scala":
379
+ elif cluster is None and inject_session and not local_spark and language == "scala":
267
380
  scala_variant = DEFAULT_SCALA_VARIANT
268
381
  notebook.metadata["kernelspec"] = {
269
382
  "name": scala_variant.kernel_id,
@@ -275,6 +388,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
275
388
  if inject_session:
276
389
  notebook = inject_cells(
277
390
  notebook, config, input_path,
391
+ local_spark=local_spark,
278
392
  scala_connect_version=scala_connect_version,
279
393
  scala_variant=scala_variant,
280
394
  )
@@ -565,18 +679,6 @@ def _print_doctor_checks(checks: list[Check]) -> list[Check]:
565
679
  return [check for check in checks if check.status == "fail"]
566
680
 
567
681
 
568
- def _cmd_kernels_doctor(args: argparse.Namespace) -> int:
569
- """Run environment validation checks."""
570
- checks = _run_kernels_doctor_checks(args)
571
- failures = _print_doctor_checks(checks)
572
- if failures:
573
- print(f"\n{len(failures)} check(s) failed.", file=sys.stderr)
574
- return 1
575
-
576
- print("\nAll checks passed.")
577
- return 0
578
-
579
-
580
682
  def _cmd_kernels(args: argparse.Namespace) -> int:
581
683
  handler = _KERNEL_HANDLERS.get(args.kernels_command)
582
684
  if handler is None:
@@ -601,18 +703,6 @@ def _cmd_runtimes_list(_args: argparse.Namespace) -> int:
601
703
  return 0
602
704
 
603
705
 
604
- def _cmd_runtimes_doctor(_args: argparse.Namespace) -> int:
605
- """Validate managed runtime receipts rooted under runtime-home."""
606
- checks = _run_runtimes_doctor_checks()
607
- failures = _print_doctor_checks(checks)
608
- if failures:
609
- print(f"\n{len(failures)} check(s) failed.", file=sys.stderr)
610
- return 1
611
-
612
- print("\nAll checks passed.")
613
- return 0
614
-
615
-
616
706
  def _cmd_runtimes(args: argparse.Namespace) -> int:
617
707
  handler = _RUNTIME_HANDLERS.get(args.runtimes_command)
618
708
  if handler is None:
@@ -640,12 +730,10 @@ _KERNEL_HANDLERS = {
640
730
  "install": _cmd_kernels_install,
641
731
  "list": _cmd_kernels_list,
642
732
  "remove": _cmd_kernels_remove,
643
- "doctor": _cmd_kernels_doctor,
644
733
  }
645
734
 
646
735
  _RUNTIME_HANDLERS = {
647
736
  "list": _cmd_runtimes_list,
648
- "doctor": _cmd_runtimes_doctor,
649
737
  }
650
738
 
651
739
 
@@ -13,6 +13,8 @@ from typing import Any
13
13
 
14
14
  import yaml
15
15
 
16
+ from databricks_agent_notebooks._constants import LOCAL_SPARK_PROFILE
17
+
16
18
 
17
19
  @dataclass(frozen=True)
18
20
  class DatabricksConfig:
@@ -82,3 +84,12 @@ def merge_config(
82
84
  cluster=cli_cluster if cli_cluster is not None else frontmatter.cluster,
83
85
  language=cli_language if cli_language is not None else frontmatter.language,
84
86
  )
87
+
88
+
89
+ def is_local_spark(config: DatabricksConfig) -> bool:
90
+ """Detect whether *config* specifies the reserved LOCAL_SPARK profile.
91
+
92
+ Case-insensitive: ``local_spark``, ``Local_Spark``, ``LOCAL_SPARK``
93
+ all match.
94
+ """
95
+ return config.profile is not None and config.profile.upper() == LOCAL_SPARK_PROFILE
@@ -19,11 +19,14 @@ from collections.abc import Sequence
19
19
  from contextlib import contextmanager
20
20
  from dataclasses import dataclass
21
21
  from pathlib import Path
22
+ from unittest.mock import patch
22
23
 
23
24
  from jupyter_client.kernelspec import KernelSpecManager, NoSuchKernel
24
25
  from nbconvert.preprocessors import ExecutePreprocessor
25
26
 
26
27
  import nbformat
28
+ import nbclient.client as _nbclient_mod
29
+ import nbformat.v4.nbbase as _nbbase
27
30
 
28
31
  from databricks_agent_notebooks.runtime.home import resolve_runtime_home
29
32
 
@@ -33,8 +36,7 @@ except ModuleNotFoundError: # pragma: no cover - exercised via packaging verifi
33
36
  install_ipykernel = None
34
37
 
35
38
 
36
- HEARTBEAT_INTERVAL_SECONDS = 30.0
37
- REDACTED_CELL_SNIPPET = "[source redacted]"
39
+ HEARTBEAT_INTERVAL_SECONDS = 60.0
38
40
 
39
41
 
40
42
  class RawProgressValue(str):
@@ -133,18 +135,11 @@ def _build_cell_label(cell: nbformat.NotebookNode, lines: list[str]) -> str:
133
135
  return f"[{cell_type} cell]"
134
136
 
135
137
 
136
- def _build_cell_snippet(_cell: nbformat.NotebookNode, lines: list[str]) -> str:
137
- if not lines:
138
- return "[empty cell]"
139
- return REDACTED_CELL_SNIPPET
140
-
141
-
142
138
  def _describe_cell(cell: nbformat.NotebookNode, *, cell_index: int) -> dict[str, object]:
143
139
  lines = _iter_meaningful_lines(cell.source)
144
140
  return {
145
141
  "cell_index": cell_index + 1,
146
142
  "cell_label": _build_cell_label(cell, lines),
147
- "cell_snippet": _build_cell_snippet(cell, lines),
148
143
  }
149
144
 
150
145
 
@@ -243,6 +238,36 @@ def _patched_execution_environment(runtime_data_dir: str):
243
238
  os.environ.pop("JUPYTER_PATH", None)
244
239
 
245
240
 
241
+ @contextmanager
242
+ def _robust_kernel_error_output():
243
+ """Ensure kernel error messages have ``traceback``, ``ename``, and ``evalue``.
244
+
245
+ Some Jupyter kernels (notably Almond/Scala) omit ``traceback`` from IOPub
246
+ ``error`` messages on compile errors. ``nbformat.v4.nbbase.output_from_msg``
247
+ accesses these fields via direct dict lookup, so a missing key raises
248
+ ``KeyError("traceback")`` which propagates as a useless error string.
249
+
250
+ This context manager monkey-patches ``output_from_msg`` at its call site in
251
+ ``nbclient.client`` (where the bound reference lives) to default missing
252
+ fields before the original function sees them, keeping the normal
253
+ ``CellExecutionError`` path intact so callers get ename/evalue in the error
254
+ message.
255
+ """
256
+ _original = _nbclient_mod.output_from_msg
257
+
258
+ def _patched(msg): # noqa: ANN001, ANN202
259
+ msg_type = msg.get("header", {}).get("msg_type") or msg.get("msg_type", "")
260
+ if msg_type == "error":
261
+ content = msg.get("content", {})
262
+ content.setdefault("traceback", [])
263
+ content.setdefault("ename", "UnknownError")
264
+ content.setdefault("evalue", "")
265
+ return _original(msg)
266
+
267
+ with patch.object(_nbclient_mod, "output_from_msg", _patched):
268
+ yield
269
+
270
+
246
271
  def _execute_notebook_local(
247
272
  notebook_path: Path,
248
273
  *,
@@ -302,7 +327,10 @@ def _execute_notebook_local(
302
327
  )
303
328
 
304
329
  try:
305
- with _patched_execution_environment(str(runtime_home.kernels_dir.parent)):
330
+ with (
331
+ _patched_execution_environment(str(runtime_home.kernels_dir.parent)),
332
+ _robust_kernel_error_output(),
333
+ ):
306
334
  executed_notebook, _ = executor.preprocess(notebook, resources)
307
335
  except Exception as exc:
308
336
  return ExecutionResult(
@@ -366,7 +394,7 @@ def _execute_notebook_subprocess(
366
394
  result_path = Path(result_file.name)
367
395
 
368
396
  try:
369
- completed = subprocess.run(
397
+ process = subprocess.Popen(
370
398
  _subprocess_command(
371
399
  python_executable=python_executable,
372
400
  notebook_path=notebook_path,
@@ -376,14 +404,30 @@ def _execute_notebook_subprocess(
376
404
  timeout=timeout,
377
405
  allow_errors=allow_errors,
378
406
  ),
379
- capture_output=True,
407
+ stdout=subprocess.PIPE,
408
+ stderr=subprocess.PIPE,
380
409
  text=True,
381
- check=False,
382
410
  )
383
- if completed.stdout:
384
- print(completed.stdout, end="", file=sys.stdout)
385
- if completed.stderr:
386
- print(completed.stderr, end="", file=sys.stderr)
411
+
412
+ stderr_lines: list[str] = []
413
+
414
+ def _stream_stderr() -> None:
415
+ assert process.stderr is not None
416
+ for line in process.stderr:
417
+ print(line, end="", file=sys.stderr, flush=True)
418
+ stderr_lines.append(line)
419
+
420
+ stderr_thread = threading.Thread(target=_stream_stderr, daemon=True)
421
+ stderr_thread.start()
422
+
423
+ stdout_text = process.stdout.read() if process.stdout else ""
424
+ process.wait()
425
+ stderr_thread.join()
426
+
427
+ if stdout_text:
428
+ print(stdout_text, end="", file=sys.stdout)
429
+
430
+ stderr_text = "".join(stderr_lines).strip()
387
431
 
388
432
  if result_path.is_file():
389
433
  payload = json.loads(result_path.read_text(encoding="utf-8"))
@@ -399,7 +443,7 @@ def _execute_notebook_subprocess(
399
443
  success=False,
400
444
  output_path=output_path,
401
445
  duration_seconds=time.monotonic() - start,
402
- error=completed.stderr.strip() or f"Managed runtime execution failed with exit code {completed.returncode}",
446
+ error=stderr_text or f"Managed runtime execution failed with exit code {process.returncode}",
403
447
  )
404
448
  finally:
405
449
  result_path.unlink(missing_ok=True)
@@ -417,7 +461,7 @@ def execute_notebook(
417
461
  if output_path is None:
418
462
  output_path = notebook_path.with_suffix(".executed.ipynb")
419
463
 
420
- if python_executable is not None and python_executable.resolve() != Path(sys.executable).resolve():
464
+ if python_executable is not None and str(python_executable.resolve().parent.parent) != sys.prefix:
421
465
  return _execute_notebook_subprocess(
422
466
  notebook_path,
423
467
  output_path=output_path,