diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 026e854056882ec645610ae2d1a36c21e88b84e4..fd51b975b58349401401f48d90862fe8e7619064 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,23 +1,23 @@
 stages:
-  - test
   - package
+  - test
   - deploy
 
 .centos:
   tags: [linux, docker]
   image: git.imp.fu-berlin.de:5000/bioroboticslab/auto/ci/centos:latest
 
+.macos:
+  tags: [macos, shell]
+
 .windows:
   tags: [windows, docker]
   image: git.imp.fu-berlin.de:5000/bioroboticslab/auto/ci/windows:latest-devel
   before_script:
     - . $Profile.AllUsersAllHosts
 
-.cpy37: &cpy37
-  PYTHON_EXECUTABLE: "python3.7"
-
-.cpy38: &cpy38
-  PYTHON_EXECUTABLE: "python3.8"
+.python38: &python38
+  PYTHON_VERSION: "3.8"
 
 .test: &test
   stage: test
@@ -28,61 +28,53 @@ stages:
   script:
     - ./ci/test.py
 
-test centos[cpy37]:
-  extends: .centos
-  <<: *test
-  variables:
-    <<: [*cpy37]
-
-test centos[cpy38]:
-  extends: .centos
-  <<: *test
-  variables:
-    <<: [*cpy38]
-
-test windows[cpy37]:
-  extends: .windows
-  <<: *test
-
 .package: &package
   stage: package
   artifacts:
     paths:
       - dist
     expire_in: 1 week
+    reports:
+      dotenv: build.env
   script:
     - ./ci/package.py
 
-package centos[cpy37]:
+package:
   extends: .centos
-  dependencies:
-    - test centos[cpy37]
   <<: *package
   variables:
-    <<: [*cpy37]
+    <<: [*python38]
+
+"test: [centos, 3.8]":
+  extends: .centos
+  <<: *test
+
+"test: [macos, 3.8]":
+  extends: .macos
+  <<: *test
 
-package windows[cpy37]:
+"test: [windows, 3.8]":
   extends: .windows
-  dependencies:
-    - test windows[cpy37]
-  <<: *package
+  <<: *test
 
-deploy centos[cpy37]:
+deploy to staging:
   extends: .centos
   stage: deploy
   only:
+    - master
     - tags
+  allow_failure: true
   dependencies:
-    - package centos[cpy37]
+    - package
   script:
-    - ./ci/deploy.py
+    - ./python/ci/deploy.py
 
-.deploy windows[cpy37]:
+deploy to production:
   extends: .centos
   stage: deploy
   only:
     - tags
   dependencies:
-    - package windows[cpy37]
+    - package
   script:
-    - ./ci/deploy.py
+    - ./python/ci/deploy.py --production
diff --git a/ci/deploy.py b/ci/deploy.py
index 6dda82b345943f9e96b35acb1d1858343e51a1c8..0437457696a996d508cb3b4235ba6feaeb49d52c 100755
--- a/ci/deploy.py
+++ b/ci/deploy.py
@@ -3,22 +3,31 @@
 
 from os import environ as env
 from subprocess import check_call
-from pathlib import Path
 from platform import system
+from argparse import ArgumentParser
 
 
 if __name__ == "__main__":
     if system() != "Linux":
         raise Exception("Uploading python package only supported on Linux")
 
+    p = ArgumentParser()
+    p.add_argument("--production", default=False, action="store_const", const=True)
+    args = p.parse_args()
+
     env["TWINE_USERNAME"] = "gitlab-ci-token"
     env["TWINE_PASSWORD"] = env["CI_JOB_TOKEN"]
 
+    if args.production:
+        target_project_id = env['ARTIFACTS_REPOSITORY_PROJECT_ID']
+    else:
+        target_project_id = env['CI_PROJECT_ID']
+
     command = ["python3"]
     command += ["-m", "twine", "upload", "dist/*"]
     command += [
         "--repository-url",
-        f"https://git.imp.fu-berlin.de/api/v4/projects/{env['ARTIFACTS_REPOSITORY_PROJECT_ID']}/packages/pypi",
+        f"https://git.imp.fu-berlin.de/api/v4/projects/{target_project_id}/packages/pypi",
     ]
 
     check_call(command)
diff --git a/ci/package.py b/ci/package.py
index 4be105e3f11445e3c9c8e9d183882fcac116d8ba..38ef2d7f540eb37068ae0a0df5123173962e2789 100755
--- a/ci/package.py
+++ b/ci/package.py
@@ -2,16 +2,17 @@
 # SPDX-License-Identifier: LGPL-3.0-or-later
 
 from os import environ as env
-from platform import system
 from subprocess import check_call
-from sys import executable
+from pathlib import Path
+from platform import system
+from shutil import which
 
 
 def python_executable():
     if system() == "Windows":
-        return executable
-    elif system() == "Linux":
-        return env["PYTHON_EXECUTABLE"]
+        return f"/Python{''.join(env['PYTHON_VERSION'].split('.'))}/python.exe"
+    elif system() == "Linux" or system() == "Darwin":
+        return which(f"python{env['PYTHON_VERSION']}")
     assert False
 
 
@@ -20,3 +21,6 @@ if __name__ == "__main__":
     command += ["setup.py", "bdist_wheel"]
 
     check_call(command)
+
+    with open("build.env", "w") as f:
+        f.write(f"PYTHON_VERSION={env['PYTHON_VERSION']}\n")
diff --git a/ci/test.py b/ci/test.py
index 992a7cf573a716ff839a26989fd45dab6ce66c6c..c6d90a39d599fc668a6a1ed525c2a940ddeeec4f 100755
--- a/ci/test.py
+++ b/ci/test.py
@@ -1,28 +1,52 @@
 #! /usr/bin/env python3
 # SPDX-License-Identifier: LGPL-3.0-or-later
 
-from os import environ as env
+from os import environ as env, pathsep
 from subprocess import check_call
+from pathlib import Path
 from platform import system
-from sys import executable
+from shutil import which
 
 
 def python_executable():
     if system() == "Windows":
-        return executable
-    elif system() == "Linux":
-        return env["PYTHON_EXECUTABLE"]
+        return f"/Python{''.join(env['PYTHON_VERSION'].split('.'))}/python.exe"
+    elif system() == "Linux" or system() == "Darwin":
+        return which(f"python{env['PYTHON_VERSION']}")
+    assert False
+
+
+def python_venv_executable():
+    if system() == "Windows":
+        return str(Path(".venv/Scripts/python.exe").resolve())
+    elif system() == "Linux" or system() == "Darwin":
+        return str(Path(".venv/bin/python").resolve())
     assert False
 
 
 if __name__ == "__main__":
-    check_call([python_executable(), "-m", "pip", "install", "pytest"])
-    # check_call([python_executable(), "-m", "pip", "install", "pytest-cov"])
-    check_call([python_executable(), "-m", "pip", "install", "h5py"])
-    check_call([python_executable(), "-m", "pip", "install", "pandas"])
-    check_call([python_executable(), "-m", "pip", "install", "deprecation"])
+    check_call(
+        [
+            python_executable(),
+            "-m",
+            "venv",
+            "--system-site-packages",
+            "--prompt",
+            "ci",
+            ".venv",
+        ],
+    )
 
-    command = [python_executable()]
-    command += ["-m", "pytest", "--junitxml=report.xml"]
+    check_call(
+        [
+            python_venv_executable(),
+            "-m",
+            "pip",
+            "install",
+            str(sorted(Path("dist").glob("*.whl"))[-1].resolve()),
+        ],
+    )
 
-    check_call(command)
+    check_call(
+        [python_venv_executable(), "-m", "pytest", "--junitxml=report.xml"],
+    )
diff --git a/setup.py b/setup.py
index 456a82480daf446983066cb830724c7fcc11548e..5be2fe4573c17f9dbb776058058ea0e38c4674aa 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,7 @@
 # SPDX-License-Identifier: LGPL-3.0-or-later
 
+from subprocess import run, PIPE
+
 from setuptools import setup, find_packages
 
 
@@ -11,9 +13,32 @@ entry_points = {
         "robofish-io-evaluate=robofish.evaluate.app:evaluate",
     ]
 }
+
+def source_version():
+    version_parts = (
+        run(["git", "describe", "--tags", "--dirty"], check=True, stdout=PIPE, encoding="utf-8")
+        .stdout.strip()
+        .split("-")
+    )
+
+    if version_parts[-1] == "dirty":
+        dirty = True
+        version_parts = version_parts[:-1]
+    else:
+        dirty = False
+
+    version = version_parts[0]
+    if len(version_parts) == 3:
+        version += ".post0"
+        version += f".dev{version_parts[1]}+{version_parts[2]}"
+    if dirty:
+        version += "+dirty"
+
+    return version
+
 setup(
     name="robofish-io",
-    version="0.1.1",
+    version=source_version(),
     author="",
     author_email="",
     install_requires=["h5py>=3", "numpy", "seaborn", "pandas", "deprecation"],
diff --git a/src/robofish/io/file.py b/src/robofish/io/file.py
index deefcc9a771d6e54966224be4be1dc1abbb69c16..1914f99d520facecaeb122da8a83b2ac86cb48be 100644
--- a/src/robofish/io/file.py
+++ b/src/robofish/io/file.py
@@ -28,26 +28,25 @@ import tempfile
 import uuid
 import deprecation
 
-
-temp_dir = tempfile.TemporaryDirectory()
 default_format_version = np.array([1, 0], dtype=np.int32)
 
 default_format_url = (
     "https://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format/-/releases/1.0"
 )
 
-
 class File(h5py.File):
-    """ Represents a hdf5 file, which should be used to store data about the
-    movement and shape of individuals or swarms in time.
+    """ Represents a RoboFish Track Format file, which should be used to store tracking data of individual animals or swarms.
 
-    This class extends the h5py.File class, and behaves in the same way.
-    robofish io Files can be created, saved, loaded, and manipulated.
+    Files can be opened (with optional creation), modified inplace, and have copies of them saved.
     """
 
+    _temp_dir = None
+
     def __init__(
         self,
         path: Union[str, Path] = None,
+        mode: str = "r",
+        *, # PEP 3102
         world_size_cm: [int, int] = None,
         strict_validate: bool = False,
         format_version: [int, int] = default_format_version,
@@ -57,25 +56,47 @@ class File(h5py.File):
         monotonic_time_points_us: Iterable = None,
         calendar_time_points: Iterable = None,
     ):
-        """ Constructor for the File class.
-
-        The constructor should either be called with a path, when loading an existing file,
-        or when a new file should be created with a world size.
-
-        Args:
-            path: optional path to a io file as a string or path object. The file will be loaded.
-            world_size_cm: optional integer array of the world size in cm
-            strict_validate: optional boolean, if the file should be strictly validated, when loaded from a path. The default is False.
-            format_version: optional version [major, minor] of the trackformat specification
+        """Create a new RoboFish Track Format object.
+
+        When called with a path, it is loaded, otherwise a new temporary file is created.
+
+        Parameters
+        ----------
+        path : str or Path, optional
+            Location of file to be opened. If not provided, mode is ignored.
+
+        mode : str
+            r        Readonly, file must exist (default)
+            r+       Read/write, file must exist
+            w        Create file, truncate if exists
+            x        Create file, fail if exists
+            a        Read/write if exists, create otherwise
+
+        world_size_cm
+            optional integer array of the world size in cm
+        strict_validate
+            optional boolean, if the file should be strictly validated, when loaded from a path. The default is False.
+        format_version
+            optional version [major, minor] of the trackformat specification
         """
 
-        self._name = str(uuid.uuid4())
-        self._tf_path = Path(temp_dir.name) / self._name
-        self.load(path, strict_validate)
-
-        if path is None or not Path(path).exists():
+        if path is None:
+            if type(self)._temp_dir is None:
+                type(self)._temp_dir = tempfile.TemporaryDirectory(prefix="robofish-io-")
+            super().__init__(Path(type(self)._temp_dir.name) /  str(uuid.uuid4()), mode="x", driver="core", backing_store=True, libver=("earliest", "v112"))
+            initialize = True
+        else:
+            #mode
+            #r        Readonly, file must exist (default)
+            #r+       Read/write, file must exist
+            #w        Create file, truncate if exists
+            #x        Create file, fail if exists
+            #a        Read/write if exists, create otherwise
+            logging.info(f"Opening File {path}")
+            initialize = not Path(path).exists()
+            super().__init__(path, mode, libver=("earliest", "v112"))
 
-            # Initialize new file
+        if initialize:
             assert world_size_cm is not None and format_version is not None
 
             self.attrs["world_size_cm"] = np.array(world_size_cm, dtype=np.float32)
@@ -93,58 +114,32 @@ class File(h5py.File):
                     calendar_time_points=calendar_time_points,
                     default=True,
                 )
+        self.validate(strict_validate)
 
-    #### File Handling ####
-    def load(self, path: Union[str, Path], strict_validate: bool = False) -> None:
-        """ Load a new file from a path
+    def __enter__(self):
+        return self
 
-        Args:
-            path: path to a io file as a string or path object
-            strict_validate: optional boolean, if the file should be strictly validated, when loaded from a path. The default is False.
-        """
-        if path is not None:
-            self._f_path = Path(path)
-
-        if path is not None and Path(path).exists():
-            logging.info(f"Opening File {path}")
-            shutil.copyfile(self._f_path, self._tf_path)
-            super().__init__(self._tf_path, "r+")
-            self.validate(strict_validate)
-        else:
-            super().__init__(self._tf_path, "w")
+    def __exit__(self, type, value, traceback):
+        self.validate()
+        self.close()
 
-    def save(self, path: Union[str, Path] = None, strict_validate: bool = True):
-        """ Load a new file from a path
+    def save_as(self, path: Union[str, Path], strict_validate: bool = True):
+        """ Save a copy of the file
 
         Args:
-            path: optional path to a io file as a string or path object. If no path is specified, the last known path (from loading or saving) is used.
+            path: path to a io file as a string or path object. If no path is specified, the last known path (from loading or saving) is used.
             strict_validate: optional boolean, if the file should be strictly validated, before saving. The default is True.
         """
 
-        # Normaly only valid files can be saved
         self.validate(strict_validate=strict_validate)
 
-        # Find the correct path
-        if path is None:
-            if self._f_path is None:
-                raise Exception(
-                    "path was not specified and there was no saved path from loading or an earlier save"
-                )
-        else:
-            self._f_path = Path(path)
-
-        # Close the temporary file
-        self.close()
-
-        # Create the parent folder if it does not exist
-        if not self._f_path.parent.exists():
-            self._f_path.parent.mkdir(parents=True, exist_ok=True)
+        # Ensure all buffered data has been written to disk
+        self.flush()
 
-        # Copy the temporaryself.create_sampling(frequency_hz, monotonic_time_points_us) file to the path
-        shutil.copyfile(self._tf_path, self._f_path)
+        path = Path(path).resolve()
+        path.parent.mkdir(parents=True, exist_ok=True)
 
-        # Reopen the temporary file
-        super().__init__(self._tf_path, "r+")
+        shutil.copyfile(Path(self.filename).resolve(), path)
 
     def create_sampling(
         self,
diff --git a/tests/robofish/io/test_file.py b/tests/robofish/io/test_file.py
index 3cb5d023681937c8d9334a6017de294820e2a128..8ce4bd4b9899f0fd785dcf315e7f75ee79eb26e2 100644
--- a/tests/robofish/io/test_file.py
+++ b/tests/robofish/io/test_file.py
@@ -26,10 +26,9 @@ def test_constructor():
 
 def test_new_file_w_path():
     sf = robofish.io.File(
-        created_by_test_path_2, world_size_cm=[100, 100], frequency_hz=25
+        created_by_test_path_2, "w", world_size_cm=[100, 100], frequency_hz=25
     )
     sf.create_entity("fish")
-    sf.save()
     sf.validate()
 
 
@@ -158,7 +157,7 @@ def test_loading_saving():
     sf = test_multiple_entities()
 
     assert not created_by_test_path.exists()
-    sf.save(created_by_test_path)
+    sf.save_as(created_by_test_path)
     assert created_by_test_path.exists()
 
     # After saving, the file should still be accessible and valid