In [1]:
import sys
sys.path.append('..')
The Named Binary Tag (NBT) file format is a simple structured binary format that is mainly used by the game Minecraft (see the official specification for more details). This short documentation will show you how you can manipulate nbt data using the nbtlib
module.
In [2]:
import nbtlib
nbt_file = nbtlib.load('nbt_files/bigtest.nbt')
nbt_file.root['stringTest']
Out[2]:
By default nbtlib.load
will figure out by itself if the specified file is gzipped, but you can also use the gzipped=
keyword only argument if you know in advance whether the file is gzipped or not.
In [3]:
uncompressed_file = nbtlib.load('nbt_files/hello_world.nbt', gzipped=False)
uncompressed_file.gzipped
Out[3]:
The nbtlib.load
function also accepts the byteorder=
keyword only argument. It lets you specify whether the file is big-endian or little-endian. The default value is 'big'
, which means that the file is interpreted as big-endian by default. You can set it to 'little'
to use the little-endian format.
In [4]:
little_endian_file = nbtlib.load('nbt_files/hello_world_little.nbt', byteorder='little')
little_endian_file.byteorder
Out[4]:
Objects returned by the nbtlib.load
function are instances of the nbtlib.File
class. The nbtlib.load
function is actually a small helper around the File.load
classmethod. If you need to load files from an already opened file-like object, you can use the File.parse
class method.
In [5]:
from nbtlib import File
with open('nbt_files/hello_world.nbt', 'rb') as f:
hello_world = File.parse(f)
hello_world
Out[5]:
In [6]:
nbt_file.keys()
Out[6]:
In [7]:
nbt_file.root == nbt_file['Level']
Out[7]:
In [8]:
from nbtlib.tag import *
with nbtlib.load('nbt_files/demo.nbt') as demo:
demo.root['counter'] = Int(demo.root['counter'] + 1)
demo
Out[8]:
If you don't want to use a context manager, you can call the .save
method manually to overwrite the original file or make a copy by specifying a different path. The .save
method also accepts the gzipped=
keyword only argument. By default, the copy will be gzipped if the original file is gzipped. Similarly, you can use the byteorder=
keyword only argument to specify whether the file should be saved using the big-endian or little-endian format. By default, the copy will be saved using the same format as the original file.
In [9]:
demo = nbtlib.load('nbt_files/demo.nbt')
...
demo.save() # overwrite
demo.save('nbt_files/demo_copy.nbt', gzipped=True) # make a gzipped copy
demo.save('nbt_files/demo_little.nbt', byteorder='little') # convert the file to little-endian
nbtlib.load('nbt_files/demo_copy.nbt').root['counter']
Out[9]:
In [10]:
nbtlib.load('nbt_files/demo_little.nbt', byteorder='little').root['counter']
Out[10]:
You can also write nbt data to an already opened file-like object using the .write
method.
In [11]:
with open('nbt_files/demo_copy.nbt', 'wb') as f:
demo.write(f)
In [12]:
new_file = File({
'': Compound({
'foo': String('bar'),
'spam': IntArray([1, 2, 3]),
'egg': List[String](['hello', 'world'])
})
})
new_file.save('nbt_files/new_file.nbt')
loaded_file = nbtlib.load('nbt_files/new_file.nbt')
loaded_file.gzipped
Out[12]:
In [13]:
loaded_file.byteorder
Out[13]:
New files are uncompressed by default. You can use the gzipped=
keyword only argument to create a gzipped file. New files are also big-endian by default. You can use the byteorder=
keyword only argument to set the endianness of the file to either 'big'
or 'little'
.
In [14]:
new_file = File(
{'': Compound({'thing': LongArray([1, 2, 3])})},
gzipped=True,
byteorder='little'
)
new_file.save('nbt_files/new_file_gzipped_little.nbt')
loaded_file = nbtlib.load('nbt_files/new_file_gzipped_little.nbt', byteorder='little')
loaded_file.gzipped
Out[14]:
In [15]:
loaded_file.byteorder
Out[15]:
With the exception of ByteArray
, IntArray
and LongArray
tags, every tag type inherits from a python builtin, allowing you to make use of their rich and familiar interfaces. ByteArray
, IntArray
and LongArray
tags on the other hand, inherit from numpy
arrays instead of the builtin array
type in order to benefit from numpy
's efficiency.
Base type | Associated nbt tags |
---|---|
int | Byte , Short , Int , Long |
float | Float , Double |
str | String |
numpy.ndarray | ByteArray , IntArray , LongArray |
list | List |
dict | Compound |
All the methods and operations that are usually available on the the base types can be used on the associated tags.
In [16]:
my_list = List[String](char.upper() for char in 'hello')
my_list.reverse()
my_list[3:]
Out[16]:
In [17]:
my_array = IntArray([1, 2, 3])
my_array + 100
Out[17]:
In [18]:
my_pizza = Compound({
'name': String('Margherita'),
'price': Double(5.7),
'size': String('medium')
})
my_pizza.update({'name': String('Calzone'), 'size': String('large')})
my_pizza['price'] = Double(my_pizza['price'] + 2.5)
my_pizza
Out[18]:
While using repr()
on nbt tags outputs a python representation of the tag, calling str()
on nbt tags (or simply printing them) will return the nbt literal representing that tag.
In [19]:
example_tag = Compound({
'numbers': IntArray([1, 2, 3]),
'foo': String('bar'),
'syntax breaking': Float(42),
'spam': String('{"text":"Hello, world!\\n"}')
})
print(repr(example_tag))
In [20]:
print(str(example_tag))
In [21]:
print(example_tag)
Converting nbt tags to strings will serialize them to snbt. If you want more control over the way nbt tags are serialized, you can use the nbtlib.serialize_tag
function. In fact, using str
on nbt tags simply calls nbtlib.serialize_tag
on the specified tag.
In [22]:
from nbtlib import serialize_tag
print(serialize_tag(example_tag))
In [23]:
serialize_tag(example_tag) == str(example_tag)
Out[23]:
You might have noticed that by default, the nbtlib.serialize_tag
function will render strings with single '
or double "
quotes based on their content to avoid escaping quoting characters. The string is serialized such that the type of quotes used is different from the first quoting character found in the string. If the string doesn't contain any quoting character, the nbtlib.serialize_tag
function will render the string as a double "
quoted string.
In [24]:
print(String("contains 'single' quotes"))
In [25]:
print(String('contains "double" quotes'))
In [26]:
print(String('''contains 'single' and "double" quotes'''))
You can overwrite this behavior by setting the quote=
keyword only argument to either a single '
or a double "
quote.
In [27]:
print(serialize_tag(String('forcing "double" quotes'), quote='"'))
The nbtlib.serialize_tag
function can be used with the compact=
keyword only argument to remove all the extra whitespace from the output.
In [28]:
print(serialize_tag(example_tag, compact=True))
If you'd rather have something a bit more readable, you can use the indent=
keyword only argument to tell the nbtlib.serialize_tag
function to output indented snbt. The argument can be either a string or an integer and will be used to define how to render each indentation level.
In [29]:
nested_tag = Compound({
'foo': List[Int]([1, 2, 3]),
'bar': String('name'),
'values': List[Compound]([
{'test': String('a'), 'thing': ByteArray([32, 32, 32])},
{'test': String('b'), 'thing': ByteArray([64, 64, 64])}
])
})
print(serialize_tag(nested_tag, indent=4))
If you need the output ot be indented with tabs instead, you can set the indent=
argument to '\t'
.
In [30]:
print(serialize_tag(nested_tag, indent='\t'))
Note that the indent=
keyword only argument can be set to any string, not just '\t'
.
In [31]:
print(serialize_tag(nested_tag, indent='. '))
In [32]:
from nbtlib import parse_nbt
parse_nbt('hello')
Out[32]:
In [33]:
parse_nbt('{foo:[{bar:[I;1,2,3]},{spam:6.7f}]}')
Out[33]:
Note that the parser ignores whitespace.
In [34]:
parse_nbt("""{
foo: [1, 2, 3],
bar: "name",
values: [
{
test: "a",
thing: [B; 32B, 32B, 32B]
},
{
test: "b",
thing: [B; 64B, 64B, 64B]
}
]
}""")
Out[34]:
In [35]:
from nbtlib import schema
MySchema = schema('MySchema', {
'foo': String,
'bar': Short
})
my_object = MySchema({'foo': 'hello world', 'bar': 21})
my_object['bar'] *= 2
my_object
Out[35]:
By default, you can interact with keys that are not defined in the schema. However, if you use the strict=
keyword only argument, the schema instance will raise a TypeError
whenever you try to access a key that wasn't defined in the original schema.
In [36]:
MyStrictSchema = schema('MyStrictSchema', {
'foo': String,
'bar': Short
}, strict=True)
strict_instance = MyStrictSchema()
strict_instance.update({'foo': 'hello world'})
strict_instance
Out[36]:
In [37]:
try:
strict_instance['something'] = List[String](['this', 'raises', 'an', 'error'])
except TypeError as exc:
print(exc)
The schema
function is a helper that creates a class that inherits from CompoundSchema
. This means that you can also inherit from the class manually.
In [38]:
from nbtlib import CompoundSchema
class MySchema(CompoundSchema):
schema = {
'foo': String,
'bar': Short
}
MySchema({'foo': 'hello world', 'bar': 42})
Out[38]:
You can also set the strict
class attribute to True
to create a strict schema type.
In [39]:
class MyStrictSchema(CompoundSchema):
schema = {
'foo': String,
'bar': Short
}
strict = True
try:
MyStrictSchema({'something': Byte(5)})
except TypeError as exc:
print(exc)
If you need to deal with files that always have a particular structure, you can create a specialized file type by combining it with a schema. For instance, this is how you would create a file type that opens minecraft structure files.
First, we need to define what a minecraft structure is, so we create a schema that matches the tag hierarchy.
In [40]:
Structure = schema('Structure', {
'DataVersion': Int,
'author': String,
'size': List[Int],
'palette': List[schema('State', {
'Name': String,
'Properties': Compound,
})],
'blocks': List[schema('Block', {
'state': Int,
'pos': List[Int],
'nbt': Compound,
})],
'entities': List[schema('Entity', {
'pos': List[Double],
'blockPos': List[Int],
'nbt': Compound,
})],
})
Now let's test our schema by creating a structure. We can see that all the types are automatically applied.
In [41]:
new_structure = Structure({
'DataVersion': 1139,
'author': 'dinnerbone',
'size': [1, 2, 1],
'palette': [
{'Name': 'minecraft:dirt'}
],
'blocks': [
{'pos': [0, 0, 0], 'state': 0},
{'pos': [0, 1, 0], 'state': 0}
],
'entities': [],
})
type(new_structure['blocks'][0]['pos'])
Out[41]:
In [42]:
type(new_structure['entities'])
Out[42]:
Now we can create a custom file type that wraps our structure schema. Since structure files are always gzipped we can override the load method to default the gzipped
argument to True
. We also overwrite the constructor so that it can take directly an instance of our structure schema as argument.
In [43]:
class StructureFile(File, schema('StructureFileSchema', {'': Structure})):
def __init__(self, structure_data=None):
super().__init__({'': structure_data or {}})
self.gzipped = True
@classmethod
def load(cls, filename, gzipped=True):
return super().load(filename, gzipped)
We can now use the custom file type to load, edit and save structure files without having to specify the tags manually.
In [44]:
structure_file = StructureFile(new_structure)
structure_file.save('nbt_files/new_structure.nbt') # you can load it in a minecraft world!
So now let's try to edit the structure. We're going to replace all the dirt blocks with stone blocks.
In [45]:
with StructureFile.load('nbt_files/new_structure.nbt') as structure_file:
structure_file.root['palette'][0]['Name'] = 'minecraft:stone'
As you can see we didn't need to specify any tag to edit the file.
In [46]:
print(serialize_tag(StructureFile.load('nbt_files/new_structure.nbt'), indent=4))