Skip to content

I/O -- Parsers & Writers

Functions and classes for reading and writing EnergyPlus models in IDF (text) and epJSON (JSON) formats.

Convenience Loaders

Top-level convenience functions:

  • load_idf(path, version=None, *, strict_parsing=True, strict=False, preserve_formatting=False) for IDF files. Strict parsing is on by default.
  • load_epjson(path, version=None, *, strict=False, preserve_formatting=False) for epJSON files.

Load an IDF file and return an IDFDocument.

Parameters:

Name Type Description Default
path str

Path to the IDF file

required
version tuple[int, int, int] | None

Optional version override (major, minor, patch)

None
strict_parsing bool

If True, fail fast on malformed IDF objects (default: True)

True
strict bool

When True, accessing or setting an unknown field name on any IDFObject raises :class:~idfkit.exceptions.InvalidFieldError instead of returning None.

True
preserve_formatting bool

When True, build a Concrete Syntax Tree (CST) so that :func:write_idf and :func:save_idf reproduce the original formatting, comments, and whitespace for unmodified objects.

False

Returns:

Type Description
IDFDocument[bool]

Parsed IDFDocument

Examples:

Load a DOE reference building and list its zones:

```python
model = load_idf("RefBldgSmallOfficeNew2004.idf")
print(f"Loaded {len(model)} objects")
for zone in model["Zone"]:
    print(zone.name)
```

Override the version for a legacy model:

```python
model = load_idf("pre_v9_building.idf", version=(9, 6, 0))
```

Lossless round-trip:

```python
model = load_idf("building.idf", preserve_formatting=True)
save_idf(model, "building_copy.idf")  # byte-identical
```
Source code in idfkit/__init__.py
def load_idf(
    path: str,
    version: tuple[int, int, int] | None = None,
    *,
    strict_parsing: bool = True,
    strict: bool = True,
    preserve_formatting: bool = False,
) -> IDFDocument[bool]:
    """
    Load an IDF file and return an IDFDocument.

    Args:
        path: Path to the IDF file
        version: Optional version override (major, minor, patch)
        strict_parsing: If True, fail fast on malformed IDF objects (default: True)
        strict: When ``True``, accessing or setting an unknown field name on any
            IDFObject raises :class:`~idfkit.exceptions.InvalidFieldError` instead
            of returning ``None``.
        preserve_formatting: When ``True``, build a Concrete Syntax Tree
            (CST) so that :func:`write_idf` and :func:`save_idf`
            reproduce the original
            formatting, comments, and whitespace for unmodified objects.

    Returns:
        Parsed IDFDocument

    Examples:
        Load a DOE reference building and list its zones:

            ```python
            model = load_idf("RefBldgSmallOfficeNew2004.idf")
            print(f"Loaded {len(model)} objects")
            for zone in model["Zone"]:
                print(zone.name)
            ```

        Override the version for a legacy model:

            ```python
            model = load_idf("pre_v9_building.idf", version=(9, 6, 0))
            ```

        Lossless round-trip:

            ```python
            model = load_idf("building.idf", preserve_formatting=True)
            save_idf(model, "building_copy.idf")  # byte-identical
            ```
    """
    from pathlib import Path

    if version is not None:
        _check_version(version)
    return parse_idf(
        Path(path),
        version=version,
        strict_parsing=strict_parsing,
        strict=strict,
        preserve_formatting=preserve_formatting,
    )

Load an epJSON file and return an IDFDocument.

Parameters:

Name Type Description Default
path str

Path to the epJSON file

required
version tuple[int, int, int] | None

Optional version override (major, minor, patch)

None
strict bool

When True, accessing or setting an unknown field name on any IDFObject raises :class:~idfkit.exceptions.InvalidFieldError instead of returning None.

True
preserve_formatting bool

When True, store the raw JSON text so that :func:write_epjson and :func:save_epjson can reproduce it byte-for-byte when no objects have been modified.

False

Returns:

Type Description
IDFDocument[bool]

Parsed IDFDocument

Examples:

Load an epJSON model and iterate over zones:

```python
model = load_epjson("SmallOffice.epJSON")
for zone in model["Zone"]:
    print(zone.name, zone.x_origin)
```

Specify an explicit EnergyPlus version:

```python
model = load_epjson("SmallOffice.epJSON", version=(24, 1, 0))
```
Source code in idfkit/__init__.py
def load_epjson(
    path: str,
    version: tuple[int, int, int] | None = None,
    *,
    strict: bool = True,
    preserve_formatting: bool = False,
) -> IDFDocument[bool]:
    """
    Load an epJSON file and return an IDFDocument.

    Args:
        path: Path to the epJSON file
        version: Optional version override (major, minor, patch)
        strict: When ``True``, accessing or setting an unknown field name on any
            IDFObject raises :class:`~idfkit.exceptions.InvalidFieldError` instead
            of returning ``None``.
        preserve_formatting: When ``True``, store the raw JSON text so
            that :func:`write_epjson` and :func:`save_epjson` can
            reproduce it byte-for-byte
            when no objects have been modified.

    Returns:
        Parsed IDFDocument

    Examples:
        Load an epJSON model and iterate over zones:

            ```python
            model = load_epjson("SmallOffice.epJSON")
            for zone in model["Zone"]:
                print(zone.name, zone.x_origin)
            ```

        Specify an explicit EnergyPlus version:

            ```python
            model = load_epjson("SmallOffice.epJSON", version=(24, 1, 0))
            ```
    """
    from pathlib import Path

    if version is not None:
        _check_version(version)
    return parse_epjson(Path(path), version=version, strict=strict, preserve_formatting=preserve_formatting)

IDF Parser

Streaming IDF parser - parses EnergyPlus IDF files into IDFDocument.

Features: - Memory-efficient streaming for large files - Regex-based tokenization - Direct parsing into IDFDocument (no intermediate structures) - Type coercion based on schema - Optional CST (Concrete Syntax Tree) for lossless round-tripping

logger = logging.getLogger(__name__) module-attribute

IDFParser

Streaming parser for IDF files.

Uses memory mapping for large files and regex for tokenization.

Source code in idfkit/idf_parser.py
322
323
324
325
326
327
328
329
330
331
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
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
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
509
510
511
512
513
514
515
516
517
518
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
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
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
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
class IDFParser:
    """
    Streaming parser for IDF files.

    Uses memory mapping for large files and regex for tokenization.
    """

    __slots__ = ("_content", "_diagnostics", "_encoding", "_filepath", "_schema", "_strict_parsing")

    _filepath: Path
    _schema: EpJSONSchema | None
    _encoding: str
    _content: bytes | None
    _strict_parsing: bool
    _diagnostics: list[ParseDiagnostic]

    def __init__(
        self,
        filepath: Path,
        schema: EpJSONSchema | None = None,
        encoding: str = "latin-1",
        strict_parsing: bool = True,
    ):
        self._filepath = filepath
        self._schema = schema
        self._encoding = encoding
        self._strict_parsing = strict_parsing
        self._content: bytes | None = None
        # Recoverable findings from the last parse: the ones that did not stop it.
        #
        # These have always been produced; they went to the logging module and nowhere else, so a
        # caller who wanted them had to install a handler before parsing. They are collected here
        # too, so `load_idf_with_diagnostics` can hand them back in one call. Every logging
        # announcement still fires exactly as it did (FR-014); this adds a second way to reach the
        # same findings and removes none.
        self._diagnostics = []

    @property
    def diagnostics(self) -> tuple[ParseDiagnostic, ...]:
        """Recoverable findings from the most recent parse, in the order they were noticed."""
        return tuple(self._diagnostics)

    def _record(
        self,
        message: str,
        *,
        code: ParseDiagnosticCode,
        obj_type: str | None = None,
        obj_name: str | None = None,
        content: bytes | None = None,
        offset: int | None = None,
    ) -> None:
        """Note a recoverable finding, resolving its position while the offset is still in hand.

        The position has to be captured here rather than recovered later. The aggregate site used
        to reduce its findings to a set of type names before warning, which threw away every offset
        it had, and a finding with no position is not the finding the other language returns.
        """
        line: int | None = None
        column: int | None = None
        if content is not None and offset is not None:
            line, column = self._line_and_column(content, offset)
        self._diagnostics.append(
            ParseDiagnostic(
                message=message,
                filepath=str(self._filepath),
                obj_type=obj_type,
                obj_name=obj_name,
                line=line,
                column=column,
                code=code,
            )
        )

    def parse(
        self,
        version: tuple[int, int, int] | None = None,
        *,
        strict: bool = True,
        preserve_formatting: bool = False,
    ) -> IDFDocument:
        """
        Parse the IDF file into an IDFDocument.

        Args:
            version: Optional version override
            strict: Enforce strict field access on the resulting document.
            preserve_formatting: Build a CST for lossless round-tripping.

        Returns:
            Parsed IDFDocument
        """
        t0 = time.perf_counter()
        # A parser may be reused, and findings belong to one parse rather than to its whole life.
        self._diagnostics = []
        logger.debug("Parsing IDF file %s", self._filepath)

        # Load content (with mmap for large files)
        content = self._load_content()

        # Detect version if not provided
        if version is None:
            version = self._detect_version(content)
            logger.debug("Detected version %d.%d.%d", *version)

        # Load schema if not provided
        schema = self._schema
        if schema is None:
            from .schema import get_schema

            schema = get_schema(version)

        # Create document
        doc = IDFDocument(version=version, schema=schema, filepath=self._filepath, strict=strict)  # type: ignore[reportCallIssue]  # .pyi uses covariant Strict

        # Parse objects
        self._parse_objects(content, doc, schema)

        # Build CST for lossless round-tripping (opt-in, no cost when False)
        if preserve_formatting:
            original_text = content.decode(self._encoding)
            cst = _build_idf_cst(original_text)
            if _link_cst_to_objects(cst, doc, self._diagnostics, str(self._filepath)):
                doc._cst = cst  # pyright: ignore[reportAttributeAccessIssue]
                doc._raw_text = original_text  # pyright: ignore[reportAttributeAccessIssue]
            else:
                logger.warning("CST discarded due to linking failure — lossless round-trip disabled")
                # No position: the failure is a property of the whole tree, not of one place in it.
                self._record(
                    "CST discarded due to linking failure, lossless round-trip disabled",
                    code="ParseError",
                )

        elapsed = time.perf_counter() - t0
        logger.info("Parsed %d objects from %s in %.3fs", len(doc), self._filepath, elapsed)

        return doc

    def _load_content(self) -> bytes:
        """Load file content, using mmap for large files."""
        file_size = self._filepath.stat().st_size
        use_mmap = file_size > _MMAP_THRESHOLD

        if use_mmap:
            logger.debug("Using mmap for large file (%d bytes)", file_size)
            # Use memory mapping for large files
            with open(self._filepath, "rb") as f:
                mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
                content = bytes(mm)
                mm.close()
        else:
            with open(self._filepath, "rb") as f:
                content = f.read()

        return content

    def _detect_version(self, content: bytes) -> tuple[int, int, int]:
        """Detect EnergyPlus version from file content."""
        # Search the whole file. ``content`` is already in memory, so a window
        # saves nothing here and costs correctness: nothing in the format puts
        # the Version object near the top, and three of the example files
        # shipped with EnergyPlus 26.1.0 carry it past the first 10 KB.
        match = _VERSION_PATTERN.search(content)
        if match:
            major = int(match.group(1))
            minor = int(match.group(2))
            patch = int(match.group(3)) if match.group(3) else 0
            return (major, minor, patch)

        raise VersionNotFoundError(str(self._filepath))

    def _parse_objects(
        self,
        content: bytes,
        doc: IDFDocument,
        schema: EpJSONSchema | None,
    ) -> None:
        """Parse all objects from content into document."""
        # Strip comments before matching to prevent phantom objects
        # (e.g. "!- X,Y,Z Origin" matching "X," as an object type)
        content = _COMMENT_PATTERN.sub(b"", content)

        # Local per-type caches avoid repeated schema lookups
        type_cache: dict[str, ParsingCache | None] = {}
        canonical_cache: dict[str, str] = {}
        encoding = self._encoding
        addidfobject = doc.addidfobject
        skipped_types: set[str] = set()

        for match in _OBJECT_PATTERN.finditer(content):
            match_offset = match.start(1)
            obj_type: str | None = None
            obj_name: str | None = None

            try:
                decoded_obj_type = match.group(1).decode(encoding).strip()
                obj_type = decoded_obj_type

                obj_name = self._extract_object_name(match, encoding)

                pc, should_skip, decoded_obj_type = self._resolve_type_cache(
                    content=content,
                    schema=schema,
                    type_cache=type_cache,
                    canonical_cache=canonical_cache,
                    skipped_types=skipped_types,
                    obj_type=decoded_obj_type,
                    obj_name=obj_name,
                    match_offset=match_offset,
                )
                if should_skip:
                    continue

                obj = self._parse_object_cached(match, pc, encoding, obj_type_override=decoded_obj_type)
                if obj:
                    addidfobject(obj)
            except IDFParseError:
                raise
            except Exception as exc:
                if self._strict_parsing:
                    self._raise_parse_error(
                        content,
                        match_offset,
                        f"Failed to parse object: {exc}",
                        obj_type=obj_type,
                        obj_name=obj_name,
                    )
                logger.warning("Skipping malformed object %r: %s", obj_type or "<decode_error>", exc)
                self._record(
                    f"Skipping malformed object: {exc}",
                    code="ParseError",
                    obj_type=obj_type,
                    obj_name=obj_name,
                    content=content,
                    offset=match_offset,
                )

        if skipped_types:
            logger.warning(
                "Skipped %d unknown object type(s): %s", len(skipped_types), ", ".join(sorted(skipped_types))
            )

    def _parse_object_cached(
        self,
        match: re.Match[bytes],
        pc: ParsingCache | None,
        encoding: str,
        *,
        obj_type_override: str | None = None,
    ) -> IDFObject | None:
        """Parse a single object from regex match using cached metadata."""
        obj_type = obj_type_override or match.group(1).decode(encoding).strip()
        fields_raw = match.group(2).decode(encoding)

        fields = self._parse_fields(fields_raw)
        if not fields:
            return None

        if pc is not None:
            has_name = pc.has_name
            # Mutable copy since extensible parsing appends to it
            field_names: list[str] = list(pc.field_names) if has_name else list(pc.all_field_names)

            name, remaining_fields = (fields[0], fields[1:]) if has_name else ("", fields)

            invalid: list[tuple[int, str, str]] = []
            data = self._build_data_dict_cached(remaining_fields, field_names, pc, invalid)
            if invalid:
                self._report_invalid_fields(match, fields_raw, encoding, obj_type, fields, pc, invalid)

            return IDFObject(
                obj_type=obj_type,
                name=name,
                data=data,
                schema=pc.obj_schema,
                field_order=field_names,
                ref_fields=pc.ref_fields,
                extensibles=frozenset(pc.ext_field_names),
                wrapper_key=pc.ext_wrapper_key,
                ext_inner_names=tuple(pc.ext_inner_props.keys()),
            )

        # No-schema fallback
        name = fields[0] if fields else ""
        remaining = fields[1:]
        data: dict[str, Any] = {}
        for i, value in enumerate(remaining):
            if value:
                data[f"field_{i + 1}"] = value
        return IDFObject(obj_type=obj_type, name=name, data=data)

    def _report_invalid_fields(
        self,
        match: re.Match[bytes],
        fields_raw: str,
        encoding: str,
        obj_type: str,
        fields: list[str],
        pc: ParsingCache,
        invalid: list[tuple[int, str, str]],
    ) -> None:
        """Report values the schema declares numeric that are neither numeric nor a legal sentinel.

        Never raises, in either mode. A value of the wrong kind does not stop the parse: the object
        is built and the document is returned, so a caller who was reading strictly still gets what
        they got before (FR-014). This adds a finding where there was silence, and nothing else.

        The finding is positioned at the offending FIELD rather than at the object, because that is
        what makes it useful: the shape this catches is a missing semicolon swallowing the object
        below, and the object's own first line is nowhere near the damage.
        """
        name_offset = 1 if pc.has_name else 0
        for index, field_name, value in invalid:
            if _string_is_legal(pc.obj_schema, field_name, value):
                continue

            offset = _field_offset(fields_raw, index + name_offset)
            prefix = 0 if offset is None else len(fields_raw[:offset].encode(encoding))
            position = match.start(2) + prefix

            self._record(
                f"Field {field_name!r} expects a number, got {value!r}",
                code="InvalidField",
                obj_type=obj_type,
                obj_name=fields[0] if pc.has_name and fields else None,
                content=match.string,
                offset=position,
            )

    def _build_data_dict_cached(
        self,
        remaining_fields: list[str],
        field_names: list[str],
        pc: ParsingCache,
        invalid: list[tuple[int, str, str]] | None = None,
    ) -> dict[str, Any]:
        """Build the data dict using pre-computed field types from the cache.

        *invalid* collects ``(index, field name, value)`` for each value the schema declares numeric
        that did not coerce. It is almost always empty, and detecting that costs one identity test
        and one frozenset lookup per field: ``_coerce_value_fast`` returns a NEW object when it
        succeeds and hands back the very string it was given when it fails, so ``is`` separates the
        two without re-parsing anything.

        Whether such a value is actually wrong is not decided here. A numeric field routinely
        accepts ``autosize``; that question needs the schema and is asked on the reporting path.
        """
        data: dict[str, Any] = {}
        field_types = pc.field_types
        num_named = len(field_names)

        for i, value in enumerate(remaining_fields):
            if i < num_named:
                field_name = field_names[i]
                if value:
                    field_type = field_types.get(field_name)
                    coerced = _coerce_value_fast(field_type, value)
                    data[field_name] = coerced
                    if invalid is not None and coerced is value and field_type in _NUMERIC_FIELD_TYPES:
                        invalid.append((i, field_name, value))
                else:
                    data[field_name] = ""

        if not pc.extensible and len(remaining_fields) > num_named and self._strict_parsing:
            overflow = len(remaining_fields) - num_named
            msg = (
                f"Object has {overflow} extra field(s) but type is not extensible "
                f"(expected at most {num_named}, got {len(remaining_fields)})"
            )
            raise ValueError(msg)

        if pc.extensible and num_named < len(remaining_fields):
            extra = remaining_fields[num_named:]
            self._append_extensible_fields(
                data=data,
                extra=extra,
                field_names=field_names,
                field_types=field_types,
                pc=pc,
            )

        return data

    def _append_extensible_fields(
        self,
        *,
        data: dict[str, Any],
        extra: list[str],
        field_names: list[str],
        field_types: dict[str, str | None],
        pc: ParsingCache,
    ) -> None:
        """Bucket extensible-group tokens into a canonical wrapper array.

        Tokens are split into ``ext_size``-wide groups; each group becomes
        a dict keyed by the schema's inner property names, and the resulting
        list is stored under ``data[wrapper_key]`` (e.g. ``data["vertices"]``).
        The wrapper key is *not* added to ``field_names`` — the IDF writer
        handles wrapper expansion separately via the schema cache.
        """
        ext_size = pc.ext_size
        if ext_size <= 0:
            if self._strict_parsing:
                msg = "Object is marked extensible but extensible group size is invalid"
                raise ValueError(msg)
            return

        ext_names = pc.ext_field_names
        num_ext = len(ext_names)
        ext_types = tuple(field_types.get(name) for name in ext_names)
        wrapper_key = pc.ext_wrapper_key

        items: list[dict[str, Any]] = []
        for group_start in range(0, len(extra), ext_size):
            group = extra[group_start : group_start + ext_size]
            item: dict[str, Any] = {}
            has_any = False
            for j, value in enumerate(group):
                if j >= num_ext:
                    continue
                inner = ext_names[j]
                if value:
                    item[inner] = _coerce_value_fast(ext_types[j], value)
                    has_any = True
                else:
                    item[inner] = ""
            # Drop trailing all-empty groups (e.g. an extra "," at the end of
            # the extensible block). Keeping them would emit phantom vertices.
            if has_any:
                items.append(item)

        if items and wrapper_key is not None:
            data[wrapper_key] = items

    def _parse_fields(self, fields_raw: str) -> list[str]:
        """Parse and clean field values from raw string."""
        # Fast path: comments already stripped by _COMMENT_PATTERN in _parse_objects
        if "!" not in fields_raw:
            return [part.strip() for part in fields_raw.split(",")]

        # Slow path: strip inline comments (safety fallback)
        lines: list[str] = []
        for line in fields_raw.split("\n"):
            idx = line.find("!")
            if idx >= 0:
                line = line[:idx]
            lines.append(line)
        clean_text = " ".join(lines)
        return [part.strip() for part in clean_text.split(",")]

    @staticmethod
    def _line_and_column(content: bytes, offset: int) -> tuple[int, int]:
        """Convert a byte offset to 1-based (line, column)."""
        line = content.count(b"\n", 0, offset) + 1
        previous_newline = content.rfind(b"\n", 0, offset)
        if previous_newline < 0:
            return (line, offset + 1)
        return (line, offset - previous_newline)

    @staticmethod
    def _extract_object_name(match: re.Match[bytes], encoding: str) -> str | None:
        """Best-effort extraction of the raw first field (typically object name)."""
        fields_raw = match.group(2).decode(encoding)
        first = fields_raw.split(",", maxsplit=1)[0].strip()
        return first or None

    def _raise_parse_error(
        self,
        content: bytes,
        offset: int,
        message: str,
        *,
        obj_type: str | None = None,
        obj_name: str | None = None,
        code: ParseDiagnosticCode = "ParseError",
    ) -> None:
        """Raise a ParseError with contextual diagnostics."""
        line, column = self._line_and_column(content, offset)
        diagnostic = ParseDiagnostic(
            message=message,
            filepath=str(self._filepath),
            obj_type=obj_type,
            obj_name=obj_name,
            line=line,
            column=column,
            code=code,
        )
        summary = "Failed to parse IDF"
        raise IDFParseError(summary, diagnostics=[diagnostic])

    def _resolve_type_cache(
        self,
        *,
        content: bytes,
        schema: EpJSONSchema | None,
        type_cache: dict[str, ParsingCache | None],
        canonical_cache: dict[str, str],
        skipped_types: set[str],
        obj_type: str,
        obj_name: str | None,
        match_offset: int,
    ) -> tuple[ParsingCache | None, bool, str]:
        """Resolve and cache parsing metadata for an object type.

        Returns ``(cache, should_skip, canonical_type)`` where *canonical_type*
        is the PascalCase schema name (may differ from the raw IDF casing).
        """
        if schema is None:
            return (None, False, obj_type)

        pc = type_cache.get(obj_type)
        if pc is None and obj_type not in type_cache:
            pc = schema.get_parsing_cache(obj_type)
            type_cache[obj_type] = pc

        if pc is not None:
            # Return cached canonical name (resolved once per raw type).
            canonical = canonical_cache.get(obj_type)
            if canonical is None:
                canonical = schema.resolve_type_name(obj_type) or obj_type
                canonical_cache[obj_type] = canonical
            return (pc, False, canonical)

        if self._strict_parsing:
            # The same code the recoverable path below uses, and the same one the other language
            # reports for this input. A finding must not change kind depending on which mode was
            # asked for: it is the same finding, arriving on a different path.
            msg = f"Unknown object type '{obj_type}'"
            self._raise_parse_error(
                content,
                match_offset,
                msg,
                obj_type=obj_type,
                obj_name=obj_name,
                code="UnknownObjectType",
            )

        # One finding per skip, positioned, and recorded HERE rather than after the loop.
        #
        # `skipped_types` is a set and stays one, because the log line it feeds is a summary and
        # changing it would change what an existing caller sees. But a set of type names is not
        # what the other language returns: it collapses four skips of the same type into one and
        # discards every offset. The offset is in hand at exactly this point and nowhere later,
        # which is why the finding is built here.
        self._record(
            f"Unknown object type '{obj_type}'",
            code="UnknownObjectType",
            obj_type=obj_type,
            obj_name=obj_name,
            content=content,
            offset=match_offset,
        )
        skipped_types.add(obj_type)
        return (None, True, obj_type)

diagnostics property

Recoverable findings from the most recent parse, in the order they were noticed.

parse(version=None, *, strict=True, preserve_formatting=False)

Parse the IDF file into an IDFDocument.

Parameters:

Name Type Description Default
version tuple[int, int, int] | None

Optional version override

None
strict bool

Enforce strict field access on the resulting document.

True
preserve_formatting bool

Build a CST for lossless round-tripping.

False

Returns:

Type Description
IDFDocument

Parsed IDFDocument

Source code in idfkit/idf_parser.py
def parse(
    self,
    version: tuple[int, int, int] | None = None,
    *,
    strict: bool = True,
    preserve_formatting: bool = False,
) -> IDFDocument:
    """
    Parse the IDF file into an IDFDocument.

    Args:
        version: Optional version override
        strict: Enforce strict field access on the resulting document.
        preserve_formatting: Build a CST for lossless round-tripping.

    Returns:
        Parsed IDFDocument
    """
    t0 = time.perf_counter()
    # A parser may be reused, and findings belong to one parse rather than to its whole life.
    self._diagnostics = []
    logger.debug("Parsing IDF file %s", self._filepath)

    # Load content (with mmap for large files)
    content = self._load_content()

    # Detect version if not provided
    if version is None:
        version = self._detect_version(content)
        logger.debug("Detected version %d.%d.%d", *version)

    # Load schema if not provided
    schema = self._schema
    if schema is None:
        from .schema import get_schema

        schema = get_schema(version)

    # Create document
    doc = IDFDocument(version=version, schema=schema, filepath=self._filepath, strict=strict)  # type: ignore[reportCallIssue]  # .pyi uses covariant Strict

    # Parse objects
    self._parse_objects(content, doc, schema)

    # Build CST for lossless round-tripping (opt-in, no cost when False)
    if preserve_formatting:
        original_text = content.decode(self._encoding)
        cst = _build_idf_cst(original_text)
        if _link_cst_to_objects(cst, doc, self._diagnostics, str(self._filepath)):
            doc._cst = cst  # pyright: ignore[reportAttributeAccessIssue]
            doc._raw_text = original_text  # pyright: ignore[reportAttributeAccessIssue]
        else:
            logger.warning("CST discarded due to linking failure — lossless round-trip disabled")
            # No position: the failure is a property of the whole tree, not of one place in it.
            self._record(
                "CST discarded due to linking failure, lossless round-trip disabled",
                code="ParseError",
            )

    elapsed = time.perf_counter() - t0
    logger.info("Parsed %d objects from %s in %.3fs", len(doc), self._filepath, elapsed)

    return doc

ParseResult dataclass

A document, and the recoverable findings from the parse that produced it.

What load_idf_with_diagnostics hands back. The findings here are the ones that did NOT stop the parse: a skipped unknown type, a malformed object, a formatting tree that could not be linked. The ones that did stop it never reach this type, because there is no document to pair them with; they arrive on IDFParseError.diagnostics instead.

Field names match the other language's ParseResult exactly, so a reader moving between the two reads the same two names in the same order.

Source code in idfkit/idf_parser.py
@dataclass(frozen=True, slots=True)
class ParseResult:
    """A document, and the recoverable findings from the parse that produced it.

    What `load_idf_with_diagnostics` hands back. The findings here are the ones that did NOT stop
    the parse: a skipped unknown type, a malformed object, a formatting tree that could not be
    linked. The ones that did stop it never reach this type, because there is no document to pair
    them with; they arrive on `IDFParseError.diagnostics` instead.

    Field names match the other language's `ParseResult` exactly, so a reader moving between the
    two reads the same two names in the same order.
    """

    document: IDFDocument
    diagnostics: tuple[ParseDiagnostic, ...]

diagnostics instance-attribute

document instance-attribute

get_idf_version(filepath)

Quick version detection without full parsing.

Reads the first 10 KB of the file, which holds the Version object in almost every model, and falls back to reading the rest only when it does not. The common case costs one small read; no valid file is refused for stating its version late. Nothing in the IDF format requires Version to appear near the top, and three of the example files shipped with EnergyPlus 26.1.0 carry it past 10 KB.

Parameters:

Name Type Description Default
filepath Path | str

Path to IDF file

required

Returns:

Type Description
tuple[int, int, int]

Version tuple (major, minor, patch)

Raises:

Type Description
VersionNotFoundError

If the file holds no Version object

Examples:

Check which EnergyPlus version a model was created for (usually reads only the first 10 KB):

```python
from idfkit import get_idf_version

version = get_idf_version("5ZoneAirCooled.idf")
print(f"EnergyPlus v{version[0]}.{version[1]}.{version[2]}")
```
Source code in idfkit/idf_parser.py
def get_idf_version(filepath: Path | str) -> tuple[int, int, int]:
    """
    Quick version detection without full parsing.

    Reads the first 10 KB of the file, which holds the ``Version`` object in
    almost every model, and falls back to reading the rest only when it does
    not. The common case costs one small read; no valid file is refused for
    stating its version late. Nothing in the IDF format requires ``Version``
    to appear near the top, and three of the example files shipped with
    EnergyPlus 26.1.0 carry it past 10 KB.

    Args:
        filepath: Path to IDF file

    Returns:
        Version tuple (major, minor, patch)

    Raises:
        VersionNotFoundError: If the file holds no Version object

    Examples:
        Check which EnergyPlus version a model was created for
        (usually reads only the first 10 KB):

            ```python
            from idfkit import get_idf_version

            version = get_idf_version("5ZoneAirCooled.idf")
            print(f"EnergyPlus v{version[0]}.{version[1]}.{version[2]}")
            ```
    """
    filepath = Path(filepath)

    with open(filepath, "rb") as f:
        head = f.read(_VERSION_SCAN_BYTES)
        match = _VERSION_PATTERN.search(head)
        if match is None:
            # The scan window may have split the Version object, so re-search
            # the whole file rather than only the remainder.
            match = _VERSION_PATTERN.search(head + f.read())

    if match:
        major = int(match.group(1))
        minor = int(match.group(2))
        patch = int(match.group(3)) if match.group(3) else 0
        return (major, minor, patch)

    raise VersionNotFoundError(str(filepath))

iter_idf_objects(filepath, encoding='latin-1')

Iterate over objects in an IDF file without loading into document.

Yields:

Type Description
tuple[str, str, list[str]]

Tuples of (object_type, name, [field_values])

This is useful for quick scanning or filtering without full parsing.

Note

Type names are returned exactly as they appear in the IDF file (no schema-based case normalization). Use case-insensitive comparisons when filtering: obj_type.upper() == "ZONE".

Examples:

Count thermal zones without loading the full document (useful for quickly sizing batch runs):

```python
from idfkit import iter_idf_objects

zone_count = sum(
    1 for obj_type, name, _
    in iter_idf_objects("5ZoneAirCooled.idf")
    if obj_type == "Zone"
)
```

Collect all material names for an audit report:

```python
materials = [
    name for obj_type, name, _
    in iter_idf_objects("LargeOffice.idf")
    if obj_type == "Material"
]
```
Source code in idfkit/idf_parser.py
def iter_idf_objects(
    filepath: Path | str,
    encoding: str = "latin-1",
) -> Iterator[tuple[str, str, list[str]]]:
    """
    Iterate over objects in an IDF file without loading into document.

    Yields:
        Tuples of (object_type, name, [field_values])

    This is useful for quick scanning or filtering without full parsing.

    Note:
        Type names are returned exactly as they appear in the IDF file
        (no schema-based case normalization).  Use case-insensitive
        comparisons when filtering: ``obj_type.upper() == "ZONE"``.

    Examples:
        Count thermal zones without loading the full document
        (useful for quickly sizing batch runs):

            ```python
            from idfkit import iter_idf_objects

            zone_count = sum(
                1 for obj_type, name, _
                in iter_idf_objects("5ZoneAirCooled.idf")
                if obj_type == "Zone"
            )
            ```

        Collect all material names for an audit report:

            ```python
            materials = [
                name for obj_type, name, _
                in iter_idf_objects("LargeOffice.idf")
                if obj_type == "Material"
            ]
            ```
    """
    filepath = Path(filepath)

    with open(filepath, "rb") as f:
        content = f.read()

    # Strip comments before matching to prevent phantom objects
    content = _COMMENT_PATTERN.sub(b"", content)

    for match in _OBJECT_PATTERN.finditer(content):
        obj_type = match.group(1).decode(encoding).strip()
        fields_raw = match.group(2).decode(encoding)

        # Split and clean fields
        fields: list[str] = []
        for part in fields_raw.split(","):
            if "!" in part:
                part = part[: part.index("!")]
            fields.append(part.strip())

        obj_name = fields[0] if fields else ""
        yield (obj_type, obj_name, fields[1:])

parse_idf(filepath, schema=None, version=None, encoding='latin-1', strict_parsing=True, strict=True, *, preserve_formatting=False)

Parse an IDF file into an IDFDocument.

Parameters:

Name Type Description Default
filepath Path | str

Path to the IDF file

required
schema EpJSONSchema | None

Optional EpJSONSchema for field ordering and type coercion

None
version tuple[int, int, int] | None

Optional version override (auto-detected if not provided)

None
encoding str

File encoding (default: latin-1 for compatibility)

'latin-1'
strict_parsing bool

If True, fail fast on malformed objects (default: True)

True
strict bool

When True, accessing or setting an unknown field name on any IDFObject raises :class:~idfkit.exceptions.InvalidFieldError instead of returning None.

True
preserve_formatting bool

If True, build a Concrete Syntax Tree (CST) so that writing the document back preserves original formatting, comments, and whitespace for unmodified objects. The default code path is unchanged — no performance penalty when False.

False

Returns:

Type Description
IDFDocument

Parsed IDFDocument

Raises:

Type Description
VersionNotFoundError

If version cannot be detected

IdfKitError

If parsing fails

Examples:

Load and inspect a DOE reference building:

```python
from idfkit import parse_idf

model = parse_idf("RefBldgSmallOfficeNew2004.idf")
for zone in model["Zone"]:
    print(zone.name, zone.x_origin)
```

Force a specific EnergyPlus version when auto-detection fails (e.g., a pre-v8.9 file that was manually upgraded):

```python
model = parse_idf("legacy_building.idf", version=(9, 6, 0))
```

Round-trip an IDF file losslessly:

```python
model = parse_idf("building.idf", preserve_formatting=True)
# write_idf(model) reproduces the original byte-for-byte
```
Source code in idfkit/idf_parser.py
def parse_idf(
    filepath: Path | str,
    schema: EpJSONSchema | None = None,
    version: tuple[int, int, int] | None = None,
    encoding: str = "latin-1",
    strict_parsing: bool = True,
    strict: bool = True,
    *,
    preserve_formatting: bool = False,
) -> IDFDocument:
    """
    Parse an IDF file into an IDFDocument.

    Args:
        filepath: Path to the IDF file
        schema: Optional EpJSONSchema for field ordering and type coercion
        version: Optional version override (auto-detected if not provided)
        encoding: File encoding (default: latin-1 for compatibility)
        strict_parsing: If True, fail fast on malformed objects (default: True)
        strict: When ``True``, accessing or setting an unknown field name on any
            IDFObject raises :class:`~idfkit.exceptions.InvalidFieldError` instead
            of returning ``None``.
        preserve_formatting: If ``True``, build a Concrete Syntax Tree (CST)
            so that writing the document back preserves original formatting,
            comments, and whitespace for unmodified objects.  The default
            code path is unchanged — no performance penalty when ``False``.

    Returns:
        Parsed IDFDocument

    Raises:
        VersionNotFoundError: If version cannot be detected
        IdfKitError: If parsing fails

    Examples:
        Load and inspect a DOE reference building:

            ```python
            from idfkit import parse_idf

            model = parse_idf("RefBldgSmallOfficeNew2004.idf")
            for zone in model["Zone"]:
                print(zone.name, zone.x_origin)
            ```

        Force a specific EnergyPlus version when auto-detection fails
        (e.g., a pre-v8.9 file that was manually upgraded):

            ```python
            model = parse_idf("legacy_building.idf", version=(9, 6, 0))
            ```

        Round-trip an IDF file losslessly:

            ```python
            model = parse_idf("building.idf", preserve_formatting=True)
            # write_idf(model) reproduces the original byte-for-byte
            ```
    """
    filepath = Path(filepath)

    if not filepath.exists():
        raise FileNotFoundError(f"IDF file not found: {filepath}")  # noqa: TRY003

    parser = IDFParser(filepath, schema, encoding, strict_parsing=strict_parsing)
    return parser.parse(version, strict=strict, preserve_formatting=preserve_formatting)

parse_idf_with_diagnostics(filepath, schema=None, version=None, encoding='latin-1', *, strict=True, preserve_formatting=False)

Parse an IDF file, keeping the findings that did not stop the parse.

The same work parse_idf(..., strict_parsing=False) does, handing back the recoverable findings alongside the document instead of only announcing them through the logging module.

strict_parsing is not a parameter: a strict parse has no recoverable findings by definition, so this function always parses non-strictly. Findings that stop a parse still raise, and still arrive on IDFParseError.diagnostics.

Every logging announcement fires exactly as it does today. A caller with a handler installed sees no change (FR-014); this is a second way to reach the same findings, not a replacement.

Source code in idfkit/idf_parser.py
def parse_idf_with_diagnostics(
    filepath: Path | str,
    schema: EpJSONSchema | None = None,
    version: tuple[int, int, int] | None = None,
    encoding: str = "latin-1",
    *,
    strict: bool = True,
    preserve_formatting: bool = False,
) -> ParseResult:
    """Parse an IDF file, keeping the findings that did not stop the parse.

    The same work `parse_idf(..., strict_parsing=False)` does, handing back the recoverable
    findings alongside the document instead of only announcing them through the logging module.

    `strict_parsing` is not a parameter: a strict parse has no recoverable findings by definition,
    so this function always parses non-strictly. Findings that stop a parse still raise, and still
    arrive on `IDFParseError.diagnostics`.

    Every logging announcement fires exactly as it does today. A caller with a handler installed
    sees no change (FR-014); this is a second way to reach the same findings, not a replacement.
    """
    filepath = Path(filepath)

    if not filepath.exists():
        raise FileNotFoundError(f"IDF file not found: {filepath}")  # noqa: TRY003

    parser = IDFParser(filepath, schema, encoding, strict_parsing=False)
    document = parser.parse(version, strict=strict, preserve_formatting=preserve_formatting)
    return ParseResult(document=document, diagnostics=parser.diagnostics)

epJSON Parser

epJSON parser - parses EnergyPlus epJSON files into IDFDocument.

The epJSON format is the native JSON representation of EnergyPlus models. Parsing is straightforward since it's already structured JSON.

logger = logging.getLogger(__name__) module-attribute

EpJSONParser

Parser for epJSON files.

epJSON is the native JSON format for EnergyPlus models, making parsing straightforward - just json.load() and transform.

Source code in idfkit/epjson_parser.py
class EpJSONParser:
    """
    Parser for epJSON files.

    epJSON is the native JSON format for EnergyPlus models, making
    parsing straightforward - just json.load() and transform.
    """

    __slots__ = ("_filepath", "_schema")

    def __init__(
        self,
        filepath: Path,
        schema: EpJSONSchema | None = None,
    ):
        self._filepath = filepath
        self._schema = schema

    def parse(
        self,
        version: tuple[int, int, int] | None = None,
        *,
        strict: bool = True,
        preserve_formatting: bool = False,
    ) -> IDFDocument:
        """
        Parse the epJSON file into an IDFDocument.

        Args:
            version: Optional version override
            strict: Enforce strict field access on the resulting document.
            preserve_formatting: Store raw JSON text for lossless round-tripping.

        Returns:
            Parsed IDFDocument
        """
        t0 = time.perf_counter()
        logger.debug("Parsing epJSON file %s", self._filepath)

        # Load JSON
        with open(self._filepath, encoding="utf-8") as f:
            raw_text = f.read()
        data: dict[str, Any] = json.loads(raw_text)

        # Detect version if not provided
        if version is None:
            version = self._detect_version(data)
            logger.debug("Detected version %d.%d.%d", *version)

        # Load schema if not provided
        schema = self._schema
        if schema is None:
            from .schema import get_schema

            schema = get_schema(version)

        # Create document
        doc = IDFDocument(version=version, schema=schema, filepath=self._filepath, strict=strict)  # type: ignore[reportCallIssue]  # .pyi uses covariant Strict

        # Parse objects
        self._parse_objects(data, doc, schema)

        # Store raw text for lossless round-tripping (opt-in).
        if preserve_formatting:
            doc._raw_text = raw_text  # pyright: ignore[reportAttributeAccessIssue]
            # Mark all objects as "pristine" with a sentinel source_text so
            # that the writer can detect mutations.
            for obj in doc.all_objects:
                object.__setattr__(obj, "_source_text", "")
            # How many there were, so that a removal is visible to the writer.
            # The sentinel above only marks the objects that are still here, and
            # a removed one is not among them to ask.
            doc._count_at_read = len(doc)  # pyright: ignore[reportAttributeAccessIssue]

        elapsed = time.perf_counter() - t0
        logger.info("Parsed %d objects from %s in %.3fs", len(doc), self._filepath, elapsed)

        return doc

    def _detect_version(self, data: dict[str, Any]) -> tuple[int, int, int]:
        """Detect EnergyPlus version from epJSON data."""
        # Version is in the "Version" object
        version_obj = data.get("Version")

        if version_obj:
            # epJSON format: {"Version": {"Version 1": {"version_identifier": "23.2"}}}
            for _name, fields in version_obj.items():
                version_str: str = fields.get("version_identifier", "")
                if version_str:
                    return self._parse_version_string(version_str)

        raise VersionNotFoundError(str(self._filepath))

    @staticmethod
    def _parse_version_string(version_str: str) -> tuple[int, int, int]:
        """Parse version string like '23.2' or '9.2.0'."""
        parts = version_str.split(".")
        major = int(parts[0]) if len(parts) > 0 else 0
        minor = int(parts[1]) if len(parts) > 1 else 0
        patch = int(parts[2]) if len(parts) > 2 else 0
        return (major, minor, patch)

    def _parse_objects(
        self,
        data: dict[str, Any],
        doc: IDFDocument,
        schema: EpJSONSchema | None,
    ) -> None:
        """Parse all objects from epJSON data into document."""
        addidfobject = doc.addidfobject

        for obj_type, objects in data.items():
            if not isinstance(objects, dict):
                continue

            # Get schema info from parsing cache
            pc: ParsingCache | None = None
            obj_schema: dict[str, Any] | None = None
            base_field_names: tuple[str, ...] | None = None
            ref_fields: frozenset[str] | None = None
            has_name = True
            if schema:
                pc = schema.get_parsing_cache(obj_type)
                if pc is not None:
                    obj_schema = pc.obj_schema
                    has_name = pc.has_name
                    base_field_names = pc.field_names if has_name else pc.all_field_names
                    ref_fields = pc.ref_fields

            # epJSON format: {"ObjectType": {"obj_name": {fields...}, ...}}
            objects_dict = cast(dict[str, Any], objects)
            for obj_name, fields in objects_dict.items():
                if not isinstance(fields, dict):
                    continue

                # Nameless objects: use empty string instead of epJSON dict key
                name = obj_name if has_name else ""

                fields_dict = cast(dict[str, Any], fields)

                # Real EnergyPlus epJSON is canonical for extensible types, but
                # hand-authored or legacy epJSON sometimes uses flat suffixed
                # keys (vertex_x_coordinate_2, time_3, ...). Coerce them into
                # the canonical wrapper-array shape so downstream consumers see
                # uniform storage.
                if pc and pc.extensible and pc.ext_wrapper_key and pc.ext_field_names:
                    fields_dict = _bucket_flat_extensibles(fields_dict, pc)

                field_order = list(base_field_names) if base_field_names is not None else None

                obj = IDFObject(
                    obj_type=obj_type,
                    name=name,
                    data=dict(fields_dict),  # Copy the fields dict
                    schema=obj_schema,
                    field_order=field_order,
                    ref_fields=ref_fields,
                    extensibles=frozenset(pc.ext_field_names) if pc else None,
                    wrapper_key=pc.ext_wrapper_key if pc else None,
                    ext_inner_names=tuple(pc.ext_inner_props.keys()) if pc else (),
                )

                addidfobject(obj)

parse(version=None, *, strict=True, preserve_formatting=False)

Parse the epJSON file into an IDFDocument.

Parameters:

Name Type Description Default
version tuple[int, int, int] | None

Optional version override

None
strict bool

Enforce strict field access on the resulting document.

True
preserve_formatting bool

Store raw JSON text for lossless round-tripping.

False

Returns:

Type Description
IDFDocument

Parsed IDFDocument

Source code in idfkit/epjson_parser.py
def parse(
    self,
    version: tuple[int, int, int] | None = None,
    *,
    strict: bool = True,
    preserve_formatting: bool = False,
) -> IDFDocument:
    """
    Parse the epJSON file into an IDFDocument.

    Args:
        version: Optional version override
        strict: Enforce strict field access on the resulting document.
        preserve_formatting: Store raw JSON text for lossless round-tripping.

    Returns:
        Parsed IDFDocument
    """
    t0 = time.perf_counter()
    logger.debug("Parsing epJSON file %s", self._filepath)

    # Load JSON
    with open(self._filepath, encoding="utf-8") as f:
        raw_text = f.read()
    data: dict[str, Any] = json.loads(raw_text)

    # Detect version if not provided
    if version is None:
        version = self._detect_version(data)
        logger.debug("Detected version %d.%d.%d", *version)

    # Load schema if not provided
    schema = self._schema
    if schema is None:
        from .schema import get_schema

        schema = get_schema(version)

    # Create document
    doc = IDFDocument(version=version, schema=schema, filepath=self._filepath, strict=strict)  # type: ignore[reportCallIssue]  # .pyi uses covariant Strict

    # Parse objects
    self._parse_objects(data, doc, schema)

    # Store raw text for lossless round-tripping (opt-in).
    if preserve_formatting:
        doc._raw_text = raw_text  # pyright: ignore[reportAttributeAccessIssue]
        # Mark all objects as "pristine" with a sentinel source_text so
        # that the writer can detect mutations.
        for obj in doc.all_objects:
            object.__setattr__(obj, "_source_text", "")
        # How many there were, so that a removal is visible to the writer.
        # The sentinel above only marks the objects that are still here, and
        # a removed one is not among them to ask.
        doc._count_at_read = len(doc)  # pyright: ignore[reportAttributeAccessIssue]

    elapsed = time.perf_counter() - t0
    logger.info("Parsed %d objects from %s in %.3fs", len(doc), self._filepath, elapsed)

    return doc

get_epjson_version(filepath)

Quick version detection from epJSON file.

Parameters:

Name Type Description Default
filepath Path | str

Path to epJSON file

required

Returns:

Type Description
tuple[int, int, int]

Version tuple (major, minor, patch)

Raises:

Type Description
VersionNotFoundError

If version cannot be detected

Examples:

Detect the EnergyPlus version of an epJSON file:

```python
from idfkit.epjson_parser import get_epjson_version

version = get_epjson_version("SmallOffice.epJSON")
print(f"EnergyPlus v{version[0]}.{version[1]}")
```
Source code in idfkit/epjson_parser.py
def get_epjson_version(filepath: Path | str) -> tuple[int, int, int]:
    """
    Quick version detection from epJSON file.

    Args:
        filepath: Path to epJSON file

    Returns:
        Version tuple (major, minor, patch)

    Raises:
        VersionNotFoundError: If version cannot be detected

    Examples:
        Detect the EnergyPlus version of an epJSON file:

            ```python
            from idfkit.epjson_parser import get_epjson_version

            version = get_epjson_version("SmallOffice.epJSON")
            print(f"EnergyPlus v{version[0]}.{version[1]}")
            ```
    """
    filepath = Path(filepath)

    with open(filepath, encoding="utf-8") as f:
        # Parse just enough to get version
        data = json.load(f)

    version_obj = data.get("Version")
    if version_obj:
        for _name, fields in version_obj.items():
            version_str: str = fields.get("version_identifier", "")
            if version_str:
                parts = version_str.split(".")
                major = int(parts[0]) if len(parts) > 0 else 0
                minor = int(parts[1]) if len(parts) > 1 else 0
                patch = int(parts[2]) if len(parts) > 2 else 0
                return (major, minor, patch)

    raise VersionNotFoundError(str(filepath))

load_epjson(filepath)

Load raw epJSON data without parsing into document.

Useful for quick inspection or manipulation when you need the raw JSON dict rather than an IDFDocument.

Examples:

Grab the raw JSON dict for custom post-processing:

```python
from idfkit.epjson_parser import load_epjson

data = load_epjson("SmallOffice.epJSON")
zone_names = list(data.get("Zone", {}).keys())
```
Source code in idfkit/epjson_parser.py
def load_epjson(filepath: Path | str) -> dict[str, Any]:
    """
    Load raw epJSON data without parsing into document.

    Useful for quick inspection or manipulation when you need the
    raw JSON dict rather than an [IDFDocument][idfkit.document.IDFDocument].

    Examples:
        Grab the raw JSON dict for custom post-processing:

            ```python
            from idfkit.epjson_parser import load_epjson

            data = load_epjson("SmallOffice.epJSON")
            zone_names = list(data.get("Zone", {}).keys())
            ```
    """
    filepath = Path(filepath)
    with open(filepath, encoding="utf-8") as f:
        return json.load(f)

parse_epjson(filepath, schema=None, version=None, strict=True, *, preserve_formatting=False)

Parse an epJSON file into an IDFDocument.

Parameters:

Name Type Description Default
filepath Path | str

Path to the epJSON file

required
schema EpJSONSchema | None

Optional EpJSONSchema for validation

None
version tuple[int, int, int] | None

Optional version override (auto-detected if not provided)

None
strict bool

When True, accessing or setting an unknown field name on any IDFObject raises :class:~idfkit.exceptions.InvalidFieldError instead of returning None.

True
preserve_formatting bool

If True, store the raw JSON text so that :func:~idfkit.writers.write_epjson and :func:~idfkit.writers.save_epjson can reproduce it byte-for-byte when no objects have been modified.

False

Returns:

Type Description
IDFDocument

Parsed IDFDocument

Raises:

Type Description
VersionNotFoundError

If version cannot be detected

IdfKitError

If parsing fails

Examples:

Load an epJSON model and list its thermal zones:

```python
from idfkit import parse_epjson

model = parse_epjson("SmallOffice.epJSON")
for zone in model["Zone"]:
    print(zone.name)
```
Source code in idfkit/epjson_parser.py
def parse_epjson(
    filepath: Path | str,
    schema: EpJSONSchema | None = None,
    version: tuple[int, int, int] | None = None,
    strict: bool = True,
    *,
    preserve_formatting: bool = False,
) -> IDFDocument:
    """
    Parse an epJSON file into an IDFDocument.

    Args:
        filepath: Path to the epJSON file
        schema: Optional EpJSONSchema for validation
        version: Optional version override (auto-detected if not provided)
        strict: When ``True``, accessing or setting an unknown field name on any
            IDFObject raises :class:`~idfkit.exceptions.InvalidFieldError` instead
            of returning ``None``.
        preserve_formatting: If ``True``, store the raw JSON text so that
            :func:`~idfkit.writers.write_epjson` and
            :func:`~idfkit.writers.save_epjson` can reproduce it
            byte-for-byte when no objects have been modified.

    Returns:
        Parsed IDFDocument

    Raises:
        VersionNotFoundError: If version cannot be detected
        IdfKitError: If parsing fails

    Examples:
        Load an epJSON model and list its thermal zones:

            ```python
            from idfkit import parse_epjson

            model = parse_epjson("SmallOffice.epJSON")
            for zone in model["Zone"]:
                print(zone.name)
            ```
    """
    filepath = Path(filepath)

    if not filepath.exists():
        raise FileNotFoundError(f"epJSON file not found: {filepath}")  # noqa: TRY003

    parser = EpJSONParser(filepath, schema)
    return parser.parse(version, strict=strict, preserve_formatting=preserve_formatting)

Writers

Writers for IDF and epJSON formats.

Provides serialization of IDFDocument to both formats. Each format has two entry points: write_* serializes to a string and never touches the disk, and save_* serializes and writes the result to a path.

The write_idf and save_idf functions accept an output_type parameter that mirrors eppy's idf.outputtype options:

  • "standard" (default): field comments included (!- Field Name).
  • "nocomment": no field comments, one field per line.
  • "compressed": entire object on a single line (minimal whitespace).

DEFAULT_COMMENT_COLUMN = 30 module-attribute

DEFAULT_INDENT = 2 module-attribute

FieldComments = Literal['preserve', 'generate'] module-attribute

Ordering = Literal['sorted', 'source'] module-attribute

OutputType = Literal['standard', 'nocomment', 'compressed'] module-attribute

logger = logging.getLogger(__name__) module-attribute

EpJSONWriter

Writes IDFDocument to epJSON format.

The epJSON format is:

{
  "Version": {
    "Version 1": {
      "version_identifier": "23.2"
    }
  },
  "Zone": {
    "Zone 1": {
      "direction_of_relative_north": 0.0,
      ...
    }
  }
}

Source code in idfkit/writers.py
class EpJSONWriter:
    """
    Writes IDFDocument to epJSON format.

    The epJSON format is:
    ```json
    {
      "Version": {
        "Version 1": {
          "version_identifier": "23.2"
        }
      },
      "Zone": {
        "Zone 1": {
          "direction_of_relative_north": 0.0,
          ...
        }
      }
    }
    ```
    """

    def __init__(self, doc: IDFDocument):
        self._doc = doc

    def to_dict(self) -> dict[str, Any]:
        """Convert document to epJSON dict."""
        result: dict[str, Any] = {}

        # Add Version
        result["Version"] = {"Version 1": {"version_identifier": _resolve_version_identifier(self._doc)}}

        # Add objects by type
        for obj_type, collection in self._doc.collections.items():
            if obj_type.upper() == "VERSION":
                continue
            if not collection:
                continue

            result[obj_type] = {}
            # A blank Name is not an absent Name. Only a type that declares no Name field at all
            # gets the synthetic "<Type> N" key, numbered 1-based in file order. A type whose Name
            # field is optional and left blank keeps the blank verbatim, so its key is "". Keying a
            # blank Name as "<Type> N" manufactures a name into a namespace the writer does not own,
            # and any object the user genuinely named "<Type> N" is then silently overwritten.
            type_has_name = self._type_has_name(obj_type, collection.first())
            nameless_counter = 0
            for obj in collection:
                obj_data = self._object_to_dict(obj)
                if type_has_name:
                    key = obj.name
                else:
                    # Generate unique key for nameless objects (e.g. Output:Variable)
                    nameless_counter += 1
                    key = f"{obj_type} {nameless_counter}"
                result[obj_type][key] = obj_data

        return result

    def _type_has_name(self, obj_type: str, sample: IDFObject | None) -> bool:
        """Whether *obj_type* declares a Name field.

        Answered from the document schema when one is loaded, otherwise from the schema dict carried
        by the objects themselves. With no schema information at all the answer is ``True``, which
        matches what the IDF parser assumes when it has no parsing cache for a type: the first field
        is the name.
        """
        schema = self._doc.schema
        if schema is not None:
            return schema.has_name(obj_type)
        if sample is not None and sample.schema_dict is not None:
            return "name" in sample.schema_dict
        return True

    def _object_to_dict(self, obj: IDFObject) -> dict[str, Any]:
        """Convert object to epJSON dict (excluding name).

        Storage is canonical, so this is a pass-through that drops empty
        scalars and routes each value through ``_format_value`` for normal
        type handling. The wrapper-key list (if any) is emitted verbatim —
        each item is already a dict matching the schema's items.properties.
        """

        result: dict[str, Any] = {}
        for field_name, value in obj.data.items():
            if value is not None and value != "":
                result[field_name] = self._format_value(value)
        return result

    def _format_value(self, value: Any) -> Any:
        """Format a field value for epJSON output."""
        # epJSON uses native JSON types
        if isinstance(value, str):
            # Check for special values
            lower = value.lower()
            if lower == "autocalculate":
                return "Autocalculate"
            if lower == "autosize":
                return "Autosize"
            if lower == "yes":
                return "Yes"
            if lower == "no":
                return "No"
        return value

    def write_to_file(self, filepath: Path | str, indent: int = 2) -> None:
        """Write to file."""
        data = self.to_dict()
        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=indent)

to_dict()

Convert document to epJSON dict.

Source code in idfkit/writers.py
def to_dict(self) -> dict[str, Any]:
    """Convert document to epJSON dict."""
    result: dict[str, Any] = {}

    # Add Version
    result["Version"] = {"Version 1": {"version_identifier": _resolve_version_identifier(self._doc)}}

    # Add objects by type
    for obj_type, collection in self._doc.collections.items():
        if obj_type.upper() == "VERSION":
            continue
        if not collection:
            continue

        result[obj_type] = {}
        # A blank Name is not an absent Name. Only a type that declares no Name field at all
        # gets the synthetic "<Type> N" key, numbered 1-based in file order. A type whose Name
        # field is optional and left blank keeps the blank verbatim, so its key is "". Keying a
        # blank Name as "<Type> N" manufactures a name into a namespace the writer does not own,
        # and any object the user genuinely named "<Type> N" is then silently overwritten.
        type_has_name = self._type_has_name(obj_type, collection.first())
        nameless_counter = 0
        for obj in collection:
            obj_data = self._object_to_dict(obj)
            if type_has_name:
                key = obj.name
            else:
                # Generate unique key for nameless objects (e.g. Output:Variable)
                nameless_counter += 1
                key = f"{obj_type} {nameless_counter}"
            result[obj_type][key] = obj_data

    return result

write_to_file(filepath, indent=2)

Write to file.

Source code in idfkit/writers.py
def write_to_file(self, filepath: Path | str, indent: int = 2) -> None:
    """Write to file."""
    data = self.to_dict()
    with open(filepath, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=indent)

FieldAnnotation dataclass

What the author wrote around one field.

before are comment lines standing on their own above the field, in order, exactly as written. They live INSIDE the object, so unlike a comment between two objects they are not carried by the text around it and are lost the moment the object is reformatted unless they are emitted with the field below them.

trailing is the comment after the field's delimiter on the same line, or None when the author left the field bare. Bare is a thing the author wrote, and telling it apart from "this field had no author" is the whole reason this carries None rather than a label.

Source code in idfkit/writers.py
@dataclass(frozen=True, slots=True)
class FieldAnnotation:
    """What the author wrote around one field.

    ``before`` are comment lines standing on their own above the field, in order, exactly as
    written. They live INSIDE the object, so unlike a comment between two objects they are not
    carried by the text around it and are lost the moment the object is reformatted unless they are
    emitted with the field below them.

    ``trailing`` is the comment after the field's delimiter on the same line, or ``None`` when the
    author left the field bare. Bare is a thing the author wrote, and telling it apart from "this
    field had no author" is the whole reason this carries ``None`` rather than a label.
    """

    before: tuple[str, ...]
    trailing: str | None
    starts_line: bool = True
    """Whether the author began a new line with this field.

    False for the second and third coordinate of a vertex written ``0,0,4.572,`` on one line.
    Writing one value per line regardless turns a four-line surface into twelve, which is the most
    visible thing a reformat does to a geometry file: 21.5 percent of the statements in the 693
    EnergyPlus example files group values this way, and 690 of those files contain at least one.
    """

before instance-attribute

starts_line = True class-attribute instance-attribute

Whether the author began a new line with this field.

False for the second and third coordinate of a vertex written 0,0,4.572, on one line. Writing one value per line regardless turns a four-line surface into twelve, which is the most visible thing a reformat does to a geometry file: 21.5 percent of the statements in the 693 EnergyPlus example files group values this way, and 690 of those files contain at least one.

trailing instance-attribute

IDFWriter

Writes IDFDocument to IDF text format.

The IDF format is:

ObjectType,
  field1,    !- Field 1 Name
  field2,    !- Field 2 Name
  field3;    !- Field 3 Name

Supports three output_type modes mirroring eppy's idf.outputtype:

  • "standard" — full comments (default).
  • "nocomment" — no field comments, one field per line.
  • "compressed" — each object on a single line.
Source code in idfkit/writers.py
class IDFWriter:
    """
    Writes IDFDocument to IDF text format.

    The IDF format is:
    ```
    ObjectType,
      field1,    !- Field 1 Name
      field2,    !- Field 2 Name
      field3;    !- Field 3 Name
    ```

    Supports three *output_type* modes mirroring eppy's
    ``idf.outputtype``:

    - ``"standard"`` — full comments (default).
    - ``"nocomment"`` — no field comments, one field per line.
    - ``"compressed"`` — each object on a single line.
    """

    def __init__(
        self,
        doc: IDFDocument,
        output_type: OutputType = "standard",
        *,
        indent: int = DEFAULT_INDENT,
        comment_column: int = DEFAULT_COMMENT_COLUMN,
        ordering: Ordering = "sorted",
        version_first: bool = True,
        field_comments: FieldComments = "preserve",
    ):
        self._doc = doc
        self._field_comments = field_comments
        self._output_type = output_type
        self._indent = indent
        self._comment_column = comment_column
        self._ordering = ordering
        self._version_first = version_first

    def _header_lines(self) -> list[str]:
        """The generator header, or nothing in compressed output.

        The `!-Option` directive states the order the file is actually in. IDFEditor reads it, so
        writing `SortedOrder` over source-ordered objects would announce an order the file does not
        have. `sorted` is the default, so the default header does not move.
        """
        if self._output_type == "compressed":
            return []

        from . import __version__

        directive = "SortedOrder" if self._ordering == "sorted" else "OriginalOrderTop"
        return [f"!-Generator idfkit v{__version__}", f"!-Option {directive}", ""]

    def _version_lines(self) -> list[str]:
        """The Version object, written by hand rather than through the field loop."""
        version_identifier = _resolve_version_identifier(self._doc)
        pad = " " * self._indent

        if self._output_type == "compressed":
            return [f"Version,{version_identifier};"]
        if self._output_type == "standard":
            # A fixed twenty-space GAP, not a column, which is the one line in a formatted file whose
            # `!-` does not align with the rest. The distinction is not pedantic: the gap puts the
            # marker at 26 after `9.0`, 27 after `26.1` and 28 after `9.0.1`, so it drifts with the
            # length of the version string while every other line holds `comment_column`.
            #
            # Kept as a literal anyway, and deliberately. Aligning it would move the first line of
            # every file this writer has ever produced, and this path is the FORMATTING one: a
            # preserving write never reaches here, so the seam it leaves is in output nobody is
            # comparing against a source. Only the indent moves when the caller moves it.
            return ["Version,", f"{pad}{version_identifier};                    !- Version Identifier", ""]
        return ["Version,", f"{pad}{version_identifier};", ""]

    def to_string(self) -> str:
        """Convert document to IDF string."""
        lines: list[str] = self._header_lines()

        def write_version() -> None:
            lines.extend(self._version_lines())

        # Version ahead of everything else, which is what this writer has always done and what
        # `version_first` defaults to, so the default does not move (FR-017). Turning it off leaves
        # Version in whichever position the chosen ordering puts it, which is what a caller wants
        # when the output is going to be diffed against a source file that did not lead with it.
        if self._version_first:
            write_version()

        # Write objects grouped by type.
        #
        # `sorted` is this writer's default and does not move. `source` keeps the document's own
        # collection order, which is what the other language does and what a caller wants when the
        # output is going to be diffed against an input rather than against another writer.
        type_order = (
            sorted(self._doc.collections.keys()) if self._ordering == "sorted" else list(self._doc.collections.keys())
        )
        for obj_type in type_order:
            if obj_type.upper() == "VERSION":
                if not self._version_first:
                    write_version()
                continue
            collection = self._doc.collections[obj_type]
            if not collection:
                continue

            for obj in collection:
                obj_str = self.format_object(obj)
                lines.append(obj_str)
                if self._output_type != "compressed":
                    lines.append("")

        return "\n".join(lines)

    def _comment_for(self, annotation: FieldAnnotation | None, label: str) -> str:
        """The comment to write beside one field, or the empty string for none.

        The author's where there is one. Where the author left the field bare, nothing, unless the
        caller asked for a label: absence is as much a thing the author wrote as the words are.
        Where there is no author at all, because the object gained this field, the label as always.
        """
        if annotation is None:
            return f"!- {label}"
        if annotation.trailing is not None:
            return annotation.trailing
        return f"!- {label}" if self._field_comments == "generate" else ""

    def _get_field_values_and_comments(  # noqa: C901
        self, obj: IDFObject, keep_at_least: int = 0
    ) -> tuple[list[str], list[str]]:
        """Get the ordered field values and comment labels for *obj*.

        Storage is canonical: extensible groups live as a list of dicts under
        the wrapper key (e.g. ``obj.data["vertices"]``). For IDF output we
        flatten them into the legacy positional sequence with comment labels
        synthesised on the fly (``Vertex X Coordinate 2`` for the second
        ``vertex_x_coordinate`` token, etc.).
        """
        obj_type = obj.obj_type
        schema = self._doc.schema

        obj_has_name = True
        pc = None
        if schema:
            obj_has_name = schema.has_name(obj_type)
            pc = schema.get_parsing_cache(obj_type)

        if obj.field_order:
            if obj_has_name:
                field_names: list[str] = ["name", *list(obj.field_order)]
            else:
                field_names = list(obj.field_order)
        elif schema:
            field_names = schema.get_all_field_names(obj_type)
        else:
            field_names = ["name", *list(obj.data.keys())] if obj_has_name else list(obj.data.keys())

        values: list[str] = []
        comments: list[str] = []
        wrapper_key = pc.ext_wrapper_key if pc and pc.extensible else None

        for field_name in field_names:
            if field_name == "name":
                values.append(obj.name or "")
                comments.append("Name")
            elif field_name == wrapper_key:
                # Skip — handled below as positional extensible expansion.
                continue
            else:
                value = obj.data.get(field_name)
                values.append(self._format_value(value))
                comment = field_name.replace("_", " ").title()
                comments.append(comment)

        # Append extensible groups as positional tokens (legacy IDF layout).
        if wrapper_key and pc and pc.ext_field_names:
            inner_names = pc.ext_field_names
            ext_items_raw: Any = obj.data.get(wrapper_key) or []
            if isinstance(ext_items_raw, list):
                ext_items: list[dict[str, Any]] = cast("list[dict[str, Any]]", ext_items_raw)
                for group_idx, item in enumerate(ext_items, start=1):
                    suffix = "" if group_idx == 1 else f" {group_idx}"
                    for inner in inner_names:
                        values.append(self._format_value(item.get(inner)))
                        comments.append(f"{inner.replace('_', ' ').title()}{suffix}")

        # Trim trailing empty fields, but never below what the author actually wrote.
        #
        # A field written out as a blank is as much a thing the author wrote as a field left bare of
        # its comment, and dropping it takes the author's own ``!- Cooling Design Capacity Method``
        # with it. A single-field edit shortened one Sizing:System from 38 lines to 22; across the
        # 693 example files it is 20,571 lines, more than any other difference a rewrite makes.
        #
        # *keep_at_least* is non-zero only on the preserving path. A write with no author behind it
        # trims as it always has, because there is nothing there to be faithful to.
        #
        # Counted in VALUES, name included, because an annotation is produced for the name too. The
        # TypeScript core states the same rule in a different index space, as an index into the
        # fixed fields with the name subtracted out. Both are right against their own annotations
        # and neither would notice if the other's convention moved, so a change to either belongs
        # in both.
        while len(values) > max(1, keep_at_least) and values[-1] == "":
            values.pop()
            comments.pop()

        return values, comments

    def format_object(self, obj: IDFObject, source_text: str | None = None) -> str:
        """Convert a single object to IDF string.

        *source_text* is the object as it was read, supplied by the preserving writer for an object
        that has been changed. Its per-field comments are reused in place of generated ones: an edit
        asks for the VALUES to be re-rendered and never for the comments, and rebuilding them
        destroys whatever the schema cannot regenerate. That is the field's unit, which the
        generated label does not carry, and any note the author wrote there.
        """
        annotations = _annotations(source_text) if source_text is not None else []
        values, comments = self._get_field_values_and_comments(obj, keep_at_least=len(annotations))
        obj_type = obj.obj_type

        if self._output_type == "compressed":
            parts = ",".join(values)
            return f"{obj_type},{parts};"

        lines: list[str] = []
        pad = " " * self._indent

        # A line under construction, so fields the author wrote together stay together. Flushed
        # when the next field opens a line of its own, and once at the end.
        #
        # It starts as the TYPE NAME rather than the type name going straight out, so that an object
        # the author wrote entirely on one line, ``Timestep,4;``, comes back on one line. That is
        # 11.3 percent of the statements in the example files, and it is the case that surprises on
        # a file with no geometry in it.
        open_line = f"{obj_type},"
        open_comment = ""

        def flush() -> None:
            nonlocal open_line, open_comment
            if not open_line:
                return
            if open_comment:
                # Minus one because the option is a COLUMN, counted from 1, while ljust takes a
                # WIDTH. Passing the column as the width put every comment one place right of where
                # the files this imitates put it. The max keeps a space after a long line.
                width = max(self._comment_column - 1, len(open_line) + 1)
                lines.append(open_line.ljust(width) + open_comment)
            else:
                lines.append(open_line)
            open_line = ""
            open_comment = ""

        if self._output_type != "standard":
            # nocomment: one value per line, as this writer has always emitted it, and none of the
            # annotations apply. Hoisted out of the field loop rather than tested per field, which
            # also retires the subtlety that used to carry it: the type name is the line still open
            # when the loop starts, so this path depended on the first `flush()` emitting it and on
            # every later one being a no-op. That dependence cost a regression once already.
            last = len(values) - 1
            body = (f"{pad}{v}{';' if i == last else ','}" for i, v in enumerate(values))
            return "\n".join([open_line, *body])

        for i, (value, comment) in enumerate(zip(values, comments, strict=False)):
            is_last = i == len(values) - 1
            terminator = ";" if is_last else ","
            annotation = annotations[i] if i < len(annotations) else None

            # No annotation means no author to be faithful to, so one value per line. Field 0 is
            # the one that decides whether the object opens on the type name's own line.
            opens_line = annotation is None or annotation.starts_line
            if opens_line:
                flush()
                # The author's own lines above the field, which live inside the object and are
                # carried by nothing else.
                if annotation is not None:
                    lines.extend(f"{pad}{line}" for line in annotation.before)
                open_line = f"{pad}{value}{terminator}"
            else:
                open_line = f"{open_line} {value}{terminator}"

            # On a line carrying several values only the last of them has a comment, which is what
            # the author wrote and what the delimiter rule recovers.
            chosen = self._comment_for(annotation, comment)
            if chosen:
                open_comment = chosen

        flush()

        return "\n".join(lines)

    def _format_value(self, value: Any) -> str:
        """Format a field value for IDF output."""
        if value is None:
            return ""
        if isinstance(value, bool):
            return "Yes" if value else "No"
        if isinstance(value, float):
            if value == 0.0:
                return "0"
            abs_val = abs(value)
            if abs_val >= 1e10 or abs_val < 0.0001:
                return f"{value:.6e}"
            return f"{value:g}"
        if isinstance(value, list):
            # Handle vertex lists etc.
            items = cast(list[Any], value)
            return ", ".join(str(v) for v in items)
        text = str(value)
        if any(ch in text for ch in (",", ";", "!")):
            logger.warning(
                "Field value %r contains IDF delimiter characters (comma, semicolon, or exclamation mark) "
                "that will produce invalid IDF output",
                text,
            )
        return text

    def write_to_file(self, filepath: Path | str, encoding: str = "latin-1") -> None:
        """Write to file."""
        content = self.to_string()
        with open(filepath, "w", encoding=encoding) as f:
            f.write(content)

format_object(obj, source_text=None)

Convert a single object to IDF string.

source_text is the object as it was read, supplied by the preserving writer for an object that has been changed. Its per-field comments are reused in place of generated ones: an edit asks for the VALUES to be re-rendered and never for the comments, and rebuilding them destroys whatever the schema cannot regenerate. That is the field's unit, which the generated label does not carry, and any note the author wrote there.

Source code in idfkit/writers.py
def format_object(self, obj: IDFObject, source_text: str | None = None) -> str:
    """Convert a single object to IDF string.

    *source_text* is the object as it was read, supplied by the preserving writer for an object
    that has been changed. Its per-field comments are reused in place of generated ones: an edit
    asks for the VALUES to be re-rendered and never for the comments, and rebuilding them
    destroys whatever the schema cannot regenerate. That is the field's unit, which the
    generated label does not carry, and any note the author wrote there.
    """
    annotations = _annotations(source_text) if source_text is not None else []
    values, comments = self._get_field_values_and_comments(obj, keep_at_least=len(annotations))
    obj_type = obj.obj_type

    if self._output_type == "compressed":
        parts = ",".join(values)
        return f"{obj_type},{parts};"

    lines: list[str] = []
    pad = " " * self._indent

    # A line under construction, so fields the author wrote together stay together. Flushed
    # when the next field opens a line of its own, and once at the end.
    #
    # It starts as the TYPE NAME rather than the type name going straight out, so that an object
    # the author wrote entirely on one line, ``Timestep,4;``, comes back on one line. That is
    # 11.3 percent of the statements in the example files, and it is the case that surprises on
    # a file with no geometry in it.
    open_line = f"{obj_type},"
    open_comment = ""

    def flush() -> None:
        nonlocal open_line, open_comment
        if not open_line:
            return
        if open_comment:
            # Minus one because the option is a COLUMN, counted from 1, while ljust takes a
            # WIDTH. Passing the column as the width put every comment one place right of where
            # the files this imitates put it. The max keeps a space after a long line.
            width = max(self._comment_column - 1, len(open_line) + 1)
            lines.append(open_line.ljust(width) + open_comment)
        else:
            lines.append(open_line)
        open_line = ""
        open_comment = ""

    if self._output_type != "standard":
        # nocomment: one value per line, as this writer has always emitted it, and none of the
        # annotations apply. Hoisted out of the field loop rather than tested per field, which
        # also retires the subtlety that used to carry it: the type name is the line still open
        # when the loop starts, so this path depended on the first `flush()` emitting it and on
        # every later one being a no-op. That dependence cost a regression once already.
        last = len(values) - 1
        body = (f"{pad}{v}{';' if i == last else ','}" for i, v in enumerate(values))
        return "\n".join([open_line, *body])

    for i, (value, comment) in enumerate(zip(values, comments, strict=False)):
        is_last = i == len(values) - 1
        terminator = ";" if is_last else ","
        annotation = annotations[i] if i < len(annotations) else None

        # No annotation means no author to be faithful to, so one value per line. Field 0 is
        # the one that decides whether the object opens on the type name's own line.
        opens_line = annotation is None or annotation.starts_line
        if opens_line:
            flush()
            # The author's own lines above the field, which live inside the object and are
            # carried by nothing else.
            if annotation is not None:
                lines.extend(f"{pad}{line}" for line in annotation.before)
            open_line = f"{pad}{value}{terminator}"
        else:
            open_line = f"{open_line} {value}{terminator}"

        # On a line carrying several values only the last of them has a comment, which is what
        # the author wrote and what the delimiter rule recovers.
        chosen = self._comment_for(annotation, comment)
        if chosen:
            open_comment = chosen

    flush()

    return "\n".join(lines)

to_string()

Convert document to IDF string.

Source code in idfkit/writers.py
def to_string(self) -> str:
    """Convert document to IDF string."""
    lines: list[str] = self._header_lines()

    def write_version() -> None:
        lines.extend(self._version_lines())

    # Version ahead of everything else, which is what this writer has always done and what
    # `version_first` defaults to, so the default does not move (FR-017). Turning it off leaves
    # Version in whichever position the chosen ordering puts it, which is what a caller wants
    # when the output is going to be diffed against a source file that did not lead with it.
    if self._version_first:
        write_version()

    # Write objects grouped by type.
    #
    # `sorted` is this writer's default and does not move. `source` keeps the document's own
    # collection order, which is what the other language does and what a caller wants when the
    # output is going to be diffed against an input rather than against another writer.
    type_order = (
        sorted(self._doc.collections.keys()) if self._ordering == "sorted" else list(self._doc.collections.keys())
    )
    for obj_type in type_order:
        if obj_type.upper() == "VERSION":
            if not self._version_first:
                write_version()
            continue
        collection = self._doc.collections[obj_type]
        if not collection:
            continue

        for obj in collection:
            obj_str = self.format_object(obj)
            lines.append(obj_str)
            if self._output_type != "compressed":
                lines.append("")

    return "\n".join(lines)

write_to_file(filepath, encoding='latin-1')

Write to file.

Source code in idfkit/writers.py
def write_to_file(self, filepath: Path | str, encoding: str = "latin-1") -> None:
    """Write to file."""
    content = self.to_string()
    with open(filepath, "w", encoding=encoding) as f:
        f.write(content)

convert_epjson_to_idf(epjson_path, idf_path=None)

Convert an epJSON file to IDF format.

Parameters:

Name Type Description Default
epjson_path Path | str

Input epJSON file path

required
idf_path Path | str | None

Output IDF path (default: same name with .idf extension)

None

Returns:

Type Description
Path

Path to the output file

Examples:

Convert an epJSON model back to classic IDF format:

```python
output = convert_epjson_to_idf("5ZoneAirCooled.epJSON")
# Creates 5ZoneAirCooled.idf

convert_epjson_to_idf("modern_model.epJSON", "classic_model.idf")
```
Source code in idfkit/writers.py
def convert_epjson_to_idf(
    epjson_path: Path | str,
    idf_path: Path | str | None = None,
) -> Path:
    """
    Convert an epJSON file to IDF format.

    Args:
        epjson_path: Input epJSON file path
        idf_path: Output IDF path (default: same name with .idf extension)

    Returns:
        Path to the output file

    Examples:
        Convert an epJSON model back to classic IDF format:

            ```python
            output = convert_epjson_to_idf("5ZoneAirCooled.epJSON")
            # Creates 5ZoneAirCooled.idf

            convert_epjson_to_idf("modern_model.epJSON", "classic_model.idf")
            ```
    """
    from .epjson_parser import parse_epjson

    epjson_path = Path(epjson_path)

    idf_path = epjson_path.with_suffix(".idf") if idf_path is None else Path(idf_path)

    doc = parse_epjson(epjson_path)
    save_idf(doc, idf_path)

    return idf_path

convert_idf_to_epjson(idf_path, epjson_path=None)

Convert an IDF file to epJSON format.

Parameters:

Name Type Description Default
idf_path Path | str

Input IDF file path

required
epjson_path Path | str | None

Output epJSON path (default: same name with .epJSON extension)

None

Returns:

Type Description
Path

Path to the output file

Examples:

Convert an IDF model to native JSON format:

```python
output = convert_idf_to_epjson("5ZoneAirCooled.idf")
# Creates 5ZoneAirCooled.epJSON

convert_idf_to_epjson("legacy_model.idf", "modern_model.epJSON")
```
Source code in idfkit/writers.py
def convert_idf_to_epjson(
    idf_path: Path | str,
    epjson_path: Path | str | None = None,
) -> Path:
    """
    Convert an IDF file to epJSON format.

    Args:
        idf_path: Input IDF file path
        epjson_path: Output epJSON path (default: same name with .epJSON extension)

    Returns:
        Path to the output file

    Examples:
        Convert an IDF model to native JSON format:

            ```python
            output = convert_idf_to_epjson("5ZoneAirCooled.idf")
            # Creates 5ZoneAirCooled.epJSON

            convert_idf_to_epjson("legacy_model.idf", "modern_model.epJSON")
            ```
    """
    from .idf_parser import parse_idf

    idf_path = Path(idf_path)

    epjson_path = idf_path.with_suffix(".epJSON") if epjson_path is None else Path(epjson_path)

    doc = parse_idf(idf_path)
    save_epjson(doc, epjson_path)

    return epjson_path

render_cst_node(doc, node, *, field_comments='preserve')

One anchored node's object, rendered the way a preserving write renders it.

The node's own text goes with it: the formatter reuses that object's field comments rather than generating new ones, which is the whole reason a rewritten object keeps the author's units and notes.

Shared with :meth:IDFDocument.render_object so a consumer splicing one object into the range it came from gets the bytes a whole write would have put there. Two constructions of the formatter would be two answers to that question, and field_comments already had to be threaded through this path by hand once.

No trailing separator: what ran between this object and the next is the caller's to keep.

Source code in idfkit/writers.py
def render_cst_node(
    doc: IDFDocument[bool],
    node: CSTNode,
    *,
    field_comments: FieldComments = "preserve",
) -> str:
    """One anchored node's object, rendered the way a preserving write renders it.

    The node's own text goes with it: the formatter reuses that object's field comments rather than
    generating new ones, which is the whole reason a rewritten object keeps the author's units and
    notes.

    Shared with :meth:`IDFDocument.render_object` so a consumer splicing one object into the range
    it came from gets the bytes a whole write would have put there. Two constructions of the
    formatter would be two answers to that question, and `field_comments` already had to be threaded
    through this path by hand once.

    No trailing separator: what ran between this object and the next is the caller's to keep.
    """
    formatter = IDFWriter(doc, output_type="standard", field_comments=field_comments)
    return formatter.format_object(node.obj, node.text) if node.obj is not None else node.text

save_epjson(doc, path, indent=2, *, preserve_formatting=None)

Serialize a document to epJSON text and write it to path.

The disk-writing counterpart of write_epjson.

Parameters:

Name Type Description Default
doc IDFDocument[bool]

The document to save.

required
path Path | str

Output path.

required
indent int

JSON indentation.

2
preserve_formatting bool | None

Lossless output control, as for write_epjson.

None

Examples:

Write to disk:

```python
save_epjson(model, "in.epJSON")
```
Source code in idfkit/writers.py
def save_epjson(
    doc: IDFDocument[bool],
    path: Path | str,
    indent: int = 2,
    *,
    preserve_formatting: bool | None = None,
) -> None:
    """
    Serialize a document to epJSON text and write it to *path*.

    The disk-writing counterpart of
    [write_epjson][idfkit.writers.write_epjson].

    Args:
        doc: The document to save.
        path: Output path.
        indent: JSON indentation.
        preserve_formatting: Lossless output control, as for
            [write_epjson][idfkit.writers.write_epjson].

    Examples:
        Write to disk:

            ```python
            save_epjson(model, "in.epJSON")
            ```
    """
    content = write_epjson(doc, indent, preserve_formatting=preserve_formatting)
    path = Path(path)
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    logger.info("Wrote epJSON (%d objects) to %s", len(doc), path)

save_idf(doc, path, encoding='latin-1', output_type='standard', *, preserve_formatting=None, indent=DEFAULT_INDENT, comment_column=DEFAULT_COMMENT_COLUMN, ordering='sorted', version_first=True)

Serialize a document to IDF text and write it to path.

The disk-writing counterpart of write_idf.

Parameters:

Name Type Description Default
doc IDFDocument[bool]

The document to save.

required
path Path | str

Output path.

required
encoding str

Output encoding.

'latin-1'
output_type OutputType

Output formatting mode, as for write_idf.

'standard'
preserve_formatting bool | None

Lossless output control, as for write_idf.

None
indent int

Field-line indent, as for write_idf.

DEFAULT_INDENT
comment_column int

Comment column, as for write_idf.

DEFAULT_COMMENT_COLUMN
ordering Ordering

Object ordering, as for write_idf.

'sorted'

Examples:

Write to disk for EnergyPlus simulation:

```python
save_idf(model, "in.idf")
```

Lossless round-trip:

```python
from idfkit import load_idf, save_idf
model = load_idf("building.idf", preserve_formatting=True)
save_idf(model, "building_copy.idf")  # byte-identical
```
Source code in idfkit/writers.py
def save_idf(
    doc: IDFDocument[bool],
    path: Path | str,
    encoding: str = "latin-1",
    output_type: OutputType = "standard",
    *,
    preserve_formatting: bool | None = None,
    indent: int = DEFAULT_INDENT,
    comment_column: int = DEFAULT_COMMENT_COLUMN,
    ordering: Ordering = "sorted",
    version_first: bool = True,
) -> None:
    """
    Serialize a document to IDF text and write it to *path*.

    The disk-writing counterpart of [write_idf][idfkit.writers.write_idf].

    Args:
        doc: The document to save.
        path: Output path.
        encoding: Output encoding.
        output_type: Output formatting mode, as for
            [write_idf][idfkit.writers.write_idf].
        preserve_formatting: Lossless output control, as for
            [write_idf][idfkit.writers.write_idf].
        indent: Field-line indent, as for
            [write_idf][idfkit.writers.write_idf].
        comment_column: Comment column, as for
            [write_idf][idfkit.writers.write_idf].
        ordering: Object ordering, as for
            [write_idf][idfkit.writers.write_idf].

    Examples:
        Write to disk for EnergyPlus simulation:

            ```python
            save_idf(model, "in.idf")
            ```

        Lossless round-trip:

            ```python
            from idfkit import load_idf, save_idf
            model = load_idf("building.idf", preserve_formatting=True)
            save_idf(model, "building_copy.idf")  # byte-identical
            ```
    """
    content = write_idf(
        doc,
        output_type,
        preserve_formatting=preserve_formatting,
        indent=indent,
        comment_column=comment_column,
        ordering=ordering,
        version_first=version_first,
    )
    path = Path(path)
    with open(path, "w", encoding=encoding) as f:
        f.write(content)
    logger.info("Wrote IDF (%d objects) to %s", len(doc), path)

write_epjson(doc, indent=2, *, preserve_formatting=None)

Serialize a document to epJSON text.

Returns the text; it never touches the disk. Use save_epjson to put the text on disk.

Parameters:

Name Type Description Default
doc IDFDocument[bool]

The document to serialize.

required
indent int

JSON indentation.

2
preserve_formatting bool | None

If True, return the original JSON text verbatim when no objects have been modified. When None (the default), automatically uses lossless output if raw text is available and no objects were mutated.

None

Returns:

Type Description
str

The epJSON text.

Examples:

Serialize the model to epJSON for use with EnergyPlus v9.3+:

>>> from idfkit import new_document, write_epjson
>>> model = new_document()
>>> model.add("Zone", "Perimeter_ZN_1")
Zone('Perimeter_ZN_1')
>>> json_str = write_epjson(model)
>>> '"Zone"' in json_str
True
Source code in idfkit/writers.py
def write_epjson(
    doc: IDFDocument[bool],
    indent: int = 2,
    *,
    preserve_formatting: bool | None = None,
) -> str:
    """
    Serialize a document to epJSON text.

    Returns the text; it never touches the disk. Use
    [save_epjson][idfkit.writers.save_epjson] to put the text on disk.

    Args:
        doc: The document to serialize.
        indent: JSON indentation.
        preserve_formatting: If ``True``, return the original JSON text
            verbatim when no objects have been modified. When ``None``
            (the default), automatically uses lossless output if raw text
            is available and no objects were mutated.

    Returns:
        The epJSON text.

    Examples:
        Serialize the model to epJSON for use with EnergyPlus v9.3+:

        >>> from idfkit import new_document, write_epjson
        >>> model = new_document()
        >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
        Zone('Perimeter_ZN_1')
        >>> json_str = write_epjson(model)
        >>> '"Zone"' in json_str
        True
    """
    # Note: epJSON preservation is all-or-nothing (any mutation or addition
    # falls back to the standard JSON writer), unlike IDF which has per-object
    # granularity via CST nodes.
    use_preserve = preserve_formatting if preserve_formatting is not None else doc.raw_text is not None

    # If preserve_formatting and we have raw text, check whether the document is
    # still the one that text describes: nothing touched, nothing added, nothing
    # removed.
    #
    # The count is the half that asking the survivors cannot supply. Every object
    # left after a removal is still pristine, so `all_clean` alone reproduced the
    # original text with the removed object still in it: a file that loads and
    # misrepresents the model. An addition is caught by the same comparison from
    # the other side, since a new object carries no sentinel and fails
    # `all_clean` anyway.
    if use_preserve and doc.raw_text is not None:
        # `# pyright: ignore` for the same reason `epjson_parser` needs one when it sets this:
        # the generated stub answers every name it does not declare with a collection, and it
        # declares no internal attribute, so reading one types as `IDFCollection` here.
        count_at_read = cast("int | None", doc._count_at_read)
        unchanged = count_at_read == len(doc) and all(obj.source_text is not None for obj in doc.all_objects)
        if unchanged:
            logger.debug("Serialized epJSON (%d objects, lossless) to string", len(doc))
            return doc.raw_text

    # Fall back to standard writer.
    writer = EpJSONWriter(doc)
    data = writer.to_dict()

    logger.debug("Serialized epJSON (%d objects) to string", len(doc))
    return json.dumps(data, indent=indent)

write_idf(doc, output_type='standard', *, preserve_formatting=None, indent=DEFAULT_INDENT, comment_column=DEFAULT_COMMENT_COLUMN, ordering='sorted', version_first=True, field_comments='preserve')

Serialize a document to IDF text.

Returns the text; it never touches the disk. Use save_idf to put the text on disk.

Parameters:

Name Type Description Default
doc IDFDocument[bool]

The document to serialize.

required
output_type OutputType

Output formatting mode, "standard" (with comments), "nocomment" (no comments), or "compressed" (single-line objects). Mirrors eppy's idf.outputtype. An output type other than "standard" takes precedence over preserve_formatting, including over an explicit True: a different output form is a different artifact, which the original text was never going to express.

'standard'
preserve_formatting bool | None

If True, reproduce the original source text for unmodified objects and apply standard formatting only to objects that were mutated or added after parsing. Requires the document to have been parsed with preserve_formatting=True. When None (the default), automatically uses lossless output if a CST is available and output_type is "standard".

Cheapest where it is used: a write that reproduces most of the file copies text rather than building it. It CROSSES OVER once most objects have changed, because this path renders each of them AND walks the tiling, which is more work than formatting alone. On a 13 MB model with every object edited it is roughly four times slower than a formatting write. Nothing to guard against, since an edit touches a handful of objects and a whole model rewrite is what preserve_formatting=False is for, but worth knowing before timing the wrong one.

None
indent int

Spaces before each field line. Defaults to the two this writer has always used.

DEFAULT_INDENT
comment_column int

Column the !- field comments are aligned to, counting from 1 as an editor does. The default puts the marker where EnergyPlus itself puts it, which is what keeps a rewritten object flush with the untouched objects around it. Defaults to 30. A value longer than the column pushes its comment right rather than being truncated.

DEFAULT_COMMENT_COLUMN
ordering Ordering

"sorted" to write object types in alphabetical order, which is the default and what the !-Option SortedOrder header declares, or "source" to keep the document's own collection order, which writes !-Option OriginalOrderTop instead. An enumeration rather than a boolean because three orderings exist across the two libraries and two formats.

'sorted'

Returns:

Type Description
str

The IDF text.

Raises:

Type Description
ValueError

If preserve_formatting is explicitly True on a document that carries a CST while indent, comment_column, ordering or version_first is away from its default. Reproducing the original text and reformatting it are contradictory requests.

Examples:

Serialize the model to an IDF string for inspection:

>>> from idfkit import new_document, write_idf
>>> model = new_document()
>>> model.add("Zone", "Perimeter_ZN_1")
Zone('Perimeter_ZN_1')
>>> idf_str = write_idf(model)
>>> "Zone," in idf_str
True

Use compressed format for batch parametric runs:

>>> compressed = write_idf(model, output_type="compressed")
>>> "\n" not in compressed.split("Zone")[1].split(";")[0]
True
Source code in idfkit/writers.py
def write_idf(
    doc: IDFDocument[bool],
    output_type: OutputType = "standard",
    *,
    preserve_formatting: bool | None = None,
    indent: int = DEFAULT_INDENT,
    comment_column: int = DEFAULT_COMMENT_COLUMN,
    ordering: Ordering = "sorted",
    version_first: bool = True,
    field_comments: FieldComments = "preserve",
) -> str:
    """
    Serialize a document to IDF text.

    Returns the text; it never touches the disk. Use
    [save_idf][idfkit.writers.save_idf] to put the text on disk.

    Args:
        doc: The document to serialize.
        output_type: Output formatting mode, ``"standard"`` (with
            comments), ``"nocomment"`` (no comments), or
            ``"compressed"`` (single-line objects). Mirrors eppy's
            ``idf.outputtype``. An output type other than ``"standard"``
            takes precedence over *preserve_formatting*, including over an
            explicit ``True``: a different output form is a different
            artifact, which the original text was never going to express.
        preserve_formatting: If ``True``, reproduce the original source
            text for unmodified objects and apply standard formatting only
            to objects that were mutated or added after parsing. Requires
            the document to have been parsed with
            ``preserve_formatting=True``. When ``None`` (the default),
            automatically uses lossless output if a CST is available and
            *output_type* is ``"standard"``.

            Cheapest where it is used: a write that reproduces most of the
            file copies text rather than building it. It CROSSES OVER once
            most objects have changed, because this path renders each of
            them AND walks the tiling, which is more work than formatting
            alone. On a 13 MB model with every object edited it is roughly
            four times slower than a formatting write. Nothing to guard
            against, since an edit touches a handful of objects and a whole
            model rewrite is what ``preserve_formatting=False`` is for, but
            worth knowing before timing the wrong one.
        indent: Spaces before each field line. Defaults to the two this
            writer has always used.
        comment_column: Column the ``!-`` field comments are aligned to, counting
            from 1 as an editor does. The default puts the marker where EnergyPlus
            itself puts it, which is what keeps a rewritten object flush with the
            untouched objects around it.
            Defaults to 30. A value longer than the column pushes its
            comment right rather than being truncated.
        ordering: ``"sorted"`` to write object types in alphabetical order,
            which is the default and what the ``!-Option SortedOrder``
            header declares, or ``"source"`` to keep the document's own
            collection order, which writes ``!-Option OriginalOrderTop``
            instead. An enumeration rather than a boolean because three
            orderings exist across the two libraries and two formats.

    Returns:
        The IDF text.

    Raises:
        ValueError: If *preserve_formatting* is explicitly ``True`` on a
            document that carries a CST while *indent*, *comment_column*,
            *ordering* or *version_first* is away from its default.
            Reproducing the original text and reformatting it are
            contradictory requests.

    Examples:
        Serialize the model to an IDF string for inspection:

        >>> from idfkit import new_document, write_idf
        >>> model = new_document()
        >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
        Zone('Perimeter_ZN_1')
        >>> idf_str = write_idf(model)
        >>> "Zone," in idf_str
        True

        Use compressed format for batch parametric runs:

        >>> compressed = write_idf(model, output_type="compressed")
        >>> "\\n" not in compressed.split("Zone")[1].split(";")[0]
        True
    """
    # An explicitly requested output FORM wins over preservation, including over an explicit
    # `preserve_formatting=True`. A form is a different artifact, which the original text was never
    # going to express, so producing it is honest; granting preservation instead drops the request
    # in silence, which is what this did until the other language's decision table was written down
    # beside it and the two were compared.
    if output_type != "standard":
        use_preserve = False
    elif preserve_formatting is not None:
        use_preserve = preserve_formatting
    else:
        use_preserve = doc.cst is not None

    # A control that is not at its default asks for output the original text does not have, so the
    # lossless path cannot honour it. Reproducing the file byte for byte and indenting to three
    # spaces are contradictory requests, and silently doing the first would be the wrong answer.
    #
    # `version_first` belongs in this set and was missing from it, so asking to move the version
    # statement while preserving was granted silently: the file came back with the statement where
    # the author left it and no word about the request. It is a reformatting control like the other
    # three, because moving a statement is a change to how the text is laid out.
    #
    # Away from its default is a REQUEST TO FORMAT on the default path too, which is what the
    # `not formatting_requested` clause below says: a caller who names a control and does not name
    # preservation gets the control, not a silent drop of it.
    #
    # The conflict is only real when the lossless path would actually be taken. `preserve_formatting=True`
    # on a document that carries no CST has always fallen back to this writer, and asking for a control at
    # the same time must not turn that quiet fallback into an error.
    formatting_requested = (
        indent != DEFAULT_INDENT
        or comment_column != DEFAULT_COMMENT_COLUMN
        or ordering != "sorted"
        or version_first is not True
    )
    if formatting_requested and use_preserve and preserve_formatting and doc.cst is not None:
        msg = (
            "preserve_formatting reproduces the original text, so it cannot also apply indent, "
            "comment_column, ordering or version_first. Pass one or the other."
        )
        raise ValueError(msg)

    if use_preserve and doc.cst is not None and not formatting_requested:
        content = _write_idf_lossless(doc, field_comments)
    else:
        writer = IDFWriter(
            doc,
            output_type=output_type,
            indent=indent,
            comment_column=comment_column,
            ordering=ordering,
            version_first=version_first,
        )
        content = writer.to_string()

    logger.debug("Serialized IDF (%d objects) to string", len(doc))
    return content