Repository: cheahjs/palworld-save-tools (language: python)

Issue:
[Feature Request] Packaging into a wheel and uploading to PyPi
I've started integrating your tool here with my fix https://github.com/xNul/palworld-host-save-fix and at the moment I'm planning to add it as a submodule but it would be very convenient to have it as a pip package.
Publish Python Module
Can you publish this as a python module so that I can use it without cnping specific functions? It feels like I'm stealing your work.
Can you support accepting byte data for the json and sav data?
Can you decouple gvas from json_to_sav and sav_to_json stuff and have it take gvas byte data instead?
Can you not do any file i/o? If I want to write to or read from a file I want to do it myself. The root python scripts can be responsible for the file i/o =).

Thanks!


Discussion / hints:

Here's an example of the gvas compression/decompression decoupled from the to_json/from_json stuff. Your code modified to be in dif functions :).

```import zlib
from typing import Union


def decompress_gvas(data: bytes) -> Union[None, tuple[bytes, int]]:
    uncompressed_len = int.from_bytes(data[0:4], byteorder='little')
    compressed_len = int.from_bytes(data[4:8], byteorder='little')
    magic_bytes = data[8:11]
    save_type = data[11]
    # Check for magic bytes
    if data[8:11] != b'PlZ':
        print(f'not a save, found {magic_bytes} instead of P1Z')
        return None
    # Valid save types
    if save_type not in [0x30, 0x31, 0x32]:
        print(f'unknown save type: {save_type}')
        return None
    # We only have 0x31 (single zlib) and 0x32 (double zlib) saves
    if save_type not in [0x31, 0x32]:
        print(f'unhandled compression type: {save_type}')
        return None
    if save_type == 0x31:
        # Check if the compressed length is correct
        if compressed_len != len(data) - 12:
            print(f'incorrect compressed length: {compressed_len}')
            return None
    # Decompress file
    uncompressed_data = zlib.decompress(data[12:])
    if save_type == 0x32:
        # Check if the compressed length is correct
        if compressed_len != len(uncompressed_data):
            print(f'incorrect compressed length: {compressed_len}')
            return None
        # Decompress file
        uncompressed_data = zlib.decompress(uncompressed_data)
    # Check if the uncompressed length is correct
    if uncompressed_len != len(uncompressed_data):
        print(f'incorrect uncompressed length: {uncompressed_len}')
        return None

    return uncompressed_data, save_type


def compress_gvas(data: bytes, save_type: int) -> bytes:
    uncompressed_len = len(data)
    compressed_data = zlib.compress(data)
    compressed_len = len(compressed_data)
    if save_type == 0x32:
        compressed_data = zlib.compress(compressed_data)

    # Create a byte array and append the necessary information
    result = bytearray()
    result.extend(uncompressed_len.to_bytes(4, byteorder='little'))
    result.extend(compressed_len.to_bytes(4, byteorder='little'))
    result.extend(b'PlZ')
    result.extend(bytes([save_type]))
    result.extend(compressed_data)

    return bytes(result)
```
Example of json<->gvas translation. Largely your code :)

```
def json_to_gvas(json_data, uesave_path="./lib/sav/uesave"):
    json_str = json.dumps(json_data)
    json_bytes = json_str.encode()

    uesave_run = subprocess.run(uesave_params_backward(uesave_path), input=json_bytes, capture_output=True)

    if uesave_run.returncode != 0:
        print(f'uesave.exe failed to convert to gvas (return {uesave_run.returncode})')
        return None

    return uesave_run.stdout


def gvas_to_json(gvas: bytes, uesave_path="./lib/sav/uesave"):
    uesave_run = subprocess.run(uesave_params_forward(uesave_path), input=gvas, capture_output=True)

    # Check if the command was successful
    if uesave_run.returncode != 0:
        print(f'uesave.exe failed to convert (return {uesave_run.returncode})')
        print(uesave_run.stdout.decode('utf-8'))
        print(uesave_run.stderr.decode('utf-8'))
        return None

    json_data = uesave_run.stdout
    json_data = json_data.decode()
    json_data = json.loads(json_data)

    return json_data
```
I can look into publishing a module for some things, my current philosophy is to ensure that any scripts users run do not require any additional dependencies outside of Python.
This might wait until I can handle parsing without uesave-rs in the loop.

Fix patch:
diff --git a/.github/workflows/package.yaml b/.github/workflows/package.yaml
index b29a107..1ae5d39 100644
--- a/.github/workflows/package.yaml
+++ b/.github/workflows/package.yaml
@@ -1,7 +1,7 @@
 name: Package files
 on: [push]
 jobs:
-  package:
+  package-windows-zip:
     permissions:
       contents: write
     runs-on: ubuntu-latest
@@ -15,8 +15,8 @@ jobs:
           mkdir -p dist
           cp LICENSE dist/
           cp README.md dist/
-          cp *.py dist/
-          cp -r lib dist/
+          cp palworld_save_tools/commands/convert.py dist/
+          cp -r palworld_save_tools dist/
           cp convenience_tools/* dist/
           cd dist
           zip -r ../palworld-save-tools-windows-${{steps.ghd.outputs.describe}}.zip *
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
new file mode 100644
index 0000000..034f3e0
--- /dev/null
+++ b/.github/workflows/publish.yaml
@@ -0,0 +1,27 @@
+name: Publish
+on:
+  release:
+    types: [published]
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: 3.12
+      - name: Install dependencies
+        run: |
+          pip install -r requirements-dev.txt
+          pip install hatch
+      - name: Build package
+        run: hatch build
+      - name: Test package
+        run: hatch run test:run
+      - name: Publish package to PyPi
+        uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.gitignore b/.gitignore
index 15f5f2e..b50f395 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,164 @@ localtest
 /legacy
 __pycache__
 *.prof
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
diff --git a/README.md b/README.md
index 6ca745c..88ebbcf 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,6 @@ Some fields that are not currently parsed:
 1. `BaseCampSaveData.Value.ModuleMap`
 1. `MapObjectSaveData.MapObjectSaveData.ConcreteModel`
 
-## Converting co-op saves to dedicated server saves
-
-Please follow the instructions provided over at https://github.com/xNul/palworld-host-save-fix
-
 ## Instructions
 
 > [!IMPORTANT]
@@ -64,13 +60,33 @@ Additional command line arguments:
 1. `--minify-json`: Minify output JSON to help speed up processing by other tools consuming JSON
 1. `--force`: Overwrite output files if they exist without prompting
 
+## Developers
+
+This library is available on PyPi, and can be installed with
+
+```shell
+pip install palworld-save-tools
+```
+
+> [!NOTE]
+> Due to ongoing rapid development and the potential for breaking changes, the recommendation is to pin to a specific version, and take updates as necessary.
 
 ## Roadmap
 
-- [] Parse all known blobs of data
-- [] Optimize CPU and memory usage
+- [ ] Parse all known blobs of data
+- [ ] Optimize CPU and memory usage
 
 ## Development philosophy
 
 - No additional dependencies. Scripts should run with a default install of Python. Distributing binary builds of Python is laden with AV false positives.
 - Correctness of the conversion process is more important than performance. SAV > JSON > SAV should yield bit-for-bit identical files (pre-compression).
+
+## Projects that make use of palworld-save-tools
+
+> [!NOTE]
+> This does not serve as an endorsement of any of these projects, use at your own risk.
+
+- [xNul/palworld-host-save-fix](https://github.com/xNul/palworld-host-save-fix/) - Migrating save data between player IDs (eg, converting coop saves to dedicated server saves)
+- [PalEdit](https://github.com/EternalWraith/PalEdit) - GUI for editing Pals
+- [palworld-server-tool](https://github.com/zaigie/palworld-server-tool) - Managing dedicated servers via RCON and SAV file parsing
+- [magicbear/palworld-save-tools](https://github.com/magicbear/palworld-save-tools/tree/clenaup-tools) - Assorted set of SAV file manipulations
diff --git a/hatch.toml b/hatch.toml
new file mode 100644
index 0000000..0c10031
--- /dev/null
+++ b/hatch.toml
@@ -0,0 +1,14 @@
+[version]
+source = "vcs"
+
+[build.targets.sdist]
+exclude = ["/.github", "/convenience_tools"]
+
+[build.targets.wheel]
+packages = ["palworld_save_tools"]
+
+[envs.test]
+dependencies = ["parameterized"]
+
+[envs.test.scripts]
+run = "python -m unittest -v"
diff --git a/lib/__init__.py b/palworld_save_tools/__init__.py
similarity index 100%
rename from lib/__init__.py
rename to palworld_save_tools/__init__.py
diff --git a/lib/archive.py b/palworld_save_tools/archive.py
similarity index 100%
rename from lib/archive.py
rename to palworld_save_tools/archive.py
diff --git a/lib/rawdata/__init__.py b/palworld_save_tools/commands/__init__.py
similarity index 100%
rename from lib/rawdata/__init__.py
rename to palworld_save_tools/commands/__init__.py
diff --git a/convert.py b/palworld_save_tools/commands/convert.py
old mode 100755
new mode 100644
similarity index 94%
rename from convert.py
rename to palworld_save_tools/commands/convert.py
index 45091aa..fa84f87
--- a/convert.py
+++ b/palworld_save_tools/commands/convert.py
@@ -4,10 +4,10 @@
 import json
 import os
 
-from lib.gvas import GvasFile
-from lib.json_tools import CustomEncoder
-from lib.palsav import compress_gvas_to_sav, decompress_sav_to_gvas
-from lib.paltypes import PALWORLD_CUSTOM_PROPERTIES, PALWORLD_TYPE_HINTS
+from palworld_save_tools.gvas import GvasFile
+from palworld_save_tools.json_tools import CustomEncoder
+from palworld_save_tools.palsav import compress_gvas_to_sav, decompress_sav_to_gvas
+from palworld_save_tools.paltypes import PALWORLD_CUSTOM_PROPERTIES, PALWORLD_TYPE_HINTS
 
 
 def main():
diff --git a/lib/gvas.py b/palworld_save_tools/gvas.py
similarity index 98%
rename from lib/gvas.py
rename to palworld_save_tools/gvas.py
index 35339b4..a72f801 100644
--- a/lib/gvas.py
+++ b/palworld_save_tools/gvas.py
@@ -1,7 +1,7 @@
 import base64
 from typing import Any, Callable
 
-from lib.archive import FArchiveReader, FArchiveWriter
+from palworld_save_tools.archive import FArchiveReader, FArchiveWriter
 
 
 def custom_version_reader(reader: FArchiveReader):
diff --git a/lib/json_tools.py b/palworld_save_tools/json_tools.py
similarity index 86%
rename from lib/json_tools.py
rename to palworld_save_tools/json_tools.py
index 6d1361d..e8e3879 100644
--- a/lib/json_tools.py
+++ b/palworld_save_tools/json_tools.py
@@ -1,7 +1,7 @@
 import json
 import uuid
 
-from lib.archive import UUID
+from palworld_save_tools.archive import UUID
 
 
 class CustomEncoder(json.JSONEncoder):
diff --git a/lib/palsav.py b/palworld_save_tools/palsav.py
similarity index 100%
rename from lib/palsav.py
rename to palworld_save_tools/palsav.py
diff --git a/lib/paltypes.py b/palworld_save_tools/paltypes.py
similarity index 98%
rename from lib/paltypes.py
rename to palworld_save_tools/paltypes.py
index d7838c5..a49fc00 100644
--- a/lib/paltypes.py
+++ b/palworld_save_tools/paltypes.py
@@ -1,7 +1,7 @@
 from typing import Any, Callable
 
-from lib.archive import FArchiveReader, FArchiveWriter
-from lib.rawdata import (
+from palworld_save_tools.archive import FArchiveReader, FArchiveWriter
+from palworld_save_tools.rawdata import (
     base_camp,
     build_process,
     character,
diff --git a/palworld_save_tools/rawdata/__init__.py b/palworld_save_tools/rawdata/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lib/rawdata/base_camp.py b/palworld_save_tools/rawdata/base_camp.py
similarity index 97%
rename from lib/rawdata/base_camp.py
rename to palworld_save_tools/rawdata/base_camp.py
index 641e889..932d0f1 100644
--- a/lib/rawdata/base_camp.py
+++ b/palworld_save_tools/rawdata/base_camp.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/base_camp_module.py b/palworld_save_tools/rawdata/base_camp_module.py
similarity index 98%
rename from lib/rawdata/base_camp_module.py
rename to palworld_save_tools/rawdata/base_camp_module.py
index 16536ca..5bc1313 100644
--- a/lib/rawdata/base_camp_module.py
+++ b/palworld_save_tools/rawdata/base_camp_module.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 NO_OP_TYPES = [
     "EPalBaseCampModuleType::Energy",
diff --git a/lib/rawdata/build_process.py b/palworld_save_tools/rawdata/build_process.py
similarity index 97%
rename from lib/rawdata/build_process.py
rename to palworld_save_tools/rawdata/build_process.py
index f6d757b..440294b 100644
--- a/lib/rawdata/build_process.py
+++ b/palworld_save_tools/rawdata/build_process.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/character.py b/palworld_save_tools/rawdata/character.py
similarity index 97%
rename from lib/rawdata/character.py
rename to palworld_save_tools/rawdata/character.py
index 07a4e8b..23566c7 100644
--- a/lib/rawdata/character.py
+++ b/palworld_save_tools/rawdata/character.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/character_container.py b/palworld_save_tools/rawdata/character_container.py
similarity index 97%
rename from lib/rawdata/character_container.py
rename to palworld_save_tools/rawdata/character_container.py
index 780c7ac..2d28f65 100644
--- a/lib/rawdata/character_container.py
+++ b/palworld_save_tools/rawdata/character_container.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/connector.py b/palworld_save_tools/rawdata/connector.py
similarity index 98%
rename from lib/rawdata/connector.py
rename to palworld_save_tools/rawdata/connector.py
index 5bc96f7..f01fd0b 100644
--- a/lib/rawdata/connector.py
+++ b/palworld_save_tools/rawdata/connector.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/debug.py b/palworld_save_tools/rawdata/debug.py
similarity index 95%
rename from lib/rawdata/debug.py
rename to palworld_save_tools/rawdata/debug.py
index a4b58e3..a0df244 100644
--- a/lib/rawdata/debug.py
+++ b/palworld_save_tools/rawdata/debug.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/dynamic_item.py b/palworld_save_tools/rawdata/dynamic_item.py
similarity index 98%
rename from lib/rawdata/dynamic_item.py
rename to palworld_save_tools/rawdata/dynamic_item.py
index fe06d69..631e435 100644
--- a/lib/rawdata/dynamic_item.py
+++ b/palworld_save_tools/rawdata/dynamic_item.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/foliage_model.py b/palworld_save_tools/rawdata/foliage_model.py
similarity index 97%
rename from lib/rawdata/foliage_model.py
rename to palworld_save_tools/rawdata/foliage_model.py
index 672acde..0378a2a 100644
--- a/lib/rawdata/foliage_model.py
+++ b/palworld_save_tools/rawdata/foliage_model.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/foliage_model_instance.py b/palworld_save_tools/rawdata/foliage_model_instance.py
similarity index 98%
rename from lib/rawdata/foliage_model_instance.py
rename to palworld_save_tools/rawdata/foliage_model_instance.py
index 4d2a69e..adb6569 100644
--- a/lib/rawdata/foliage_model_instance.py
+++ b/palworld_save_tools/rawdata/foliage_model_instance.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/group.py b/palworld_save_tools/rawdata/group.py
similarity index 99%
rename from lib/rawdata/group.py
rename to palworld_save_tools/rawdata/group.py
index 1a357b7..9995479 100644
--- a/lib/rawdata/group.py
+++ b/palworld_save_tools/rawdata/group.py
@@ -1,6 +1,6 @@
 from typing import Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/item_container.py b/palworld_save_tools/rawdata/item_container.py
similarity index 97%
rename from lib/rawdata/item_container.py
rename to palworld_save_tools/rawdata/item_container.py
index 9f12afd..3d45b98 100644
--- a/lib/rawdata/item_container.py
+++ b/palworld_save_tools/rawdata/item_container.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/item_container_slots.py b/palworld_save_tools/rawdata/item_container_slots.py
similarity index 97%
rename from lib/rawdata/item_container_slots.py
rename to palworld_save_tools/rawdata/item_container_slots.py
index 1ee2d12..a6b73a4 100644
--- a/lib/rawdata/item_container_slots.py
+++ b/palworld_save_tools/rawdata/item_container_slots.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/map_concrete_model.py b/palworld_save_tools/rawdata/map_concrete_model.py
similarity index 98%
rename from lib/rawdata/map_concrete_model.py
rename to palworld_save_tools/rawdata/map_concrete_model.py
index 491be07..1e14fc5 100644
--- a/lib/rawdata/map_concrete_model.py
+++ b/palworld_save_tools/rawdata/map_concrete_model.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 # def decode_map_concrete_model(
 #     reader: FArchiveReader, type_name: str, size: int, path: str
diff --git a/lib/rawdata/map_model.py b/palworld_save_tools/rawdata/map_model.py
similarity index 98%
rename from lib/rawdata/map_model.py
rename to palworld_save_tools/rawdata/map_model.py
index 7cc8fa8..98f0cdd 100644
--- a/lib/rawdata/map_model.py
+++ b/palworld_save_tools/rawdata/map_model.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/work.py b/palworld_save_tools/rawdata/work.py
similarity index 99%
rename from lib/rawdata/work.py
rename to palworld_save_tools/rawdata/work.py
index 954a8cc..5915f05 100644
--- a/lib/rawdata/work.py
+++ b/palworld_save_tools/rawdata/work.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 WORK_BASE_TYPES = set(
     [
diff --git a/lib/rawdata/work_collection.py b/palworld_save_tools/rawdata/work_collection.py
similarity index 97%
rename from lib/rawdata/work_collection.py
rename to palworld_save_tools/rawdata/work_collection.py
index 1d0ce3d..0bf4022 100644
--- a/lib/rawdata/work_collection.py
+++ b/palworld_save_tools/rawdata/work_collection.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/lib/rawdata/worker_director.py b/palworld_save_tools/rawdata/worker_director.py
similarity index 97%
rename from lib/rawdata/worker_director.py
rename to palworld_save_tools/rawdata/worker_director.py
index 04ebdee..e5e66ac 100644
--- a/lib/rawdata/worker_director.py
+++ b/palworld_save_tools/rawdata/worker_director.py
@@ -1,6 +1,6 @@
 from typing import Any, Sequence
 
-from lib.archive import *
+from palworld_save_tools.archive import *
 
 
 def decode(
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9c7a492
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,25 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "palworld-save-tools"
+authors = [
+  { name="Jun Siang Cheah", email="me@jscheah.me" },
+]
+description = "Tools for converting Palworld .sav files to JSON and back"
+readme = "README.md"
+requires-python = ">=3.9"
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "License :: OSI Approved :: MIT License",
+    "Operating System :: OS Independent",
+]
+dynamic = ["version"]
+
+[project.urls]
+Homepage = "https://github.com/cheahjs/palworld-save-tools"
+Issues = "https://github.com/cheahjs/palworld-save-tools/issues"
+
+[project.scripts]
+palworld-save-tools = "palworld_save_tools.commands.convert:main"


Test patch:
diff --git a/resave_test.py b/palworld_save_tools/commands/resave_test.py
old mode 100755
new mode 100644
similarity index 87%
rename from resave_test.py
rename to palworld_save_tools/commands/resave_test.py
index 8ca0780..0865c68
--- a/resave_test.py
+++ b/palworld_save_tools/commands/resave_test.py
@@ -2,8 +2,11 @@
 # This scripts takes a .sav file as input, and runs through the sav > JSON > sav process to ensure that the output is the same as the input.
 import sys
 
-from convert import convert_json_to_sav, convert_sav_to_json
-from lib.palsav import decompress_sav_to_gvas
+from palworld_save_tools.commands.convert import (
+    convert_json_to_sav,
+    convert_sav_to_json,
+)
+from palworld_save_tools.palsav import decompress_sav_to_gvas
 
 
 def main():
diff --git a/tests/test_archive.py b/tests/test_archive.py
index 0cc2417..9ef9717 100644
--- a/tests/test_archive.py
+++ b/tests/test_archive.py
@@ -3,7 +3,7 @@
 
 from parameterized import parameterized
 
-from lib.archive import UUID, FArchiveReader, FArchiveWriter
+from palworld_save_tools.archive import UUID, FArchiveReader, FArchiveWriter
 
 
 class TestArchive(unittest.TestCase):
diff --git a/tests/test_cli_scripts.py b/tests/test_cli_scripts.py
index c75d14a..3c23fb3 100644
--- a/tests/test_cli_scripts.py
+++ b/tests/test_cli_scripts.py
@@ -33,7 +33,8 @@ def test_sav_roundtrip(self, file_name):
             run = subprocess.run(
                 [
                     "python3",
-                    "convert.py",
+                    "-m",
+                    "palworld_save_tools.commands.convert",
                     f"tests/testdata/{dir_name}/{base_name}",
                 ]
             )
@@ -49,7 +50,8 @@ def test_sav_roundtrip(self, file_name):
             run = subprocess.run(
                 [
                     "python3",
-                    "convert.py",
+                    "-m",
+                    "palworld_save_tools.commands.convert",
                     f"tests/testdata/{dir_name}/1-{base_name}.json",
                 ]
             )
@@ -63,7 +65,8 @@ def test_sav_roundtrip(self, file_name):
             run = subprocess.run(
                 [
                     "python3",
-                    "convert.py",
+                    "-m",
+                    "palworld_save_tools.commands.convert",
                     f"tests/testdata/{dir_name}/2-{base_name}",
                 ]
             )
@@ -79,7 +82,8 @@ def test_sav_roundtrip(self, file_name):
             run = subprocess.run(
                 [
                     "python3",
-                    "convert.py",
+                    "-m",
+                    "palworld_save_tools.commands.convert",
                     f"tests/testdata/{dir_name}/3-{base_name}.json",
                 ]
             )
diff --git a/tests/test_gvas.py b/tests/test_gvas.py
index 3616669..e165afb 100644
--- a/tests/test_gvas.py
+++ b/tests/test_gvas.py
@@ -1,15 +1,14 @@
 import base64
 import json
 import unittest
-import uuid
 
 from parameterized import parameterized
 
-from lib.archive import UUID, FArchiveReader, FArchiveWriter
-from lib.gvas import GvasFile, GvasHeader
-from lib.json_tools import CustomEncoder
-from lib.palsav import decompress_sav_to_gvas
-from lib.paltypes import PALWORLD_CUSTOM_PROPERTIES, PALWORLD_TYPE_HINTS
+from palworld_save_tools.archive import UUID, FArchiveReader, FArchiveWriter
+from palworld_save_tools.gvas import GvasFile, GvasHeader
+from palworld_save_tools.json_tools import CustomEncoder
+from palworld_save_tools.palsav import decompress_sav_to_gvas
+from palworld_save_tools.paltypes import PALWORLD_CUSTOM_PROPERTIES, PALWORLD_TYPE_HINTS
 
 
 class TestGvas(unittest.TestCase):
diff --git a/tests/test_rawdata.py b/tests/test_rawdata.py
index f61ee37..1e9d241 100644
--- a/tests/test_rawdata.py
+++ b/tests/test_rawdata.py
@@ -4,9 +4,9 @@
 
 from parameterized import parameterized
 
-from lib.archive import FArchiveReader
-from lib.json_tools import CustomEncoder
-from lib.rawdata import character, foliage_model_instance, group
+from palworld_save_tools.archive import FArchiveReader
+from palworld_save_tools.json_tools import CustomEncoder
+from palworld_save_tools.rawdata import character, foliage_model_instance, group
 
 
 class TestRawData(unittest.TestCase):