Python API

latform can also be used as a Python library for parsing, formatting, and analyzing Bmad lattice files programmatically.

Parsing

parse

Parse a Bmad lattice string into a list of statements.

import latform

statements = latform.parse("""
parameter[particle] = electron
Q1: quadrupole, L=0.5, k1=1.2
D1: drift, L=2.0
FODO: line = (Q1, D1)
use, FODO
""")

for st in statements:
    print(type(st).__name__, st)

parse_file

Parse a single .bmad file from disk.

statements = latform.parse_file("my_lattice.bmad")

parse_file_recursive

Parse a lattice file and all files it references via call statements. Returns a Files object containing parsed statements organized by filename.

files = latform.parse_file_recursive("my_lattice.bmad")

# Iterate over all files and their statements
for filename, statements in files.by_filename.items():
    print(f"{filename}: {len(statements)} statements")

# Access the call graph
for caller, callees in files.filename_calls.items():
    for callee in callees:
        print(f"{caller} -> {callee}")

Formatting

format_statements

Format parsed statements back to a Bmad string using FormatOptions.

from latform.output import format_statements
from latform.types import FormatOptions

statements = latform.parse_file("my_lattice.bmad")

options = FormatOptions(
    line_length=100,
    name_case="upper",
    kind_case="lower",
)

formatted = format_statements(statements, options)
print(formatted)

format_file

A convenience function that parses and formats a file in one step.

from latform.output import format_file
from latform.types import FormatOptions

formatted = format_file("my_lattice.bmad", FormatOptions())

FormatOptions

All formatting behavior is controlled through the FormatOptions dataclass. The defaults match the latform CLI defaults:

from latform.types import FormatOptions

options = FormatOptions(
    line_length=100,             # target line length
    max_line_length=130,         # force multiline above this (default: 130% of line_length)
    compact=False,               # if True, no blank lines between statement types
    indent_size=2,               # spaces per indent level
    indent_char=" ",             # indentation character
    comment_col=40,              # column for inline comment alignment
    name_case="upper",           # element names: "upper", "lower", "same"
    attribute_case="lower",      # attribute names: "upper", "lower", "same"
    kind_case="lower",           # element types/keywords: "upper", "lower", "same"
    builtin_case="lower",        # builtin functions: "upper", "lower", "same"
    section_break_character="-", # character for section break lines
    section_break_width=None,    # width of section breaks (None = line_length)
    trailing_comma=False,        # trailing comma in multiline blocks
    renames={},                  # element rename mapping {"old": "new"}
    flatten_call=False,          # inline call statements
    flatten_inline=False,        # inline call:: arguments
    strip_comments=False,        # remove all comments
    newline_at_eof=True,         # ensure trailing newline
)

Statement Types

Parsed statements are instances of these classes from latform.statements:

Class Description Example
Element Element definition Q1: quadrupole, L=0.5
Line Beamline definition FODO: line = (Q1, D1)
Constant Constant assignment K1_VAL = 1.5
Parameter Bracketed parameter parameter[particle] = electron
Simple Keyword statement use, FODO or call, file=sub.bmad
Assignment General assignment Q1[k1] = 0.5
Empty Empty/whitespace-only

Working with Files

The Files class manages recursive parsing. MemoryFiles is a subclass that starts from a string rather than a file on disk.

from latform.parser import Files, MemoryFiles

# From disk
files = Files(main=pathlib.Path("my_lattice.bmad"))
files.parse(recurse=True)
files.annotate()

# From a string
files = MemoryFiles.from_contents(
    contents="Q1: quadrupole, L=0.5\n",
    root_path="/path/to/lattice_dir/virtual.bmad",
)
files.parse()
files.annotate()

Diffing

Compare two parsed lattice files programmatically.

from latform.parser import Files
from latform.diff import calculate_diff

files1 = Files(main=pathlib.Path("old_lattice.bmad"))
files1.parse()
files1.annotate()

files2 = Files(main=pathlib.Path("new_lattice.bmad"))
files2.parse()
files2.annotate()

diff = calculate_diff(files1, files2)

for p in diff.params_added:
    print(f"Added parameter: {p.name} = {p.new_value}")
for name, details in diff.eles_changed.items():
    print(f"Changed element: {name}")
    for attr, old, new in details.attrs_changed:
        print(f"  {attr}: {old} -> {new}")

API Reference

latform

latform.output

latform.output.format_statements
format_statements(statements, options)

Format a statement and return the code string

Source code in latform/output.py
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
def format_statements(
    statements: Sequence[Statement] | Statement,
    options: FormatOptions,
) -> str:
    """Format a statement and return the code string"""
    if isinstance(statements, Statement):
        statements = [statements]

    res: list[OutputLine] = []

    def maybe_add_blank_line():
        if res and not res[-1].parts:
            return
        res.append(OutputLine())

    lower_renames = {from_.lower(): to for from_, to in options.renames.items()}

    last_statement = None
    for statement in statements:
        if options.newline_before_new_type:
            if last_statement is not None:
                if (
                    options.newline_between_lines
                    and isinstance(statement, Line)
                    and isinstance(last_statement, Line)
                ):
                    maybe_add_blank_line()

                elif not isinstance(statement, type(last_statement)):
                    maybe_add_blank_line()
                elif (
                    isinstance(statement, Simple)
                    and statement.statement != last_statement.statement
                ):
                    maybe_add_blank_line()

        if isinstance(statement, Parameter):
            name = format_nodes([statement.target])[0].render(options)
            if name.lower() in lower_renames:
                new_name = lower_renames[name.lower()]
                statement.target = Token(new_name, role=Role.name_)

            # if "::" in name:
            #     prefix, name = name.split("::", 1)
            #     if name.lower() in lower_renames:
            #         new_name = lower_renames[name.lower()]
            #         statement.target = Token(f"{prefix}::{new_name}", role=Role.name_)

        for line in format_nodes(statement, options=options):
            if not line.parts and not line.comment:
                maybe_add_blank_line()
            else:
                res.append(line)

        last_statement = statement

    if options.renames:

        def apply_rename(item: Token | str):
            if not isinstance(item, Token):
                return item

            if item.lower() in lower_renames:
                return lower_renames[item.lower()]

            return item

        for line in res:
            line.parts = [apply_rename(part) for part in line.parts]

    while res and not res[0].parts and not res[0].comment:
        res = res[1:]

    text = "\n".join(line.render(options) for line in res)
    if options.newline_at_eof and text:
        return text + "\n"
    return text

latform.types

latform.types.Block dataclass
Block(opener=None, closer=None, items=list())
latform.types.Block.to_token
to_token(include_opener=True)

Convert this Block to a single Token with merged location information.

Source code in latform/types.py
409
410
411
412
413
def to_token(self, include_opener: bool = True) -> Token:
    """
    Convert this Block to a single Token with merged location information.
    """
    return Token.join(self.flatten(include_opener=include_opener))
latform.types.OutputLine dataclass
OutputLine(indent=0, parts=list(), comment=None)

A single line of output with indentation and an optional comment.

latform.types.Seq dataclass
Seq(opener=None, closer=None, items=list(), delimiter=SPACE)

Ordered sequence of mixed items: * Attribute (a named value, i.e., a name=value pair) * Expression (may be a single token) * Seq (a nested sequence)

latform.types.Seq.to_call_name
to_call_name()

Convert Seq to a single Token.

Source code in latform/types.py
177
178
179
180
181
182
183
184
def to_call_name(self) -> CallName:
    """Convert Seq to a single Token."""
    match self.items:
        case Token() as name, Seq(opener="(") as args:
            return CallName(name=name, args=args)
    raise UnexpectedCallName(
        f"Expected function call pattern not matched: {self} at {self.loc}"
    )
latform.types.Seq.to_text
to_text(opts=None)

Convert Seq to its full output representation.

Source code in latform/types.py
205
206
207
208
209
210
211
212
def to_text(self, opts: FormatOptions | None = None) -> str:
    """Convert Seq to its full output representation."""
    from .output import FormatOptions, format_nodes

    if opts is None:
        opts = FormatOptions()
    lines = format_nodes([self], options=opts)
    return "\n".join(line.render(options=opts) for line in lines)
latform.types.Seq.to_token
to_token(include_opener=True)

Convert Seq to a single Token.

Source code in latform/types.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def to_token(self, include_opener: bool = True) -> Token:
    """Convert Seq to a single Token."""
    from .output import FormatOptions, format_nodes

    if not include_opener:
        nodes = Seq(items=self.items, delimiter=self.delimiter).to_output_nodes()
    else:
        nodes = self.to_output_nodes()

    def check_can_tokenize(seq: Seq):
        for item in seq.items:
            if isinstance(item, Attribute):
                raise ValueError("Unable to tokenize Attributes")
            elif isinstance(item, Seq):
                check_can_tokenize(item)

    check_can_tokenize(self)

    opts = FormatOptions()
    (line,) = format_nodes(list(nodes), options=opts)
    line.comment = None
    return Token(line.render(options=opts), loc=self.loc)

latform.parser

latform.parser.Files dataclass
Files(main, stack=list(), by_filename=dict(), local_file_to_source_filename=dict(), filename_calls=dict())

Represents a collection of parsed files starting from a main entry point.

latform.parser.Files.call_graph_edges property
call_graph_edges

Return a list of (caller, callee) string edges for visualization.

latform.parser.Files.annotate
annotate()

Resolve named items across all parsed files.

Source code in latform/parser.py
510
511
512
513
514
515
516
517
def annotate(self):
    """
    Resolve named items across all parsed files.
    """
    named = self.get_named_items()
    for statements in self.by_filename.values():
        for st in statements:
            st.annotate(named=named)
latform.parser.Files.get_named_items
get_named_items()

Aggregate named items from all files.

Source code in latform/parser.py
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
def get_named_items(self) -> dict[Token, Statement]:
    """
    Aggregate named items from all files.
    """
    named_items = {}
    for statements in self.by_filename.values():
        new_items = get_named_items(statements)
        # TODO: potential for linting with redef
        named_items.update(new_items)

    if "BEGINNING" not in named_items:
        named_items["BEGINNING"] = Element(
            name=Token("BEGINNING", loc=implicit_location, role=Role.name_),
            keyword=Token(
                "BEGINNING_ELE",
                loc=implicit_location,
                role=Role.kind,
            ),
        )
    if "END" not in named_items:
        named_items["END"] = Element(
            name=Token("END", loc=implicit_location, role=Role.name_),
            keyword=Token("MARKER", loc=implicit_location, role=Role.kind),
        )
    return named_items
latform.parser.Files.parse
parse(recurse=True)

Parse the main file and optionally its dependencies recursively.

Source code in latform/parser.py
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
def parse(self, recurse: bool = True):
    """
    Parse the main file and optionally its dependencies recursively.
    """
    self.main = self.main.resolve()
    if not self.stack:
        # We treat the main file as relative to its own parent for consistency
        self.stack = [(pathlib.Path(self.main.name), self.main.parent)]
        self.local_file_to_source_filename[self.main] = self.main.name

    # We need to track processed files to avoid infinite loops in circular refs
    processed = set(self.by_filename.keys())

    while self.stack:
        filename_part, parent_dir = self.stack.pop()

        # Resolve the full path based on the parent context
        # (Note: filename_part might already be absolute if it's the main entry from disk)
        if filename_part.is_absolute():
            full_path = filename_part
        else:
            full_path = parent_dir / filename_part

        # Optimization: skip if already parsed
        if full_path in processed:
            continue

        logger.debug("Processing %s", full_path)
        processed.add(full_path)

        try:
            contents = self._get_file_contents(full_path)
        except FileNotFoundError:
            logger.error(
                f"Could not find file: {full_path} (parent={parent_dir} file={filename_part})"
            )
            continue

        # We don't annotate individually here, we do it in bulk later or let caller decide
        statements = list(parse(contents=contents, filename=full_path, annotate=False))
        self.by_filename[full_path] = statements

        for st in statements:
            if is_call_statement(st):
                st.metadata["local_path"] = self._add_file_by_statement(
                    statement_filename=full_path, st=st
                )

        if not recurse:
            break

    return self.by_filename
latform.parser.Files.reformat
reformat(options)

Reformat all files in the collection.

Source code in latform/parser.py
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def reformat(self, options: FormatOptions) -> None:
    """
    Reformat all files in the collection.
    """
    from .output import format_statements

    if options.flatten_call:
        statements = self.flatten(call=options.flatten_call, inline=options.flatten_inline)
        formatted = format_statements(statements, options)
        self._write_reformatted(self.main, formatted)

    for fn, statements in self.by_filename.items():
        formatted = format_statements(statements, options)
        self._write_reformatted(fn, formatted)
latform.parser.MemoryFiles dataclass
MemoryFiles(main, stack=list(), by_filename=dict(), local_file_to_source_filename=dict(), filename_calls=dict(), initial_contents='', _formatted_contents=None)

Bases: Files

Files alternative that starts parsing from a string in memory rather than a file on disk. Recursion will look to the filesystem relative to root_path.

latform.parser.MemoryFiles.formatted_contents property
formatted_contents

Get the formatted result of the initial memory contents.

latform.parser.MemoryFiles.from_contents classmethod
from_contents(contents, root_path)

Create a MemoryFiles instance from a string.

Parameters:
  • contents (str) –

    The source code content.

  • root_path (Path | str) –

    A "virtual" path where this file supposedly lives, used for resolving relative calls to other files.

Returns:
  • MemoryFiles

    The initialized object (call .parse() on it next).

Source code in latform/parser.py
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
@classmethod
def from_contents(cls, contents: str, root_path: pathlib.Path | str) -> "MemoryFiles":
    """
    Create a MemoryFiles instance from a string.

    Parameters
    ----------
    contents : str
        The source code content.
    root_path : pathlib.Path | str
        A "virtual" path where this file supposedly lives, used for resolving
        relative calls to other files.

    Returns
    -------
    MemoryFiles
        The initialized object (call .parse() on it next).
    """
    return cls(main=pathlib.Path(root_path).resolve(), initial_contents=contents)

latform.diff

latform-diff - compare two lattice files.

latform.diff.ElementDiffDetails dataclass
ElementDiffDetails(type_change=None, attrs_added=list(), attrs_removed=list(), attrs_changed=list())

Holds specific differences for a single element.

Attributes:
  • type_change (tuple[str, str] | None) –

    (old_type, new_type) if changed, else None.

  • attrs_added (list[tuple[str, str]]) –

    List of (attr_name, value).

  • attrs_removed (list[tuple[str, str]]) –

    List of (attr_name, value).

  • attrs_changed (list[tuple[str, str, str]]) –

    List of (attr_name, old_value, new_value).

latform.diff.LatticeDiff dataclass
LatticeDiff(params_added=list(), params_removed=list(), params_changed=list(), eles_added=list(), eles_removed=list(), eles_changed=dict(), eles_renamed=list())

Aggregates all differences between two lattice definitions.

Attributes:
  • params_added (list[ParameterChange]) –
  • params_removed (list[ParameterChange]) –
  • params_changed (list[ParameterChange]) –
  • eles_added (list[str]) –

    Names of added elements.

  • eles_removed (list[str]) –

    Names of removed elements.

  • eles_changed (dict[str, ElementDiffDetails]) –

    Map of Element Name -> Diff Details.

latform.diff.ParameterChange dataclass
ParameterChange(target, name, old_value, new_value)

Represents a change in a single parameter (target, name).

latform.diff.calculate_diff
calculate_diff(files1, files2)

Compute differences between two file sets and return a dataclass.

Parameters:
  • files1 (Files) –

    Left-hand file set.

  • files2 (Files) –

    Right-hand file set.

Returns:
Source code in latform/diff.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
def calculate_diff(files1: Files, files2: Files) -> LatticeDiff:
    """
    Compute differences between two file sets and return a dataclass.

    Parameters
    ----------
    files1 : Files
        Left-hand file set.
    files2 : Files
        Right-hand file set.

    Returns
    -------
    LatticeDiff
        The structured differences.
    """
    diff = LatticeDiff()

    params1 = _collect_parameters(files1)
    params2 = _collect_parameters(files2)

    p_keys1 = set(params1.keys())
    p_keys2 = set(params2.keys())

    # Added
    for key in sorted(p_keys2 - p_keys1):
        diff.params_added.append(
            ParameterChange(key[0], key[1], old_value=None, new_value=params2[key])
        )

    # Removed
    for key in sorted(p_keys1 - p_keys2):
        diff.params_removed.append(
            ParameterChange(key[0], key[1], old_value=params1[key], new_value=None)
        )

    # Changed
    for key in sorted(p_keys1 & p_keys2):
        if params1[key] != params2[key]:
            diff.params_changed.append(
                ParameterChange(key[0], key[1], old_value=params1[key], new_value=params2[key])
            )

    elements1 = _collect_elements(files1)
    elements2 = _collect_elements(files2)

    e_keys1 = set(elements1.keys())
    e_keys2 = set(elements2.keys())

    diff.eles_added = sorted(e_keys2 - e_keys1)
    diff.eles_removed = sorted(e_keys1 - e_keys2)
    common_eles = e_keys1 & e_keys2

    def is_same_ele(ele1, ele2):
        e1 = elements1[ele1]
        e2 = elements2[ele2]
        return e1["type"] == e2["type"] and e1["attributes"] == e2["attributes"]

    diff.eles_renamed = [
        (ele1, ele2)
        for ele1 in diff.eles_removed
        for ele2 in diff.eles_added
        if is_same_ele(ele1, ele2)
    ]
    # Could detect multiple renames; A -> B1, B2
    # Not technically valid as far as a rename goes;
    # Make this instead a remove/add? Hmm
    for ele1, ele2 in diff.eles_renamed:
        try:
            diff.eles_removed.remove(ele1)
        except ValueError:
            pass
        try:
            diff.eles_added.remove(ele2)
        except ValueError:
            pass

    for name in common_eles:
        e1 = elements1[name]
        e2 = elements2[name]

        details = ElementDiffDetails()

        if e1["type"] != e2["type"]:
            details = dataclasses.replace(details, type_change=(e1["type"], e2["type"]))

        attrs1 = e1["attributes"]
        attrs2 = e2["attributes"]

        a_keys1 = set(attrs1.keys())
        a_keys2 = set(attrs2.keys())

        for a in sorted(a_keys2 - a_keys1):
            details.attrs_added.append((a, attrs2[a]))

        for a in sorted(a_keys1 - a_keys2):
            details.attrs_removed.append((a, attrs1[a]))

        for a in sorted(a_keys1 & a_keys2):
            if attrs1[a] != attrs2[a]:
                details.attrs_changed.append((a, attrs1[a], attrs2[a]))

        if details.has_changes:
            diff.eles_changed[name] = details

    return diff
latform.diff.print_diff
print_diff(diff, console)

Render the diff using Rich tables.

Source code in latform/diff.py
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
def print_diff(diff: LatticeDiff, console: Console) -> None:
    """
    Render the diff using Rich tables.
    """
    if diff.has_param_diffs:
        console.rule("[bold]Parameters[/bold]")
        table = Table(show_header=True, header_style="bold magenta")
        table.add_column("State")
        table.add_column("Target")
        table.add_column("Name")
        table.add_column("Value (Left)", style="red")
        table.add_column("Value (Right)", style="green")

        for p in diff.params_added:
            table.add_row("Added", p.target, p.name, "", p.value_new_str, style="green")

        for p in diff.params_removed:
            table.add_row("Removed", p.target, p.name, p.value_old_str, "", style="red")

        for p in diff.params_changed:
            table.add_row(
                "Changed", p.target, p.name, p.value_old_str, p.value_new_str, style="yellow"
            )

        console.print(table)
        console.print()

    if diff.has_ele_diffs:
        console.rule("[bold]Elements[/bold]")

        table = Table(show_header=True, header_style="bold cyan")
        table.add_column("State")
        table.add_column("Element")
        table.add_column("Property/Attribute")
        table.add_column("Value (Left)", style="red")
        table.add_column("Value (Right)", style="green")

        for name in diff.eles_added:
            table.add_row("Added", name, "Element", "", "Exist", style="green")

        for name in diff.eles_removed:
            table.add_row("Removed", name, "Element", "Exist", "", style="red")

        for from_, to in diff.eles_renamed:
            table.add_row("Renamed", from_, "Element", from_, to, style="red")

        for name in sorted(diff.eles_changed.keys()):
            details = diff.eles_changed[name]

            if details.type_change:
                old_t, new_t = details.type_change
                table.add_row("Changed", name, "Type", old_t, new_t, style="magenta")

            for attr, val in details.attrs_added:
                table.add_row("Changed", name, f"Attr: {attr}", "", val, style="green")

            for attr, val in details.attrs_removed:
                table.add_row("Changed", name, f"Attr: {attr}", val, "", style="red")

            for attr, old_v, new_v in details.attrs_changed:
                table.add_row("Changed", name, f"Attr: {attr}", old_v, new_v, style="yellow")

        console.print(table)

latform.statements

latform.statements.Constant dataclass
Constant(name, value, redef=False, *, comments=Comments(), metadata=dict())

Bases: Statement

There are five types of parameters in Bmad: reals, integers, switches, logicals (booleans), and strings.