Sections on top
A [SERVER_ID] header reads better than another brace, and headers
may be dotted to nest or quoted when a name contains a dot.
Configuration language
TOML’s sections. JSON’s nesting. Neither one’s ceremony.
.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.
# Keys before the first header belong to the document root.
version: 2
[SERVER_ID]
config: {
disabled_channels:, # empty, on purpose
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 a file a
human keeps editing.
A [SERVER_ID] header reads better than another brace, and headers
may be dotted to nest or quoted when a name contains a dot.
Inside a section it is objects and arrays, as deep as you like, with the shape you already know from every API you have ever touched.
Unquoted keys, comments anywhere including inside objects and arrays, and trailing or repeated commas quietly ignored.
disabled_channels:, means the key exists and has nothing in it yet
— a real state in a config, and one JSON can only spell as null.
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.
There is not much of it. Four ideas and a table of value types cover the whole language.
A [NAME] header opens a root key. Everything below it, until the
next header, belongs to that section.
Headers may be dotted to nest, and quoted when a name contains a dot. Key/value pairs written before the first header land at the document root.
[SERVER_ID]
prefix: "!"
[guild.limits] # -> {"guild": {"limits": {…}}}
["weird.name"] # -> {"weird.name": {…}}
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,]
"key: with punctuation": true
A key with nothing after the colon parses to None / null.
Because of that, 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.
config: {
disabled_channels:, # -> null
timeout: # -> null
}
# An array element cannot be empty, so it says so:
slots: [null, 3]
# runs to the end of the line and is allowed anywhere, including
inside objects and arrays.
They are the one thing a rewrite cannot keep: the writer works from parsed data, and comments are not part of it.
# Template config -- copy to servers.jp and fill in.
[1234567890]
limits: {
# Nested objects may go as deep as you need.
rate: {per_minute: 30, burst: 5},
warn_threshold: 3
}
| Type | Examples |
|---|---|
| String | "hello" 'hello' hello world |
| 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 and trailing
whitespace you want to keep. Strings honour the usual escapes — \n,
\t, \\, \", \uXXXX,
\U0001F600, and \ at end of line to continue onto the next.
The same format, the same error messages and the same writer, so a file written by one is byte-identical to a file written by the other.
Python 3.14+, typed, no runtime dependencies.
pip install jpml
ESM and TypeScript types, with a /core entry point for the browser.
npm install jpml-lang
Highlighting, live errors, outline, folding and Format Document for VS Code.
code --install-extension jpml-lang.jpml
import jpml
data = jpml.load("data/servers.jp")
jpml.dump(data, "data/servers.jp")
# A whole folder, one key per file.
config = jpml.load_dir("data")
# Or edit in place, and save atomically.
from jpml import JPConfig
cfg = JPConfig.load("data/servers.jp", missing_ok=True)
cfg.set_path("SERVER_ID.config.disabled_users", [9892])
cfg.save()
import { load, dump, loadDir, JPConfig } from "jpml-lang";
const data = await load("data/servers.jp");
await dump(data, "data/servers.jp");
// A whole folder, one key per file.
const config = await loadDir("data");
// Or edit in place, and save atomically.
const cfg = await JPConfig.load("data/servers.jp", {
missingOk: true,
});
cfg.setPath("SERVER_ID.config.disabled_users", [9892]);
await cfg.save();
Writes 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.
Every error derives from JPError. A decode error carries
.line, .col, .pos, .filename and
.raw_message, so you can render the failure yourself — or just print it.
data/servers.jp:2:8: expected ':' after key 'prefix', found '"'
prefix "!"
^
JPDecodeError is a ValueError and
JPEncodeError a TypeError, so existing
except clauses keep working. The encode error explains what could
not be serialised: an unsupported type, a non-string key, a circular reference.
A repeated key is an error by default rather than a silent overwrite. Pass
duplicate_keys="first" or "last" if you would rather
it not be.
The VS Code extension runs the same parser as your program, so the editor and the runtime always agree about what is valid.
Both packages install a jpml command. python -m jpml … works
identically, and - reads standard input.
jpml check data/*.jpjpml check -qjpml fmt -w servers.jp--indent --width --sort-keysjpml get servers.jp SERVER_ID.prefix-r prints strings unquoted.jpml to-json jpml from-json-o or to stdout.
dumps is deterministic, so a file rewritten twice is byte-identical. That
is what makes it safe for a program to own a config a person also edits.
{} being the only
inline form.None is written as an empty value inside mappings
(key:) and as null inside arrays.sort_keys=True.Two things do not survive a rewrite. Comments are dropped, because they are not part of the parsed data — which is why Format Document in VS Code refuses a file that has any, until you tell it otherwise. And root-level scalars move above the first section, since anything after a header would be read back as part of that section.
Nothing is enforced, but this is the layout load_dir is built for, and a
few habits that save pain later.
data/
├─ servers.jp # one file per concern
├─ roles.jp
├─ servers.example.jp # committed template
└─ guilds/ # one file per entity
├─ 1234567890.jp
└─ 9876543210.jp
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 ignored, !data/*.example.jp kept.
Use IDs as section names. [1234567890] parses to
the string key "1234567890", and integer keys are stringified on
write, so the shape round-trips.
Write through save() rather than by hand, so an
interrupted write cannot truncate a live config — and
validate in CI with jpml check.