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}}}
Why another format
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 jpml
No runtime dependencies. Python 3.14+.
The format
Sections
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: "!"
Entries
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,]
Empty values
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.
Values
| 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.
Comments
# runs to the end of the line and is allowed anywhere, including inside
objects and arrays.
What you can do with it
Read and write files
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) # -> dict
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.
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 friends
Edit a config in place
JPConfig 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 dict
missing_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)
Load a whole folder
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.
Find mistakes quickly
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.
Work from the shell
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.jp
python -m jpml ... works identically, and - reads stdin.
Round trips
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.
Organising your configs
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.
Licence
MIT. Contributing, tests and release process: CONTRIBUTING.md.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file jpml-1.1.1.tar.gz.
File metadata
- Download URL: jpml-1.1.1.tar.gz
- Upload date:
- Size: 21.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f17098324d869f73fa6730a36842fe47e5b00fa581cec887f8672e090310af0d
|
|
| MD5 |
cee354e997566926c13688add8033f3e
|
|
| BLAKE2b-256 |
ab79f7a2536c3d13e257bf3911ecc926f03106683e9845820ec38cb792fe3291
|
Provenance
The following attestation bundles were made for jpml-1.1.1.tar.gz:
Publisher:
publish.yml on doughmination/jpml
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
jpml-1.1.1.tar.gz -
Subject digest:
f17098324d869f73fa6730a36842fe47e5b00fa581cec887f8672e090310af0d - Sigstore transparency entry: 2800402019
- Sigstore integration time:
-
Permalink:
doughmination/jpml@a3f195e7e079e33d275185493b5a603a73ee1f36 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/doughmination
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a3f195e7e079e33d275185493b5a603a73ee1f36 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file jpml-1.1.1-py3-none-any.whl.
File metadata
- Download URL: jpml-1.1.1-py3-none-any.whl
- Upload date:
- Size: 25.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59a8bf81c81100fa10486df673b5d313a500af2b365b5f7ad801506b3d7387ea
|
|
| MD5 |
78ad5839762eb78dae0d5fce53ab1cfa
|
|
| BLAKE2b-256 |
566ecd214a671f0f4f677ff74729d9c73b619f9f7300318bd2be1fb74bc8f408
|
Provenance
The following attestation bundles were made for jpml-1.1.1-py3-none-any.whl:
Publisher:
publish.yml on doughmination/jpml
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
jpml-1.1.1-py3-none-any.whl -
Subject digest:
59a8bf81c81100fa10486df673b5d313a500af2b365b5f7ad801506b3d7387ea - Sigstore transparency entry: 2800402086
- Sigstore integration time:
-
Permalink:
doughmination/jpml@a3f195e7e079e33d275185493b5a603a73ee1f36 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/doughmination
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a3f195e7e079e33d275185493b5a603a73ee1f36 -
Trigger Event:
workflow_dispatch
-
Statement type: