Python package for JPML
A configuration language that borrows TOML's sections and JSON's nesting.
.jp files use [SECTION] headers at the top level and {...} / [...]
structures inside them. Keys need no quotes, # starts a comment, trailing
commas are fine, and a value is allowed to be empty.
[SERVER_ID]
config: {
disabled_channels:,
disabled_users: [9892, 82082, 8209]
}
[SERVER_ID_2]
prefix: "!"
modules: {
moderation: true,
fun: false
}
>>> import jpml
>>> jpml.load("data/servers.jp")
{'SERVER_ID': {'config': {'disabled_channels': None,
'disabled_users': [9892, 82082, 8209]}},
'SERVER_ID_2': {'prefix': '!', 'modules': {'moderation': True, 'fun': False}}}JSON has no comments, demands quotes on every key, and rejects a trailing comma. TOML has comments and headers, but nesting anything non-trivial means either deeply dotted keys or a table per level.
.jp takes the half of each that suits configuration files people edit by hand:
- Sections for the top level.
[SERVER_ID]reads better than another brace. - JSON for everything below it. Nest objects and arrays as deep as you like.
- No ceremony. Unquoted keys, comments anywhere, trailing commas ignored.
- Empty values are legal.
disabled_channels:,means the key exists and has no value yet — a real state in configs that JSON can only spell asnull.
It is a small, fully specified format with a strict parser, precise error messages, and a deterministic writer, so files stay stable when a program rewrites them.
pip install jpml # or: uv add jpmlNo runtime dependencies. Python 3.14+.
A [NAME] header opens a root key. Everything below it, until the next header,
belongs to that section.
[SERVER_ID]
prefix: "!"
Headers may be dotted to nest, and quoted when a name contains a dot:
[guild.limits] # -> {"guild": {"limits": {...}}}
["weird.name"] # -> {"weird.name": {...}}
Key/value pairs written before the first header land at the document root:
version: 2
[SERVER_ID]
prefix: "!"
An entry is key: value. Keys need no quotes; a bare key may contain spaces but
not brackets, commas or quotes — quote it if it needs those.
Entries are separated by a line break, a comma, or both. Trailing and repeated commas are accepted:
[SERVER_ID]
a: 1
b: {x: 1, y: 2,}
c: [1, 2, 3,]
A key with nothing after the colon parses to None:
config: {
disabled_channels:, # -> None
timeout: # -> None
}
Because of this, a value must start on the same line as its :. An opening
{ or [ goes on the colon's line; its contents may then wrap freely.
| Type | Examples |
|---|---|
| String | "hello", 'hello', hello world (unquoted) |
| Integer | 42, -7, 1_000, 0xff, 0o755, 0b1010 |
| Float | 3.5, 1e3, inf, -inf, nan |
| Boolean | true, false (case-insensitive, so True works too) |
| Null | null, none, nil, or nothing at all |
| Object | {a: 1, b: 2} |
| Array | [1, 2, 3] |
Unquoted values are read as a keyword first, then a number, then a plain string.
Quote a value if it contains a #, a comma, a bracket, or leading/trailing
whitespace you want to keep.
Strings honour the usual escapes — \n, \t, \\, \", \uXXXX,
\U0001F600, plus \ at end of line to continue onto the next.
# runs to the end of the line and is allowed anywhere, including inside
objects and arrays.
import jpml
data = jpml.load("data/servers.jp") # -> dict
jpml.dump(data, "data/servers.jp") # formatted, atomic write
text = jpml.dumps(data) # -> str
data = jpml.loads(text) # -> dictWrites are atomic by default: the file goes to a temporary neighbour and is renamed into place, so a crash or a concurrent reader never sees half a config.
Options worth knowing:
jpml.load("servers.jp", duplicate_keys="last") # "error" (default), "first", "last"
jpml.dumps(data, indent=4, sort_keys=True) # also: width, ensure_ascii
jpml.dumps(data, default=str) # convert datetimes and friendsJPConfig is a MutableMapping that remembers the file it came from.
from jpml import JPConfig
cfg = JPConfig.load("data/servers.jp", missing_ok=True)
cfg["SERVER_ID"]["prefix"] # plain dict access
cfg.get_path("SERVER_ID.config.disabled_users", []) # never raises
cfg.set_path("SERVER_ID.config.disabled_users", [9892]) # creates missing sections
cfg.has_path("SERVER_ID.prefix")
cfg.section("NEW_SERVER", create=True)["prefix"] = "?"
cfg.merge({"SERVER_ID": {"modules": {"fun": True}}}) # deep merge
cfg.save() # atomic, back to its own path
cfg.reload() # discard in-memory changes
cfg.to_dict() # deep copy as a plain dictmissing_ok=True gives an empty config bound to the path, which is what you
want for a program that writes its config on first run. Formatting options given
to the constructor are remembered by save():
cfg = JPConfig.load("data/servers.jp", indent=4, sort_keys=True)config = jpml.load_dir("data") # {'servers': {...}, 'roles': {...}}
guilds = jpml.load_dir("data/guilds") # {'1234567890': {...}, ...}
everything = jpml.load_dir("data", recursive=True)Each file becomes one key, named after the file.
Every error derives from jpml.JPError. JPDecodeError (a ValueError) points
at the exact character:
data/servers.jp:2:8: expected ':' after key 'prefix', found '"'
prefix "!"
^
It carries .line, .col, .pos, .filename and .raw_message if you want to
render the failure yourself. JPEncodeError (a TypeError) explains what could
not be serialised — an unsupported type, a non-string key, a circular reference.
By default a repeated key is an error rather than a silent overwrite; pass
duplicate_keys="first" or "last" if you would rather it not be.
jpml check data/*.jp # validate; non-zero exit on failure
jpml fmt -w data/servers.jp # reformat in place
jpml get data/servers.jp SERVER_ID.prefix # read one value
jpml to-json data/servers.jp -o out.json
jpml from-json out.json -o data/servers.jppython -m jpml ... works identically, and - reads stdin.
dumps is deterministic, so a file rewritten twice is byte-identical:
- every top-level mapping becomes a
[SECTION], separated by a blank line; - section entries sit one per line, with no separating commas;
- nested objects always expand across lines,
{}being the only inline form; - arrays stay inline while they fit inside
width(default 88), then break one element per line; Noneis written as an empty value inside mappings (key:) and asnullinside arrays, since an array element cannot be empty;- insertion order is preserved unless
sort_keys=True.
Two things do not survive a rewrite:
- Comments are dropped. Rewriting a hand-annotated file loses its notes.
- Root-level scalars move above the first section, because anything after a header would be read back as part of that section.
Nothing is enforced, but this layout is what load_dir is built for:
your-project/
├─ data/
│ ├─ servers.jp # one file per concern
│ ├─ roles.jp
│ ├─ servers.example.jp # committed template, safe to publish
│ └─ guilds/ # optional: one file per entity
│ ├─ 1234567890.jp
│ └─ 9876543210.jp
└─ src/
A few habits that save pain later:
- One file per concern. A parse error then takes out one feature, not everything.
- Keep live data out of git, and commit a template instead:
data/*.jp !data/*.example.jp
- Use IDs as section names.
[1234567890]parses to the string key"1234567890", and integer keys are stringified on write, so{1234567890: {...}}round-trips. - Write through
JPConfig.save()rather than by hand, so an interrupted write cannot truncate a live config. - Validate in CI with
jpml check data/*.jp.
MIT. Contributing, tests and release process: CONTRIBUTING.md.