In [1]:
import sys
sys.path.append('..')

Using nbtlib

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.

Loading a file


In [2]:
import nbtlib

nbt_file = nbtlib.load('nbt_files/bigtest.nbt')
nbt_file.root['stringTest']


Out[2]:
String('HELLO WORLD THIS IS A TEST STRING ÅÄÖ!')

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]:
False

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]:
'little'

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]:
<File 'hello world': Compound({'name': String('Bananrama')})>

Accessing file data

The File class inherits from Compound, which inherits from dict. This means that you can use standard dict operations to access data inside of the file. As most files usually contain a single root tag, there is a shorthand to access it directly.


In [6]:
nbt_file.keys()


Out[6]:
dict_keys(['Level'])

In [7]:
nbt_file.root == nbt_file['Level']


Out[7]:
True

Modifying files


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]:
<File 'test': Compound({'counter': Int(60)})>

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]:
Int(60)

In [10]:
nbtlib.load('nbt_files/demo_little.nbt', byteorder='little').root['counter']


Out[10]:
Int(60)

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)

Creating files


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]:
False

In [13]:
loaded_file.byteorder


Out[13]:
'big'

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]:
True

In [15]:
loaded_file.byteorder


Out[15]:
'little'

Performing operations on tags

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]:
[String('E'), String('H')]

In [17]:
my_array = IntArray([1, 2, 3])
my_array + 100


Out[17]:
IntArray([101, 102, 103])

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]:
Compound({'name': String('Calzone'), 'price': Double(8.2), 'size': String('large')})

Serializing nbt tags to snbt

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))


Compound({'numbers': IntArray([1, 2, 3]), 'foo': String('bar'), 'syntax breaking': Float(42.0), 'spam': String('{"text":"Hello, world!\\n"}')})

In [20]:
print(str(example_tag))


{numbers: [I; 1, 2, 3], foo: "bar", "syntax breaking": 42.0f, spam: '{"text":"Hello, world!\\n"}'}

In [21]:
print(example_tag)


{numbers: [I; 1, 2, 3], foo: "bar", "syntax breaking": 42.0f, spam: '{"text":"Hello, world!\\n"}'}

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))


{numbers: [I; 1, 2, 3], foo: "bar", "syntax breaking": 42.0f, spam: '{"text":"Hello, world!\\n"}'}

In [23]:
serialize_tag(example_tag) == str(example_tag)


Out[23]:
True

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"))


"contains 'single' quotes"

In [25]:
print(String('contains "double" quotes'))


'contains "double" quotes'

In [26]:
print(String('''contains 'single' and "double" quotes'''))


"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='"'))


"forcing \"double\" quotes"

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))


{numbers:[I;1,2,3],foo:"bar","syntax breaking":42.0f,spam:'{"text":"Hello, world!\\n"}'}

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))


{
    foo: [1, 2, 3], 
    bar: "name", 
    values: [
        {
            test: "a", 
            thing: [B; 32B, 32B, 32B]
        }, 
        {
            test: "b", 
            thing: [B; 64B, 64B, 64B]
        }
    ]
}

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'))


{
	foo: [1, 2, 3], 
	bar: "name", 
	values: [
		{
			test: "a", 
			thing: [B; 32B, 32B, 32B]
		}, 
		{
			test: "b", 
			thing: [B; 64B, 64B, 64B]
		}
	]
}

Note that the indent= keyword only argument can be set to any string, not just '\t'.


In [31]:
print(serialize_tag(nested_tag, indent='.   '))


{
.   foo: [1, 2, 3], 
.   bar: "name", 
.   values: [
.   .   {
.   .   .   test: "a", 
.   .   .   thing: [B; 32B, 32B, 32B]
.   .   }, 
.   .   {
.   .   .   test: "b", 
.   .   .   thing: [B; 64B, 64B, 64B]
.   .   }
.   ]
}

Creating tags from nbt literals

nbtlib supports creating nbt tags from their literal representation. The nbtlib.parse_nbt function can parse snbt and return the appropriate tag.


In [32]:
from nbtlib import parse_nbt

parse_nbt('hello')


Out[32]:
String('hello')

In [33]:
parse_nbt('{foo:[{bar:[I;1,2,3]},{spam:6.7f}]}')


Out[33]:
Compound({'foo': List[Compound]([Compound({'bar': IntArray([1, 2, 3])}), Compound({'spam': Float(6.7)})])})

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]:
Compound({'foo': List[Int]([Int(1), Int(2), Int(3)]), 'bar': String('name'), 'values': List[Compound]([Compound({'test': String('a'), 'thing': ByteArray([32, 32, 32])}), Compound({'test': String('b'), 'thing': ByteArray([64, 64, 64])})])})

Defining schemas

In order to avoid wrapping values manually every time you edit a compound tag, you can define a schema that will take care of converting python types to predefined nbt tags automatically.


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]:
MySchema({'foo': String('hello world'), 'bar': Short(42)})

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]:
MyStrictSchema({'foo': String('hello world')})

In [37]:
try:
    strict_instance['something'] = List[String](['this', 'raises', 'an', 'error'])
except TypeError as exc:
    print(exc)


Invalid key 'something'

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]:
MySchema({'foo': String('hello world'), 'bar': Short(42)})

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)


Invalid key 'something'

Combining schemas and custom file types

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]:
nbtlib.tag.List[Int]

In [42]:
type(new_structure['entities'])


Out[42]:
nbtlib.tag.List[Entity]

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))


{
    "": {
        DataVersion: 1139, 
        author: "dinnerbone", 
        size: [1, 2, 1], 
        palette: [
            {
                Name: "minecraft:stone"
            }
        ], 
        blocks: [
            {
                pos: [0, 0, 0], 
                state: 0
            }, 
            {
                pos: [0, 1, 0], 
                state: 0
            }
        ], 
        entities: []
    }
}