This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 2a23ba59d8 chore(pybuilder): aggregate PyBuilder at root and add API
spec for non-macro pieces (#5024)
2a23ba59d8 is described below
commit 2a23ba59d8ebe584127aadb2cc41725e8671e276
Author: Yicong Huang <[email protected]>
AuthorDate: Sun May 10 23:40:36 2026 -0700
chore(pybuilder): aggregate PyBuilder at root and add API spec for
non-macro pieces (#5024)
### What changes were proposed in this PR?
Three closely related changes scoped to `common/pybuilder`:
1. **Add `PyBuilder` to the root `TexeraProject` `aggregate(...)`**,
grouped under "common libraries" alongside `Auth`, `Config`, `DAO`,
`WorkflowCore`, and `WorkflowOperator` (alphabetized within each group).
Before this PR, `PyBuilder` was the only sbt project defined but not
aggregated, so `sbt test` and `sbt scalafmtCheckAll` from the repo root
silently skipped it. CI still ran `PyBuilder/jacoco` explicitly, so
coverage reporting was unaffected, but the local/CI matrix mismatch was
confusing.
2. **Apply scalafmt to four pre-existing PyBuilder files**
(`BoundaryValidator.scala`, `EncodableInspector.scala`,
`PythonTemplateBuilder.scala`, `PythonTemplateBuilderSpec.scala`).
`scalafmtCheckAll` only iterates over aggregated sub-projects, so change
1 brought PyBuilder under the format gate for the first time and
surfaced debt that had been accumulating invisibly since the project was
introduced. Reformatting these files is the necessary follow-on for
change 1 to leave CI green; the diffs are purely scaladoc-star
indentation and multi-line case-class parameter alignment, no semantic
changes.
3. **Add `PythonTemplateBuilderApiSpec`** covering non-macro pieces of
`PythonTemplateBuilder` that `PythonTemplateBuilderSpec` only touches
incidentally — factory constructors, render-mode singletons, renderer
behavior in both modes, `fromInterpolated` precondition, `+(String)`
`UnsupportedOperationException`, and `render()` CR/CRLF normalization.
Also pins current `PythonLexerUtils` behavior on Python triple-quoted
strings (the lexer is intentionally conservative and not
triple-quote-aware; pinned so a future triple-quote-aware change trips
the spec deliberately).
### Any related issues, documentation, discussions?
Resolves #5023
### How was this PR tested?
`sbt PyBuilder/test` — 125 tests pass (32 new). `sbt PyBuilder/jacoco`
line coverage went from 30.12% to 31.73% and method coverage from 13.17%
to 15.30% (6 newly covered methods); the rest of
`PythonTemplateBuilder.scala` is the `pybImpl` macro body which jacoco
cannot instrument at runtime. `sbt scalafmtCheckAll` now passes cleanly
with PyBuilder included.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.7 (1M context)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
build.sbt | 13 +-
.../texera/amber/pybuilder/BoundaryValidator.scala | 52 ++--
.../amber/pybuilder/EncodableInspector.scala | 56 ++--
.../amber/pybuilder/PythonTemplateBuilder.scala | 336 +++++++++++----------
.../pybuilder/PythonTemplateBuilderApiSpec.scala | 247 +++++++++++++++
.../pybuilder/PythonTemplateBuilderSpec.scala | 50 +--
6 files changed, 512 insertions(+), 242 deletions(-)
diff --git a/build.sbt b/build.sbt
index d80a858c93..b7b6b3cfb2 100644
--- a/build.sbt
+++ b/build.sbt
@@ -159,15 +159,18 @@ lazy val WorkflowExecutionService = (project in
file("amber"))
// root project definition
lazy val TexeraProject = (project in file("."))
.aggregate(
- DAO,
- Config,
- ConfigService,
- AccessControlService,
+ // common libraries
Auth,
+ Config,
+ DAO,
+ PyBuilder,
WorkflowCore,
+ WorkflowOperator,
+ // services
+ AccessControlService,
ComputingUnitManagingService,
+ ConfigService,
FileService,
- WorkflowOperator,
WorkflowCompilingService,
WorkflowExecutionService
)
diff --git
a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/BoundaryValidator.scala
b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/BoundaryValidator.scala
index 8475661d73..59713f0d6d 100644
---
a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/BoundaryValidator.scala
+++
b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/BoundaryValidator.scala
@@ -22,21 +22,21 @@ package org.apache.texera.amber.pybuilder
import scala.reflect.macros.blackbox
/**
- * Macro-only helper: validates boundaries for Encodable insertions.
- *
- * Compile-time: abort with good messages for direct Encodable args.
- * Runtime: for nested builders (unknown content at compile time), generate a
check that throws if the builder contains Encodable chunks.
- */
+ * Macro-only helper: validates boundaries for Encodable insertions.
+ *
+ * Compile-time: abort with good messages for direct Encodable args.
+ * Runtime: for nested builders (unknown content at compile time), generate a
check that throws if the builder contains Encodable chunks.
+ */
final class BoundaryValidator[C <: blackbox.Context](val c: C) {
import PythonLexerUtils._
import c.universe._
/**
- * Centralized, templatized error messages (Option A).
- *
- * NOTE: This object lives inside the class so it can freely use string
templates
- * without any macro-context type gymnastics.
- */
+ * Centralized, templatized error messages (Option A).
+ *
+ * NOTE: This object lives inside the class so it can freely use string
templates
+ * without any macro-context type gymnastics.
+ */
private object BoundaryErrors {
// Provide a hint that can differ between compile-time and runtime wording.
@@ -76,19 +76,19 @@ final class BoundaryValidator[C <: blackbox.Context](val c:
C) {
}
final case class CompileTimeContext(
- leftPart: String,
- rightPart: String,
- prefixSource: String,
- argIndex: Int,
- errorPos: Position
- )
+ leftPart: String,
+ rightPart: String,
+ prefixSource: String,
+ argIndex: Int,
+ errorPos: Position
+ )
final case class RuntimeContext(
- leftPart: String,
- rightPart: String,
- prefixSource: String,
- argIndex: Int
- )
+ leftPart: String,
+ rightPart: String,
+ prefixSource: String,
+ argIndex: Int
+ )
def validateCompileTime(ctx: CompileTimeContext): Unit = {
val prefixLine = lineTail(ctx.prefixSource)
@@ -130,11 +130,11 @@ final class BoundaryValidator[C <: blackbox.Context](val
c: C) {
}
/**
- * Generate runtime checks for nested PythonTemplateBuilder args.
- *
- * This is only emitted when the boundary context is unsafe. The runtime
guard is:
- * if (arg.containsEncodableString) throw ...
- */
+ * Generate runtime checks for nested PythonTemplateBuilder args.
+ *
+ * This is only emitted when the boundary context is unsafe. The runtime
guard is:
+ * if (arg.containsEncodableString) throw ...
+ */
def runtimeChecksForNestedBuilder(ctx: RuntimeContext, argIdent: Tree):
List[Tree] = {
val prefixLine = lineTail(ctx.prefixSource)
val argNum = ctx.argIndex + 1
diff --git
a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableInspector.scala
b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableInspector.scala
index 781c1c9a0a..f8622f81fd 100644
---
a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableInspector.scala
+++
b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableInspector.scala
@@ -22,10 +22,10 @@ package org.apache.texera.amber.pybuilder
import scala.reflect.macros.blackbox
/**
- * Macro-only helper: inspects argument trees / types / symbols to decide if a
value is Encodable-marked.
- *
- * NOTE: This must be context-bound because Tree/Type/Annotation are from
`c.universe`.
- */
+ * Macro-only helper: inspects argument trees / types / symbols to decide if
a value is Encodable-marked.
+ *
+ * NOTE: This must be context-bound because Tree/Type/Annotation are from
`c.universe`.
+ */
final class EncodableInspector[C <: blackbox.Context](val c: C) {
import c.universe._
@@ -49,10 +49,10 @@ final class EncodableInspector[C <: blackbox.Context](val
c: C) {
"org.apache.texera.amber.pybuilder.EncodableStringAnnotation"
/**
- * If we are pointing at a getter/accessor, hop to its accessed field symbol
when possible.
- *
- * Why: Many annotations are placed on constructor params/fields, but call
sites see the accessor.
- */
+ * If we are pointing at a getter/accessor, hop to its accessed field
symbol when possible.
+ *
+ * Why: Many annotations are placed on constructor params/fields, but call
sites see the accessor.
+ */
private def safeAccessed(sym: Symbol): Symbol =
sym match {
case termAccessor: TermSymbol if termAccessor.isAccessor =>
termAccessor.accessed
@@ -65,15 +65,15 @@ final class EncodableInspector[C <: blackbox.Context](val
c: C) {
val annotationType = annotation.tree.tpe
annotationType != null && (
annotationType.typeSymbol.fullName == encodableStringAnnotationFqn ||
- (annotationType <:< typeOf[EncodableStringAnnotation])
- )
+ (annotationType <:< typeOf[EncodableStringAnnotation])
+ )
}
/**
- * True if a [[Type]] carries @EncodableStringAnnotation as a TYPE_USE
annotation (via [[java.lang.reflect.AnnotatedType]]).
- *
- * Walks common wrappers (existentials, refinements, type refs) to find
nested annotations.
- */
+ * True if a [[Type]] carries @EncodableStringAnnotation as a TYPE_USE
annotation (via [[java.lang.reflect.AnnotatedType]]).
+ *
+ * Walks common wrappers (existentials, refinements, type refs) to find
nested annotations.
+ */
private def typeHasEncodableString(typeToCheck: Type): Boolean = {
def loop(t: Type): Boolean = {
if (t == null) false
@@ -101,17 +101,17 @@ final class EncodableInspector[C <: blackbox.Context](val
c: C) {
}
/**
- * Checks @EncodableStringAnnotation on either:
- * - accessed symbol (field/param), or
- * - type (TYPE_USE), via [[java.lang.reflect.AnnotatedType]].
- */
+ * Checks @EncodableStringAnnotation on either:
+ * - accessed symbol (field/param), or
+ * - type (TYPE_USE), via [[java.lang.reflect.AnnotatedType]].
+ */
def treeHasEncodableString(tree: Tree): Boolean = {
val rawSym = tree.symbol
val symHasAnn =
rawSym != null && rawSym != NoSymbol && {
val accessed = safeAccessed(rawSym)
accessed != null && accessed != NoSymbol &&
- accessed.annotations.exists(annIsEncodableString)
+ accessed.annotations.exists(annIsEncodableString)
}
val methodReturnHasAnn =
@@ -123,7 +123,7 @@ final class EncodableInspector[C <: blackbox.Context](val
c: C) {
})
symHasAnn || methodReturnHasAnn ||
- (tree.tpe != null && typeHasEncodableString(tree.tpe))
+ (tree.tpe != null && typeHasEncodableString(tree.tpe))
}
def isPythonTemplateBuilderArg(argExpr: c.Expr[Any]): Boolean = {
@@ -145,18 +145,18 @@ final class EncodableInspector[C <: blackbox.Context](val
c: C) {
// - treat already-wrapped EncodableStringRenderer as encodable
// - OR detect @EncodableStringAnnotation on symbol/type
(tpe != null && (tpe.dealias.widen <:< encodableStringRendererTpe)) ||
- treeHasEncodableString(argExpr.tree)
+ treeHasEncodableString(argExpr.tree)
}
}
/**
- * Wrap an argument expression as a [[PythonTemplateBuilder.StringRenderer]]
AST node.
- *
- * Priority:
- * 1) If it's already a StringRenderer, keep it (cast).
- * 2) Else if Encodable-marked, wrap as EncodableStringRenderer.
- * 3) Else wrap as PyLiteralStringRenderer.
- */
+ * Wrap an argument expression as a
[[PythonTemplateBuilder.StringRenderer]] AST node.
+ *
+ * Priority:
+ * 1) If it's already a StringRenderer, keep it (cast).
+ * 2) Else if Encodable-marked, wrap as EncodableStringRenderer.
+ * 3) Else wrap as PyLiteralStringRenderer.
+ */
def wrapArg(argExpr: c.Expr[Any]): Tree = {
val argTree = argExpr.tree
val argType = argTree.tpe
diff --git
a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilder.scala
b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilder.scala
index dc9e977d32..a79f162de1 100644
---
a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilder.scala
+++
b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilder.scala
@@ -27,43 +27,43 @@ import scala.language.experimental.macros
import scala.reflect.macros.blackbox
/**
- * Convenience type aliases for strings passed into the
[[PythonTemplateBuilder]] interpolator.
- *
- * Design intent:
- * - Some strings are “UI-provided” and must be rendered as a Python
expression that decodes base64 at runtime.
- * - Other strings are regular Python source fragments and should be spliced
in as-is.
- *
- * The macro distinguishes Encodable strings via a TYPE_USE annotation
(`String @EncodableStringAnnotation`).
- */
+ * Convenience type aliases for strings passed into the
[[PythonTemplateBuilder]] interpolator.
+ *
+ * Design intent:
+ * - Some strings are “UI-provided” and must be rendered as a Python
expression that decodes base64 at runtime.
+ * - Other strings are regular Python source fragments and should be
spliced in as-is.
+ *
+ * The macro distinguishes Encodable strings via a TYPE_USE annotation
(`String @EncodableStringAnnotation`).
+ */
object PyStringTypes {
/**
- * Treated as an Encodable string by the macro via a TYPE_USE annotation.
- *
- * Example:
- * {{{
- * import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableStringType
- * import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._
- *
- * val label: EncodableStringType = "Hello"
- * val code = pyb"print($label)"
- * }}}
- */
+ * Treated as an Encodable string by the macro via a TYPE_USE annotation.
+ *
+ * Example:
+ * {{{
+ * import
org.apache.texera.amber.pybuilder.PyStringTypes.EncodableStringType
+ * import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._
+ *
+ * val label: EncodableStringType = "Hello"
+ * val code = pyb"print($label)"
+ * }}}
+ */
type EncodableString = String @EncodableStringAnnotation
/**
- * Normal python string (macro defaults to [[PythonLiteral]] when no
[[EncodableStringAnnotation]] is present).
- *
- * This alias exists mostly for readability and symmetry with
[[EncodableStringFactory]].
- */
+ * Normal python string (macro defaults to [[PythonLiteral]] when no
[[EncodableStringAnnotation]] is present).
+ *
+ * This alias exists mostly for readability and symmetry with
[[EncodableStringFactory]].
+ */
type PythonLiteral = String
/**
- * Helper “constructor” and constants for [[EncodableString]].
- *
- * Note: the object and members are annotated so downstream type inference
tends
- * to keep the TYPE_USE annotation attached in common scenarios.
- */
+ * Helper “constructor” and constants for [[EncodableString]].
+ *
+ * Note: the object and members are annotated so downstream type inference
tends
+ * to keep the TYPE_USE annotation attached in common scenarios.
+ */
@EncodableStringAnnotation
object EncodableStringFactory {
@@ -77,10 +77,10 @@ object PyStringTypes {
}
/**
- * Helper “constructor” and constants for [[PythonLiteral]].
- *
- * This does not apply any Encodable semantics. It is regular Scala `String`
usage.
- */
+ * Helper “constructor” and constants for [[PythonLiteral]].
+ *
+ * This does not apply any Encodable semantics. It is regular Scala
`String` usage.
+ */
object PyLiteralFactory {
/** Identity wrapper, used as a readability hint at call sites. */
@@ -92,117 +92,117 @@ object PyStringTypes {
}
/**
- * =PythonTemplateBuilder: ergonomic Python codegen via `pyb"..."`=
- *
- * This module provides a tiny DSL for assembling Python source code from
Scala while preserving two competing goals:
- * (1) developers want to write templates that look like normal Python, and
(2) user-provided text must not be injected
- * into the emitted Python as raw literals that can break syntax or create
ambiguous token boundaries.
- *
- * The core idea is that every value spliced into a `pyb"..."` template is
first classified into one of two buckets:
- *
- * - '''Python literals''' (ordinary Scala strings or already-safe fragments)
are inserted as-is.
- * - '''Encodable strings''' (typically UI-provided text) are base64-encoded
at build time and rendered as a *Python
- * expression* that decodes at runtime, rather than being embedded as a
Python string literal.
- *
- * This classification is driven by a TYPE_USE annotation: `String
@EncodableStringAnnotation`. The annotation is defined
- * with a runtime retention and is allowed on fields, parameters, local
variables, and type uses, so it survives many
- * common Scala typing patterns (e.g., inferred vals, constructor params, or
aliases). Users normally do not construct the
- * annotation directly; instead, they use helper type aliases/factories in
`PyStringTypes` for readability.
- *
- * ==Render modes==
- *
- * A `PythonTemplateBuilder` can be rendered in two modes:
- *
- * - `plain`: emit everything as raw text (useful for debugging or when you
know all content is safe).
- * - `encode`: emit encodable chunks as Python decode expressions (the
default `toString` behavior).
- *
- * Internally this is represented as a small sealed trait enum
(`RenderMode.Plain` / `RenderMode.Encode`) rather than an
- * integer flag, to keep call sites self-documenting and avoid “magic numbers”.
- *
- * ==Chunk model (immutable, composable)==
- *
- * A builder is an immutable list of chunks:
- *
- * - `Text(value)` for literal template parts
- * - `Value(renderer)` for interpolated arguments that know how to render in
each mode
- *
- * Two concrete renderers are provided:
- *
- * - `EncodableStringRenderer`: pre-encodes `stringValue` as base64 (UTF-8)
once, and in `Encode` mode produces a Python
- * expression like `self.decode_python_template('<b64>')` given by
[[wrapWithPythonDecoderExpr]].
- * - `PyLiteralStringRenderer`: always emits the raw string value unchanged.
- *
- * Builders can be concatenated with `+` (builder + builder), which merges
adjacent `Text` chunks for compactness.
- * Direct concatenation with a plain `String` is intentionally unsupported to
prevent bypassing the macro’s safety checks.
- *
- * ==How the `pyb"..."` macro works==
- *
- * The `pyb` interpolator is implemented as a Scala macro. At compile time it
receives:
- *
- * - the literal parts from the `StringContext` (the “gaps” around `$args`)
- * - the argument trees corresponding to each `$arg`
- *
- * The macro’s pipeline is:
- *
- * 1. '''Extract literal parts''' from the `StringContext` AST and ensure
they are *string literals*. If any part is not
- * a literal, compilation aborts. This prevents “template text” from being
computed dynamically where correctness and
- * boundary analysis would become unreliable.
- *
- * 2. '''Classify direct encodable arguments''' using `EncodableInspector`:
- * it inspects both the argument symbol and the argument type to determine
whether the encodable annotation is present.
- * This includes a small “accessor hop” so that annotations placed on
fields/constructor params are still visible when
- * call sites reference getters.
- *
- * 3. '''Compile-time boundary validation for direct encodables''':
- * if an argument is directly encodable (and not a nested builder),
`BoundaryValidator.validateCompileTime` is run on
- * its surrounding literal context. The validator performs quick lexical
checks on the current line:
- *
- * - the splice must not occur inside an unclosed single/double-quoted
string
- * - the splice must not occur after a `#` comment marker
- * - the splice must not be immediately adjacent to identifier
characters or quote characters on either side
- *
- * These restrictions exist because an Encodable string renders as a Python
*expression*, not a Python string literal.
- * Putting an expression inside quotes, inside a comment, or glued to an
identifier would either be invalid Python or
- * silently change tokenization in surprising ways.
- *
- * 4. '''Lower each argument into a builder''':
- * every `$arg` becomes a `PythonTemplateBuilder`.
- *
- * - If the argument is already a `PythonTemplateBuilder`, it is used
directly.
- * - Otherwise, it is wrapped into a `StringRenderer`
(`EncodableStringRenderer` or `PyLiteralStringRenderer`) and
- * turned into a minimal builder containing a single `Value(...)`
chunk.
- *
- * Each argument is evaluated once and stored in a fresh local `val
__pyb_argN` so that expensive expressions or
- * side-effects are not duplicated by expansion.
- *
- * 5. '''Runtime safety for nested builders''':
- * for arguments that are themselves `PythonTemplateBuilder`s, the macro
cannot always know at compile time whether they
- * contain Encodable chunks (they may be computed, returned, or composed
elsewhere). For these nested builders, the macro
- * conditionally emits runtime guards *only when the surrounding context is
unsafe* (inside quotes, after comments, or
- * adjacent to “bad neighbor” characters). The guard pattern is:
- *
- * {{{
- * if (__pyb_argN.containsEncodableString) throw new
IllegalArgumentException("...")
- * }}}
- *
- * This preserves the ergonomics of composing builders while keeping the same
safety contract as direct splices.
- *
- * 6. '''Assemble the final builder''':
- * the macro concatenates `text0 + arg0 + text1 + arg1 + ... + textN` into one
`PythonTemplateBuilder`.
- *
- * ==Lexical checks (best-effort, intentionally small)==
- *
- * The boundary rules rely on `PythonLexerUtils`, a tiny state machine that
scans only the “current line tail” to decide
- * whether quotes are unbalanced and whether a `#` begins a comment outside
quotes. This is not a full Python parser.
- * It is deliberately lightweight so the macro stays fast and so the helpers
can be unit-tested independently.
- *
- * ==Extensibility notes==
- *
- * The design keeps all rendering behavior behind `StringRenderer`, and keeps
boundary policy in `BoundaryValidator`.
- * If new encoding schemes, alternate runtime decode helpers, or additional
safety rules are needed, they can be introduced
- * without rewriting the template-building API. In particular, swapping
`wrapWithPythonDecoderExpr` or adding new renderers
- * is a contained change: the macro only needs to decide *which renderer* to
use, not *how it renders*.
- */
+ * =PythonTemplateBuilder: ergonomic Python codegen via `pyb"..."`=
+ *
+ * This module provides a tiny DSL for assembling Python source code from
Scala while preserving two competing goals:
+ * (1) developers want to write templates that look like normal Python, and
(2) user-provided text must not be injected
+ * into the emitted Python as raw literals that can break syntax or create
ambiguous token boundaries.
+ *
+ * The core idea is that every value spliced into a `pyb"..."` template is
first classified into one of two buckets:
+ *
+ * - '''Python literals''' (ordinary Scala strings or already-safe
fragments) are inserted as-is.
+ * - '''Encodable strings''' (typically UI-provided text) are base64-encoded
at build time and rendered as a *Python
+ * expression* that decodes at runtime, rather than being embedded as a
Python string literal.
+ *
+ * This classification is driven by a TYPE_USE annotation: `String
@EncodableStringAnnotation`. The annotation is defined
+ * with a runtime retention and is allowed on fields, parameters, local
variables, and type uses, so it survives many
+ * common Scala typing patterns (e.g., inferred vals, constructor params, or
aliases). Users normally do not construct the
+ * annotation directly; instead, they use helper type aliases/factories in
`PyStringTypes` for readability.
+ *
+ * ==Render modes==
+ *
+ * A `PythonTemplateBuilder` can be rendered in two modes:
+ *
+ * - `plain`: emit everything as raw text (useful for debugging or when you
know all content is safe).
+ * - `encode`: emit encodable chunks as Python decode expressions (the
default `toString` behavior).
+ *
+ * Internally this is represented as a small sealed trait enum
(`RenderMode.Plain` / `RenderMode.Encode`) rather than an
+ * integer flag, to keep call sites self-documenting and avoid “magic
numbers”.
+ *
+ * ==Chunk model (immutable, composable)==
+ *
+ * A builder is an immutable list of chunks:
+ *
+ * - `Text(value)` for literal template parts
+ * - `Value(renderer)` for interpolated arguments that know how to render in
each mode
+ *
+ * Two concrete renderers are provided:
+ *
+ * - `EncodableStringRenderer`: pre-encodes `stringValue` as base64 (UTF-8)
once, and in `Encode` mode produces a Python
+ * expression like `self.decode_python_template('<b64>')` given by
[[wrapWithPythonDecoderExpr]].
+ * - `PyLiteralStringRenderer`: always emits the raw string value unchanged.
+ *
+ * Builders can be concatenated with `+` (builder + builder), which merges
adjacent `Text` chunks for compactness.
+ * Direct concatenation with a plain `String` is intentionally unsupported to
prevent bypassing the macro’s safety checks.
+ *
+ * ==How the `pyb"..."` macro works==
+ *
+ * The `pyb` interpolator is implemented as a Scala macro. At compile time it
receives:
+ *
+ * - the literal parts from the `StringContext` (the “gaps” around `$args`)
+ * - the argument trees corresponding to each `$arg`
+ *
+ * The macro’s pipeline is:
+ *
+ * 1. '''Extract literal parts''' from the `StringContext` AST and ensure
they are *string literals*. If any part is not
+ * a literal, compilation aborts. This prevents “template text” from
being computed dynamically where correctness and
+ * boundary analysis would become unreliable.
+ *
+ * 2. '''Classify direct encodable arguments''' using `EncodableInspector`:
+ * it inspects both the argument symbol and the argument type to determine
whether the encodable annotation is present.
+ * This includes a small “accessor hop” so that annotations placed on
fields/constructor params are still visible when
+ * call sites reference getters.
+ *
+ * 3. '''Compile-time boundary validation for direct encodables''':
+ * if an argument is directly encodable (and not a nested builder),
`BoundaryValidator.validateCompileTime` is run on
+ * its surrounding literal context. The validator performs quick lexical
checks on the current line:
+ *
+ * - the splice must not occur inside an unclosed single/double-quoted
string
+ * - the splice must not occur after a `#` comment marker
+ * - the splice must not be immediately adjacent to identifier
characters or quote characters on either side
+ *
+ * These restrictions exist because an Encodable string renders as a Python
*expression*, not a Python string literal.
+ * Putting an expression inside quotes, inside a comment, or glued to an
identifier would either be invalid Python or
+ * silently change tokenization in surprising ways.
+ *
+ * 4. '''Lower each argument into a builder''':
+ * every `$arg` becomes a `PythonTemplateBuilder`.
+ *
+ * - If the argument is already a `PythonTemplateBuilder`, it is used
directly.
+ * - Otherwise, it is wrapped into a `StringRenderer`
(`EncodableStringRenderer` or `PyLiteralStringRenderer`) and
+ * turned into a minimal builder containing a single `Value(...)`
chunk.
+ *
+ * Each argument is evaluated once and stored in a fresh local `val
__pyb_argN` so that expensive expressions or
+ * side-effects are not duplicated by expansion.
+ *
+ * 5. '''Runtime safety for nested builders''':
+ * for arguments that are themselves `PythonTemplateBuilder`s, the macro
cannot always know at compile time whether they
+ * contain Encodable chunks (they may be computed, returned, or composed
elsewhere). For these nested builders, the macro
+ * conditionally emits runtime guards *only when the surrounding context is
unsafe* (inside quotes, after comments, or
+ * adjacent to “bad neighbor” characters). The guard pattern is:
+ *
+ * {{{
+ * if (__pyb_argN.containsEncodableString) throw new
IllegalArgumentException("...")
+ * }}}
+ *
+ * This preserves the ergonomics of composing builders while keeping the same
safety contract as direct splices.
+ *
+ * 6. '''Assemble the final builder''':
+ * the macro concatenates `text0 + arg0 + text1 + arg1 + ... + textN` into
one `PythonTemplateBuilder`.
+ *
+ * ==Lexical checks (best-effort, intentionally small)==
+ *
+ * The boundary rules rely on `PythonLexerUtils`, a tiny state machine that
scans only the “current line tail” to decide
+ * whether quotes are unbalanced and whether a `#` begins a comment outside
quotes. This is not a full Python parser.
+ * It is deliberately lightweight so the macro stays fast and so the helpers
can be unit-tested independently.
+ *
+ * ==Extensibility notes==
+ *
+ * The design keeps all rendering behavior behind `StringRenderer`, and keeps
boundary policy in `BoundaryValidator`.
+ * If new encoding schemes, alternate runtime decode helpers, or additional
safety rules are needed, they can be introduced
+ * without rewriting the template-building API. In particular, swapping
`wrapWithPythonDecoderExpr` or adding new renderers
+ * is a contained change: the macro only needs to decide *which renderer* to
use, not *how it renders*.
+ */
object PythonTemplateBuilder {
// ===== render mode enum (no Ints) =====
@@ -218,19 +218,19 @@ object PythonTemplateBuilder {
// ===== wrappers =====
/**
- * Base abstraction for values that can be spliced into a
[[PythonTemplateBuilder]].
- *
- * A [[StringRenderer]] knows how to render itself depending on `mode`.
- */
+ * Base abstraction for values that can be spliced into a
[[PythonTemplateBuilder]].
+ *
+ * A [[StringRenderer]] knows how to render itself depending on `mode`.
+ */
sealed trait StringRenderer extends Product with Serializable {
def stringValue: String
def render(mode: RenderMode): String
}
/**
- * Encodable string: encoded-mode wraps with [[wrapWithPythonDecoderExpr]],
- * plain-mode is raw `stringValue`.
- */
+ * Encodable string: encoded-mode wraps with [[wrapWithPythonDecoderExpr]],
+ * plain-mode is raw `stringValue`.
+ */
final case class EncodableStringRenderer(stringValue: String) extends
StringRenderer {
private val encodedB64: String =
Base64.getEncoder.encodeToString(stringValue.getBytes(StandardCharsets.UTF_8))
@@ -240,8 +240,8 @@ object PythonTemplateBuilder {
}
/**
- * Python literal string: always raw `stringValue` regardless of mode.
- */
+ * Python literal string: always raw `stringValue` regardless of mode.
+ */
final case class PyLiteralStringRenderer(stringValue: String) extends
StringRenderer {
override def render(mode: RenderMode): String = stringValue
}
@@ -253,12 +253,15 @@ object PythonTemplateBuilder {
private[pybuilder] final case class Value(value: StringRenderer) extends
Chunk
/**
- * Build a [[PythonTemplateBuilder]] from literal parts and already-wrapped
args.
- *
- * @param literalParts raw StringContext parts (length = args + 1)
- * @param pyArgs args wrapped as [[StringRenderer]]
- */
- private[amber] def fromInterpolated(literalParts: List[String], pyArgs:
List[StringRenderer]): PythonTemplateBuilder = {
+ * Build a [[PythonTemplateBuilder]] from literal parts and already-wrapped
args.
+ *
+ * @param literalParts raw StringContext parts (length = args + 1)
+ * @param pyArgs args wrapped as [[StringRenderer]]
+ */
+ private[amber] def fromInterpolated(
+ literalParts: List[String],
+ pyArgs: List[StringRenderer]
+ ): PythonTemplateBuilder = {
require(
literalParts.length == pyArgs.length + 1,
s"pyb interpolator mismatch: parts=${literalParts.length},
args=${pyArgs.length}"
@@ -303,7 +306,8 @@ object PythonTemplateBuilder {
// ===== custom interpolator =====
/** Adds the `pyb"..."` string interpolator. */
- implicit final class PythonTemplateBuilderStringContext(private val
stringContext: StringContext) extends AnyVal {
+ implicit final class PythonTemplateBuilderStringContext(private val
stringContext: StringContext)
+ extends AnyVal {
def pyb(argValues: Any*): PythonTemplateBuilder = macro Macros.pybImpl
}
@@ -311,7 +315,7 @@ object PythonTemplateBuilder {
/** Macro entry point for `pyb"..."`. */
def pybImpl(macroCtx: blackbox.Context)(
- argValues: macroCtx.Expr[Any]*
+ argValues: macroCtx.Expr[Any]*
): macroCtx.Expr[PythonTemplateBuilder] = {
import macroCtx.universe._
@@ -443,10 +447,11 @@ object PythonTemplateBuilder {
}
/**
- * An immutable builder for Python source produced via `pyb"..."`
interpolation.
- */
-final class PythonTemplateBuilder private[pybuilder] (private val chunks:
List[PythonTemplateBuilder.Chunk])
- extends Serializable {
+ * An immutable builder for Python source produced via `pyb"..."`
interpolation.
+ */
+final class PythonTemplateBuilder private[pybuilder] (
+ private val chunks: List[PythonTemplateBuilder.Chunk]
+) extends Serializable {
import PythonTemplateBuilder._
def +(that: PythonTemplateBuilder): PythonTemplateBuilder =
@@ -473,8 +478,7 @@ final class PythonTemplateBuilder private[pybuilder]
(private val chunks: List[P
case Text(text) => out.append(text)
case Value(renderer) => out.append(renderer.render(renderMode))
}
- out.toString
- .stripMargin
+ out.toString.stripMargin
.replace("\r\n", "\n")
.replace("\r", "\n")
}
diff --git
a/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderApiSpec.scala
b/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderApiSpec.scala
new file mode 100644
index 0000000000..acbed3031f
--- /dev/null
+++
b/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderApiSpec.scala
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.amber.pybuilder
+
+import
org.apache.texera.amber.pybuilder.PythonTemplateBuilder.RenderMode.{Encode,
Plain}
+import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{
+ EncodableStringRenderer,
+ PyLiteralStringRenderer,
+ fromInterpolated,
+ wrapWithPythonDecoderExpr
+}
+import org.scalatest.funsuite.AnyFunSuite
+
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+
+/**
+ * Covers the non-macro public surface of PythonTemplateBuilder that
PythonTemplateBuilderSpec
+ * exercises only incidentally: factories, renderer mode constants, render
normalization,
+ * concatenation operators, and require/throw preconditions.
+ */
+class PythonTemplateBuilderApiSpec extends AnyFunSuite {
+
+ private def b64(s: String): String =
+ Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8))
+
+ // -------- wrapWithPythonDecoderExpr --------
+
+ test("wrapWithPythonDecoderExpr wraps text into a decode_python_template
call") {
+ assert(wrapWithPythonDecoderExpr("abc") ==
"self.decode_python_template('abc')")
+ }
+
+ test("wrapWithPythonDecoderExpr does not escape inner content (caller's
responsibility)") {
+ // The current contract simply interpolates the raw text. Pinning this so
a future
+ // escape-aware version trips this spec deliberately.
+ assert(wrapWithPythonDecoderExpr("a'b") ==
"self.decode_python_template('a'b')")
+ }
+
+ // -------- RenderMode --------
+
+ test("RenderMode.Plain and RenderMode.Encode are distinct singletons") {
+ assert(Plain != Encode)
+ assert(Plain eq PythonTemplateBuilder.RenderMode.Plain)
+ assert(Encode eq PythonTemplateBuilder.RenderMode.Encode)
+ }
+
+ // -------- EncodableStringRenderer --------
+
+ test("EncodableStringRenderer.render(Plain) returns the raw stringValue") {
+ val r = EncodableStringRenderer("abc")
+ assert(r.render(Plain) == "abc")
+ assert(r.stringValue == "abc")
+ }
+
+ test("EncodableStringRenderer.render(Encode) wraps base64 with the python
decoder expr") {
+ val r = EncodableStringRenderer("abc")
+ assert(r.render(Encode) == s"self.decode_python_template('${b64("abc")}')")
+ }
+
+ test("EncodableStringRenderer handles empty string in both modes") {
+ val r = EncodableStringRenderer("")
+ assert(r.render(Plain) == "")
+ assert(r.render(Encode) == "self.decode_python_template('')")
+ }
+
+ test("EncodableStringRenderer uses UTF-8 base64 for non-ASCII content") {
+ val raw = "你好"
+ val r = EncodableStringRenderer(raw)
+ assert(r.render(Encode) == s"self.decode_python_template('${b64(raw)}')")
+ }
+
+ // -------- PyLiteralStringRenderer --------
+
+ test("PyLiteralStringRenderer.render ignores mode and returns the raw
stringValue") {
+ val r = PyLiteralStringRenderer("print('x')")
+ assert(r.render(Plain) == "print('x')")
+ assert(r.render(Encode) == "print('x')")
+ }
+
+ // -------- PyStringTypes factories --------
+
+ test("PyStringTypes.EncodableStringFactory.apply returns the input string
unchanged") {
+ val out: String = PyStringTypes.EncodableStringFactory("hi")
+ assert(out == "hi")
+ }
+
+ test("PyStringTypes.EncodableStringFactory.empty is the empty string") {
+ val out: String = PyStringTypes.EncodableStringFactory.empty
+ assert(out.isEmpty)
+ }
+
+ test("PyStringTypes.PyLiteralFactory.apply returns the input string
unchanged") {
+ assert(PyStringTypes.PyLiteralFactory("hi") == "hi")
+ }
+
+ test("PyStringTypes.PyLiteralFactory.empty is the empty string") {
+ assert(PyStringTypes.PyLiteralFactory.empty.isEmpty)
+ }
+
+ // -------- fromInterpolated precondition --------
+
+ test("fromInterpolated requires parts.length == args.length + 1") {
+ val thrown = intercept[IllegalArgumentException] {
+ fromInterpolated(List("only-one-part"),
List(EncodableStringRenderer("x")))
+ }
+ assert(thrown.getMessage.contains("pyb interpolator mismatch"))
+ assert(thrown.getMessage.contains("parts=1"))
+ assert(thrown.getMessage.contains("args=1"))
+ }
+
+ test("fromInterpolated with zero args and one literal part renders that
part") {
+ val b = fromInterpolated(List("only"), Nil)
+ assert(b.plain == "only")
+ }
+
+ test("fromInterpolated alternates text/value chunks in order") {
+ val b = fromInterpolated(
+ List("a-", "-b-", "-c"),
+ List(PyLiteralStringRenderer("X"), PyLiteralStringRenderer("Y"))
+ )
+ assert(b.plain == "a-X-b-Y-c")
+ }
+
+ // -------- PythonTemplateBuilder.+ and concatChunks --------
+
+ test("operator + merges adjacent literal-only builders into a single text
chunk") {
+ val left = fromInterpolated(List("hello "), Nil)
+ val right = fromInterpolated(List("world"), Nil)
+ val merged = left + right
+ assert(merged.plain == "hello world")
+ // Round-trip through encode mode to ensure no chunk fan-out side effects.
+ assert(merged.encode == "hello world")
+ }
+
+ test("operator + preserves value chunks across the join boundary") {
+ val left = fromInterpolated(List("pre-", "-mid"),
List(EncodableStringRenderer("L")))
+ val right = fromInterpolated(List("-end"), Nil)
+ val merged = left + right
+ assert(merged.plain == "pre-L-mid-end")
+ assert(merged.encode == s"pre-${"self.decode_python_template('" + b64("L")
+ "')"}-mid-end")
+ }
+
+ test("operator + with empty left builder returns content equivalent to
right") {
+ val left = fromInterpolated(List(""), Nil)
+ val right = fromInterpolated(List("hi"), Nil)
+ assert((left + right).plain == "hi")
+ }
+
+ test("operator + with empty right builder returns content equivalent to
left") {
+ val left = fromInterpolated(List("hi"), Nil)
+ val right = fromInterpolated(List(""), Nil)
+ assert((left + right).plain == "hi")
+ }
+
+ test("operator +(String) is unsupported and includes the offending string in
the message") {
+ val b = fromInterpolated(List("x"), Nil)
+ val thrown = intercept[UnsupportedOperationException] {
+ b + "oops"
+ }
+ assert(thrown.getMessage.contains("oops"))
+ }
+
+ // -------- render() line-ending normalization --------
+
+ test("render normalizes CRLF to LF") {
+ val b = fromInterpolated(List("a\r\nb"), Nil)
+ assert(b.plain == "a\nb")
+ }
+
+ test("render normalizes lone CR to LF") {
+ val b = fromInterpolated(List("a\rb"), Nil)
+ assert(b.plain == "a\nb")
+ }
+
+ test("render preserves existing LF unchanged") {
+ val b = fromInterpolated(List("a\nb"), Nil)
+ assert(b.plain == "a\nb")
+ }
+
+ test("render applies stripMargin (margin char '|' strips preceding
whitespace per line)") {
+ val b = fromInterpolated(List("first\n |second"), Nil)
+ assert(b.plain == "first\nsecond")
+ }
+
+ // -------- containsEncodableString on edge inputs --------
+
+ test("containsEncodableString is false for a pure-text builder") {
+ val b = fromInterpolated(List("just text"), Nil)
+ assert(!b.containsEncodableString)
+ }
+
+ test("containsEncodableString is false for a builder holding only
PyLiteralStringRenderer") {
+ val b = fromInterpolated(
+ List("", ""),
+ List(PyLiteralStringRenderer("raw"))
+ )
+ assert(!b.containsEncodableString)
+ }
+
+ test("containsEncodableString is true if any chunk is an
EncodableStringRenderer") {
+ val b = fromInterpolated(
+ List("", "", ""),
+ List(PyLiteralStringRenderer("a"), EncodableStringRenderer("b"))
+ )
+ assert(b.containsEncodableString)
+ }
+
+ // -------- triple-quoted Python: pinning current (not-triple-quote-aware)
behavior --------
+ //
+ // PythonLexerUtils tracks single/double quote state one character at a time
and does not
+ // recognize Python triple-quoted strings as a single token. With six
balanced quotes the
+ // lexer happens to also report balanced, but the *intermediate* states
matter: any time the
+ // line tail ends with an odd count of `"`/`'`, hasUnclosedQuote returns
true.
+ //
+ // These pin the current conservative behavior. If a future change makes the
lexer aware of
+ // triple-quoted strings, these specs should be revisited intentionally.
+
+ test("hasUnclosedQuote: six matched double quotes are seen as balanced") {
+ assert(!PythonLexerUtils.hasUnclosedQuote("\"\"\"abc\"\"\""))
+ }
+
+ test("hasUnclosedQuote: three opening double quotes count as unclosed") {
+ // A Python triple-quoted string opener `\"\"\"` is currently reported as
'inside string'.
+ assert(PythonLexerUtils.hasUnclosedQuote("\"\"\"abc"))
+ }
+
+ test("hasUnclosedQuote: three opening single quotes count as unclosed") {
+ assert(PythonLexerUtils.hasUnclosedQuote("'''abc"))
+ }
+}
diff --git
a/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderSpec.scala
b/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderSpec.scala
index 7347ba8c2f..727f5cf14c 100644
---
a/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderSpec.scala
+++
b/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderSpec.scala
@@ -20,7 +20,11 @@
package org.apache.texera.amber.pybuilder
import org.apache.texera.amber.pybuilder.PyStringTypes.{EncodableString,
PythonLiteral}
-import
org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{EncodableStringRenderer,
PyLiteralStringRenderer, PythonTemplateBuilderStringContext}
+import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{
+ EncodableStringRenderer,
+ PyLiteralStringRenderer,
+ PythonTemplateBuilderStringContext
+}
import org.scalatest.funsuite.AnyFunSuite
import java.nio.charset.StandardCharsets
@@ -235,7 +239,8 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
}
test("@StringUI lambda parameter triggers UI encoding") {
- val uiToBuilder: (String @EncodableStringAnnotation) =>
PythonTemplateBuilder = uiText => pyb"$uiText"
+ val uiToBuilder: (String @EncodableStringAnnotation) =>
PythonTemplateBuilder =
+ uiText => pyb"$uiText"
val builder = uiToBuilder("lambda")
assert(builder.encode == decodeExpr("lambda"))
}
@@ -243,7 +248,9 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
test("@StringUI lambda param + map + mkString triggers UI encoding per
element") {
val rawItems = List("a", "b", "c")
val joinedEncoded =
- rawItems.map((uiItem: String @EncodableStringAnnotation) =>
pyb"$uiItem").mkString("[", ", ", "]")
+ rawItems
+ .map((uiItem: String @EncodableStringAnnotation) => pyb"$uiItem")
+ .mkString("[", ", ", "]")
assert(joinedEncoded == s"[${rawItems.map(decodeExpr).mkString(", ")}]")
}
@@ -256,7 +263,8 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
test("Erasing List[String @StringUI] to List[String] drops UI encoding") {
val uiItems: List[String @EncodableStringAnnotation] = List("erased")
- val erased: List[String] = uiItems.map((uiItem: String
@EncodableStringAnnotation) => (uiItem: String))
+ val erased: List[String] =
+ uiItems.map((uiItem: String @EncodableStringAnnotation) => (uiItem:
String))
val builder = pyb"${erased.head}"
assert(builder.encode == "erased")
}
@@ -410,7 +418,6 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
)
}
-
test("PyString (EncodableString) glued to identifier on the left does not
compile") {
assertDoesNotCompile("""
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._
@@ -433,11 +440,12 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
// This is intentionally exhaustive over the implementation-defined "bad
neighbor" set.
// We assert only compile success/failure, not the specific error message.
- badChars.zipWithIndex.foreach { case (ch, i) =>
- val esc = scalaUnicodeEscape(ch)
+ badChars.zipWithIndex.foreach {
+ case (ch, i) =>
+ val esc = scalaUnicodeEscape(ch)
- val leftAdj =
- s"""
+ val leftAdj =
+ s"""
|import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._
|import org.apache.texera.amber.pybuilder.PyStringTypes._
|object UiBadLeft_$i {
@@ -446,8 +454,8 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
|}
|""".stripMargin
- val rightAdj =
- s"""
+ val rightAdj =
+ s"""
|import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._
|import org.apache.texera.amber.pybuilder.PyStringTypes._
|object UiBadRight_$i {
@@ -456,8 +464,8 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
|}
|""".stripMargin
- assertToolboxDoesNotCompile(leftAdj)
- assertToolboxDoesNotCompile(rightAdj)
+ assertToolboxDoesNotCompile(leftAdj)
+ assertToolboxDoesNotCompile(rightAdj)
}
}
@@ -500,7 +508,9 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
assert(outer.encode == s"pre X=${decodeExpr("Z")} post")
}
- test("nested PythonTemplateBuilder without UI can appear inside python
quotes (no runtime checks)") {
+ test(
+ "nested PythonTemplateBuilder without UI can appear inside python quotes
(no runtime checks)"
+ ) {
val inner = pyb"hello"
val outer = pyb"print('$inner')"
assert(outer.plain == "print('hello')")
@@ -530,7 +540,9 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
}
}
- test("nested PythonTemplateBuilder containing UI after comment marker throws
at runtime (with and without whitespace)") {
+ test(
+ "nested PythonTemplateBuilder containing UI after comment marker throws at
runtime (with and without whitespace)"
+ ) {
val inner = pyb"${EncodableStringRenderer("x")}"
intercept[IllegalArgumentException] {
pyb"foo # $inner"
@@ -548,7 +560,9 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
intercept[IllegalArgumentException] { pyb"${inner}2" }
}
- test("runtime guard does NOT throw when nested builder has no UI, even in
unsafe boundary contexts") {
+ test(
+ "runtime guard does NOT throw when nested builder has no UI, even in
unsafe boundary contexts"
+ ) {
val inner = pyb"hello"
val outer1 = pyb"foo$inner"
val outer2 = pyb"${inner}bar"
@@ -595,7 +609,9 @@ class PythonTemplateBuilderSpec extends AnyFunSuite {
assert(builder.encode.contains("self.decode_python_template("))
}
- test("format(): nested PythonTemplateBuilder containing UI is allowed (no
runtime false positive)") {
+ test(
+ "format(): nested PythonTemplateBuilder containing UI is allowed (no
runtime false positive)"
+ ) {
val workflowParam = "wf"
val portParam = pyb"int
(${PythonTemplateBuilder.EncodableStringRenderer("\\.")}),"