Skip to content

Instantly share code, notes, and snippets.

@tobiashochguertel
Last active July 19, 2026 08:24
Show Gist options
  • Select an option

  • Save tobiashochguertel/c2b6a72528a5e1652d924e2dcb2b0b4d to your computer and use it in GitHub Desktop.

Select an option

Save tobiashochguertel/c2b6a72528a5e1652d924e2dcb2b0b4d to your computer and use it in GitHub Desktop.
json-five — Python JSON5 Library Guide (comment-preserving round-trip, model API, custom loaders/dumpers, recipes)
# json-five
Python JSON5 Library Guide — Comment-Preserving Round-Trip, Model API, Custom Loaders/Dumpers, and Recipes
A comprehensive guide to the json-five Python library (pip install json-five, import as json5) covering the json-compatible API, the model API for round-trip comment preservation, custom loaders and dumpers, tokenization, common recipes, and gotchas. Includes three exploration scripts that create, read, update, and delete actual JSON/JSON5/JSONC files with comment preservation.
Source: https://gist.github.com/tobiashochguertel/c2b6a72528a5e1652d924e2dcb2b0b4d
__pycache__/
*.pyc

json-five — Python JSON5 Guide

A practical guide to json-five (pip install json-five, import as json5) — the Python library that parses, round-trips, and emits JSON5.

JSON5 extends JSON with comments, unquoted keys, single-quoted strings, trailing commas, hex numbers, Infinity, NaN, and multi-line strings. The json5 package mirrors the stdlib json API so most code is a drop-in replacement, plus a model API that preserves comments and whitespace on round-trip.


Table of contents


Why JSON5?

Feature JSON JSON5
Comments No // line and /* block */
Unquoted keys No foo: "bar" (identifier rules)
Single-quoted strings No 'bar' equivalent to "bar"
Trailing commas No {a: 1, b: 2,} is valid
Hex integers No 0xC0FFEE
Infinity / NaN No Infinity, NaN
Multi-line strings No Backslash-continued lines

JSON5 is useful for configuration files where humans need comments and flexibility, but you still want machine-parseable structure. The json5 Python package lets you read those files and write them back without losing comments — the killer feature that stdlib json cannot do.


Installation

pip install json-five      # install name
import json5               # import name — note the mismatch!

Requires Python 3.8+. The PyPI package is json-five; the import name is json5.


Quick start

import json5

# Parse a JSON5 string
text = """{
  // app configuration
  host: 'localhost',
  port: 8080,
  debug: true,  // trailing comma is fine
}"""

config = json5.loads(text)
print(config)
# {'host': 'localhost', 'port': 8080, 'debug': True}

# Dump back to a JSON5 string
print(json5.dumps(config))
# {host: "localhost", port: 8080, debug: true}

JSON5 syntax at a glance

{
  // Line comment
  /* Block comment
     spanning multiple lines */

  unquoted_key: "value",           // identifiers don't need quotes
  'single-quoted': "value",        // single-quoted keys are fine too
  "double-quoted": 'single value', // single-quoted strings work

  hex: 0xC0FFEE,                   // hexadecimal integers
  negative: -0xFF,
  leading_decimal: .5,             // leading zero optional
  trailing_decimal: 5.,            // trailing zero optional

  infinity: Infinity,
  negative_inf: -Infinity,
  nan: NaN,

  trailing_comma: true,            // trailing commas allowed everywhere

  multi_line: "line 1 \
line 2",                            // backslash line continuation
}

Basic API — the json-compatible interface

The load/loads/dump/dumps functions mirror stdlib json with the same keyword arguments:

import json5

# load / dump — file-based
with open('config.json5') as f:
    data = json5.load(f)

with open('out.json5', 'w') as f:
    json5.dump(data, f, indent=2)

# loads / dumps — string-based
data = json5.loads('{foo: "bar"}')        # {'foo': 'bar'}
text = json5.dumps(data)                  # {foo: "bar"}

Supported json-compatible keyword arguments

Argument load/loads dump/dumps Meaning
parse_int Yes Callable for integer parsing
parse_float Yes Callable for float parsing
parse_constant Yes Callable for Infinity/NaN/-Infinity
object_hook Yes Called for each object
object_pairs_hook Yes Called with list of pairs
indent Yes Indentation (int or string)
quote_keys Yes Force double-quoted keys (default False)
trailing_comma Yes Emit trailing commas (default False)

Note: The cls keyword from stdlib json is not supported. For custom serialization, use custom dumpers.

The parse_json5_identifiers hook

By default, unquoted JSON5 identifiers in object keys become JsonIdentifier objects (a str subclass). This preserves round-trip fidelity — dumps(loads(text)) keeps identifiers unquoted:

>>> text = '{bacon: "eggs"}'
>>> json5.dumps(json5.loads(text))
'{bacon: "eggs"}'           # key stays unquoted

>>> json5.dumps(json5.loads(text, parse_json5_identifiers=str))
'{"bacon": "eggs"}'         # key now double-quoted

Use parse_json5_identifiers=str when you want all keys as plain strings (e.g., for comparison with json-parsed data).


Round-trip with comments — the model API

The default loads/dumps pair loses comments because they operate on plain Python objects. To preserve comments, whitespace, and quoting style, use ModelLoader and ModelDumper:

from json5.loader import loads, ModelLoader
from json5.dumper import dumps, ModelDumper

json5_text = """{
  // database settings
  host: 'localhost',
  port: 5432,  // default Postgres port
  /* SSL only in production */
  ssl: false,
}"""

# Load into a model (not plain Python objects)
model = loads(json5_text, loader=ModelLoader())

# Dump the model back — comments and formatting are preserved
roundtripped = dumps(model, dumper=ModelDumper())
print(roundtripped)
# {
#   // database settings
#   host: 'localhost',
#   port: 5432,  // default Postgres port
#   /* SSL only in production */
#   ssl: false,
# }

How it works

The model is a tree of node objects representing the JSON5 grammar:

>>> model
JSONText(
    value=JSONObject(
        key_value_pairs=[
            KeyValuePair(
                key=Identifier(name='host'),
                value=SingleQuotedString(characters='localhost', raw_value="'localhost'"),
                ...
            ),
            ...
        ],
        trailing_comma=None,
    )
)

Each node has .wsc_before and .wsc_after attributes (whitespace and comments) that are preserved through round-trip.


Editing a model in place

You can modify the model and dump it back — comments on untouched nodes are preserved:

from json5.loader import loads, ModelLoader
from json5.dumper import dumps, ModelDumper
from json5.dumper import modelize
from json5.model import DoubleQuotedString

model = loads("""{
  // app config
  host: 'localhost',
  port: 8080,
}""", loader=ModelLoader())

# Add a new key-value pair to the inner JSONObject
inner = model.value
inner.keys.append(DoubleQuotedString("debug", '"debug"'))
inner.values.append(modelize(True))

print(dumps(model, dumper=ModelDumper()))
# {
#   // app config
#   host: 'localhost',
#   port: 8080,
# "debug":true}

Note: The model API does not validate that your edits produce valid JSON5. You are responsible for structural correctness (e.g., comma placement between entries).

The set_key pattern

A common helper for replacing or adding a top-level key while preserving everything else:

from json5.loader import loads, ModelLoader
from json5.model import DoubleQuotedString, Identifier, JSONText

def set_key(model: JSONText, key_name: str, new_value) -> None:
    """Replace or add a top-level key in a JSONText model."""
    json_obj = model.value
    new_value_model = modelize(new_value)

    for i, key in enumerate(json_obj.keys):
        if isinstance(key, Identifier) and key.name == key_name:
            json_obj.values[i] = new_value_model
            return
        if isinstance(key, DoubleQuotedString) and key.characters == key_name:
            json_obj.values[i] = new_value_model
            return

    # Key doesn't exist — append it
    json_obj.keys.append(DoubleQuotedString(key_name, f'"{key_name}"'))
    json_obj.values.append(new_value_model)

Usage:

from json5.dumper import modelize

model = loads('{"foo": "bar", /* preserved */ "baz": "qux"}', loader=ModelLoader())
set_key(model, "foo", "updated")
set_key(model, "new_key", 42)
print(dumps(model, dumper=ModelDumper()))
# {"foo": "updated", /* preserved */ "baz": "qux","new_key":42}

Custom loaders and dumpers

Extending the default loader

Subclass DefaultLoader to customize how specific node types are loaded:

from json5.loader import DefaultLoader, loads
from json5.model import JSONArray

class SingleItemUnwrapper(DefaultLoader):
    """Unwrap single-item arrays to their scalar value."""
    def load(self, node):
        if isinstance(node, JSONArray) and len(node.values) == 1:
            return self.load(node.values[0])
        return super().load(node)

text = "{foo: ['bar', 'baz'], bacon: ['eggs']}"
print(loads(text))                           # {'foo': ['bar', 'baz'], 'bacon': ['eggs']}
print(loads(text, loader=SingleItemUnwrapper()))  # {'foo': ['bar', 'baz'], 'bacon': 'eggs'}

Extending the default dumper

Subclass DefaultDumper to customize serialization:

from json5.dumper import DefaultDumper, dumps

class IntBoolDumper(DefaultDumper):
    """Dump booleans as integers (1/0) instead of true/false."""
    def dump(self, node):
        if isinstance(node, bool):
            return self.dump(int(node))
        return super().dump(node)

print(dumps([True, False]))                        # [true, false]
print(dumps([True, False], dumper=IntBoolDumper()))  # [1, 0]

Tokenization

You can tokenize JSON5 text directly — useful for linters, syntax highlighters, or custom parsers:

from json5.tokenizer import tokenize

for token in tokenize("{foo: 'bar'}"):
    print(f"{token.type:25s} {token.value!r}")

Output:

LBRACE                    '{'
NAME                      'foo'
COLON                     ':'
WHITESPACE                ' '
SINGLE_QUOTE_STRING       "'bar'"
RBRACE                    '}'

Token types include: LBRACE, RBRACE, LBRACKET, RBRACKET, COLON, COMMA, NAME, WHITESPACE, SINGLE_QUOTE_STRING, DOUBLE_QUOTE_STRING, NUMBER, TRUE, FALSE, NULL, NAN, INFINITY.


Common recipes

Read a JSON5 config file with comments preserved, update one key, write back

from json5.loader import load, ModelLoader
from json5.dumper import dump, ModelDumper
from json5.dumper import modelize
from json5.model import DoubleQuotedString, Identifier
from pathlib import Path

config_path = Path("app.json5")

# Read with comment preservation
model = load(config_path.open(), loader=ModelLoader())

# Update the "port" key
inner = model.value
for i, key in enumerate(inner.keys):
    if isinstance(key, (Identifier, DoubleQuotedString)) and (
        getattr(key, 'name', None) == 'port' or
        getattr(key, 'characters', None) == 'port'
    ):
        inner.values[i] = modelize(9090)
        break

# Write back — comments and formatting preserved
with config_path.open("w") as f:
    dump(model, f, dumper=ModelDumper())

Convert JSON5 to strict JSON

import json5

json5_text = """{
  // comment
  foo: 'bar',
  hex: 0xFF,
}"""

data = json5.loads(json5_text, parse_json5_identifiers=str)
import json
print(json.dumps(data, indent=2))
# {
#   "foo": "bar",
#   "hex": 255
# }

Build a JSON5 model from scratch

from json5.model import *
from json5.dumper import dumps, ModelDumper

model = JSONText(value=JSONObject(
    KeyValuePair(key=Identifier('bacon'), value=Infinity()),
))
print(dumps(model, dumper=ModelDumper()))
# {bacon:Infinity}

Gotchas

ModelDumper emits JSON5, not JSON

The ModelDumper and modelize functions produce JSON5 output — single-quoted strings, unquoted identifiers, no trailing commas by default. This is not valid JSON:

>>> from json5.dumper import modelize, dumps, ModelDumper
>>> dumps(modelize({"foo": "bar"}), dumper=ModelDumper())
"{'foo':'bar'}"                    # single quotes — invalid JSON

If you need strict JSON output, use the default dumps (not ModelDumper) or post-process with a formatter like prettier or biome:

# Correct — uses DefaultDumper, produces valid JSON
>>> json5.dumps({"foo": "bar"})
'{"foo": "bar"}'                   # wait, this is actually JSON5 with quoted keys

# For strict JSON, use stdlib json
>>> import json
>>> json.dumps({"foo": "bar"})
'{"foo": "bar"}'

Import name != install name

pip install json-five    # install name has a hyphen
import json5             # import name has no hyphen

load/loads lose comments by default

The default loads returns plain Python objects. Comments, whitespace details, and quoting style are lost. Use ModelLoader + ModelDumper for round-trip with comments.

The cls keyword is not supported

Unlike stdlib json, you cannot pass cls=MyEncoder. Use custom dumpers instead.

Model API is not stability-guaranteed

The underlying model node classes (JSONText, JSONObject, DoubleQuotedString, etc.) are subject to breaking changes even in minor releases. Pin your version if you depend on them:

pip install json-five==1.1.2

JsonIdentifier is a str subclass — but not always str

>>> data = json5.loads('{foo: "bar"}')
>>> type(list(data.keys())[0])
<class 'json5.model.JsonIdentifier'>
>>> isinstance(list(data.keys())[0], str)
True                                # it IS a str subclass
>>> list(data.keys())[0] == "foo"
True                                # and compares equal

Most code won't notice, but type(key) is str checks will fail. Use parse_json5_identifiers=str if you need plain str keys.


Comparison with stdlib json

Capability json json5
Parse standard JSON Yes Yes
Parse JSON5 (comments, unquoted keys, etc.) No Yes
load/loads/dump/dumps API Yes Yes (compatible)
parse_int/parse_float/parse_constant Yes Yes
object_hook/object_pairs_hook Yes Yes
cls keyword (custom encoder/decoder) Yes No — use custom loaders/dumpers
Comment preservation (round-trip) No Yes — via ModelLoader/ModelDumper
Tokenization No Yes
Abstract model API No Yes
Hex integers (0xFF) No Yes
Infinity / NaN literals No Yes

Files in this Gist

File Description
README.md This guide — comprehensive json-five usage reference
explore_basics.py Create, read, and write actual JSON and JSON5 files — shows the difference between stdlib json and json5, and demonstrates that default json5.dumps loses comments
explore_model_api.py Full CRUD (create, read, update, add, delete) on a JSON5 file with comments — demonstrates comment preservation through every operation via ModelLoader/ModelDumper
explore_double_quoted.py Full file lifecycle (create → read → update → add → delete) on both .json (valid JSON, stdlib-parseable) and .jsonc (comments preserved) files using the _modelize_json pattern
AGENTS.md Instructions for AI agents maintaining this guide
CHANGELOG.md Version history of this guide
#json-five-GistSummary One-line summary for gist listing

Running the exploration scripts

Each script is a self-contained PEP 723 file — run directly with uv:

uv run --script explore_basics.py
uv run --script explore_model_api.py
uv run --script explore_double_quoted.py

Files are created in a temp directory and cleaned up at the end — no side effects on your system.


Requirements

  • Python 3.8+
  • json-five (pip install json-five)

References

name json_five_guide_dev
description Expert agent for maintaining the json-five Python library guide published as a GitHub Gist.
applyTo **
priority medium

json-five Guide — Maintenance Agent

You are an expert maintainer for the json-five guide gist: a comprehensive usage reference for the json-five Python library (pip install json-five, import as json5).

Persona

  • You specialise in the json-five Python library and the JSON5 specification
  • You understand the stdlib json module API and how json5 extends it
  • You understand the model API (ModelLoader, ModelDumper, modelize) and its round-trip comment preservation
  • Your primary responsibility: keep this guide accurate, comprehensive, and up-to-date with json-five releases

Project Knowledge

Tech Stack:

  • Python 3.8+ (json-five minimum requirement)
  • json-five library (pip install json-five, import as json5)
  • JSON5 specification (https://spec.json5.org/)

File Structure:

File Purpose Update when…
README.md Main guide — all content, examples, recipes, gotchas json-five releases new features, API changes, or you find inaccuracies
explore_basics.py Exploration script: create/read/write actual JSON and JSON5 files json-five changes basic load/dump behavior
explore_model_api.py Exploration script: full CRUD on JSON5 files with comment preservation json-five model API changes (ModelLoader, ModelDumper, node classes)
explore_double_quoted.py Exploration script: full file lifecycle with _modelize_json pattern for valid JSON output json-five modelize behavior changes, or new model node types
AGENTS.md This file — instructions for AI agents maintaining the guide Maintenance workflow changes
CHANGELOG.md Version history of the guide Every edit — always add an entry under ## [Unreleased]
#json-five-GistSummary One-line summary for gist listing Only if the guide's purpose changes

Key References:

✅ Accuracy — CRITICAL

Every code example in this guide MUST be verified before publishing.

  • Never write an example from memory — always run it against the installed json5 package
  • Never guess API signatures — check the actual source or docs
  • Never describe behavior you haven't tested — run the code and paste the real output
  • If a json-five update changes behavior, update the affected examples and re-verify

File Checklist — Run on Every Change

Before committing, verify:

1. README.md

  • All code examples have been run against the installed json5 package
  • Output shown in the guide matches actual output
  • API signatures match the current json-five version
  • Section links in the Table of Contents are correct
  • No broken cross-references between sections
  • "Files in this Gist" table matches the actual files in the gist

2. Exploration scripts (explore_*.py)

  • Each script runs successfully: uv run --script explore_*.py
  • Scripts create/modify/delete actual files in temp directories
  • Scripts clean up their temp directories at the end
  • Output shown matches actual json-five behavior on this version

3. CHANGELOG.md

  • Entry added under ## [Unreleased] describing what changed (Added / Changed / Fixed / Removed)

4. AGENTS.md

  • Only update if the maintenance workflow itself changes
  • File Structure table matches actual files in the gist

Commands

# Verify examples (run from the gist directory)
cd /path/to/gist
python3 -c "import json5; print(json5.__version__)"

# Run the exploration scripts (each does real file operations)
uv run --script explore_basics.py
uv run --script explore_model_api.py
uv run --script explore_double_quoted.py

# Run a specific example from the guide
python3 <<'PY'
import json5
# paste example here
PY

# Git (gist is a git repo)
git --no-pager log --oneline -10
git --no-pager diff
git add -A && git commit -m "docs: ..." && git push

Naming Conventions

  • Guide version: date-based in CHANGELOG (e.g., 2026-07-19)
  • Commit messages: docs: <description> (this is documentation, not code)
  • Section headers: sentence case, ## for major sections, ### for subsections
  • Code blocks: always specify language (python, json5, bash)

Code Style for Examples

✅ Good — self-contained, runnable example:

import json5

text = '{foo: "bar", /* comment */ bacon: "eggs"}'
data = json5.loads(text)
print(data)  # {'foo': 'bar', 'bacon': 'eggs'}

❌ Anti-pattern — partial snippet, not runnable:

# Bad: assumes context the reader doesn't have
data = loads(text)
print(data)

Workflows & Procedures

For Any Edit

  1. Verify: Run every code example that will be affected by your change
  2. Edit: Update README.md with accurate, verified content
  3. Update CHANGELOG: Add entry under ## [Unreleased]
  4. Commit: git add -A && git commit -m "docs: <description>" with Co-authored-by trailer
  5. Push: git push

For json-five Version Updates

  1. Check the json-five changelog
  2. pip install --upgrade json-five
  3. Re-run ALL examples in the guide against the new version
  4. Update any examples whose output changed
  5. Add a CHANGELOG entry noting the json-five version the guide now targets

Boundaries

✅ Always Do

  • Run every code example before adding or modifying it
  • Add CHANGELOG.md entry for every commit
  • Keep the Table of Contents in sync with section headers
  • Use real output in examples (not approximated)

⚠️ Ask First

  • Restructuring the guide into multiple files (gists are flat repos)
  • Adding external dependencies to examples (keep examples self-contained)
  • Changing the guide's target audience (currently: Python developers using json-five)

🚫 Never Do

  • Publish unverified examples
  • Guess json-five behavior without testing
  • Skip updating CHANGELOG.md
  • Remove the "Gotchas" section — it documents real pitfalls
  • Commit __pycache__/ or .pyc files

Changelog

All notable changes to the json-five guide are documented here.

This guide targets json-five v1.1.2 (latest as of 2026-07-19). All examples have been verified against that version.


[Unreleased]

Changed

  • Updated README.md "Files in this Gist" table with accurate descriptions of the exploration scripts (now describing real file operations, not abstract demos) and added a "Running the exploration scripts" subsection with uv run --script commands

  • Updated AGENTS.md File Structure table to include the three exploration scripts with their update triggers

  • Updated AGENTS.md File Checklist to add a section for verifying the exploration scripts (run successfully, create temp files, clean up)

  • Updated AGENTS.md Commands section to include uv run --script commands for all three exploration scripts

  • Updated #json-five-GistSummary to mention the exploration scripts

  • Rewrote exploration scripts to do real file operations instead of abstract in-memory demonstrations. Each script now creates, reads, updates, and deletes actual JSON/JSON5/JSONC files in a temp directory:

    • explore_basics.py — create .json and .json5 files, read with stdlib json and json5, write modifications back, verify parseability
    • explore_model_api.py — full CRUD (create, read, update, add, delete) on a JSON5 file with comments, demonstrating comment preservation through every operation via ModelLoader/ModelDumper
    • explore_double_quoted.py — full lifecycle on both .json (stdlib json parseable) and .jsonc (comments preserved), plus the modelize vs _modelize_json comparison on real output
  • Refactored to use a Step data structure pattern: each file operation builds a Step(title, file_path, content_before, content_after, results) dataclass, then all steps are rendered by a single render_steps() function. Shows before/after file contents at each step.

Added

  • Three exploration scripts demonstrating json-five usage patterns:

    • explore_basics.py — basic load/dump, JSON5 syntax features (comments, unquoted keys, single quotes, hex, Infinity, NaN, trailing commas), parse_json5_identifiers hook, file-based load/dump
    • explore_model_api.py — ModelLoader vs DefaultLoader, model structure inspection, round-trip with comment preservation, editing models (add/replace keys), the set_key pattern
    • explore_double_quoted.py — the _modelize_json pattern: how to produce valid JSON output (double-quoted strings) via the model API instead of the single-quoted JSON5 that modelize() produces by default. Includes a production-ready set_key helper and a comparison table of modelize vs _modelize_json
  • Updated README.md "Files in this Gist" table to list the exploration scripts

Fixed

  • Replace stale gist URL in #json-five-GistSummary with the correct gist ID (c2b6a72528a5e1652d924e2dcb2b0b4d). The summary previously pointed to a deleted gist from the create/delete/recreate iteration during initial publishing.

Added

  • Initial publication of the json-five guide as a public GitHub Gist
  • Comprehensive README.md covering:
    • Why JSON5? (feature comparison table: JSON vs JSON5)
    • Installation (pip install json-five, import as json5)
    • Quick start example
    • JSON5 syntax at a glance (comments, unquoted keys, single quotes, hex, Infinity, NaN, trailing commas, multi-line strings)
    • Basic API — the json-compatible interface (load/loads/dump/dumps)
    • parse_json5_identifiers hook documentation
    • Round-trip with comments — the model API (ModelLoader / ModelDumper)
    • Editing a model in place (including the set_key pattern)
    • Custom loaders and dumpers (extending DefaultLoader / DefaultDumper)
    • Tokenization API
    • Common recipes (config file round-trip, JSON5-to-JSON conversion, model building from scratch)
    • Gotchas section (ModelDumper emits JSON5 not JSON, import name mismatch, comment loss with default loads, no cls keyword, model API instability, JsonIdentifier subclass behavior)
    • Comparison table: stdlib json vs json5
    • Files in this Gist, Requirements, References
  • AGENTS.md with maintenance instructions for AI agents
  • #json-five-GistSummary one-line summary file
  • .gitignore for Python cache files

Verified

  • All code examples in README.md have been run against json-five v1.1.2
  • Output shown in examples matches actual library output
  • API signatures match the current version
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = ["json-five>=1.1.0"]
# ///
"""explore_basics.py — Create, read, and write actual JSON and JSON5 files.
Demonstrates real file operations with json-five:
- Create a .json file (strict JSON, no comments)
- Create a .json5 file (with comments)
- Read both back
- Write modified data back
- Show the file contents at each step
Files are created in a temp directory and cleaned up at the end.
Run: uv run --script explore_basics.py
"""
from __future__ import annotations
import json as stdlib_json
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
import json5
@dataclass
class Step:
"""A single file operation step with description and result."""
title: str
description: str = ""
file_path: Path | None = None
content_before: str | None = None
content_after: str | None = None
result_lines: list[str] = field(default_factory=list)
def render_steps(steps: list[Step]) -> None:
"""Render all steps to stdout."""
for step in steps:
print(f"{'=' * 70}")
print(step.title)
print(f"{'=' * 70}")
if step.description:
print(f" {step.description}")
if step.file_path:
print(f" file: {step.file_path}")
if step.content_before is not None:
print(" --- before ---")
print(step.content_before)
if step.content_after is not None:
print(" --- after ---")
print(step.content_after)
for line in step.result_lines:
print(f" {line}")
print()
def run() -> list[Step]:
"""Run all file operations and return the steps."""
steps: list[Step] = []
tmpdir = Path(tempfile.mkdtemp(prefix="json5-explore-"))
# ── 1. CREATE a strict JSON file (no comments) ────────────────────────
json_path = tmpdir / "config.json"
config_data = {
"host": "localhost",
"port": 8080,
"debug": True,
"features": ["auth", "logging"],
}
json_path.write_text(stdlib_json.dumps(config_data, indent=2), encoding="utf-8")
steps.append(Step(
title="1. CREATE a strict JSON file (no comments)",
file_path=json_path,
content_after=json_path.read_text(encoding="utf-8"),
result_lines=[
f"stdlib json.dumps → {json_path}",
f"file exists: {json_path.exists()}",
f"file size: {json_path.stat().st_size} bytes",
],
))
# ── 2. CREATE a JSON5 file (with comments) ────────────────────────────
json5_path = tmpdir / "config.json5"
json5_content = """{
// server configuration
host: 'localhost',
port: 8080, // default port
/* enable for development */
debug: true,
features: ['auth', 'logging'],
}"""
json5_path.write_text(json5_content, encoding="utf-8")
steps.append(Step(
title="2. CREATE a JSON5 file (with comments)",
file_path=json5_path,
content_after=json5_path.read_text(encoding="utf-8"),
result_lines=[
f"raw text written → {json5_path}",
"contains // line comments: True",
"contains /* block comments */: True",
"contains unquoted keys: True (host, port, debug, features)",
"contains single-quoted strings: True",
],
))
# ── 3. READ the strict JSON file with stdlib json ─────────────────────
with json_path.open(encoding="utf-8") as f:
loaded_json = stdlib_json.load(f)
steps.append(Step(
title="3. READ the strict JSON file with stdlib json",
file_path=json_path,
result_lines=[
f"stdlib json.load → {loaded_json}",
f"host: {loaded_json['host']!r}",
f"port: {loaded_json['port']} (type: {type(loaded_json['port']).__name__})",
f"features: {loaded_json['features']}",
],
))
# ── 4. READ the strict JSON file with json5 ───────────────────────────
with json_path.open(encoding="utf-8") as f:
loaded_with_json5 = json5.load(f)
steps.append(Step(
title="4. READ the strict JSON file with json5 (json5 is a superset of json)",
file_path=json_path,
result_lines=[
f"json5.load → {loaded_with_json5}",
f"same result as stdlib: {loaded_with_json5 == loaded_json}",
],
))
# ── 5. READ the JSON5 file with json5 (comments are ignored by default) ─
with json5_path.open(encoding="utf-8") as f:
loaded_json5 = json5.load(f)
steps.append(Step(
title="5. READ the JSON5 file with json5 (comments ignored by default loads)",
file_path=json5_path,
result_lines=[
f"json5.load → {loaded_json5}",
f"host: {loaded_json5['host']!r}",
f"debug: {loaded_json5['debug']}",
f"same data as strict JSON: {loaded_json5 == loaded_json}",
"NOTE: comments are LOST — json5.load returns plain Python objects",
],
))
# ── 6. WRITE modified data back to the JSON file ──────────────────────
loaded_json["port"] = 9090
loaded_json["ssl"] = False
json_path.write_text(stdlib_json.dumps(loaded_json, indent=2), encoding="utf-8")
steps.append(Step(
title="6. WRITE modified data back to the JSON file (stdlib json)",
file_path=json_path,
content_before=stdlib_json.dumps(config_data, indent=2),
content_after=json_path.read_text(encoding="utf-8"),
result_lines=[
"changed port 8080 → 9090",
"added ssl: false",
f"updated file size: {json_path.stat().st_size} bytes",
],
))
# ── 7. WRITE the JSON5 file back with json5.dumps (comments lost) ─────
updated_json5 = json5.dumps(loaded_json5, indent=2)
json5_path.write_text(updated_json5, encoding="utf-8")
steps.append(Step(
title="7. WRITE the JSON5 file back with json5.dumps (comments LOST)",
file_path=json5_path,
content_before=json5_content,
content_after=json5_path.read_text(encoding="utf-8"),
result_lines=[
"json5.dumps produces valid JSON5 but DROPS all comments",
"// line comments: gone",
"/* block comments */: gone",
"keys become double-quoted (json5 default with indent)",
"See explore_model_api.py for comment-preserving round-trip",
],
))
# ── 8. VERIFY both files are still parseable ──────────────────────────
with json_path.open() as f:
final_json = stdlib_json.load(f)
with json5_path.open() as f:
final_json5 = json5.load(f)
steps.append(Step(
title="8. VERIFY both files are still parseable",
result_lines=[
f"config.json → stdlib json.load: OK ({len(final_json)} keys)",
f"config.json5 → json5.load: OK ({len(final_json5)} keys)",
],
))
# ── Cleanup ───────────────────────────────────────────────────────────
import shutil
shutil.rmtree(tmpdir)
steps.append(Step(
title="CLEANUP",
result_lines=[f"removed temp directory: {tmpdir}"],
))
return steps
if __name__ == "__main__":
render_steps(run())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = ["json-five>=1.1.0"]
# ///
"""explore_double_quoted.py — Full file lifecycle with valid JSON output via the model API.
Demonstrates real file operations using the _modelize_json pattern:
- CREATE a JSON file from scratch (valid JSON, double-quoted)
- READ it back with stdlib json (proves it's valid JSON)
- UPDATE a key (write back, read with stdlib json again)
- ADD a new key with nested data
- DELETE a key
- CREATE a JSONC file (with comments) and do the same operations
(read with json5, write back with comments preserved, output is valid JSONC)
The _modelize_json function creates DoubleQuotedString nodes (instead of
SingleQuotedString from modelize), so ModelDumper output is valid JSON
when there are no comments, and valid JSONC when there are comments —
without needing external formatters like prettier or biome.
Files are created in a temp directory and cleaned up at the end.
Run: uv run --script explore_double_quoted.py
"""
from __future__ import annotations
import json as stdlib_json
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import json5
from json5.dumper import ModelDumper, modelize, dumps
from json5.loader import loads, ModelLoader
from json5.model import (
BooleanLiteral,
DoubleQuotedString,
Float,
Identifier,
Integer,
JSONArray,
JSONObject,
JSONText,
NullLiteral,
)
@dataclass
class Step:
"""A single file operation step."""
title: str
description: str = ""
file_path: Path | None = None
content_before: str | None = None
content_after: str | None = None
result_lines: list[str] = field(default_factory=list)
def render_steps(steps: list[Step]) -> None:
"""Render all steps to stdout."""
for step in steps:
print(f"{'=' * 70}")
print(step.title)
print(f"{'=' * 70}")
if step.description:
print(f" {step.description}")
if step.file_path:
print(f" file: {step.file_path}")
if step.content_before is not None:
print(" --- before ---")
print(step.content_before)
if step.content_after is not None:
print(" --- after ---")
print(step.content_after)
for line in step.result_lines:
print(f" {line}")
print()
# ── The custom modelizer (double-quoted strings) ────────────────────────────
def _modelize_json(value: Any, indent_level: int = 1) -> Any:
"""Convert a Python value to a json-five model with double-quoted strings.
Unlike json5.dumper.modelize() (which creates SingleQuotedString), this
creates DoubleQuotedString nodes so ModelDumper output is valid JSON / JSONC.
"""
indent = " " * indent_level
if value is None:
return NullLiteral()
if isinstance(value, bool):
return BooleanLiteral(value=value)
if isinstance(value, int):
return Integer(raw_value=str(value), is_hex=False, is_octal=False)
if isinstance(value, float):
return Float(raw_value=str(value), value=value)
if isinstance(value, str):
raw = stdlib_json.dumps(value)
return DoubleQuotedString(characters=value, raw_value=raw)
if isinstance(value, list):
arr = JSONArray(trailing_comma=None)
for item in value:
arr.values.append(_modelize_json(item, indent_level + 1))
return arr
if isinstance(value, dict):
obj = JSONObject(trailing_comma=None)
for k, v in value.items():
key_str = str(k)
key = DoubleQuotedString(
characters=key_str,
raw_value=stdlib_json.dumps(key_str),
)
key.wsc_before = [f"\n{indent}"]
obj.keys.append(key)
obj.values.append(_modelize_json(v, indent_level + 1))
return obj
return modelize(value)
# ── File operations using _modelize_json ────────────────────────────────────
def create_json_file(path: Path, data: dict) -> JSONText:
"""Create a JSON file from scratch with double-quoted strings."""
model = JSONText(value=JSONObject(trailing_comma=None))
for key, value in data.items():
new_key = DoubleQuotedString(key, f'"{key}"')
new_key.wsc_before = ["\n "]
model.value.keys.append(new_key)
model.value.values.append(_modelize_json(value))
path.write_text(dumps(model, dumper=ModelDumper()), encoding="utf-8")
return model
def update_key_json(model: JSONText, key_name: str, new_value: Any) -> bool:
"""Update an existing key's value using _modelize_json."""
json_obj = model.value
new_value_model = _modelize_json(new_value)
for i, key in enumerate(json_obj.keys):
if isinstance(key, Identifier) and key.name == key_name:
json_obj.values[i] = new_value_model
return True
if isinstance(key, DoubleQuotedString) and key.characters == key_name:
json_obj.values[i] = new_value_model
return True
return False
def add_key_json(model: JSONText, key_name: str, new_value: Any) -> None:
"""Add a new key using _modelize_json."""
json_obj = model.value
new_key = DoubleQuotedString(key_name, f'"{key_name}"')
new_key.wsc_before = ["\n "]
json_obj.keys.append(new_key)
json_obj.values.append(_modelize_json(new_value))
def delete_key(model: JSONText, key_name: str) -> bool:
"""Delete a key from the model."""
json_obj = model.value
for i, key in enumerate(json_obj.keys):
if isinstance(key, Identifier) and key.name == key_name:
del json_obj.keys[i]
del json_obj.values[i]
return True
if isinstance(key, DoubleQuotedString) and key.characters == key_name:
del json_obj.keys[i]
del json_obj.values[i]
return True
return False
def write_model(model: JSONText, path: Path) -> None:
"""Write a model to a file with ModelDumper (comments preserved)."""
path.write_text(dumps(model, dumper=ModelDumper()), encoding="utf-8")
# ── Run the file operations ─────────────────────────────────────────────────
def run() -> list[Step]:
"""Run all file operations and return the steps."""
steps: list[Step] = []
tmpdir = Path(tempfile.mkdtemp(prefix="json5-dq-"))
# ── Part A: JSON file (no comments) — full lifecycle with stdlib json ──
json_path = tmpdir / "config.json"
# 1. CREATE
initial_data = {"version": 1, "host": "localhost", "port": 8080}
model = create_json_file(json_path, initial_data)
content = json_path.read_text(encoding="utf-8")
steps.append(Step(
title="1. CREATE a JSON file from scratch (double-quoted, valid JSON)",
file_path=json_path,
content_after=content,
result_lines=[
f"has single quotes: {'\'' in content}",
f"stdlib json.loads: {stdlib_json.loads(content)}",
"no external formatters needed — output is valid JSON",
],
))
# 2. READ with stdlib json (proves it's valid JSON)
with json_path.open() as f:
loaded = stdlib_json.load(f)
steps.append(Step(
title="2. READ the JSON file with stdlib json (proves it's valid JSON)",
file_path=json_path,
result_lines=[
f"stdlib json.load: OK → {loaded}",
f"matches original data: {loaded == initial_data}",
],
))
# 3. UPDATE a key (port: 8080 → 9090)
content_before = json_path.read_text(encoding="utf-8")
found = update_key_json(model, "port", 9090)
write_model(model, json_path)
content_after = json_path.read_text(encoding="utf-8")
loaded_after = stdlib_json.loads(content_after)
steps.append(Step(
title="3. UPDATE a key (port: 8080 → 9090)",
file_path=json_path,
content_before=content_before,
content_after=content_after,
result_lines=[
f"key found: {found}",
f"stdlib json.loads after update: {loaded_after}",
f"port is now {loaded_after['port']}",
f"still valid JSON (no single quotes): {'\'' not in content_after}",
],
))
# 4. ADD a new key with nested data
content_before = json_path.read_text(encoding="utf-8")
add_key_json(model, "hooks", {
"SessionStart": [
{"matcher": "", "hooks": [{"type": "command", "command": "echo hi", "timeout": 10}]}
]
})
write_model(model, json_path)
content_after = json_path.read_text(encoding="utf-8")
loaded_after = stdlib_json.loads(content_after)
steps.append(Step(
title="4. ADD a new key with nested data (hooks)",
file_path=json_path,
content_before=content_before,
content_after=content_after,
result_lines=[
f"stdlib json.loads: OK → {len(loaded_after)} top-level keys",
f"hooks.SessionStart present: {'SessionStart' in loaded_after['hooks']}",
f"still valid JSON: {'\'' not in content_after}",
],
))
# 5. DELETE a key (host)
content_before = json_path.read_text(encoding="utf-8")
deleted = delete_key(model, "host")
write_model(model, json_path)
content_after = json_path.read_text(encoding="utf-8")
loaded_after = stdlib_json.loads(content_after)
steps.append(Step(
title="5. DELETE a key (host)",
file_path=json_path,
content_before=content_before,
content_after=content_after,
result_lines=[
f"key deleted: {deleted}",
f"stdlib json.loads: OK → {loaded_after}",
f"'host' in data: {'host' in loaded_after} (should be False)",
f"still valid JSON: {'\'' not in content_after}",
],
))
# ── Part B: JSONC file (with comments) — same lifecycle with json5 ────
jsonc_path = tmpdir / "config.jsonc"
jsonc_initial = """{
// app configuration
"host": "localhost",
"port": 8080
}"""
jsonc_path.write_text(jsonc_initial, encoding="utf-8")
# 6. CREATE (write) a JSONC file with comments
model2 = loads(jsonc_initial, loader=ModelLoader())
steps.append(Step(
title="6. CREATE a JSONC file with comments (for round-trip test)",
file_path=jsonc_path,
content_after=jsonc_path.read_text(encoding="utf-8"),
))
# 7. UPDATE with comment preservation + valid JSONC output
content_before = jsonc_path.read_text(encoding="utf-8")
update_key_json(model2, "port", 9090)
add_key_json(model2, "debug", True)
write_model(model2, jsonc_path)
content_after = jsonc_path.read_text(encoding="utf-8")
loaded_after = json5.loads(content_after) # JSONC → json5.loads
steps.append(Step(
title="7. UPDATE + ADD keys (comments preserved, double-quoted values)",
file_path=jsonc_path,
content_before=content_before,
content_after=content_after,
result_lines=[
f"// app configuration preserved: {'// app configuration' in content_after}",
f"json5.loads: OK → {loaded_after}",
f"port is now {loaded_after['port']}",
f"debug is {loaded_after['debug']}",
f"no single quotes in new values: {'\'' not in content_after.replace('localhost', '')}",
],
))
# 8. DELETE with comment preservation
content_before = jsonc_path.read_text(encoding="utf-8")
delete_key(model2, "host")
write_model(model2, jsonc_path)
content_after = jsonc_path.read_text(encoding="utf-8")
loaded_after = json5.loads(content_after)
steps.append(Step(
title="8. DELETE a key from JSONC file (comments preserved)",
file_path=jsonc_path,
content_before=content_before,
content_after=content_after,
result_lines=[
f"// app configuration preserved: {'// app configuration' in content_after}",
f"json5.loads: OK → {loaded_after}",
f"'host' in data: {'host' in loaded_after} (should be False)",
],
))
# ── Part C: Comparison — modelize vs _modelize_json on a real file ─────
# 9. Show the difference: same data, two outputs
test_data = {"name": "test", "value": 42, "active": True}
modelize_out = dumps(JSONText(value=modelize(test_data)), dumper=ModelDumper())
dq_model = JSONText(value=JSONObject(trailing_comma=None))
for k, v in test_data.items():
new_key = DoubleQuotedString(k, f'"{k}"')
new_key.wsc_before = ["\n "]
dq_model.value.keys.append(new_key)
dq_model.value.values.append(_modelize_json(v))
dq_out = dumps(dq_model, dumper=ModelDumper())
steps.append(Step(
title="9. COMPARISON: modelize() vs _modelize_json() on the same data",
result_lines=[
f"modelize() output: {modelize_out!r}",
f"_modelize_json() output: {dq_out!r}",
f"modelize has single quotes: {'\'' in modelize_out}",
f"_modelize_json has single quotes: {'\'' in dq_out}",
f"modelize stdlib json.loads: {'OK' if _try_json(modelize_out) else 'FAIL'}",
f"_modelize_json stdlib json.loads: {'OK' if _try_json(dq_out) else 'FAIL'}",
],
))
# ── Cleanup ───────────────────────────────────────────────────────────
import shutil
shutil.rmtree(tmpdir)
steps.append(Step(
title="CLEANUP",
result_lines=[f"removed temp directory: {tmpdir}"],
))
return steps
def _try_json(text: str) -> bool:
"""Try to parse with stdlib json. Returns True if successful."""
try:
stdlib_json.loads(text)
return True
except stdlib_json.JSONDecodeError:
return False
if __name__ == "__main__":
render_steps(run())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = ["json-five>=1.1.0"]
# ///
"""explore_model_api.py — Read, update, and delete keys in JSON5 files with comment preservation.
Demonstrates real file operations using json-five's model API:
- Read a .json5 file WITH comments preserved (ModelLoader)
- Update an existing key value (comments preserved)
- Add a new key (comments on existing keys preserved)
- Delete a key (comments on other keys preserved)
- Write back with ModelDumper (comments preserved in the file)
- Verify by reading the file again
Files are created in a temp directory and cleaned up at the end.
Run: uv run --script explore_model_api.py
"""
from __future__ import annotations
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import json5
from json5.dumper import ModelDumper, dumps, modelize
from json5.loader import loads, ModelLoader
from json5.model import (
DoubleQuotedString,
Identifier,
JSONText,
)
@dataclass
class Step:
"""A single file operation step."""
title: str
description: str = ""
file_path: Path | None = None
content_before: str | None = None
content_after: str | None = None
result_lines: list[str] = field(default_factory=list)
def render_steps(steps: list[Step]) -> None:
"""Render all steps to stdout."""
for step in steps:
print(f"{'=' * 70}")
print(step.title)
print(f"{'=' * 70}")
if step.description:
print(f" {step.description}")
if step.file_path:
print(f" file: {step.file_path}")
if step.content_before is not None:
print(" --- before ---")
print(step.content_before)
if step.content_after is not None:
print(" --- after ---")
print(step.content_after)
for line in step.result_lines:
print(f" {line}")
print()
# ── File operations with comment preservation ───────────────────────────────
def read_json5_with_comments(path: Path) -> JSONText:
"""Read a JSON5 file and return the model (comments preserved)."""
with path.open(encoding="utf-8") as f:
return loads(f.read(), loader=ModelLoader())
def write_json5_with_comments(model: JSONText, path: Path) -> None:
"""Write a model back to a JSON5 file (comments preserved)."""
path.write_text(dumps(model, dumper=ModelDumper()), encoding="utf-8")
def update_key(model: JSONText, key_name: str, new_value: Any) -> bool:
"""Update an existing key's value. Returns True if the key was found."""
json_obj = model.value
new_value_model = modelize(new_value)
for i, key in enumerate(json_obj.keys):
if isinstance(key, Identifier) and key.name == key_name:
json_obj.values[i] = new_value_model
return True
if isinstance(key, DoubleQuotedString) and key.characters == key_name:
json_obj.values[i] = new_value_model
return True
return False
def add_key(model: JSONText, key_name: str, new_value: Any) -> None:
"""Add a new key to the model with proper whitespace."""
json_obj = model.value
new_value_model = modelize(new_value)
new_key = DoubleQuotedString(key_name, f'"{key_name}"')
new_key.wsc_before = ["\n "]
json_obj.keys.append(new_key)
json_obj.values.append(new_value_model)
def delete_key(model: JSONText, key_name: str) -> bool:
"""Delete a key from the model. Returns True if the key was found."""
json_obj = model.value
for i, key in enumerate(json_obj.keys):
if isinstance(key, Identifier) and key.name == key_name:
del json_obj.keys[i]
del json_obj.values[i]
return True
if isinstance(key, DoubleQuotedString) and key.characters == key_name:
del json_obj.keys[i]
del json_obj.values[i]
return True
return False
# ── Run the file operations ─────────────────────────────────────────────────
INITIAL_CONFIG = """{
// server configuration
host: 'localhost',
port: 8080, // default port
/* enable for development */
debug: true,
features: ['auth', 'logging'],
}"""
def run() -> list[Step]:
"""Run all file operations and return the steps."""
steps: list[Step] = []
tmpdir = Path(tempfile.mkdtemp(prefix="json5-model-"))
config_path = tmpdir / "config.json5"
# ── 1. CREATE the initial JSON5 file ──────────────────────────────────
config_path.write_text(INITIAL_CONFIG, encoding="utf-8")
steps.append(Step(
title="1. CREATE initial JSON5 file with comments",
file_path=config_path,
content_after=config_path.read_text(encoding="utf-8"),
))
# ── 2. READ with ModelLoader (comments preserved in the model) ────────
model = read_json5_with_comments(config_path)
# Verify the data is accessible
plain_data = json5.loads(config_path.read_text(encoding="utf-8"))
steps.append(Step(
title="2. READ with ModelLoader (comments preserved in the model)",
file_path=config_path,
result_lines=[
f"model type: {type(model).__name__}",
f"inner type: {type(model.value).__name__}",
f"keys in model: {len(model.value.keys)}",
f"data (via json5.loads): {plain_data}",
"comments are preserved in the model's wsc_before/wsc_after attrs",
],
))
# ── 3. UPDATE an existing key (port: 8080 → 9090) ─────────────────────
content_before_update = config_path.read_text(encoding="utf-8")
found = update_key(model, "port", 9090)
write_json5_with_comments(model, config_path)
content_after_update = config_path.read_text(encoding="utf-8")
steps.append(Step(
title="3. UPDATE existing key (port: 8080 → 9090)",
file_path=config_path,
content_before=content_before_update,
content_after=content_after_update,
result_lines=[
f"key found and updated: {found}",
f"// server configuration preserved: {'// server configuration' in content_after_update}",
f"// default port preserved: {'// default port' in content_after_update}",
f"/* enable for development */ preserved: {'/* enable for development */' in content_after_update}",
f"port value changed to 9090: {':9090' in content_after_update or '9090' in content_after_update}",
],
))
# ── 4. ADD a new key (ssl: false) ─────────────────────────────────────
content_before_add = config_path.read_text(encoding="utf-8")
add_key(model, "ssl", False)
write_json5_with_comments(model, config_path)
content_after_add = config_path.read_text(encoding="utf-8")
steps.append(Step(
title="4. ADD new key (ssl: false)",
file_path=config_path,
content_before=content_before_add,
content_after=content_after_add,
result_lines=[
f"// server configuration preserved: {'// server configuration' in content_after_add}",
f"// default port preserved: {'// default port' in content_after_add}",
f"/* enable for development */ preserved: {'/* enable for development */' in content_after_add}",
f"ssl key added: {'ssl' in content_after_add}",
],
))
# ── 5. DELETE a key (features) ────────────────────────────────────────
content_before_delete = config_path.read_text(encoding="utf-8")
deleted = delete_key(model, "features")
write_json5_with_comments(model, config_path)
content_after_delete = config_path.read_text(encoding="utf-8")
steps.append(Step(
title="5. DELETE a key (features)",
file_path=config_path,
content_before=content_before_delete,
content_after=content_after_delete,
result_lines=[
f"key found and deleted: {deleted}",
f"// server configuration preserved: {'// server configuration' in content_after_delete}",
f"// default port preserved: {'// default port' in content_after_delete}",
f"/* enable for development */ preserved: {'/* enable for development */' in content_after_delete}",
f"features key removed: {'features' not in content_after_delete}",
],
))
# ── 6. VERIFY final file is still parseable ───────────────────────────
final_data = json5.loads(config_path.read_text(encoding="utf-8"))
steps.append(Step(
title="6. VERIFY final file is still parseable",
file_path=config_path,
result_lines=[
f"json5.loads: OK → {final_data}",
f"host: {final_data['host']!r}",
f"port: {final_data['port']} (updated to 9090)",
f"debug: {final_data['debug']}",
f"ssl: {final_data['ssl']} (added)",
f"'features' in data: {'features' in final_data} (should be False — deleted)",
],
))
# ── Cleanup ───────────────────────────────────────────────────────────
import shutil
shutil.rmtree(tmpdir)
steps.append(Step(
title="CLEANUP",
result_lines=[f"removed temp directory: {tmpdir}"],
))
return steps
if __name__ == "__main__":
render_steps(run())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment