jpcl 1.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jpcl-1.1.1/LICENSE +21 -0
- jpcl-1.1.1/PKG-INFO +323 -0
- jpcl-1.1.1/README.md +300 -0
- jpcl-1.1.1/pyproject.toml +47 -0
- jpcl-1.1.1/pyproject.toml.orig +38 -0
- jpcl-1.1.1/src/jpcl/__init__.py +62 -0
- jpcl-1.1.1/src/jpcl/__main__.py +8 -0
- jpcl-1.1.1/src/jpcl/cli.py +183 -0
- jpcl-1.1.1/src/jpcl/errors.py +73 -0
- jpcl-1.1.1/src/jpcl/jp_config.py +446 -0
- jpcl-1.1.1/src/jpcl/parser.py +348 -0
- jpcl-1.1.1/src/jpcl/py.typed +0 -0
- jpcl-1.1.1/src/jpcl/scanner.py +285 -0
- jpcl-1.1.1/src/jpcl/writer.py +281 -0
jpcl-1.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clove Twilight
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jpcl-1.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jpcl
|
|
3
|
+
Version: 1.1.1
|
|
4
|
+
Summary: A hybrid JSON/TOML configuration language (.jp files)
|
|
5
|
+
Keywords: config,configuration,parser,toml,json,jp,jpcl
|
|
6
|
+
Author: Clove Twilight
|
|
7
|
+
Author-email: Clove Twilight <admin@doughmination.win>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: File Formats
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Project-URL: Homepage, https://github.com/jpcl-lang/jpcl-py
|
|
20
|
+
Project-URL: Repository, https://github.com/jpcl-lang/jpcl-py
|
|
21
|
+
Project-URL: Issues, https://github.com/jpcl-lang/jpcl-py/issues
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# jpcl-py
|
|
25
|
+
|
|
26
|
+
Python package for JPCL
|
|
27
|
+
|
|
28
|
+
[](https://github.com/jpcl-lang/jpcl-py/actions/workflows/ci.yml)
|
|
29
|
+
[](https://pypi.org/project/jpcl/)
|
|
30
|
+
|
|
31
|
+
**A configuration language that borrows TOML's sections and JSON's nesting.**
|
|
32
|
+
|
|
33
|
+
`.jp` files use `[SECTION]` headers at the top level and `{...}` / `[...]`
|
|
34
|
+
structures inside them. Keys need no quotes, `#` starts a comment, trailing
|
|
35
|
+
commas are fine, and a value is allowed to be *empty*.
|
|
36
|
+
|
|
37
|
+
```jp
|
|
38
|
+
[SERVER_ID]
|
|
39
|
+
config: {
|
|
40
|
+
disabled_channels:,
|
|
41
|
+
disabled_users: [9892, 82082, 8209]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
[SERVER_ID_2]
|
|
45
|
+
prefix: "!"
|
|
46
|
+
modules: {
|
|
47
|
+
moderation: true,
|
|
48
|
+
fun: false
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
>>> import jpcl
|
|
54
|
+
>>> jpcl.load("data/servers.jp")
|
|
55
|
+
{'SERVER_ID': {'config': {'disabled_channels': None,
|
|
56
|
+
'disabled_users': [9892, 82082, 8209]}},
|
|
57
|
+
'SERVER_ID_2': {'prefix': '!', 'modules': {'moderation': True, 'fun': False}}}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Why another format
|
|
63
|
+
|
|
64
|
+
JSON has no comments, demands quotes on every key, and rejects a trailing
|
|
65
|
+
comma. TOML has comments and headers, but nesting anything non-trivial means
|
|
66
|
+
either deeply dotted keys or a table per level.
|
|
67
|
+
|
|
68
|
+
`.jp` takes the half of each that suits configuration files people edit by hand:
|
|
69
|
+
|
|
70
|
+
* **Sections for the top level.** `[SERVER_ID]` reads better than another brace.
|
|
71
|
+
* **JSON for everything below it.** Nest objects and arrays as deep as you like.
|
|
72
|
+
* **No ceremony.** Unquoted keys, comments anywhere, trailing commas ignored.
|
|
73
|
+
* **Empty values are legal.** `disabled_channels:,` means the key exists and has
|
|
74
|
+
no value yet — a real state in configs that JSON can only spell as `null`.
|
|
75
|
+
|
|
76
|
+
It is a small, fully specified format with a strict parser, precise error
|
|
77
|
+
messages, and a deterministic writer, so files stay stable when a program
|
|
78
|
+
rewrites them.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install jpcl # or: uv add jpcl
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
No runtime dependencies. Python 3.14+.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## The format
|
|
89
|
+
|
|
90
|
+
### Sections
|
|
91
|
+
|
|
92
|
+
A `[NAME]` header opens a root key. Everything below it, until the next header,
|
|
93
|
+
belongs to that section.
|
|
94
|
+
|
|
95
|
+
```jp
|
|
96
|
+
[SERVER_ID]
|
|
97
|
+
prefix: "!"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Headers may be dotted to nest, and quoted when a name contains a dot:
|
|
101
|
+
|
|
102
|
+
```jp
|
|
103
|
+
[guild.limits] # -> {"guild": {"limits": {...}}}
|
|
104
|
+
["weird.name"] # -> {"weird.name": {...}}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Key/value pairs written *before* the first header land at the document root:
|
|
108
|
+
|
|
109
|
+
```jp
|
|
110
|
+
version: 2
|
|
111
|
+
|
|
112
|
+
[SERVER_ID]
|
|
113
|
+
prefix: "!"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Entries
|
|
117
|
+
|
|
118
|
+
An entry is `key: value`. Keys need no quotes; a bare key may contain spaces but
|
|
119
|
+
not brackets, commas or quotes — quote it if it needs those.
|
|
120
|
+
|
|
121
|
+
Entries are separated by a line break, a comma, or both. Trailing and repeated
|
|
122
|
+
commas are accepted:
|
|
123
|
+
|
|
124
|
+
```jp
|
|
125
|
+
[SERVER_ID]
|
|
126
|
+
a: 1
|
|
127
|
+
b: {x: 1, y: 2,}
|
|
128
|
+
c: [1, 2, 3,]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Empty values
|
|
132
|
+
|
|
133
|
+
A key with nothing after the colon parses to `None`:
|
|
134
|
+
|
|
135
|
+
```jp
|
|
136
|
+
config: {
|
|
137
|
+
disabled_channels:, # -> None
|
|
138
|
+
timeout: # -> None
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Because of this, **a value must start on the same line as its `:`**. An opening
|
|
143
|
+
`{` or `[` goes on the colon's line; its contents may then wrap freely.
|
|
144
|
+
|
|
145
|
+
### Values
|
|
146
|
+
|
|
147
|
+
| Type | Examples |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| String | `"hello"`, `'hello'`, `hello world` (unquoted) |
|
|
150
|
+
| Integer | `42`, `-7`, `1_000`, `0xff`, `0o755`, `0b1010` |
|
|
151
|
+
| Float | `3.5`, `1e3`, `inf`, `-inf`, `nan` |
|
|
152
|
+
| Boolean | `true`, `false` (case-insensitive, so `True` works too) |
|
|
153
|
+
| Null | `null`, `none`, `nil`, or nothing at all |
|
|
154
|
+
| Object | `{a: 1, b: 2}` |
|
|
155
|
+
| Array | `[1, 2, 3]` |
|
|
156
|
+
|
|
157
|
+
Unquoted values are read as a keyword first, then a number, then a plain string.
|
|
158
|
+
Quote a value if it contains a `#`, a comma, a bracket, or leading/trailing
|
|
159
|
+
whitespace you want to keep.
|
|
160
|
+
|
|
161
|
+
Strings honour the usual escapes — `\n`, `\t`, `\\`, `\"`, `\uXXXX`,
|
|
162
|
+
`\U0001F600`, plus `\` at end of line to continue onto the next.
|
|
163
|
+
|
|
164
|
+
### Comments
|
|
165
|
+
|
|
166
|
+
`#` runs to the end of the line and is allowed anywhere, including inside
|
|
167
|
+
objects and arrays.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## What you can do with it
|
|
172
|
+
|
|
173
|
+
### Read and write files
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
import jpcl
|
|
177
|
+
|
|
178
|
+
data = jpcl.load("data/servers.jp") # -> dict
|
|
179
|
+
jpcl.dump(data, "data/servers.jp") # formatted, atomic write
|
|
180
|
+
|
|
181
|
+
text = jpcl.dumps(data) # -> str
|
|
182
|
+
data = jpcl.loads(text) # -> dict
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Writes are atomic by default: the file goes to a temporary neighbour and is
|
|
186
|
+
renamed into place, so a crash or a concurrent reader never sees half a config.
|
|
187
|
+
|
|
188
|
+
Options worth knowing:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
jpcl.load("servers.jp", duplicate_keys="last") # "error" (default), "first", "last"
|
|
192
|
+
jpcl.dumps(data, indent=4, sort_keys=True) # also: width, ensure_ascii
|
|
193
|
+
jpcl.dumps(data, default=str) # convert datetimes and friends
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Edit a config in place
|
|
197
|
+
|
|
198
|
+
`JPConfig` is a `MutableMapping` that remembers the file it came from.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from jpcl import JPConfig
|
|
202
|
+
|
|
203
|
+
cfg = JPConfig.load("data/servers.jp", missing_ok=True)
|
|
204
|
+
|
|
205
|
+
cfg["SERVER_ID"]["prefix"] # plain dict access
|
|
206
|
+
cfg.get_path("SERVER_ID.config.disabled_users", []) # never raises
|
|
207
|
+
cfg.set_path("SERVER_ID.config.disabled_users", [9892]) # creates missing sections
|
|
208
|
+
cfg.has_path("SERVER_ID.prefix")
|
|
209
|
+
cfg.section("NEW_SERVER", create=True)["prefix"] = "?"
|
|
210
|
+
cfg.merge({"SERVER_ID": {"modules": {"fun": True}}}) # deep merge
|
|
211
|
+
cfg.save() # atomic, back to its own path
|
|
212
|
+
cfg.reload() # discard in-memory changes
|
|
213
|
+
cfg.to_dict() # deep copy as a plain dict
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`missing_ok=True` gives an empty config bound to the path, which is what you
|
|
217
|
+
want for a program that writes its config on first run. Formatting options given
|
|
218
|
+
to the constructor are remembered by `save()`:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
cfg = JPConfig.load("data/servers.jp", indent=4, sort_keys=True)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Load a whole folder
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
config = jpcl.load_dir("data") # {'servers': {...}, 'roles': {...}}
|
|
228
|
+
guilds = jpcl.load_dir("data/guilds") # {'1234567890': {...}, ...}
|
|
229
|
+
everything = jpcl.load_dir("data", recursive=True)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Each file becomes one key, named after the file.
|
|
233
|
+
|
|
234
|
+
### Find mistakes quickly
|
|
235
|
+
|
|
236
|
+
Every error derives from `jpcl.JPError`. `JPDecodeError` (a `ValueError`) points
|
|
237
|
+
at the exact character:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
data/servers.jp:2:8: expected ':' after key 'prefix', found '"'
|
|
241
|
+
prefix "!"
|
|
242
|
+
^
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
It carries `.line`, `.col`, `.pos`, `.filename` and `.raw_message` if you want to
|
|
246
|
+
render the failure yourself. `JPEncodeError` (a `TypeError`) explains what could
|
|
247
|
+
not be serialised — an unsupported type, a non-string key, a circular reference.
|
|
248
|
+
|
|
249
|
+
By default a repeated key is an error rather than a silent overwrite; pass
|
|
250
|
+
`duplicate_keys="first"` or `"last"` if you would rather it not be.
|
|
251
|
+
|
|
252
|
+
### Work from the shell
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
jpcl check data/*.jp # validate; non-zero exit on failure
|
|
256
|
+
jpcl fmt -w data/servers.jp # reformat in place
|
|
257
|
+
jpcl get data/servers.jp SERVER_ID.prefix # read one value
|
|
258
|
+
jpcl to-json data/servers.jp -o out.json
|
|
259
|
+
jpcl from-json out.json -o data/servers.jp
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`python -m jpcl ...` works identically, and `-` reads stdin.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Round trips
|
|
267
|
+
|
|
268
|
+
`dumps` is deterministic, so a file rewritten twice is byte-identical:
|
|
269
|
+
|
|
270
|
+
* every top-level mapping becomes a `[SECTION]`, separated by a blank line;
|
|
271
|
+
* section entries sit one per line, with no separating commas;
|
|
272
|
+
* nested objects always expand across lines, `{}` being the only inline form;
|
|
273
|
+
* arrays stay inline while they fit inside `width` (default 88), then break one
|
|
274
|
+
element per line;
|
|
275
|
+
* `None` is written as an empty value inside mappings (`key:`) and as `null`
|
|
276
|
+
inside arrays, since an array element cannot be empty;
|
|
277
|
+
* insertion order is preserved unless `sort_keys=True`.
|
|
278
|
+
|
|
279
|
+
Two things do not survive a rewrite:
|
|
280
|
+
|
|
281
|
+
* **Comments are dropped.** Rewriting a hand-annotated file loses its notes.
|
|
282
|
+
* **Root-level scalars move above the first section**, because anything after a
|
|
283
|
+
header would be read back as part of that section.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Organising your configs
|
|
288
|
+
|
|
289
|
+
Nothing is enforced, but this layout is what `load_dir` is built for:
|
|
290
|
+
|
|
291
|
+
```
|
|
292
|
+
your-project/
|
|
293
|
+
├─ data/
|
|
294
|
+
│ ├─ servers.jp # one file per concern
|
|
295
|
+
│ ├─ roles.jp
|
|
296
|
+
│ ├─ servers.example.jp # committed template, safe to publish
|
|
297
|
+
│ └─ guilds/ # optional: one file per entity
|
|
298
|
+
│ ├─ 1234567890.jp
|
|
299
|
+
│ └─ 9876543210.jp
|
|
300
|
+
└─ src/
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
A few habits that save pain later:
|
|
304
|
+
|
|
305
|
+
1. **One file per concern.** A parse error then takes out one feature, not
|
|
306
|
+
everything.
|
|
307
|
+
2. **Keep live data out of git**, and commit a template instead:
|
|
308
|
+
```gitignore
|
|
309
|
+
data/*.jp
|
|
310
|
+
!data/*.example.jp
|
|
311
|
+
```
|
|
312
|
+
3. **Use IDs as section names.** `[1234567890]` parses to the string key
|
|
313
|
+
`"1234567890"`, and integer keys are stringified on write, so
|
|
314
|
+
`{1234567890: {...}}` round-trips.
|
|
315
|
+
4. **Write through `JPConfig.save()`** rather than by hand, so an interrupted
|
|
316
|
+
write cannot truncate a live config.
|
|
317
|
+
5. **Validate in CI** with `jpcl check data/*.jp`.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Licence
|
|
322
|
+
|
|
323
|
+
MIT. Contributing, tests and release process: [CONTRIBUTING.md](CONTRIBUTING.md).
|
jpcl-1.1.1/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# jpcl-py
|
|
2
|
+
|
|
3
|
+
Python package for JPCL
|
|
4
|
+
|
|
5
|
+
[](https://github.com/jpcl-lang/jpcl-py/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/jpcl/)
|
|
7
|
+
|
|
8
|
+
**A configuration language that borrows TOML's sections and JSON's nesting.**
|
|
9
|
+
|
|
10
|
+
`.jp` files use `[SECTION]` headers at the top level and `{...}` / `[...]`
|
|
11
|
+
structures inside them. Keys need no quotes, `#` starts a comment, trailing
|
|
12
|
+
commas are fine, and a value is allowed to be *empty*.
|
|
13
|
+
|
|
14
|
+
```jp
|
|
15
|
+
[SERVER_ID]
|
|
16
|
+
config: {
|
|
17
|
+
disabled_channels:,
|
|
18
|
+
disabled_users: [9892, 82082, 8209]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
[SERVER_ID_2]
|
|
22
|
+
prefix: "!"
|
|
23
|
+
modules: {
|
|
24
|
+
moderation: true,
|
|
25
|
+
fun: false
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
>>> import jpcl
|
|
31
|
+
>>> jpcl.load("data/servers.jp")
|
|
32
|
+
{'SERVER_ID': {'config': {'disabled_channels': None,
|
|
33
|
+
'disabled_users': [9892, 82082, 8209]}},
|
|
34
|
+
'SERVER_ID_2': {'prefix': '!', 'modules': {'moderation': True, 'fun': False}}}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Why another format
|
|
40
|
+
|
|
41
|
+
JSON has no comments, demands quotes on every key, and rejects a trailing
|
|
42
|
+
comma. TOML has comments and headers, but nesting anything non-trivial means
|
|
43
|
+
either deeply dotted keys or a table per level.
|
|
44
|
+
|
|
45
|
+
`.jp` takes the half of each that suits configuration files people edit by hand:
|
|
46
|
+
|
|
47
|
+
* **Sections for the top level.** `[SERVER_ID]` reads better than another brace.
|
|
48
|
+
* **JSON for everything below it.** Nest objects and arrays as deep as you like.
|
|
49
|
+
* **No ceremony.** Unquoted keys, comments anywhere, trailing commas ignored.
|
|
50
|
+
* **Empty values are legal.** `disabled_channels:,` means the key exists and has
|
|
51
|
+
no value yet — a real state in configs that JSON can only spell as `null`.
|
|
52
|
+
|
|
53
|
+
It is a small, fully specified format with a strict parser, precise error
|
|
54
|
+
messages, and a deterministic writer, so files stay stable when a program
|
|
55
|
+
rewrites them.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install jpcl # or: uv add jpcl
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
No runtime dependencies. Python 3.14+.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## The format
|
|
66
|
+
|
|
67
|
+
### Sections
|
|
68
|
+
|
|
69
|
+
A `[NAME]` header opens a root key. Everything below it, until the next header,
|
|
70
|
+
belongs to that section.
|
|
71
|
+
|
|
72
|
+
```jp
|
|
73
|
+
[SERVER_ID]
|
|
74
|
+
prefix: "!"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Headers may be dotted to nest, and quoted when a name contains a dot:
|
|
78
|
+
|
|
79
|
+
```jp
|
|
80
|
+
[guild.limits] # -> {"guild": {"limits": {...}}}
|
|
81
|
+
["weird.name"] # -> {"weird.name": {...}}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Key/value pairs written *before* the first header land at the document root:
|
|
85
|
+
|
|
86
|
+
```jp
|
|
87
|
+
version: 2
|
|
88
|
+
|
|
89
|
+
[SERVER_ID]
|
|
90
|
+
prefix: "!"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Entries
|
|
94
|
+
|
|
95
|
+
An entry is `key: value`. Keys need no quotes; a bare key may contain spaces but
|
|
96
|
+
not brackets, commas or quotes — quote it if it needs those.
|
|
97
|
+
|
|
98
|
+
Entries are separated by a line break, a comma, or both. Trailing and repeated
|
|
99
|
+
commas are accepted:
|
|
100
|
+
|
|
101
|
+
```jp
|
|
102
|
+
[SERVER_ID]
|
|
103
|
+
a: 1
|
|
104
|
+
b: {x: 1, y: 2,}
|
|
105
|
+
c: [1, 2, 3,]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Empty values
|
|
109
|
+
|
|
110
|
+
A key with nothing after the colon parses to `None`:
|
|
111
|
+
|
|
112
|
+
```jp
|
|
113
|
+
config: {
|
|
114
|
+
disabled_channels:, # -> None
|
|
115
|
+
timeout: # -> None
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Because of this, **a value must start on the same line as its `:`**. An opening
|
|
120
|
+
`{` or `[` goes on the colon's line; its contents may then wrap freely.
|
|
121
|
+
|
|
122
|
+
### Values
|
|
123
|
+
|
|
124
|
+
| Type | Examples |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| String | `"hello"`, `'hello'`, `hello world` (unquoted) |
|
|
127
|
+
| Integer | `42`, `-7`, `1_000`, `0xff`, `0o755`, `0b1010` |
|
|
128
|
+
| Float | `3.5`, `1e3`, `inf`, `-inf`, `nan` |
|
|
129
|
+
| Boolean | `true`, `false` (case-insensitive, so `True` works too) |
|
|
130
|
+
| Null | `null`, `none`, `nil`, or nothing at all |
|
|
131
|
+
| Object | `{a: 1, b: 2}` |
|
|
132
|
+
| Array | `[1, 2, 3]` |
|
|
133
|
+
|
|
134
|
+
Unquoted values are read as a keyword first, then a number, then a plain string.
|
|
135
|
+
Quote a value if it contains a `#`, a comma, a bracket, or leading/trailing
|
|
136
|
+
whitespace you want to keep.
|
|
137
|
+
|
|
138
|
+
Strings honour the usual escapes — `\n`, `\t`, `\\`, `\"`, `\uXXXX`,
|
|
139
|
+
`\U0001F600`, plus `\` at end of line to continue onto the next.
|
|
140
|
+
|
|
141
|
+
### Comments
|
|
142
|
+
|
|
143
|
+
`#` runs to the end of the line and is allowed anywhere, including inside
|
|
144
|
+
objects and arrays.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## What you can do with it
|
|
149
|
+
|
|
150
|
+
### Read and write files
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
import jpcl
|
|
154
|
+
|
|
155
|
+
data = jpcl.load("data/servers.jp") # -> dict
|
|
156
|
+
jpcl.dump(data, "data/servers.jp") # formatted, atomic write
|
|
157
|
+
|
|
158
|
+
text = jpcl.dumps(data) # -> str
|
|
159
|
+
data = jpcl.loads(text) # -> dict
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Writes are atomic by default: the file goes to a temporary neighbour and is
|
|
163
|
+
renamed into place, so a crash or a concurrent reader never sees half a config.
|
|
164
|
+
|
|
165
|
+
Options worth knowing:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
jpcl.load("servers.jp", duplicate_keys="last") # "error" (default), "first", "last"
|
|
169
|
+
jpcl.dumps(data, indent=4, sort_keys=True) # also: width, ensure_ascii
|
|
170
|
+
jpcl.dumps(data, default=str) # convert datetimes and friends
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Edit a config in place
|
|
174
|
+
|
|
175
|
+
`JPConfig` is a `MutableMapping` that remembers the file it came from.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from jpcl import JPConfig
|
|
179
|
+
|
|
180
|
+
cfg = JPConfig.load("data/servers.jp", missing_ok=True)
|
|
181
|
+
|
|
182
|
+
cfg["SERVER_ID"]["prefix"] # plain dict access
|
|
183
|
+
cfg.get_path("SERVER_ID.config.disabled_users", []) # never raises
|
|
184
|
+
cfg.set_path("SERVER_ID.config.disabled_users", [9892]) # creates missing sections
|
|
185
|
+
cfg.has_path("SERVER_ID.prefix")
|
|
186
|
+
cfg.section("NEW_SERVER", create=True)["prefix"] = "?"
|
|
187
|
+
cfg.merge({"SERVER_ID": {"modules": {"fun": True}}}) # deep merge
|
|
188
|
+
cfg.save() # atomic, back to its own path
|
|
189
|
+
cfg.reload() # discard in-memory changes
|
|
190
|
+
cfg.to_dict() # deep copy as a plain dict
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`missing_ok=True` gives an empty config bound to the path, which is what you
|
|
194
|
+
want for a program that writes its config on first run. Formatting options given
|
|
195
|
+
to the constructor are remembered by `save()`:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
cfg = JPConfig.load("data/servers.jp", indent=4, sort_keys=True)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Load a whole folder
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
config = jpcl.load_dir("data") # {'servers': {...}, 'roles': {...}}
|
|
205
|
+
guilds = jpcl.load_dir("data/guilds") # {'1234567890': {...}, ...}
|
|
206
|
+
everything = jpcl.load_dir("data", recursive=True)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Each file becomes one key, named after the file.
|
|
210
|
+
|
|
211
|
+
### Find mistakes quickly
|
|
212
|
+
|
|
213
|
+
Every error derives from `jpcl.JPError`. `JPDecodeError` (a `ValueError`) points
|
|
214
|
+
at the exact character:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
data/servers.jp:2:8: expected ':' after key 'prefix', found '"'
|
|
218
|
+
prefix "!"
|
|
219
|
+
^
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
It carries `.line`, `.col`, `.pos`, `.filename` and `.raw_message` if you want to
|
|
223
|
+
render the failure yourself. `JPEncodeError` (a `TypeError`) explains what could
|
|
224
|
+
not be serialised — an unsupported type, a non-string key, a circular reference.
|
|
225
|
+
|
|
226
|
+
By default a repeated key is an error rather than a silent overwrite; pass
|
|
227
|
+
`duplicate_keys="first"` or `"last"` if you would rather it not be.
|
|
228
|
+
|
|
229
|
+
### Work from the shell
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
jpcl check data/*.jp # validate; non-zero exit on failure
|
|
233
|
+
jpcl fmt -w data/servers.jp # reformat in place
|
|
234
|
+
jpcl get data/servers.jp SERVER_ID.prefix # read one value
|
|
235
|
+
jpcl to-json data/servers.jp -o out.json
|
|
236
|
+
jpcl from-json out.json -o data/servers.jp
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`python -m jpcl ...` works identically, and `-` reads stdin.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Round trips
|
|
244
|
+
|
|
245
|
+
`dumps` is deterministic, so a file rewritten twice is byte-identical:
|
|
246
|
+
|
|
247
|
+
* every top-level mapping becomes a `[SECTION]`, separated by a blank line;
|
|
248
|
+
* section entries sit one per line, with no separating commas;
|
|
249
|
+
* nested objects always expand across lines, `{}` being the only inline form;
|
|
250
|
+
* arrays stay inline while they fit inside `width` (default 88), then break one
|
|
251
|
+
element per line;
|
|
252
|
+
* `None` is written as an empty value inside mappings (`key:`) and as `null`
|
|
253
|
+
inside arrays, since an array element cannot be empty;
|
|
254
|
+
* insertion order is preserved unless `sort_keys=True`.
|
|
255
|
+
|
|
256
|
+
Two things do not survive a rewrite:
|
|
257
|
+
|
|
258
|
+
* **Comments are dropped.** Rewriting a hand-annotated file loses its notes.
|
|
259
|
+
* **Root-level scalars move above the first section**, because anything after a
|
|
260
|
+
header would be read back as part of that section.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Organising your configs
|
|
265
|
+
|
|
266
|
+
Nothing is enforced, but this layout is what `load_dir` is built for:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
your-project/
|
|
270
|
+
├─ data/
|
|
271
|
+
│ ├─ servers.jp # one file per concern
|
|
272
|
+
│ ├─ roles.jp
|
|
273
|
+
│ ├─ servers.example.jp # committed template, safe to publish
|
|
274
|
+
│ └─ guilds/ # optional: one file per entity
|
|
275
|
+
│ ├─ 1234567890.jp
|
|
276
|
+
│ └─ 9876543210.jp
|
|
277
|
+
└─ src/
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
A few habits that save pain later:
|
|
281
|
+
|
|
282
|
+
1. **One file per concern.** A parse error then takes out one feature, not
|
|
283
|
+
everything.
|
|
284
|
+
2. **Keep live data out of git**, and commit a template instead:
|
|
285
|
+
```gitignore
|
|
286
|
+
data/*.jp
|
|
287
|
+
!data/*.example.jp
|
|
288
|
+
```
|
|
289
|
+
3. **Use IDs as section names.** `[1234567890]` parses to the string key
|
|
290
|
+
`"1234567890"`, and integer keys are stringified on write, so
|
|
291
|
+
`{1234567890: {...}}` round-trips.
|
|
292
|
+
4. **Write through `JPConfig.save()`** rather than by hand, so an interrupted
|
|
293
|
+
write cannot truncate a live config.
|
|
294
|
+
5. **Validate in CI** with `jpcl check data/*.jp`.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Licence
|
|
299
|
+
|
|
300
|
+
MIT. Contributing, tests and release process: [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jpcl"
|
|
3
|
+
version = "1.1.1"
|
|
4
|
+
description = "A hybrid JSON/TOML configuration language (.jp files)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = []
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
keywords = [
|
|
11
|
+
"config",
|
|
12
|
+
"configuration",
|
|
13
|
+
"parser",
|
|
14
|
+
"toml",
|
|
15
|
+
"json",
|
|
16
|
+
"jp",
|
|
17
|
+
"jpcl",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: File Formats",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Topic :: Text Processing :: Markup",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Clove Twilight"
|
|
32
|
+
email = "admin@doughmination.win"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/jpcl-lang/jpcl-py"
|
|
36
|
+
Repository = "https://github.com/jpcl-lang/jpcl-py"
|
|
37
|
+
Issues = "https://github.com/jpcl-lang/jpcl-py/issues"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
jpcl = "jpcl.cli:main"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.12.9,<0.13.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = ["pytest>=8"]
|