From 16127deb97be30fcb73365ca2f40a038136174e2 Mon Sep 17 00:00:00 2001 From: Jan Rydzewski Date: Wed, 1 Nov 2023 03:08:59 +0100 Subject: [PATCH 1/7] Use pytest to run tests --- .github/workflows/tests.yaml | 4 +-- pytest.ini | 4 +++ tests/alltests.py | 56 ------------------------------------ 3 files changed, 6 insertions(+), 58 deletions(-) create mode 100644 pytest.ini delete mode 100755 tests/alltests.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 1eb319e..a3c3ef9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,6 +20,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pip install pillow + run: pip install pillow pytest - name: Run tests - run: python tests/alltests.py + run: pytest diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..95335fe --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +python_files = + test_*.py + *tests.py diff --git a/tests/alltests.py b/tests/alltests.py deleted file mode 100755 index 724e737..0000000 --- a/tests/alltests.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import sys -import logging - -import unittest -try: - from unittest import skip as _skip -except ImportError: - # Python 2.6 has an older unittest API. The backported package is available from pypi. - import unittest2 as unittest - -testmodules = ['examplestests', 'nbttests', 'regiontests'] -"""Files to check for test cases. Do not include the .py extension.""" - - -def get_testsuites_in_module(module): - """ - Return a list of unittest.TestSuite subclasses defined in module. - """ - suites = [] - for name in dir(module): - obj = getattr(module, name) - if isinstance(obj, type) and issubclass(obj, unittest.TestSuite): - suites.append(obj) - return suites - - -def load_tests_in_modules(modulenames): - """ - Given a list of module names, import the modules, load and run the - test cases in these modules. The modules are typically files in the - current directory, but this is not a requirement. - """ - loader = unittest.TestLoader() - suites = [] - for name in modulenames: - module = __import__(name) - suite = loader.loadTestsFromModule(module) - for suiteclass in get_testsuites_in_module(module): - # Wrap suite in TestSuite classes - suite = suiteclass(suite) - suites.append(suite) - suite = unittest.TestSuite(suites) - return suite - - - -if __name__ == "__main__": - logger = logging.getLogger("nbt.tests") - if len(logger.handlers) == 0: - # Logging is not yet configured. Configure it. - logging.basicConfig(level=logging.INFO, stream=sys.stderr, format='%(levelname)-8s %(message)s') - testresult = unittest.TextTestRunner(verbosity=2).run(load_tests_in_modules(testmodules)) - sys.exit(0 if testresult.wasSuccessful() else 1) From 766e9b7c7bb646b8e594a18249f2092d9d948ad2 Mon Sep 17 00:00:00 2001 From: Jan Rydzewski Date: Wed, 1 Nov 2023 20:23:52 +0100 Subject: [PATCH 2/7] Configure flake8 a bit --- setup.cfg | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e124bab --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[flake8] +ignore= + # line too long + E501, From 6efd46e79641e31654170ced3e6cb2db8f4f1843 Mon Sep 17 00:00:00 2001 From: Jan Rydzewski Date: Wed, 1 Nov 2023 21:22:01 +0100 Subject: [PATCH 3/7] Convert tests directory to python package --- tests/__init__.py | 0 tests/examplestests.py | 2 +- tests/regiontests.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/examplestests.py b/tests/examplestests.py index b23e1c1..2b9a388 100755 --- a/tests/examplestests.py +++ b/tests/examplestests.py @@ -20,7 +20,7 @@ import unittest2 as unittest # local modules -import downloadsample +from . import downloadsample if sys.version_info[0] < 3: def _deletechars(text, deletechars): diff --git a/tests/regiontests.py b/tests/regiontests.py index c0520e8..efa4cb9 100755 --- a/tests/regiontests.py +++ b/tests/regiontests.py @@ -15,7 +15,7 @@ import unittest2 as unittest # local modules -from utils import open_files +from .utils import open_files # Search parent directory first, to make sure we test the local nbt module, # not an installed nbt module. From b772f97e2d2845c041b5cdc9590e5c73742a8e76 Mon Sep 17 00:00:00 2001 From: Jan Rydzewski Date: Wed, 1 Nov 2023 21:37:14 +0100 Subject: [PATCH 4/7] Test failure reading Minecraft 1.20.2 world --- .github/workflows/tests.yaml | 2 ++ tests/.gitignore | 3 +- tests/sample_server.py | 63 ++++++++++++++++++++++++++++++++++++ tests/test_real_mc_world.py | 29 +++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/sample_server.py create mode 100644 tests/test_real_mc_world.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a3c3ef9..6927823 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -14,6 +14,8 @@ jobs: ] steps: + - name: Select java runtime version + run: sudo update-java-alternatives --set $JAVA_HOME_21_X64 - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 diff --git a/tests/.gitignore b/tests/.gitignore index 1f523fa..5460457 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -1,2 +1,3 @@ Sample World -Sample_World.tar.gz \ No newline at end of file +Sample_World.tar.gz +sample_server diff --git a/tests/sample_server.py b/tests/sample_server.py new file mode 100644 index 0000000..afc93be --- /dev/null +++ b/tests/sample_server.py @@ -0,0 +1,63 @@ +import hashlib +import os +import subprocess + +from .downloadsample import download_with_external_tool + +server_dir = 'tests/sample_server' +server_jar_path = os.path.join(server_dir, 'server.jar') +world_dir = os.path.join(server_dir, 'world') + + +# minecraft 1.20.2 +server_jar_url = 'https://piston-data.mojang.com/v1/objects/5b868151bd02b41319f54c8d4061b8cae84e665c/server.jar' +server_jar_sha256_hex = '1daee4838569ad46e41f0a6f459684c500c7f2685356a40cfb7e838d6e78eae8' +stop_command = b'/stop\n' + + +def make_world(): + if os.path.exists(world_dir): + # if world exists, do nothing + return + + os.makedirs(server_dir, exist_ok=True) + install_server() + start_server() + + +def install_server(): + if not is_server_digest_ok(): + download_with_external_tool(server_jar_url, server_jar_path) + assert is_server_digest_ok() + + # fill in eula + with open(os.path.join(server_dir, 'eula.txt'), 'wt') as f: + # Well, is this even legal? + f.write('eula=true\n') + + # configure server.properties + with open(os.path.join(server_dir, 'server.properties'), 'wt') as f: + f.write('level-seed=testseed\n') + + +def start_server(): + subprocess.run( + ['java', '-jar', 'server.jar', 'nogui'], + cwd=server_dir, + input=stop_command, + check=True, + timeout=120, # seconds + ) + + +def is_server_digest_ok(): + if os.path.exists(server_jar_path): + with open(server_jar_path, 'rb') as f: + current_sha256_hex = hashlib.sha256(f.read()).hexdigest() + else: + current_sha256_hex = None + return current_sha256_hex == server_jar_sha256_hex + + +if __name__ == '__main__': + make_world() diff --git a/tests/test_real_mc_world.py b/tests/test_real_mc_world.py new file mode 100644 index 0000000..ec167e7 --- /dev/null +++ b/tests/test_real_mc_world.py @@ -0,0 +1,29 @@ +import os + +import pytest + +from nbt.world import WorldFolder + +from .sample_server import make_world +from .sample_server import world_dir as sample_world_dir + + +@pytest.fixture(scope='session', autouse=True) +def world_dir(): + make_world() + yield sample_world_dir + + +def test_we_have_a_world_dir(world_dir): + assert os.path.isdir(world_dir) + assert os.path.exists(os.path.join(world_dir, 'level.dat')) + + +@pytest.mark.xfail(reason="NBT is not (yet) compatible with Minecraft 1.20.2") +def test_read_no_smoke(world_dir): + """We dont crash when reading a world""" + world = WorldFolder(world_dir) + assert world.get_boundingbox() + assert world.chunk_count() + for chunk in world.iter_chunks(): + assert chunk From 87daf2c5a9f732f31a974805275bcfa23744413a Mon Sep 17 00:00:00 2001 From: Jan Rydzewski Date: Thu, 2 Nov 2023 01:07:18 +0100 Subject: [PATCH 5/7] Add support for reading blocks for Minecraft version 1.20.2 --- examples/block_analysis.py | 2 +- examples/map.py | 5 +++++ nbt/chunk.py | 22 ++++++++++++++-------- tests/examplestests.py | 26 +++++++++----------------- tests/test_real_mc_world.py | 9 ++++++--- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/examples/block_analysis.py b/examples/block_analysis.py index cc79ce8..33adb9c 100755 --- a/examples/block_analysis.py +++ b/examples/block_analysis.py @@ -107,7 +107,7 @@ def print_results(): print(locale.format_string("%20s: %12d", (block_id, block_count))) block_total += block_count - solid_blocks = block_total - block_counts ['air'] + solid_blocks = block_total - block_counts['minecraft:air'] solid_ratio = (solid_blocks+0.0)/block_total print(locale.format_string("%d total blocks in world, %d are non-air (%0.4f", (block_total, solid_blocks, 100.0*solid_ratio))+"%)") diff --git a/examples/map.py b/examples/map.py index 817503f..e8af76c 100755 --- a/examples/map.py +++ b/examples/map.py @@ -73,6 +73,7 @@ def get_heightmap_image(chunk, buffer=False, gmin=False, gmax=False): # 'redstone_ore', 'lapis_ore', 'emerald_ore', # 'cobweb', ] +block_ignore = [f'minecraft:{b}' for b in block_ignore] # Map of block colors from names @@ -165,6 +166,10 @@ def get_heightmap_image(chunk, buffer=False, gmin=False, gmax=False): 'wheat': {'h':123, 's':60, 'l':50 }, 'white_wool': {'h':0, 's':0, 'l':100}, } +block_colors = { + f'minecraft:{b}': c + for b, c in block_colors.items() +} def get_map(chunk): diff --git a/nbt/chunk.py b/nbt/chunk.py index a59fcc0..2c94195 100644 --- a/nbt/chunk.py +++ b/nbt/chunk.py @@ -100,7 +100,7 @@ def block_id_to_name(bid): class Chunk(object): """Class for representing a single chunk.""" def __init__(self, nbt): - self.chunk_data = nbt['Level'] + self.chunk_data = nbt self.coords = self.chunk_data['xPos'],self.chunk_data['zPos'] def get_coords(self): @@ -152,7 +152,8 @@ def __init__(self, nbt, version): elif version >= 2566 and version <= 2730: # MC 1.16.0 to MC 1.17.2 (latest tested version) self._init_index_padded(nbt) else: - raise NotImplementedError() + # best guess for other versions + self._init_index_padded(nbt) # Section contains 4096 blocks whatever data version @@ -228,12 +229,17 @@ def _init_index_unpadded(self, nbt): # Contains palette of block names and indexes packed with padding if elements don't fit (post 1.16 format) def _init_index_padded(self, nbt): - - for p in nbt['Palette']: + for p in nbt['block_states']['palette']: name = p['Name'].value self.names.append(name) - states = nbt['BlockStates'].value + # When there is only one element in the palette and no data + # we have single block fill the whole section. + if len(self.names) == 1 and 'data' not in nbt['block_states']: + self.indexes = [0] * 4096 + return + + states = nbt['block_states']['data'].value num_bits = (len(self.names) - 1).bit_length() if num_bits < 4: num_bits = 4 mask = 2**num_bits - 1 @@ -294,9 +300,9 @@ def __init__(self, nbt): # Load all sections self.sections = {} - if 'Sections' in self.chunk_data: - for s in self.chunk_data['Sections']: - if "BlockStates" in s.keys() or "Blocks" in s.keys(): # sections may only contain lighting information + if 'sections' in self.chunk_data: + for s in self.chunk_data['sections']: + if 'block_states' in s.keys(): # sections may only contain lighting information self.sections[s['Y'].value] = AnvilSection(s, version) diff --git a/tests/examplestests.py b/tests/examplestests.py index 2b9a388..71c5df1 100755 --- a/tests/examplestests.py +++ b/tests/examplestests.py @@ -21,6 +21,7 @@ # local modules from . import downloadsample +from . import sample_server if sys.version_info[0] < 3: def _deletechars(text, deletechars): @@ -64,7 +65,6 @@ def runScript(self, script, args): env['LC_ALL'] = 'C' # Open a subprocess, wait till it is done, and get the STDOUT result p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) - p.wait() output = [r.decode('utf-8') for r in p.stdout.readlines()] for l in p.stderr.readlines(): sys.stdout.write("%s: %s" % (script, l.decode('utf-8'))) @@ -73,6 +73,7 @@ def runScript(self, script, args): p.stderr.close() except IOError: pass + p.wait() self.assertEqual(p.returncode, 0, "return code is %d" % p.returncode) return output def assertEqualOutput(self, actual, expected): @@ -106,16 +107,10 @@ class BiomeAnalysisScriptTest(ScriptTestCase): # output = self.runScript('biome_analysis.py', [self.anvilfolder]) class BlockAnalysisScriptTest(ScriptTestCase): - - def testMcRegionWorld(self): - output = self.runScript('block_analysis.py', [self.mcregionfolder]) - self.assertTrue(len(output) == 60, "Expected output of 60 lines long") - self.assertEqualString(output[-1], '21397504 total blocks in world, 11181987 are non-air (52.2584%)') + anvilfolder = sample_server.world_dir def testAnvilWorld(self): - output = self.runScript('block_analysis.py', [self.anvilfolder]) - self.assertTrue(len(output) == 60, "Expected output of 60 lines long") - self.assertEqualString(output[-1], '13819904 total blocks in world, 11181987 are non-air (80.9122%)') + self.runScript('block_analysis.py', [self.anvilfolder]) class ChestAnalysisScriptTest(ScriptTestCase): @@ -140,18 +135,14 @@ def has_PIL(): class MapScriptTest(ScriptTestCase): - @unittest.skipIf(not has_PIL(), "PIL library not available") - def testMcRegionWorld(self): - output = self.runScript('map.py', ['--noshow', self.mcregionfolder]) - self.assertTrue(output[-1].startswith("Saved map as ")) - # TODO: this currently writes the map to tests/nbtmcregion*.png files. - # The locations should be a tempfile, and the file should be deleted afterwards. - + anvilfolder = sample_server.world_dir + @unittest.skipIf(not has_PIL(), "PIL library not available") def testAnvilWorld(self): output = self.runScript('map.py', ['--noshow', self.anvilfolder]) self.assertTrue(output[-1].startswith("Saved map as ")) - # TODO: same as above + # TODO: this currently writes the map to tests/nbtmcregion*.png files. + # The locations should be a tempfile, and the file should be deleted afterwards. class MobAnalysisScriptTest(ScriptTestCase): @@ -243,6 +234,7 @@ def testScoreboard(self): def setUpModule(): """Download sample world, and copy Anvil and McRegion files to temporary folders.""" + sample_server.make_world() if ScriptTestCase.worldfolder == None: downloadsample.install() ScriptTestCase.worldfolder = downloadsample.worlddir diff --git a/tests/test_real_mc_world.py b/tests/test_real_mc_world.py index ec167e7..447257e 100644 --- a/tests/test_real_mc_world.py +++ b/tests/test_real_mc_world.py @@ -19,11 +19,14 @@ def test_we_have_a_world_dir(world_dir): assert os.path.exists(os.path.join(world_dir, 'level.dat')) -@pytest.mark.xfail(reason="NBT is not (yet) compatible with Minecraft 1.20.2") def test_read_no_smoke(world_dir): """We dont crash when reading a world""" world = WorldFolder(world_dir) assert world.get_boundingbox() assert world.chunk_count() - for chunk in world.iter_chunks(): - assert chunk + + chunk = next(world.iter_chunks()) + assert chunk.get_max_height() + + block = chunk.get_block(0, 0, 0) + assert block From d8faeb4544c0a2895895e51f61ad81fa5011d3c0 Mon Sep 17 00:00:00 2001 From: Jan Rydzewski Date: Thu, 2 Nov 2023 01:30:27 +0100 Subject: [PATCH 6/7] Remove some unused imports --- examples/biome_analysis.py | 5 +---- examples/map.py | 4 +--- examples/player_print.py | 2 +- examples/regionfile_analysis.py | 2 +- examples/scoreboard.py | 4 ++-- examples/utilities.py | 2 +- tests/examplestests.py | 1 - tests/regiontests.py | 2 +- tests/worldtests.py | 5 ----- 9 files changed, 8 insertions(+), 19 deletions(-) diff --git a/examples/biome_analysis.py b/examples/biome_analysis.py index 12c32a3..753ef25 100755 --- a/examples/biome_analysis.py +++ b/examples/biome_analysis.py @@ -3,7 +3,6 @@ Counter the number of biomes in the world. Works only for Anvil-based world folders. """ import locale, os, sys -from struct import pack, unpack # local module try: @@ -14,9 +13,7 @@ if not os.path.exists(os.path.join(extrasearchpath,'nbt')): raise sys.path.append(extrasearchpath) -from nbt.region import RegionFile -from nbt.chunk import Chunk -from nbt.world import AnvilWorldFolder,UnknownWorldFormat +from nbt.world import AnvilWorldFolder BIOMES = { 0 : "Ocean", diff --git a/examples/map.py b/examples/map.py index 817503f..015b57b 100755 --- a/examples/map.py +++ b/examples/map.py @@ -15,9 +15,7 @@ if not os.path.exists(os.path.join(extrasearchpath,'nbt')): raise sys.path.append(extrasearchpath) -from nbt.region import RegionFile -from nbt.chunk import Chunk -from nbt.world import WorldFolder,McRegionWorldFolder +from nbt.world import WorldFolder # PIL module (not build-in) try: from PIL import Image diff --git a/examples/player_print.py b/examples/player_print.py index dfdc325..b765f1c 100755 --- a/examples/player_print.py +++ b/examples/player_print.py @@ -3,7 +3,7 @@ Finds and prints different entities in a game file, including mobs, items, and vehicles. """ -import locale, os, sys +import os, sys # local module try: diff --git a/examples/regionfile_analysis.py b/examples/regionfile_analysis.py index 4589659..656976d 100755 --- a/examples/regionfile_analysis.py +++ b/examples/regionfile_analysis.py @@ -3,7 +3,7 @@ Defragment a given region file. """ -import locale, os, sys +import os, sys import collections from optparse import OptionParser import gzip diff --git a/examples/scoreboard.py b/examples/scoreboard.py index b5e104c..a74743f 100755 --- a/examples/scoreboard.py +++ b/examples/scoreboard.py @@ -15,7 +15,7 @@ if not os.path.exists(os.path.join(extrasearchpath,'nbt')): raise sys.path.append(extrasearchpath) -from nbt.nbt import NBTFile, TAG_Long, TAG_Int, TAG_String, TAG_List, TAG_Compound +from nbt.nbt import NBTFile def main(world_folder, show=True): scorefile = world_folder + '/data/scoreboard.dat' @@ -40,4 +40,4 @@ def main(world_folder, show=True): print("No such folder as "+world_folder) sys.exit(72) # EX_IOERR - sys.exit(main(world_folder, show)) \ No newline at end of file + sys.exit(main(world_folder, show)) diff --git a/examples/utilities.py b/examples/utilities.py index 2f2dde0..fd61cec 100755 --- a/examples/utilities.py +++ b/examples/utilities.py @@ -16,7 +16,7 @@ if not os.path.exists(os.path.join(extrasearchpath,'nbt')): raise sys.path.append(extrasearchpath) -from nbt.nbt import NBTFile, TAG_Long, TAG_Int, TAG_String, TAG_List, TAG_Compound +from nbt.nbt import TAG_Long, TAG_String, TAG_List, TAG_Compound def unpack_nbt(tag): """ diff --git a/tests/examplestests.py b/tests/examplestests.py index b23e1c1..33bfb36 100755 --- a/tests/examplestests.py +++ b/tests/examplestests.py @@ -8,7 +8,6 @@ import os import subprocess import shutil -import tempfile import glob import logging diff --git a/tests/regiontests.py b/tests/regiontests.py index c0520e8..60e3271 100755 --- a/tests/regiontests.py +++ b/tests/regiontests.py @@ -23,7 +23,7 @@ if parentdir not in sys.path: sys.path.insert(1, parentdir) # insert ../ just after ./ -from nbt.region import RegionFile, RegionFileFormatError, NoRegionHeader, \ +from nbt.region import RegionFile, RegionFileFormatError, \ RegionHeaderError, ChunkHeaderError, ChunkDataError, InconceivedChunk from nbt.nbt import NBTFile, TAG_Compound, TAG_Byte_Array, TAG_Long, TAG_Int, TAG_String diff --git a/tests/worldtests.py b/tests/worldtests.py index 65248e2..e80d816 100755 --- a/tests/worldtests.py +++ b/tests/worldtests.py @@ -1,11 +1,6 @@ #!/usr/bin/env python import sys,os -import tempfile, shutil -from io import BytesIO import logging -import random -import time -import zlib import unittest try: From e7d5d47ed4627148e7210d71f27e16693e1ff7ad Mon Sep 17 00:00:00 2001 From: Jan Rydzewski Date: Thu, 2 Nov 2023 21:53:47 +0100 Subject: [PATCH 7/7] Run tests against multiple minecraft versions --- .github/workflows/tests.yaml | 2 + examples/chest_analysis.py | 18 +---- examples/seed.py | 2 +- nbt/region.py | 5 +- setup.cfg | 2 + tests/examplestests.py | 48 ++---------- tests/regiontests.py | 2 +- tests/sample_server.py | 138 +++++++++++++++++++++++++---------- tests/test_real_mc_world.py | 14 ++-- 9 files changed, 127 insertions(+), 104 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6927823..0748042 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,5 +23,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: pip install pillow pytest + - name: Pregenerate minecraft worlds + run: python -m tests.sample_server - name: Run tests run: pytest diff --git a/examples/chest_analysis.py b/examples/chest_analysis.py index 1da204b..cef49c7 100755 --- a/examples/chest_analysis.py +++ b/examples/chest_analysis.py @@ -51,21 +51,7 @@ def chests_per_chunk(chunk): chests = [] - for entity in chunk['Entities']: - eid = entity["id"].value - if eid == "Minecart" and entity["type"].value == 1 or eid == "minecraft:chest_minecart": - x,y,z = entity["Pos"] - x,y,z = x.value,y.value,z.value - - # Treasures are empty upon first opening - - try: - items = items_from_nbt(entity["Items"]) - except KeyError: - items = {} - chests.append(Chest("Minecart with chest",(x,y,z),items)) - - for entity in chunk['TileEntities']: + for entity in chunk['block_entities']: eid = entity["id"].value if eid == "Chest" or eid == "minecraft:chest": x,y,z = entity["x"].value,entity["y"].value,entity["z"].value @@ -103,7 +89,7 @@ def main(world_folder): try: for chunk in world.iter_nbt(): - print_results(chests_per_chunk(chunk["Level"])) + print_results(chests_per_chunk(chunk)) except KeyboardInterrupt: return 75 # EX_TEMPFAIL diff --git a/examples/seed.py b/examples/seed.py index eafd2cd..6c1ca49 100755 --- a/examples/seed.py +++ b/examples/seed.py @@ -19,7 +19,7 @@ def main(world_folder): filename = os.path.join(world_folder,'level.dat') level = NBTFile(filename) - print(level["Data"]["RandomSeed"]) + print(level["Data"]["WorldGenSettings"]["seed"].value) return 0 # NOERR diff --git a/nbt/region.py b/nbt/region.py index d1c8f46..26a8871 100644 --- a/nbt/region.py +++ b/nbt/region.py @@ -193,7 +193,7 @@ class RegionFile(object): """Constant indicating an normal status: the chunk does not exist. Deprecated. Use :const:`nbt.region.STATUS_CHUNK_NOT_CREATED` instead.""" - def __init__(self, filename=None, fileobj=None, chunkclass = None): + def __init__(self, filename=None, for_write=False, fileobj=None, chunkclass = None): """ Read a region file by filename or file object. If a fileobj is specified, it is not closed after use; it is the callers responibility to close it. @@ -206,7 +206,8 @@ def __init__(self, filename=None, fileobj=None, chunkclass = None): self.chunkclass = chunkclass if filename: self.filename = filename - self.file = open(filename, 'r+b') # open for read and write in binary mode + mode = 'r+b' if for_write else 'rb' + self.file = open(filename, mode) self._closefile = True elif fileobj: if hasattr(fileobj, 'name'): diff --git a/setup.cfg b/setup.cfg index e124bab..f362cd0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,3 +2,5 @@ ignore= # line too long E501, + # line breaks around binary operator + W503,W504, diff --git a/tests/examplestests.py b/tests/examplestests.py index 8a46f25..2d9a18e 100755 --- a/tests/examplestests.py +++ b/tests/examplestests.py @@ -18,8 +18,9 @@ # Python 2.6 has an older unittest API. The backported package is available from pypi. import unittest2 as unittest +import pytest + # local modules -from . import downloadsample from . import sample_server if sys.version_info[0] < 3: @@ -50,8 +51,6 @@ def _copyrename(srcdir, destdir, src, dest): class ScriptTestCase(unittest.TestCase): """Test Case with helper functions for running a script, and installing a Minecraft sample world.""" - worldfolder = None - mcregionfolder = None anvilfolder = None examplesdir = os.path.normpath(os.path.join(__file__, os.pardir, os.pardir, 'examples')) def runScript(self, script, args): @@ -106,23 +105,13 @@ class BiomeAnalysisScriptTest(ScriptTestCase): # output = self.runScript('biome_analysis.py', [self.anvilfolder]) class BlockAnalysisScriptTest(ScriptTestCase): - anvilfolder = sample_server.world_dir - def testAnvilWorld(self): self.runScript('block_analysis.py', [self.anvilfolder]) class ChestAnalysisScriptTest(ScriptTestCase): - def testMcRegionWorld(self): - output = self.runScript('chest_analysis.py', [self.mcregionfolder]) - self.assertEqual(len(output), 178) - count = len(list(filter(lambda l: l.startswith('Chest at '), output))) - self.assertEqual(count, 38) def testAnvilWorld(self): - output = self.runScript('chest_analysis.py', [self.anvilfolder]) - self.assertEqual(len(output), 178) - count = len(list(filter(lambda l: l.startswith('Chest at '), output))) - self.assertEqual(count, 38) + self.runScript('chest_analysis.py', [self.anvilfolder]) def has_PIL(): @@ -134,8 +123,6 @@ def has_PIL(): class MapScriptTest(ScriptTestCase): - anvilfolder = sample_server.world_dir - @unittest.skipIf(not has_PIL(), "PIL library not available") def testAnvilWorld(self): output = self.runScript('map.py', ['--noshow', self.anvilfolder]) @@ -145,12 +132,7 @@ def testAnvilWorld(self): class MobAnalysisScriptTest(ScriptTestCase): - def testMcRegionWorld(self): - output = self.runScript('mob_analysis.py', [self.mcregionfolder]) - self.assertEqual(len(output), 413) - output = sorted(output) - self.assertEqualString(output[0], "Chicken at 107.6,88.0,374.5") - self.assertEqualString(output[400], "Zombie at 249.3,48.0,368.1") + @pytest.mark.skip def testAnvilWorld(self): output = self.runScript('mob_analysis.py', [self.anvilfolder]) self.assertEqual(len(output), 413) @@ -159,12 +141,9 @@ def testAnvilWorld(self): self.assertEqualString(output[400], "Zombie at 249.3,48.0,368.1") class SeedScriptTest(ScriptTestCase): - def testMcRegionWorld(self): - output = self.runScript('seed.py', [self.mcregionfolder]) - self.assertEqualOutput(output, ["-3195717715052600521"]) def testAnvilWorld(self): output = self.runScript('seed.py', [self.anvilfolder]) - self.assertEqualOutput(output, ["-3195717715052600521"]) + self.assertEqualOutput(output, ["-1145865725\n"]) class GenerateLevelDatScriptTest(ScriptTestCase): expected = [ @@ -232,24 +211,11 @@ def testScoreboard(self): # calls it for each subclass. def setUpModule(): - """Download sample world, and copy Anvil and McRegion files to temporary folders.""" - sample_server.make_world() - if ScriptTestCase.worldfolder == None: - downloadsample.install() - ScriptTestCase.worldfolder = downloadsample.worlddir - if ScriptTestCase.mcregionfolder == None: - ScriptTestCase.mcregionfolder = downloadsample.temp_mcregion_world() if ScriptTestCase.anvilfolder == None: - ScriptTestCase.anvilfolder = downloadsample.temp_anvil_world() + ScriptTestCase.anvilfolder = sample_server.get_world_dir() + def tearDownModule(): - """Remove temporary folders with Anvil and McRegion files.""" - if ScriptTestCase.mcregionfolder != None: - downloadsample.cleanup_temp_world(ScriptTestCase.mcregionfolder) - if ScriptTestCase.anvilfolder != None: - downloadsample.cleanup_temp_world(ScriptTestCase.anvilfolder) - ScriptTestCase.worldfolder = None - ScriptTestCase.mcregionfolder = None ScriptTestCase.anvilfolder = None diff --git a/tests/regiontests.py b/tests/regiontests.py index ac47931..8ce823e 100755 --- a/tests/regiontests.py +++ b/tests/regiontests.py @@ -201,7 +201,7 @@ def setUp(self): self.tempdir = tempfile.mkdtemp() self.filename = os.path.join(self.tempdir, 'regiontest.mca') shutil.copy(REGIONTESTFILE, self.filename) - self.region = RegionFile(filename = self.filename) + self.region = RegionFile(filename = self.filename, for_write=True) def tearDown(self): del self.region diff --git a/tests/sample_server.py b/tests/sample_server.py index afc93be..51b4205 100644 --- a/tests/sample_server.py +++ b/tests/sample_server.py @@ -1,34 +1,79 @@ import hashlib import os +import stat import subprocess +from concurrent.futures import ThreadPoolExecutor from .downloadsample import download_with_external_tool -server_dir = 'tests/sample_server' -server_jar_path = os.path.join(server_dir, 'server.jar') -world_dir = os.path.join(server_dir, 'world') - - -# minecraft 1.20.2 -server_jar_url = 'https://piston-data.mojang.com/v1/objects/5b868151bd02b41319f54c8d4061b8cae84e665c/server.jar' -server_jar_sha256_hex = '1daee4838569ad46e41f0a6f459684c500c7f2685356a40cfb7e838d6e78eae8' -stop_command = b'/stop\n' - - -def make_world(): - if os.path.exists(world_dir): - # if world exists, do nothing - return +servers_dir = os.path.join('tests', 'sample_server') + +versions = { + '1.20.2': { + 'server_jar_url': 'https://piston-data.mojang.com/v1/objects/5b868151bd02b41319f54c8d4061b8cae84e665c/server.jar', + 'server_jar_sha256_hex': '1daee4838569ad46e41f0a6f459684c500c7f2685356a40cfb7e838d6e78eae8', + 'stop_command': b'/stop\n', + 'port': 25565, + }, + '1.19.4': { + 'server_jar_url': 'https://piston-data.mojang.com/v1/objects/8f3112a1049751cc472ec13e397eade5336ca7ae/server.jar', + 'server_jar_sha256_hex': 'a524a10da550741785c8c3a0fb39047f106b0c7c2cfa255e8278cb6f1abe3f53', + 'stop_command': b'/stop\n', + 'port': 25564, + }, + '1.18.2': { + 'server_jar_url': 'https://piston-data.mojang.com/v1/objects/c8f83c5655308435b3dcf03c06d9fe8740a77469/server.jar', + 'server_jar_sha256_hex': '57be9d1e35aa91cfdfa246adb63a0ea11a946081e0464d08bc3d36651718a343', + 'stop_command': b'/stop\n', + 'port': 25563, + }, + '1.17.1': { + 'server_jar_url': 'https://piston-data.mojang.com/v1/objects/a16d67e5807f57fc4e550299cf20226194497dc2/server.jar', + 'server_jar_sha256_hex': 'e8c211b41317a9f5a780c98a89592ecb72eb39a6e475d4ac9657e5bc9ffaf55f', + 'stop_command': b'/stop\n', + 'port': 25562, + }, + '1.16.5': { + 'server_jar_url': 'https://piston-data.mojang.com/v1/objects/1b557e7b033b583cd9f66746b7a9ab1ec1673ced/server.jar', + 'server_jar_sha256_hex': '58f329c7d2696526f948470aa6fd0b45545039b64cb75015e64c12194b373da6', + 'stop_command': b'/stop\n', + 'port': 25561, + }, +} + +latest_version = '1.20.2' + + +def get_world_dir(version=latest_version): + server_dir = os.path.join(servers_dir, version) + world_dir = os.path.join(server_dir, 'world') + + if not os.path.exists(world_dir): + os.makedirs(server_dir, exist_ok=True) + start_server(server_dir, version) + assert os.path.exists(world_dir) + mark_readonly(server_dir) + + return world_dir + + +def start_server(server_dir, version): + install_server(server_dir, version) + subprocess.run( + ['java', '-jar', 'server.jar', 'nogui'], + cwd=server_dir, + input=versions[version]['stop_command'], + check=True, + timeout=120, # seconds + ) - os.makedirs(server_dir, exist_ok=True) - install_server() - start_server() +def install_server(server_dir, version): + server_jar_path = os.path.join(server_dir, 'server.jar') + server_jar_url = versions[version]['server_jar_url'] + server_jar_sha256_hex = versions[version]['server_jar_sha256_hex'] -def install_server(): - if not is_server_digest_ok(): - download_with_external_tool(server_jar_url, server_jar_path) - assert is_server_digest_ok() + ensure_downloaded(server_jar_path, server_jar_sha256_hex, server_jar_url) # fill in eula with open(os.path.join(server_dir, 'eula.txt'), 'wt') as f: @@ -38,26 +83,45 @@ def install_server(): # configure server.properties with open(os.path.join(server_dir, 'server.properties'), 'wt') as f: f.write('level-seed=testseed\n') + f.write(f'server-port={versions[version]["port"]}\n') + f.write('server-ip=127.0.0.1\n') -def start_server(): - subprocess.run( - ['java', '-jar', 'server.jar', 'nogui'], - cwd=server_dir, - input=stop_command, - check=True, - timeout=120, # seconds +def mark_readonly(directory): + permission_mask = ( + stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH | + stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH ) + for root, dirs, files in os.walk(directory): + for d in dirs: + current_mode = os.stat(os.path.join(root, d)).st_mode + os.chmod(os.path.join(root, d), current_mode & permission_mask) + for f in files: + current_mode = os.stat(os.path.join(root, f)).st_mode + os.chmod(os.path.join(root, f), current_mode & permission_mask) + current_mode = os.stat(directory).st_mode + os.chmod(directory, current_mode & permission_mask) + + +def ensure_downloaded(local_path, sha256_hex, url): + if get_sha256_hex(local_path) != sha256_hex: + download_with_external_tool(url, local_path) + real_sha256_hex = get_sha256_hex(local_path) + assert real_sha256_hex == sha256_hex, f'file {local_path}: expected digest {sha256_hex}, got {real_sha256_hex}' + + +def get_sha256_hex(path): + if not os.path.exists(path): + return None + with open(path, 'rb') as f: + return hashlib.sha256(f.read()).hexdigest() -def is_server_digest_ok(): - if os.path.exists(server_jar_path): - with open(server_jar_path, 'rb') as f: - current_sha256_hex = hashlib.sha256(f.read()).hexdigest() - else: - current_sha256_hex = None - return current_sha256_hex == server_jar_sha256_hex +def ensure_all(): + with ThreadPoolExecutor(max_workers=2) as executor: + for version in versions.keys(): + executor.submit(get_world_dir, version) if __name__ == '__main__': - make_world() + ensure_all() diff --git a/tests/test_real_mc_world.py b/tests/test_real_mc_world.py index 447257e..37991bc 100644 --- a/tests/test_real_mc_world.py +++ b/tests/test_real_mc_world.py @@ -4,14 +4,16 @@ from nbt.world import WorldFolder -from .sample_server import make_world -from .sample_server import world_dir as sample_world_dir +from .sample_server import get_world_dir, versions -@pytest.fixture(scope='session', autouse=True) -def world_dir(): - make_world() - yield sample_world_dir +@pytest.fixture( + scope='session', + autouse=True, + params=versions.keys(), +) +def world_dir(request): + yield get_world_dir(version=request.param) def test_we_have_a_world_dir(world_dir):