Techniques
This section documents common patterns of use with examples and suggestions.
Validate with Voluptuous
This example shows how to use voluptuous to validate and parse a NestedText
file and it demonstrates how to use the keymap argument from loads()
or
load()
to add location information to Voluptuous error messages.
The input file in this case specifies deployment settings for a web server:
debug: false
secret key: t=)40**y&883y9gdpuw%aiig+wtc033(ui@^1ur72w#zhw3_ch
allowed hosts:
- www.example.com
database:
engine: django.db.backends.mysql
host: db.example.com
port: 3306
user: www
webmaster email: admin@example.com
Below is the code to parse this file. Note how the structure of the data is
specified using basic Python objects. The Coerce()
function is
necessary to have Voluptuous convert string input to the given type; otherwise
it would simply check that the input matches the given type:
#!/usr/bin/env python3
import nestedtext as nt
from voluptuous import Schema, Coerce, MultipleInvalid
from voluptuous_errors import report_voluptuous_errors
from inform import error, full_stop, terminate
from pprint import pprint
schema = Schema({
'debug': Coerce(bool),
'secret_key': str,
'allowed_hosts': [str],
'database': {
'engine': str,
'host': str,
'port': Coerce(int),
'user': str,
},
'webmaster_email': str,
})
def normalize_key(key, parent_keys):
return '_'.join(key.lower().split())
filename = "deploy.nt"
try:
keymap = {}
raw = nt.load(filename, keymap=keymap, normalize_key=normalize_key)
config = schema(raw)
except nt.NestedTextError as e:
e.terminate()
except MultipleInvalid as e:
report_voluptuous_errors(e, keymap, filename)
terminate()
pprint(config)
This example uses the following code to adapt error reporting in Voluptuous to NestedText.
from inform import cull, error, full_stop
import nestedtext as nt
voluptuous_error_msg_mappings = {
"extra keys not allowed": ("unknown key", "key"),
"expected a dict": ("expected a key-value pair", "value"),
"required key not provided": ("required key is missing", "value"),
}
def report_voluptuous_errors(multiple_invalid, keymap, source=None, sep="›"):
source = str(source) if source else ""
for err in multiple_invalid.errors:
# convert message to something easier for non-savvy user to understand
msg, kind = voluptuous_error_msg_mappings.get(
err.msg, (err.msg, 'value')
)
# get metadata about error
if keymap:
culprit = nt.get_keys(err.path, keymap=keymap, strict="found", sep=sep)
line_nums = nt.get_line_numbers(err.path, keymap, kind=kind, sep="-", strict=False)
loc = nt.get_location(err.path, keymap)
if loc:
codicil = loc.as_line(kind)
else: # required key is missing
missing = nt.get_keys(err.path, keymap, strict="missing", sep=sep)
codicil = f"‘{missing}’ was not found."
file_and_lineno = f"{source!s}@{line_nums}"
culprit = cull((file_and_lineno, culprit))
else:
keys = sep.join(str(c) for c in err.path)
culprit = cull([source, keys])
codicil = None
# report error
error(full_stop(msg), culprit=culprit, codicil=codicil)
This produces the following data structure:
{'allowed_hosts': ['www.example.com'],
'database': {'engine': 'django.db.backends.mysql',
'host': 'db.example.com',
'port': 3306,
'user': 'www'},
'debug': False,
'secret_key': 't=)40**y&883y9gdpuw%aiig+wtc033(ui@^1ur72w#zhw3_ch',
'webmaster_email': 'admin@example.com'}
See the PostMortem example for a more flexible approach to validating with Voluptuous.
Validate with Pydantic
This example shows how to use pydantic to validate and parse a NestedText file. The input file is the same as in the previous example, i.e. deployment settings for a web server:
debug: false
secret key: t=)40**y&883y9gdpuw%aiig+wtc033(ui@^1ur72w#zhw3_ch
allowed hosts:
- www.example.com
database:
engine: django.db.backends.mysql
host: db.example.com
port: 3306
user: www
webmaster email: admin@example.com
Below is the code to parse this file. Note that basic types like integers, strings, Booleans, and lists are specified using standard type annotations. Dictionaries with specific keys are represented by model classes, and it is possible to reference one model from within another. Pydantic also has built-in support for validating email addresses, which we can take advantage of here:
#!/usr/bin/env python3
import nestedtext as nt
from pydantic import BaseModel, EmailStr
from typing import List
from pprint import pprint
class Database(BaseModel):
engine: str
host: str
port: int
user: str
class Config(BaseModel):
debug: bool
secret_key: str
allowed_hosts: List[str]
database: Database
webmaster_email: EmailStr
def normalize_key(key, parent_keys):
return '_'.join(key.lower().split())
obj = nt.load('deploy.nt', normalize_key=normalize_key)
config = Config.parse_obj(obj)
pprint(config.dict())
This produces the same result as in the previous example.
Normalizing Keys
With data files created by non-programmers it is often desirable to allow
a certain amount of flexibility in the keys. For example, you may wish to
ignore case and if you allow multi-word keys you may want to be tolerant of
extra spaces between the words. However, the end applications often needs the
keys to be specific values. It is possible to normalize the keys using
a schema, but this can interfere with error reporting. Imagine there is an
error in the value associated with a set of keys, if the keys have been changed
by the schema the keymap can no longer be used to convert the keys into a line
number for an error message. NestedText provides the normalize_key argument
to load()
and loads()
to address this issue. It allows you to pass
in a function that normalizes the keys before the keymap is created, releasing
the schema from that task.
The following contact look-up program demonstrates both the normalization of keys and the associated error reporting. In this case, the first level of keys contains the names of the contacts and should not be normalized. Keys at all other levels are considered keywords and so should be normalized.
#!/usr/bin/env python3
"""
Display Contact Information
Usage:
contact <name>...
"""
from docopt import docopt
from inform import codicil, display, error, full_stop, os_error, terminate
import nestedtext as nt
from voluptuous import Schema, Any, MultipleInvalid
import re
contacts_file = "address.nt"
def normalize_key(key, parent_keys):
if len(parent_keys) == 0:
return key
return '_'.join(key.lower().split())
def render_contact(data, keymap=None):
text = nt.dumps(data, map_keys=keymap)
return re.sub(r'^(\s*)[>:][ ]?(.*)$', r'\1\2', text, flags=re.M)
cmdline = docopt(__doc__)
names = cmdline['<name>']
try:
# define structure of contacts database
contacts_schema = Schema({
str: {
'position': str,
'address': str,
'phone': Any({str:str},str),
'email': Any({str:str},str),
'additional_roles': Any(list,str),
}
})
# read contacts database
contacts = contacts_schema(
nt.load(
contacts_file,
top = 'dict',
normalize_key = normalize_key,
keymap = (keymap:={})
)
)
# display requested contact information, excluding additional_roles
filtered = {}
for fullname, contact_info in contacts.items():
for name in names:
if name in fullname.lower():
filtered[fullname] = contact_info
if 'additional_roles' in contact_info:
del contact_info['additional_roles']
# display contact using normalized keys
# display(render_contact(filtered))
# display contact using original keys
display(render_contact(filtered, keymap))
except nt.NestedTextError as e:
e.report()
except MultipleInvalid as exception:
for e in exception.errors:
kind = 'key' if 'key' in e.msg else 'value'
keys = tuple(e.path)
codicil = keymap[keys].as_line(kind) if keys in keymap else None
line_num, col_num = keymap[keys].as_tuple()
file_and_lineno = f"{contacts_file!s}@{line_num}"
key_path = nt.join_keys(keys, keymap=keymap, sep="›")
error(
full_stop(e.msg),
culprit = (file_and_lineno, key_path),
codicil = codicil
)
except OSError as e:
error(os_error(e))
terminate()
This program takes a name as a command line argument and prints out the corresponding address. It uses the pretty print idea described below to render the contact information. Voluptuous checks the validity of the contacts database, which is shown next. Notice the variability in the keys given in Fumiko’s entry:
# Contact information for our officers
Katheryn McDaniel:
position: president
address:
> 138 Almond Street
> Topeka, Kansas 20697
phone:
cell: 1-210-555-5297
# Katheryn prefers that we call her on her cell phone
work: 1-210-555-8470
email: KateMcD@aol.com
additional roles:
- board member
Margaret Hodge:
position: vice president
address:
> 2586 Marigold Lane
> Topeka, Kansas 20682
phone: 1-470-555-0398
email: margaret.hodge@ku.edu
additional roles:
- new membership task force
- accounting task force
Fumiko Purvis:
Position: Treasurer
# Fumiko's term is ending at the end of the year.
Address:
> 3636 Buffalo Ave
> Topeka, Kansas 20692
Phone: 1-268-555-0280
EMail: fumiko.purvis@hotmail.com
Additional Roles:
- accounting task force
There are two display statements near the end of the program, the first of which is commented out. The first outputs the contact information using normalized keys, and the second outputs the information using the original keys.
Now, requesting Fumiko’s contact information gives:
Fumiko Purvis:
Position: treasurer
Address:
3636 Buffalo Ave
Topeka, Kansas 20692
Phone: 1-268-555-0280
EMail: fumiko.purvis@hotmail.com
Notice that any processing of the information (error checking, deleting additional_roles) is performed using the normalized keys, but by choice, the information is output using the original keys.
Duplicate Keys
There are occasions where it is useful to be able to read dictionaries from
NestedText that contain duplicate keys. For example, imagine that you have two
contacts with the same name, and the name is used as a key. Normally
load()
and loads()
throw an exception if duplicate keys are detected
because the underlying Python dictionaries cannot hold items with duplicate
keys. However, you can pass a function to the on_dup argument that
de-duplicates the keys, making them safe for Python dictionaries. For example
the following NestedText document that contains duplicate keys:
Michael Jordan:
occupation: basketball player
Michael Jordan:
occupation: actor
Michael Jordan:
occupation: football player
In the following, the de_dup function adds “#*N*” to the end of the key where N starts at 2 and increases as more duplicates are found.
#!/usr/bin/env python3
from inform import codicil, display, fatal, full_stop, os_error
import nestedtext as nt
filename = "michael_jordan.nt"
def de_dup(key, state):
if key not in state:
state[key] = 1
state[key] += 1
return f"{key} #{state[key]}"
try:
# read contacts database
data = nt.load(filename, 'dict', on_dup=de_dup, keymap=(keymap:={}))
# display contact using deduplicated keys
display("DE-DUPLICATED KEYS:")
display(nt.dumps(data))
# display contact using original keys
display()
display("ORIGINAL KEYS:")
display(nt.dumps(data, map_keys=keymap))
except nt.NestedTextError as e:
e.terminate()
except OSError as e:
fatal(os_error(e))
As shown below, this code outputs the data twice, the first time with the de-duplicated keys and the second time using the original keys. Notice that the first contains the duplication markers whereas the second does not.
DE-DUPLICATED KEYS:
Michael Jordan:
occupation: basketball player
Michael Jordan #2:
occupation: actor
Michael Jordan #3:
occupation: football player
ORIGINAL KEYS:
Michael Jordan:
occupation: basketball player
Michael Jordan:
occupation: actor
Michael Jordan:
occupation: football player
Sorting Keys
The default order of dictionary items in the NestedText output of dump()
and dumps()
is the natural order of the underlying dictionary, but you can
use sort_keys argument to change the order. For example, here are two
different ways of sorting the address list. The first is a simple alphabetic
sort of the keys at each level, which you get by simply specifying
sort_keys=True.
>>> addresses = nt.load( 'examples/addresses/address.nt')
>>> print(nt.dumps(addresses, sort_keys=True))
Fumiko Purvis:
Additional Roles:
- accounting task force
Address:
> 3636 Buffalo Ave
> Topeka, Kansas 20692
EMail: fumiko.purvis@hotmail.com
Phone: 1-268-555-0280
Position: Treasurer
Katheryn McDaniel:
additional roles:
- board member
address:
> 138 Almond Street
> Topeka, Kansas 20697
email: KateMcD@aol.com
phone:
cell: 1-210-555-5297
work: 1-210-555-8470
position: president
Margaret Hodge:
additional roles:
- new membership task force
- accounting task force
address:
> 2586 Marigold Lane
> Topeka, Kansas 20682
email: margaret.hodge@ku.edu
phone: 1-470-555-0398
position: vice president
The second sorts only the first level, by last name then remaining names. It passes a function to sort_keys. That function takes two arguments, the key to be sorted and the tuple of parent keys. The key to be sorted is also a tuple that contains the key and the rendered item. The key is the key as specified in the object being dumped, and rendered item is a string that takes the form “mapped_key: value”.
The sort_keys function is expected to return a string that contains the sort key, the key used by the sort. For example, in this case a first level key “Fumiko Purvis” is mapped to “Purvis Fumiko” for the purposes of determining the sort order. At all other levels any key is mapped to “”. In this way the sort keys are all identical, and so the original order is retained.
>>> def sort_key(key, parent_keys):
... if len(parent_keys) == 0:
... # rearrange names so that last name is given first
... names = key[0].split()
... return ' '.join([names[-1]] + names[:-1])
... return '' # do not reorder lower levels
>>> print(nt.dumps(addresses, sort_keys=sort_key))
Margaret Hodge:
position: vice president
address:
> 2586 Marigold Lane
> Topeka, Kansas 20682
phone: 1-470-555-0398
email: margaret.hodge@ku.edu
additional roles:
- new membership task force
- accounting task force
Katheryn McDaniel:
position: president
address:
> 138 Almond Street
> Topeka, Kansas 20697
phone:
cell: 1-210-555-5297
work: 1-210-555-8470
email: KateMcD@aol.com
additional roles:
- board member
Fumiko Purvis:
Position: Treasurer
Address:
> 3636 Buffalo Ave
> Topeka, Kansas 20692
Phone: 1-268-555-0280
EMail: fumiko.purvis@hotmail.com
Additional Roles:
- accounting task force
Key Presentation
When generating a NestedText document, it is sometimes desirable to transform the keys upon output. Generally one transforms the keys in order to change the presentation of the key, not the meaning. For example, you may want change its case, rearrange it (ex: swap first and last names), translate it, etc. These are done by passing a function to the map_keys argument. This function takes two arguments: the key after it has been rendered to a string and the tuple of parent keys. It is expected to return the transformed string. For example, lets print the address book again, this time with names printed with the last name first.
>>> def last_name_first(key, parent_keys):
... if len(parent_keys) == 0:
... # rearrange names so that last name is given first
... names = key.split()
... return f"{names[-1]}, {' '.join(names[:-1])}"
>>> def sort_key(key, parent_keys):
... return key if len(parent_keys) == 0 else '' # only sort first level keys
>>> print(nt.dumps(addresses, map_keys=last_name_first, sort_keys=sort_key))
Hodge, Margaret:
position: vice president
address:
> 2586 Marigold Lane
> Topeka, Kansas 20682
phone: 1-470-555-0398
email: margaret.hodge@ku.edu
additional roles:
- new membership task force
- accounting task force
McDaniel, Katheryn:
position: president
address:
> 138 Almond Street
> Topeka, Kansas 20697
phone:
cell: 1-210-555-5297
work: 1-210-555-8470
email: KateMcD@aol.com
additional roles:
- board member
Purvis, Fumiko:
Position: Treasurer
Address:
> 3636 Buffalo Ave
> Topeka, Kansas 20692
Phone: 1-268-555-0280
EMail: fumiko.purvis@hotmail.com
Additional Roles:
- accounting task force
When round-tripping a NestedText document (reading the document and then later writing it back out), one often wants to undo any changes that were made to the keys when reading the documents. These modifications would be due to key normalization or key de-duplication. This is easily accomplished by simply retaining the keymap from the original load and passing it to the dumper by way of the map_keys argument.
>>> def normalize_key(key, parent_keys):
... if len(parent_keys) == 0:
... return key
... return '_'.join(key.lower().split())
>>> keymap = {}
>>> addresses = nt.load(
... 'examples/addresses/address.nt',
... normalize_key=normalize_key,
... keymap=keymap
... )
>>> filtered = {k:v for k,v in addresses.items() if 'fumiko' in k.lower()}
>>> print(nt.dumps(filtered))
Fumiko Purvis:
position: Treasurer
address:
> 3636 Buffalo Ave
> Topeka, Kansas 20692
phone: 1-268-555-0280
email: fumiko.purvis@hotmail.com
additional_roles:
- accounting task force
>>> print(nt.dumps(filtered, map_keys=keymap))
Fumiko Purvis:
Position: Treasurer
Address:
> 3636 Buffalo Ave
> Topeka, Kansas 20692
Phone: 1-268-555-0280
EMail: fumiko.purvis@hotmail.com
Additional Roles:
- accounting task force
Notice that the keys differ between the two. The normalized key are output in the former and original keys in the latter.
Finally consider the case where you want to do both things; you want to return to the original keys but you also want to change the presentation. For example, imagine wanting to display the original keys in blue. That can be done as follows:
>>> from inform import Color
>>> blue = Color('blue', enable=Color.isTTY())
>>> def format_key(key, parent_keys):
... orig_keys = nt.get_original_keys(parent_keys + (key,), keymap)
... return blue(orig_keys[-1])
>>> print(nt.dumps(filtered, map_keys=format_key))
Fumiko Purvis:
Position: Treasurer
Address:
> 3636 Buffalo Ave
> Topeka, Kansas 20692
Phone: 1-268-555-0280
EMail: fumiko.purvis@hotmail.com
Additional Roles:
- accounting task force
The result looks identical in the documentation, but if you ran this program in a terminal you would see the keys in blue.
References
A reference allows you to define some content once and insert that content multiple places in the document. A reference is also referred to as a macro. Both simple and parametrized references can be easily implemented. For parametrized references, the arguments list is treated as an embedded NestedText document.
The technique is demonstrated with an example. This example is a fragment of a diet program. It reads two NestedText documents, one containing the known foods, and the other that documents the actual meals as consumed. The foods may be single ingredient, like steel cut oats, or it may contain multiple ingredients, like oatmeal. The use of parametrized references allows one to override individual ingredients in a composite ingredient. In this example, the user simply specifies the composite ingredient oatmeal on 21 March. On 22 March, they specify it as a simple reference, meaning that they end up with the same ingredients, but this time they are listed separately in the final summary. Finally, on 23 March they specify oatmeal using a parametrized reference so as to override the number of tangerines consumed and add some almonds.
#!/usr/bin/env python3
from inform import Error, display, dedent
import nestedtext as nt
import re
foods = nt.loads(dedent("""
oatmeal:
steel cut oats: 1/4 cup
tangerines: 1 each
whole milk: 1/4 cup
steel cut oats:
calories by weight: 150/40 cals/gram
tangerines:
calories each: 40 cals
calories by weight: 53/100 cals/gram
whole milk:
calories by weight: 149/255 cals/gram
calories by volume: 149 cals/cup
almonds:
calories each: 40 cals
calories by weight: 822/143 cals/gram
calories by volume: 822 cals/cup
"""), dict)
meals = nt.loads(dedent("""
21 March 2023:
breakfast: oatmeal
22 March 2023:
breakfast: @oatmeal
23 March 2023:
breakfast: @oatmeal(tangerines: 0 each, almonds: 10 each)
"""), dict)
def expand_foods(value):
# allows macro values to be defined as a top-level food.
# allows macro reference to be found anywhere.
if isinstance(value, str):
value = value.strip()
if value[:1] == '@':
value = parse_macro(value[1:].strip())
return value
if isinstance(value, dict):
return {k:expand_foods(v) for k, v in value.items()}
if isinstance(value, list):
return [expand_foods(v) for v in value]
raise NotImplementedError(value)
def parse_macro(macro):
match = re.match(r'(\w+)(?:\((.*)\))?', macro)
if match:
name, args = match.groups()
try:
food = foods[name].copy()
except KeyError:
raise Error("unknown food.", culprit=name)
if args:
args = nt.loads('{' + args + '}', dict)
food.update(args)
return food
raise Error("unknown macro.", culprit=macro)
try:
meals = expand_foods(meals)
display(nt.dumps(meals))
except Error as e:
e.terminate()
It produces the following output:
21 March 2023:
breakfast: oatmeal
22 March 2023:
breakfast:
steel cut oats: 1/4 cup
tangerines: 1 each
whole milk: 1/4 cup
23 March 2023:
breakfast:
steel cut oats: 1/4 cup
tangerines: 0 each
whole milk: 1/4 cup
almonds: 10 each
In this example the content for the references was pulled from a different NestedText document. See the PostMortem as an example that pulls the referenced content from the same document.
Accumulation
This example demonstrates how to used NestedText so that it supports some common common aspects of settings files; specifically you can override or accumulate to previously specified settings by repeating their names.
It implements an example settings file reader that supports a small variety of settings. NestedText is configured to normalize and de-duplicate the keys (the names of the settings) with the result being processed to identify and report errors and to implement overrides, accumulations, and simple conversions. Accumulation is indicated adding a plus sign to the beginning of the key. The keys are normalized by converting them to snake case (all lower case, contiguous spaces replaced by a single underscore).
from inform import Error, full_stop, os_error
import nestedtext as nt
from pathlib import Path
schema = dict(
name = str,
limit = float,
actions = dict,
patterns = list,
)
list_settings = set(k for k, v in schema.items() if v == list)
dict_settings = set(k for k, v in schema.items() if v == dict)
def de_dup(key, state):
if key not in state:
state[key] = 1
state[key] += 1
return f"{key}#{state[key]}"
def normalize_key(key, parent_keys):
return '_'.join(key.lower().split()) # convert key to snake case
def read_settings(path, processed=None):
if processed is None:
processed = {}
try:
keymap = {}
settings = nt.load(
path,
top = dict,
normalize_key = normalize_key,
on_dup = de_dup,
keymap = keymap
)
except OSError as e:
raise Error(os_error(e))
def report_error(msg):
keys = key_as_given,
offset = key_as_given.index(key)
raise Error(
full_stop(msg),
culprit = path,
codicil = nt.get_location(keys, keymap=keymap).as_line('key', offset=offset)
)
# process settings
for key_as_given, value in settings.items():
# remove any decorations on the key
key = key_as_given
accumulate = '+' in key_as_given
if accumulate:
cruft, _, key = key_as_given.partition('+')
if cruft:
report_error("‘+’ must precede setting name")
if '#' in key: # get original name for duplicate key
key, _, _ = key.partition('#')
key = key.strip('_')
if not key.isidentifier():
report_error("expected identifier")
# check the type of the value
if key in list_settings:
if isinstance(value, str):
value = value.split()
if not isinstance(value, list):
report_error(f"expected list, found {value.__class__.__name__}")
if accumulate:
base = processed.get(key, [])
value = base + value
elif key in dict_settings:
if value == "":
value = {}
if not isinstance(value, dict):
report_error(f"expected dict, found {value.__class__.__name__}")
if accumulate:
base = processed.get(key, {})
base.update(value)
value = base
elif key in schema:
if accumulate:
report_error("setting is unsuitable for accumulation")
value = schema[key](value) # cast to desired type
else:
report_error("unknown setting")
processed[key] = value
return processed
It would interpret this settings file:
name: trantor
actions:
default: clean
patterns: ..
limit: 60
name: terminus
+patterns: ../**/{name}.nt
+patterns: ../**/*.{name}:*.nt
+ actions:
final: archive
as equivalent to this settings file:
name: terminus
actions:
default: clean
final: archive
patterns:
- ..
- ../**/{name}.nt
- ../**/*.{name}:*.nt
limit: 60.0
Pretty Printing
Besides being a readable file format, NestedText makes a reasonable display format for structured data. This example further simplifies the output by stripping leading multiline string tags.
>>> import nestedtext as nt
>>> import re
>>>
>>> def pp(data):
... try:
... text = nt.dumps(data, default=repr)
... print(re.sub(r'^(\s*)[>:][ ]?(.*)$', r'\1\2', text, flags=re.M))
... except nt.NestedTextError as e:
... e.report()
>>> addresses = nt.load('examples/addresses/address.nt')
>>> pp(addresses['Katheryn McDaniel'])
position: president
address:
138 Almond Street
Topeka, Kansas 20697
phone:
cell: 1-210-555-5297
work: 1-210-555-8470
email: KateMcD@aol.com
additional roles:
- board member
Stripping leading multiline string tags results in the output no longer being valid NestedText and so should not be done if the output needs to be readable later as NestedText..
Long Lines
One of the benefits of NestedText is that no escaping of special characters is ever needed. However, you might find it helpful to add your own support for removing escaped newlines in multi-line strings. Doing so allows you to keep your lines short in the source document so as to make them easier to interpret in windows of limited width.
This example uses the pretty-print function from the previous example.
>>> import nestedtext as nt
>>> from textwrap import dedent
>>> from voluptuous import Schema
>>> document = dedent(r"""
... lorum ipsum:
... > Lorem ipsum dolor sit amet, \
... > consectetur adipiscing elit.
... > Sed do eiusmod tempor incididunt \
... > ut labore et dolore magna aliqua.
... """)
>>> def reverse_escaping(text):
... return text.replace("\\\n", "")
>>> schema = Schema({str: reverse_escaping})
>>> data = schema(nt.loads(document))
>>> pp(data)
lorum ipsum:
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
An alternative to using a backslash to escape the newline is to simply join lines that end with a space. This might be more natural for non-programmers and can work well for prose.