soml-lang 0.0.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.
@@ -0,0 +1,570 @@
1
+ Metadata-Version: 2.4
2
+ Name: soml-lang
3
+ Version: 0.0.1
4
+ Summary: Read and write SOML, a config format for humans
5
+ Keywords: soml,config,configuration,parser,serializer,json,toml,yaml
6
+ Author: Sindre Sorhus
7
+ Author-email: Sindre Sorhus <sindresorhus@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: license
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.14
19
+ Project-URL: Repository, https://github.com/soml-lang/soml-python
20
+ Description-Content-Type: text/markdown
21
+
22
+ # soml
23
+
24
+ > Read and write [SOML](https://soml.sh), a config format for humans, in Python
25
+
26
+ > [!NOTE]
27
+ > The format is a draft. See the [specification](https://github.com/soml-lang/soml/blob/main/spec.md).
28
+
29
+ - Strict: every rule in the specification is enforced, and every error says what is wrong and where
30
+ - Exact: an int is an `int` and a float is a `float`, so `3` and `3.0` keep their types
31
+ - Canonical: `dumps(value, canonical=True)` writes the one canonical form, so equal values give equal text, and by default `dumps()` keeps the order of your keys
32
+ - Formatter: `format()` normalizes the layout of a file and keeps its comments and spelling, with the same output as the JavaScript implementation
33
+ - Lossless editing: `soml.tree` changes a value in a file and keeps its comments, order, and spelling
34
+ - Familiar: the API of `json` and `tomllib`
35
+ - Fast for pure Python: parses 1.5 to 2 times as fast as `tomllib`, which is pure Python too
36
+ - Typed: strict type hints, checked with mypy and pyright in strict mode
37
+ - No dependencies
38
+
39
+ ## Install
40
+
41
+ ```sh
42
+ pip install soml-lang
43
+ ```
44
+
45
+ Requires Python 3.14 or later.
46
+
47
+ ## Usage
48
+
49
+ ```python
50
+ >>> import soml
51
+ >>> config = soml.loads('''
52
+ ... # The edge service.
53
+ ... name: 'api-gateway'
54
+ ... replicas: 3
55
+ ... timeout: 30.0
56
+ ... grace: 1m30s
57
+ ... deployed-at: 2026-09-19T14:00:00Z
58
+ ... postgres: {host: 'db.internal'}
59
+ ... ''')
60
+ >>> config['replicas'], config['timeout']
61
+ (3, 30.0)
62
+ >>> config['grace']
63
+ datetime.timedelta(seconds=90)
64
+ >>> config['postgres']
65
+ {'host': 'db.internal'}
66
+ >>> print(soml.dumps(config), end='')
67
+ name: 'api-gateway'
68
+ replicas: 3
69
+ timeout: 30.0
70
+ grace: 1m30s
71
+ deployed-at: 2026-09-19T14:00:00Z
72
+ postgres: {
73
+ host: 'db.internal'
74
+ }
75
+
76
+ ```
77
+
78
+ Read and write files in binary mode, as with `tomllib`:
79
+
80
+ ```python
81
+ with open('config.soml', 'rb') as file:
82
+ config = soml.load(file)
83
+
84
+ with open('config.soml', 'wb') as file:
85
+ soml.dump(config, file)
86
+ ```
87
+
88
+ ## Types
89
+
90
+ | SOML | Python | `dumps()` also accepts |
91
+ |---|---|---|
92
+ | string | `str` | `str` subclasses, such as `StrEnum` |
93
+ | int | `int` | `int` subclasses, such as `IntEnum` |
94
+ | float | `float` | `float` subclasses |
95
+ | bool | `bool` | |
96
+ | null | `None` | |
97
+ | instant | `datetime` in UTC | a `datetime` with any time zone |
98
+ | duration | `timedelta` | |
99
+ | array | `list` | `tuple` and any other `Sequence`, except `str`, `UserString`, `bytes`, `bytearray`, and `memoryview` |
100
+ | object | `dict`, in document order | any `Mapping` with `str` keys |
101
+
102
+ An int is an `int` and a float is a `float` in both directions, so `3` and `3.0` stay different:
103
+
104
+ ```python
105
+ >>> soml.loads('a: 3\nb: 3.0')
106
+ {'a': 3, 'b': 3.0}
107
+ >>> soml.dumps({'a': 3, 'b': 3.0})
108
+ 'a: 3\nb: 3.0\n'
109
+
110
+ ```
111
+
112
+ > [!IMPORTANT]
113
+ > Python compares `3 == 3.0 == True` as equal, so `soml.loads('a: 3') == soml.loads('a: 3.0')` is `True` even though the types differ. Compare the types too when that matters.
114
+
115
+ With this mapping, two things always hold:
116
+
117
+ - `loads(dumps(value)) == value`, for a value made of the types `loads()` returns. (A `datetime` comes back in UTC, a `tuple` as a `list`, and `-0.0` as `0.0`.)
118
+ - `dumps(loads(text), canonical=True)` is the canonical form of `text`, and `dumps(loads(canonical), canonical=True) == canonical`.
119
+
120
+ ### Instants and durations
121
+
122
+ > [!WARNING]
123
+ > The specification requires nanosecond precision, but `datetime` and `timedelta` stop at microseconds. A document with an instant or a duration that has a nonzero part below a microsecond, such as `1ns` or `2026-09-19T14:00:00.123456789Z`, is rejected with a `SOMLDecodeError`. It is never rounded. A fraction with trailing zeros, such as `.123000000`, is fine. `format()` accepts it, because it never converts a value.
124
+
125
+ An instant is read as a `datetime` in UTC, because the offset is how the instant was written, not part of its value. `dumps()` needs a `datetime` with a time zone, and writes it in UTC:
126
+
127
+ ```python
128
+ >>> soml.loads('a: 2026-09-19T14:00:00+02:00')['a']
129
+ datetime.datetime(2026, 9, 19, 12, 0, tzinfo=datetime.timezone.utc)
130
+
131
+ ```
132
+
133
+ ## API
134
+
135
+ ### loads(text)
136
+
137
+ Parse a document. Returns a `dict` or a `list`, because a document is always a collection.
138
+
139
+ `text` is a `str`, or UTF-8 `bytes` or `bytearray`. Bytes are decoded strictly, and invalid UTF-8 is reported with its position.
140
+
141
+ Raises `SOMLDecodeError` when the document is not valid, and `TypeError` when `text` is not a `str` or bytes.
142
+
143
+ ### load(file)
144
+
145
+ Parse a document from a file opened in binary mode, like `open('config.soml', 'rb')`.
146
+
147
+ Raises `SOMLDecodeError` when the document is not valid, and `TypeError` when the file is opened in text mode.
148
+
149
+ ### `dumps(value, *, canonical=False)`
150
+
151
+ Serialize a `dict` or a `list`. Members keep the order of the input, nesting is written with braces and tabs, every member and item is on its own line with no commas, and the text ends with one line feed. Comments and block strings are never written.
152
+
153
+ The output follows every rule of canonical form except member order: members keep the order of the input, because a person reads the file, and sorted keys put `description` before `name`:
154
+
155
+ ```python
156
+ >>> print(soml.dumps({'name': 'api', 'description': 'The edge service.'}), end='')
157
+ name: 'api'
158
+ description: 'The edge service.'
159
+
160
+ ```
161
+
162
+ With `canonical=True`, members are sorted by key, and the output is [canonical form](https://github.com/soml-lang/soml/blob/main/spec.md#canonical-form): equal values give equal text, so it can be hashed, signed, or compared.
163
+
164
+ ```python
165
+ >>> print(soml.dumps({'name': 'api', 'description': 'The edge service.'}, canonical=True), end='')
166
+ description: 'The edge service.'
167
+ name: 'api'
168
+
169
+ ```
170
+
171
+ Raises `TypeError` for a value of a type that cannot be written, such as a `set`, a `date`, a non-`str` key, or a scalar at the top level, and when `canonical` is not a `bool`. Raises `ValueError` for a value of the right type that cannot be written: NaN, an int outside the 64-bit range, a string or key with a carriage return or a lone surrogate, a `datetime` without a time zone, a `datetime` outside the years 0001 to 9999 in UTC, a `timedelta` outside the 64-bit range of nanoseconds, a circular reference, or nesting deeper than 100 levels.
172
+
173
+ ### dump(value, file, *, canonical=False)
174
+
175
+ Write a `dict` or a `list` to a file opened in binary mode, like `open('config.soml', 'wb')`, as `dumps()` writes it. Raises the same errors as `dumps()`, and writes nothing when it does. Raises `TypeError` when the file is opened in text mode.
176
+
177
+ ### `format(text)`
178
+
179
+ Format a document. Returns it with its layout normalized, ending with one line feed. `text` is a `str`, or UTF-8 `bytes` or `bytearray`.
180
+
181
+ The layout follows the formatter in the specification: one tab per level, every member and item on its own line with no commas, and no trailing whitespace or runs of blank lines. An object or an array whose brackets are on one line stays on one line, as in `ports: [80, 443]`, with a comma and a space between its members or items. To give a one-line container one member or item per line, put a line break anywhere inside it. Comments, member order, block strings, and the spelling of every value stay as they are, so the value never changes. A file is already formatted when `format()` returns it unchanged.
182
+
183
+ ```python
184
+ >>> print(soml.format('pool: {min: 2, max: 16,} # Connections'), end='')
185
+ pool: {min: 2, max: 16} # Connections
186
+ >>> print(soml.format('pool: {\nmin: 2, max: 16}'), end='')
187
+ pool: {
188
+ min: 2
189
+ max: 16
190
+ }
191
+
192
+ ```
193
+
194
+ In detail, as the specification states:
195
+
196
+ - A block comment that a value follows on the same line, as in `1, /* note */ 2`, stays in front of that value when each item goes on its own line.
197
+ - One space follows each `:`, as in canonical form.
198
+ - A value on the line after its `key:` moves up to that line, unless a comment comes between them. Then it stays on its own line, one level deeper than the key.
199
+ - A block string begins on the line after its key, and its delimiters and content have the indentation of that line. Its lines are not otherwise changed.
200
+ - There are no blank lines at the start of the file or directly inside brackets.
201
+ - The one change inside a block comment is the same layout rule: trailing whitespace is removed, and runs of blank lines collapse to one.
202
+
203
+ Raises the same `SOMLDecodeError` as `loads()`, and `TypeError` when `text` is not a `str` or bytes. The exception is an instant or a duration finer than a microsecond: `format()` accepts it, because it keeps the text of every value and never converts it to a `datetime` or a `timedelta`.
204
+
205
+ ### `SOMLDecodeError`
206
+
207
+ Raised by `loads()`, `load()`, `format()`, and `soml.tree.parse()`. A subclass of `ValueError`, with the same attributes as `json.JSONDecodeError` and `tomllib.TOMLDecodeError`:
208
+
209
+ ```python
210
+ >>> try:
211
+ ... soml.loads('name: api-gateway')
212
+ ... except soml.SOMLDecodeError as error:
213
+ ... print(error)
214
+ ... print(error.lineno, error.colno)
215
+ Unexpected “api-gateway”. A string value must be quoted, as in 'api-gateway' (at line 1, column 7)
216
+ 1 7
217
+
218
+ ```
219
+
220
+ | Attribute | |
221
+ |---|---|
222
+ | `msg` | What is wrong, without the position |
223
+ | `doc` | The document. For bytes that are not valid UTF-8, the part before the first invalid byte. |
224
+ | `pos` | The index in `doc` |
225
+ | `lineno` | The 1-based line |
226
+ | `colno` | The 1-based column, counted in code points |
227
+ | `code_frame` | Up to three lines that end at the error, with a caret under it, as the JavaScript and Rust implementations draw it |
228
+
229
+ ```python
230
+ >>> try:
231
+ ... soml.loads('a: 1\nb: nope')
232
+ ... except soml.SOMLDecodeError as error:
233
+ ... print(error.code_frame)
234
+ 1 | a: 1
235
+ > 2 | b: nope
236
+ | ^
237
+
238
+ ```
239
+
240
+ The frame is also the error's note (`error.__notes__`, [PEP 678](https://peps.python.org/pep-0678/)), so an uncaught error shows it in the traceback, under the message. `str(error)` is only the message.
241
+
242
+ A long line is clipped around the error, and characters that a terminal acts on, such as an escape or a bidirectional control, are replaced in the frame and the message.
243
+
244
+ ### Types for type checkers
245
+
246
+ - `soml.Value`: a value that `loads()` returns.
247
+ - `soml.Document`: what `loads()` returns, a `dict[str, Value]` or a `list[Value]`.
248
+ - `soml.Serializable`: a value that `dumps()` accepts.
249
+
250
+ The examples here leave out the narrowing that a type checker needs. `loads()` returns a `dict` or a `list`, and a value can be any of the types above, so typed code checks first:
251
+
252
+ ```python
253
+ config = soml.loads(text)
254
+ assert isinstance(config, dict)
255
+ port = config['port']
256
+ assert isinstance(port, int)
257
+ ```
258
+
259
+ The same applies to the tree: `document.body` is an `ObjectNode` or an `ArrayNode`, `get()` returns `None` when no node is at the path, and a member's `value` is any value node. The nodes are dataclasses, so `match` narrows them and reads their fields in one step:
260
+
261
+ ```python
262
+ >>> from soml import tree
263
+ >>> document = tree.parse('port: 0x1F90')
264
+ >>> match document.get('port'):
265
+ ... case tree.IntegerNode(value=port, radix=16):
266
+ ... print(f'{port} in hexadecimal')
267
+ ... case tree.IntegerNode(value=port):
268
+ ... print(port)
269
+ ... case None:
270
+ ... print('No port')
271
+ ... case _:
272
+ ... print('The port is not an int')
273
+ 8080 in hexadecimal
274
+
275
+ ```
276
+
277
+ ## Syntax tree
278
+
279
+ `soml.tree` reads a document into a syntax tree that keeps everything the author wrote. Change the nodes, then print the tree, and whatever you did not change is copied from the source byte for byte, comments included. Use it to change a config file from a program without rewriting the rest of it.
280
+
281
+ ```python
282
+ >>> from soml import tree
283
+ >>> document = tree.parse('''\
284
+ ... # The edge service.
285
+ ... name: 'api-gateway'
286
+ ... port: 0x1F90 # Hex, on purpose.
287
+ ... postgres: {host: 'db.internal'}
288
+ ... tags: ['edge', 'eu']
289
+ ... ''')
290
+ >>> document.get('port').value = 9090
291
+ >>> document.set(('postgres', 'host'), 'db2.internal')
292
+ >>> document.set(('tags', 2), 'canary')
293
+ >>> document.set('debug', False)
294
+ >>> print(tree.unparse(document), end='')
295
+ # The edge service.
296
+ name: 'api-gateway'
297
+ port: 0x2382 # Hex, on purpose.
298
+ postgres: {host: 'db2.internal'}
299
+ tags: ['edge', 'eu', 'canary']
300
+ debug: false
301
+
302
+ ```
303
+
304
+ ### Paths
305
+
306
+ `DocumentNode` reads and changes values by path. A path is a key, an array index, or a sequence of keys and indices, such as `('servers', 0, 'port')`.
307
+
308
+ #### `document.get(path)`
309
+
310
+ The value node at a path, or `None` when no node is there. Set `value` on the node to change a value and keep its spelling, as with `0x1F90` above.
311
+
312
+ All three methods, `in`, and `del` raise `ValueError` for an empty path or a negative index, and `TypeError` for an item of a path that is not a `str` or an `int`.
313
+
314
+ #### `document.set(path, value)`
315
+
316
+ Set a plain value at a path, replacing what is there or adding it:
317
+
318
+ - A replaced value keeps the comments around it.
319
+ - A new member goes after the last member of its object, with that member's layout. It goes on its own line with no comma, because the line break separates it, after the comments that the member before it owns (see `remove()`), so before a comment on a line of its own. When something other than its comma and those comments follows the member before it on that member's line, such as the `}` of `{a: 1}`, it goes on that line after a comma instead, and it has a comma after it when the member before has one. So `{a: 1}` becomes `{a: 1, b: 2}`, and `[1, 2 /* note */]` with a new item becomes `[1, 2 /* note */, 3]`.
320
+ - A missing parent object is made with braces, written on several lines, or on one line in a container that stays on one line.
321
+ - An index equal to the length of an array appends to it. A path below it makes that item, with objects for the keys after it, so `('b', 0, 'c')` in `b: []` adds the item `{c: 1}`.
322
+ - In a container that stays on one line, a new or replaced value is written on one line too, so `[1, 2]` with a new item `{'a': 3}` becomes `[1, 2, {a: 3}]`. A container stays on one line when its brackets are on one line with something other than spaces and tabs between them, as in `[1, 2]` and `[/* note */]`, or when it is inside a container that stays on one line. A new member or item in a container with no members or items goes on a line of its own, as in an empty `[]` or `{}`, unless the container stays on one line: then it goes before the closing bracket, so `[/* note */]` becomes `[/* note */ 1]`, and `a: [1, []]` becomes `a: [1, [2]]`.
323
+
324
+ Raises `ValueError` when the path does not fit the document (it goes through a scalar, uses a key on an array or an index on an object, has an index past the end, or has an index below a value that does not exist), when the result would not be a valid document, and with the errors of `dumps()` for a value that cannot be written. The document is unchanged after an error. Each change reads the whole document back to check it.
325
+
326
+ #### `document.remove(path)`
327
+
328
+ Remove the member or item at a path, with the comments it owns and the comma after it. A member or item owns the comments that `format()` keeps with it, as the "Editing" rules of the specification say: the comments after it on its line, also after its comma when nothing else follows there, and the block comments before it on its line, after the comma or bracket before it. So removing `2` from `[1, /* note */ 2]` gives `[1]`. A comment on a line of its own belongs to no member or item, so the comment lines above it stay. When it is the last one and has no comma, it takes the comma before it instead, when only spaces, tabs, and the comments it owns are between, so `[1, 2]` becomes `[1]`. No other comma changes. Removing the only member of a document without braces leaves `{}` in its place, because a document is never empty. Like a value that `set()` replaces, such an empty object keeps the comments around the member, so `port: 8080 # Note` becomes `{} # Note`.
329
+
330
+ Removing a value that does not exist changes nothing and is not an error: a missing member, a missing object or array on the way, or an index at or past the end of its array. So removing the same path twice is safe. Returns whether something was removed, so a caller that expected a value can tell that it was missing. Raises `ValueError` when the path does not fit the document before it leads to nothing: it goes through a scalar, or uses a key on an array or an index on an object. The document is unchanged after an error.
331
+
332
+ ```python
333
+ >>> document = tree.parse('port: 8080\n')
334
+ >>> document.remove('port'), document.remove('port')
335
+ (True, False)
336
+
337
+ ```
338
+
339
+ #### path in document, del document[path]
340
+
341
+ `in` tells whether there is a value at a path. `del` removes as `remove()` does, but when nothing is there, where `remove()` returns `False`, `del` raises `KeyError`, as on a `dict`:
342
+
343
+ ```python
344
+ >>> document = tree.parse("postgres: {host: 'db.internal'}\nport: 8080\n")
345
+ >>> 'postgres' in document, ('postgres', 'port') in document
346
+ (True, False)
347
+ >>> del document['port']
348
+ >>> print(tree.unparse(document), end='')
349
+ postgres: {host: 'db.internal'}
350
+
351
+ ```
352
+
353
+ There is no `document[path]` to read or write, because `get()` returns a node and `set()` takes a plain value.
354
+
355
+ ### `tree.parse(text)`
356
+
357
+ Parse a document into a `DocumentNode`. Raises the same `SOMLDecodeError` as `loads()` for the same input.
358
+
359
+ ### `tree.unparse(document)`
360
+
361
+ Print a tree as text. It is named after `ast.unparse()`, but it keeps the source:
362
+
363
+ - A tree that was not changed prints its source exactly.
364
+ - A node that was not changed prints its source text, so `0x1F90`, `1_000_000`, `90m`, a block string, a quoted key, and an offset such as `+02:00` stay as they were written.
365
+ - A changed int keeps its radix (a negative one is written in decimal), and a changed string stays `'...'` or `"..."`, unless `'...'` cannot hold the new value. A changed block string is written on one line. When a block string began on the line after its key, it goes on the line of the key when it changes or a value that `set()` or a program makes replaces it, unless a comment is between them, so a formatted document stays formatted.
366
+ - A removed member or item takes the comments it owns, as `remove()` says, and its own lines when nothing else is on them. A comment on its own line above it stays. A value that `set()` replaces keeps the comments around it. A node that you put in a list yourself is a new node, so it gets the layout of the ones next to it, and a node moved to another container loses the comment at the end of its line, because comments belong to the source around a node, not to the node.
367
+ - A new member or item gets the layout of the ones next to it, and a node you made is written in canonical layout, but in your order. In a container that stays on one line, as `set()` describes, it is written on one line, as in `[1, 2, {a: 3}]`.
368
+ - Commas follow the "Editing" rules of the specification, so that an edit changes only the commas of what it adds or removes. A new member or item goes on its own line with no comma, after the comments that the one before it owns. When something other than its comma and those comments follows the one before it on its line, such as the `}` of `{a: 1}`, it goes on that line after a comma, and it has a comma after it when the one before has one. A removed member or item takes the comma after it. The removed ones at the end take the comma before them when the last of them has none and only spaces, tabs, and the comments that they own are between, so `[1, 2]` and `[1, /* note */ 2]` without `2` become `[1]`. Every other comma stays, so `[\n\t1,\n\t2\n]` without `2` keeps the comma after `1`, which is valid.
369
+ - A removed member or item does not leave a new blank line next to another one, directly inside a bracket, or at the start or end of the document. When it could take the blank line before it or the one after it, it takes the one before.
370
+
371
+ The output is always a valid document. A tree that does not make one, for example because an edit added a duplicate key, raises `ValueError`. The layout of what changed is best effort.
372
+
373
+ ### `tree.walk(node)`
374
+
375
+ Yield the node and every node inside it, in source order: a node comes before its children, and a member's key before its value. It is like `ast.walk()`, but in source order, for tools such as linters. Comments and tokens are in `DocumentNode.comments` and `DocumentNode.tokens`.
376
+
377
+ ```python
378
+ >>> [node.value for node in tree.walk(tree.parse('a: [1, 2]')) if isinstance(node, tree.IntegerNode)]
379
+ [1, 2]
380
+
381
+ ```
382
+
383
+ ### `tree.from_value(value)`
384
+
385
+ Make a node from a plain value, as `dumps()` would write it, and object members keep their order. Raises the same errors as `dumps()` for a value it cannot write. A scalar is fine, because a node is not a document.
386
+
387
+ ### `tree.DocumentNode.from_value(value)`
388
+
389
+ Make a new document from a `dict` or a `list`, such as a starter config that a tool writes. It is written as `tree.from_value()` writes a node, and a top-level object has no braces. Raises the same errors as `dumps()`, including `TypeError` for a scalar.
390
+
391
+ A member that you make can have `comments`, which `unparse()` writes as `#` lines above it:
392
+
393
+ ```python
394
+ >>> document = tree.DocumentNode.from_value({'name': 'api', 'port': 8080})
395
+ >>> document.body.members[1].comments = ['The port to listen on.']
396
+ >>> print(tree.unparse(document), end='')
397
+ name: 'api'
398
+ # The port to listen on.
399
+ port: 8080
400
+
401
+ ```
402
+
403
+ `comments` works for a new member in a parsed document too, when the member starts its own line. `unparse()` raises `ValueError` for a comment with a line break, for a new member with comments that goes on the line of the text before it, as in `{a: 1}`, and for a member from the source with comments, because the comments of the source cannot be edited.
404
+
405
+ ### Nodes
406
+
407
+ Every node is a mutable dataclass. A node from `parse()` has a `range`, which is `(start, end)` as indices into the source, and a node that you make has `range=None`.
408
+
409
+ | Node | Fields |
410
+ |---|---|
411
+ | `DocumentNode` | `body`: an `ObjectNode` or an `ArrayNode`; `comments`; `tokens`; `source`; `get()`, `set()`, `remove()`, `in`, and `del`; `position(offset)`, the 1-based line and column of an index |
412
+ | `ObjectNode` | `members`; `braced`, which is `False` only for a top-level object without braces. Changing it prints the object fresh, so the comments between and after its members go. |
413
+ | `MemberNode` | `key`; `value`; `comments`, the comment lines above a member that you make |
414
+ | `ArrayNode` | `elements` |
415
+ | `KeyNode` | `value`, decoded; `style`: `'bare'`, `'literal'`, or `'escaped'` |
416
+ | `StringNode` | `value`, decoded; `style`: `'literal'` or `'escaped'`; `block` |
417
+ | `IntegerNode` | `value`; `radix`: `2`, `8`, `10`, or `16` |
418
+ | `FloatNode` | `value`, including `inf` and `-inf` |
419
+ | `BooleanNode` | `value` |
420
+ | `NullNode` | |
421
+ | `InstantNode` | `value`, a `datetime` in UTC |
422
+ | `DurationNode` | `value`, a `timedelta` |
423
+
424
+ The value nodes and `DocumentNode` have `to_value()`, which returns what `loads()` returns for that part of the document. `tree.ValueNode` is the union of the value nodes, and `tree.Node` the union of all nodes, for type annotations.
425
+
426
+ A `Comment` has `value` (without `#`, or without `/*` and `*/`), `kind` (`'line'` or `'block'`), and `range`. A `Token` has `value` (its source text), `kind` (`'punctuator'`, `'bare_key'`, `'string'`, `'integer'`, `'float'`, `'keyword'`, `'instant'`, or `'duration'`, where a quoted key is a `'string'`), and `range`. Comments and tokens are read-only: changing the lists changes nothing that `unparse()` writes.
427
+
428
+ ## Recipes
429
+
430
+ ### Typed config with msgspec
431
+
432
+ `msgspec.convert()` checks a plain value against a class. Pass `builtin_types`, or msgspec also accepts a string such as `'PT90S'` for a `timedelta`:
433
+
434
+ ```python
435
+ from datetime import datetime, timedelta
436
+
437
+ import msgspec
438
+ import soml
439
+
440
+
441
+ class Server(msgspec.Struct):
442
+ name: str
443
+ port: int
444
+ timeout: timedelta
445
+ started: datetime
446
+
447
+
448
+ with open('config.soml', 'rb') as file:
449
+ server = msgspec.convert(soml.load(file), Server, builtin_types=(datetime, timedelta))
450
+ ```
451
+
452
+ It refuses `port: 8080.0`, because a float is not an int.
453
+
454
+ ### Typed config with pydantic
455
+
456
+ Use strict mode, or pydantic accepts `3.0` and `'3'` for an `int`:
457
+
458
+ ```python
459
+ from datetime import timedelta
460
+
461
+ import pydantic
462
+ import soml
463
+
464
+
465
+ class Config(pydantic.BaseModel):
466
+ model_config = pydantic.ConfigDict(strict=True)
467
+ name: str
468
+ port: int
469
+ timeout: timedelta
470
+
471
+
472
+ with open('config.soml', 'rb') as file:
473
+ config = Config.model_validate(soml.load(file))
474
+ ```
475
+
476
+ ### Settings with pydantic-settings
477
+
478
+ A settings source that reads a SOML file, so that environment variables override it:
479
+
480
+ ```python
481
+ from pathlib import Path
482
+
483
+ import soml
484
+ from pydantic_settings import BaseSettings, InitSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict
485
+ from pydantic_settings.sources import ConfigFileSourceMixin
486
+
487
+
488
+ class SOMLConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin):
489
+ def __init__(self, settings_class: type[BaseSettings], path: Path) -> None:
490
+ super().__init__(settings_class, self._read_files(path))
491
+
492
+ def _read_file(self, path: Path) -> dict[str, object]:
493
+ # Binary mode, because text mode turns a carriage return into a line feed, which SOML must refuse.
494
+ with path.open('rb') as file:
495
+ value = soml.load(file)
496
+
497
+ if not isinstance(value, dict):
498
+ raise TypeError(f'{path} must hold an object, not an array')
499
+
500
+ return value
501
+
502
+
503
+ class Settings(BaseSettings):
504
+ model_config = SettingsConfigDict(env_prefix='APP_', strict=True)
505
+ name: str
506
+ port: int
507
+
508
+ @classmethod
509
+ def settings_customise_sources(
510
+ cls,
511
+ settings_cls: type[BaseSettings],
512
+ init_settings: PydanticBaseSettingsSource,
513
+ env_settings: PydanticBaseSettingsSource,
514
+ dotenv_settings: PydanticBaseSettingsSource,
515
+ file_secret_settings: PydanticBaseSettingsSource,
516
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
517
+ return (init_settings, env_settings, SOMLConfigSettingsSource(settings_cls, Path('config.soml')))
518
+
519
+
520
+ settings = Settings() # `APP_PORT=9090` overrides `port: 8080` in the file.
521
+ ```
522
+
523
+ ### Compare two configs
524
+
525
+ Canonical form has one sorted member on each line, so a line diff shows every change, including a change of type, which comparing the values with `==` misses, because `3 == 3.0` in Python:
526
+
527
+ ```python
528
+ >>> import difflib
529
+ >>> before = soml.dumps(soml.loads('a: 3\nb: 1'), canonical=True)
530
+ >>> after = soml.dumps(soml.loads('a: 3.0\nb: 1'), canonical=True)
531
+ >>> print(''.join(difflib.unified_diff(before.splitlines(keepends=True), after.splitlines(keepends=True))), end='')
532
+ ---
533
+ +++
534
+ @@ -1,2 +1,2 @@
535
+ -a: 3
536
+ +a: 3.0
537
+ b: 1
538
+
539
+ ```
540
+
541
+ ### Hash a config
542
+
543
+ Equal values give equal canonical text, so hash it to compare, cache, or sign a value. The comments, the order of members, and the spelling of values do not change the hash:
544
+
545
+ ```python
546
+ >>> import hashlib
547
+ >>> hashlib.sha256(soml.dumps(soml.loads('b: 1\na: 0x10 # Note.'), canonical=True).encode()).hexdigest() == hashlib.sha256(soml.dumps(soml.loads('a: 16\nb: 1'), canonical=True).encode()).hexdigest()
548
+ True
549
+
550
+ ```
551
+
552
+ ## Limits
553
+
554
+ - **Nesting is limited to 100 levels**, as the specification requires, in reading, writing, and the tree. Every array and object is one level, including the document's own collection.
555
+ - **Microseconds**, not nanoseconds, for instants and durations (see above), except in `format()`.
556
+ - **Comments cannot be edited** through the tree, only added above a new member. Each comment belongs to the source around a child: the comment lines above it, and the comments that it owns, as `remove()` says. A run of block comments that has a line break inside one of them goes with one child as a whole, as in the Rust implementation.
557
+
558
+ ## Development
559
+
560
+ ```sh
561
+ uv run pytest
562
+ uv run ruff check
563
+ uv run ruff format --check
564
+ uv run mypy
565
+ uv run pyright
566
+ ```
567
+
568
+ `HYPOTHESIS_PROFILE=thorough uv run pytest` runs the property tests with 10,000 examples each, and `uv run --group bench python bench/benchmark.py` compares the speed with `json` and `tomllib`.
569
+
570
+ The conformance suite in [`tests/conformance`](tests/conformance) is a copy of the one in the [`soml`](https://github.com/soml-lang/soml/tree/main/conformance) spec repository. To update it, check out `soml` next to this repository and run `../soml/sync-conformance.sh tests/conformance`.
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.