Repository: oussjarrousse/pytest-minio-mock (language: python)

Issue:
Support bucket versionning



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index f9db1c3..669760d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,6 @@ venv
 *.egg
 build
 dist
+
+#pypi
+.pypirc
diff --git a/setup.py b/setup.py
index 5d0aca6..7b2d036 100644
--- a/setup.py
+++ b/setup.py
@@ -21,7 +21,7 @@
     long_description=open("README.md").read(),
     keywords="pytest minio mock",
     extras_require={"dev": ["pre-commit", "tox"]},
-    version="0.1.10",
+    version="0.2.10",
     long_description_content_type="text/markdown",
     classifiers=[
         "Framework :: Pytest",


Test patch:
diff --git a/pytest_minio_mock/__init__.py b/pytest_minio_mock/__init__.py
index f2c0c67..70f4ad4 100644
--- a/pytest_minio_mock/__init__.py
+++ b/pytest_minio_mock/__init__.py
@@ -15,5 +15,5 @@
 
 """
 
-__version__ = "0.1.10"
+__version__ = "0.2.10"
 __author__ = "Oussama Jarrousse"
diff --git a/pytest_minio_mock/plugin.py b/pytest_minio_mock/plugin.py
index c9bddb4..6984f15 100644
--- a/pytest_minio_mock/plugin.py
+++ b/pytest_minio_mock/plugin.py
@@ -5,7 +5,9 @@
 degree that is useful for testing purposes.
 
 Classes:
+    MockMinioObjectVersion: Represents a mock object version stored in Minio.
     MockMinioObject: Represents a mock object stored in Minio.
+    MockMinioBucket: Represents a mock bucket stored in Minio.
     MockMinioServer: Represents a single mock Minio server.
     MockMinioServers: Manages multiple mock Minio server instances.
     MockMinioClient: A mock version of the Minio client.
@@ -24,13 +26,17 @@
 import io
 import logging
 import os
-from collections import namedtuple
+from uuid import uuid4
 
 import pytest
 import validators
 from minio import Minio
+from minio.commonconfig import ENABLED
 from minio.datatypes import Object
 from minio.error import S3Error
+from minio.versioningconfig import OFF
+from minio.versioningconfig import SUSPENDED
+from minio.versioningconfig import VersioningConfig
 from urllib3.connection import HTTPConnection
 from urllib3.response import HTTPResponse
 
@@ -39,31 +45,60 @@
 # ObjectInfo = namedtuple("ObjectInfo", ["object_name"])
 
 
-class MockMinioObject:
+class MockMinioObjectVersion:
     """
-    Represents a mock object in Minio storage.
+    Represents a mock object version in Minio storage.
 
     Attributes:
         object_name (str): The name of the object.
         data (bytes or io.BytesIO): The data the object contains.
+        version_id (str, optional): The version of the object.
+        is_delete_marker (bool): whether this object is marked as deleted on a
+          versioned bucket
     """
 
-    def __init__(self, object_name, data):
+    def __init__(self, object_name, data, version_id, is_delete_marker, is_latest):
         """
-        Initialize the MockMinioObject with a name and data.
+        Initialize the MockMinioObjectVersion with a name and data.
 
         Args:
             object_name (str): The name of the object.
             data (bytes or io.BytesIO): The data the object contains.
+            version_id (str, optional): The version of the object.
+            is_delete_marker (bool): whether this object is marked as deleted on a
+              versioned bucket
         """
         self._object_name = object_name
         self._data = data
+        self._version_id = version_id
+        self._is_delete_marker = is_delete_marker
+        self._last_modified = datetime.datetime.utcnow()
+        self._is_latest = is_latest
 
     @property
     def data(self):
         """Get the data the object contains."""
         return self._data
 
+    @property
+    def version_id(self):
+        """Get the version of the object."""
+        return self._version_id
+
+    @property
+    def last_modified(self):
+        """return the last modified datetime of the object."""
+        return self._last_modified
+
+    @property
+    def is_delete_marker(self):
+        """Is the object deleted n a versioned bucket ?"""
+        return self._is_delete_marker
+
+    @is_delete_marker.setter
+    def is_delete_marker(self, value):
+        self._is_delete_marker = value
+
     @data.setter
     def data(self, value):
         """Set the data the object contains."""
@@ -74,6 +109,445 @@ def object_name(self):
         """Get the name of the object."""
         return self._object_name
 
+    @property
+    def is_latest(self):
+        """is this object marked latest"""
+        return self._is_latest
+
+    @is_latest.setter
+    def is_latest(self, value):
+        """Set the value of is_latest"""
+        self._is_latest = value
+
+
+class MockMinioObject:
+    """
+    Represents a mock object in Minio storage.
+    """
+
+    def __init__(self, object_name):
+        self._object_name = object_name
+        self._versions = {}
+
+    @property
+    def object_name(self):
+        """Get the name of the object stored at initialization"""
+        return self._object_name
+
+    @property
+    def versions(self):
+        """Get the versions stored in the object"""
+        return self._versions
+
+    def reset_latest(self):
+        """
+        Sets the value of the latest flag to False in all object versions
+        """
+        for _, obj in self._versions.items():
+            if obj.is_latest:
+                obj.is_latest = False
+        return
+
+    def get_latest(self):
+        """
+        Returns:
+            The version that is marked latest.
+            If the object has no versions, it returns None.
+            if none of the versions is marked latest it raises an exception that indicates an implementation error
+        """
+        if not self._versions:
+            return None
+        for _, obj in self._versions.items():
+            if obj.is_latest:
+                return obj
+        raise RuntimeError("Implemnetation Error")
+
+    def put_object_version(self, version_id, obj):
+        """
+        Inserts a object version in the _versions map.
+        """
+        self.reset_latest()
+        obj.latest = True
+        self._versions[version_id] = obj
+
+    def put_object(
+        self,
+        object_name: str,
+        data,
+        length: int,
+        content_type: str = "application/octet-stream",
+        metadata=None,
+        sse=None,
+        progress=None,
+        part_size: int = 0,
+        # num_parallel_uploads: int = 3,
+        # tags = None,
+        # retention = None,
+        # legal_hold: bool = False,
+        versioning: VersioningConfig = VersioningConfig(),
+    ):
+        """
+        Returns:
+            the put object
+        """
+
+        # According to
+        # https://min.io/docs/minio/linux/administration/object-management/object-versioning.html#suspend-bucket-versioning
+        # objects created when versioning is suspended have a 'null' version ID (None in Python)
+
+        version_id = "null"
+        # If status is enabled, create a new version UUID
+        if versioning.status == ENABLED:
+            version_id = str(uuid4())
+
+        obj = MockMinioObjectVersion(
+            object_name=object_name,
+            data=data,
+            version_id=version_id,
+            is_delete_marker=False,
+            is_latest=True,
+        )
+        # If versioning is OFF, there can only be one version of an object (store a read version_id non-the-less)
+        if versioning.status == OFF:
+            self._versions = {}
+
+        self.put_object_version(version_id, obj)
+        return obj
+
+    def get_object(self, version_id, versioning: VersioningConfig):
+        """
+        Returns
+            the stored object if versioning is disabled
+            the latest version of the object if versioning is enabled
+        """
+        if version_id and version_id != "null" and not validators.uuid(version_id):
+            raise S3Error(
+                message="Invalid version id specified",
+                resource=f"/{self.bucket_name}/{object_name}",
+                request_id=None,
+                host_id=None,
+                response="mocked_response",
+                code=422,
+                bucket_name=self.bucket_name,
+                object_name=object_name,
+            )
+
+        # If version == None, give version_id a value
+        if not version_id:
+            if not self._versions:
+                raise S3Error(
+                    # code=NoSuchKey
+                    message="The specified key does not exist.",
+                    resource="",
+                    request_id=None,
+                    host_id=None,
+                    response="mocked_response",
+                    code=404,
+                    bucket_name=None,
+                    object_name=self.object_name,
+                )
+
+            # if versioning is disabled, look for the object with version_id = "null"
+            if versioning.status == OFF:
+                version_id = "null"
+            # if versioning is enabled or suspended, get the version_id of the latest_version
+            else:
+                latest_object_version = self.get_latest()
+                if latest_object_version:
+                    version_id = latest_object_version.version_id
+
+        # now try to get the object with that version_id
+        try:
+            the_object_version = self._versions[version_id]
+        except KeyError as exc:
+            raise S3Error(
+                message="The specified version does not exist",
+                resource="",
+                request_id=None,
+                host_id=None,
+                response="mocked_response",
+                code=404,
+                bucket_name=None,
+                object_name=self.object_name,
+            ) from exc
+
+        # if the delete_marker is set raise an error
+        if the_object_version.is_delete_marker:
+            raise S3Error(
+                message="The specified method is not allowed against this resource.",
+                resource="",
+                request_id=None,
+                host_id=None,
+                response="mocked_response",
+                code=403,
+                bucket_name=None,
+                object_name=self.object_name,
+            )
+        return the_object_version
+
+    def list_versions(self):
+        versions_list = list(
+            sorted(
+                self._versions.items(),
+                key=lambda i: (
+                    i[1].is_delete_marker,
+                    -i[1].last_modified.timestamp(),
+                ),
+            )
+        )
+        return versions_list
+
+    def remove_object(self, version_id, versioning: VersioningConfig):
+        """
+        Returns
+            nothing
+        """
+        try:
+            if versioning.status == OFF:
+                if version_id:
+                    if version_id in self.versions:
+                        del self.versions[version_id]
+                else:
+                    raise RuntimeError("This should not happen")
+                return
+        except Exception as e:
+            logging.error("remove_object(): Exception")
+            logging.error(e)
+            raise e
+        try:
+            if versioning.status == ENABLED:
+                if version_id:
+                    if version_id not in self.versions:
+                        # version_id does not exist
+                        # nothing to do
+                        return
+                    else:
+                        latest_obj = self.get_latest()
+                        del self.versions[version_id]
+                        if version_id == latest_obj.version_id:
+                            obj = list(self.versions.values())[0]
+                            obj.is_latest = True
+                else:  # version_id is False
+                    latest_obj = self.get_latest()
+                    if latest_obj.is_delete_marker:
+                        # nothing to do
+                        return
+
+                    version_id = str(uuid4())
+
+                    obj = MockMinioObjectVersion(
+                        object_name=self.object_name,
+                        data=b"",
+                        version_id=version_id,
+                        is_delete_marker=True,
+                        is_latest=True,
+                    )
+                    self.put_object_version(version_id=obj.version_id, obj=obj)
+                    return
+            elif versioning.status == SUSPENDED:
+                if version_id:
+                    latest_obj = self.get_latest()
+                    del self.versions[version_id]
+                    if version_id == latest_obj.version_id:
+                        obj = list(self.versions.values())[0]
+                        obj.is_latest = True
+                else:
+                    latest_obj = self.get_latest()
+                    latest_obj.is_delete_marker = True
+            else:
+                raise Exception("unexpected")
+        except Exception as e:
+            logging.error("remove_object(): Exception")
+            logging.error(e)
+            raise
+
+
+class MockMinioBucket:
+    """
+    Represents a mock Bucket in Minio storage.
+    """
+
+    def __init__(
+        self,
+        bucket_name,
+        versioning: VersioningConfig,
+        location=None,
+        object_lock=False,
+    ):
+        self._bucket_name = bucket_name
+        self._versioning = versioning
+        self._objects = {}
+        self._location = location
+        self._object_lock = object_lock
+
+    @property
+    def bucket_name(self):
+        """Gets the name of the bucket stored at initialization."""
+        return self._bucket_name
+
+    @property
+    def objects(self):
+        """Get the objects stored in the bucket."""
+        return self._objects
+
+    @property
+    def versioning(self):
+        """Get the the config of versioning of the bucket."""
+        return self._versioning
+
+    @versioning.setter
+    def versioning(self, versioning: VersioningConfig):
+        """Set the versioning config of the bucket."""
+        self._versioning = versioning
+
+    def put_object(
+        self,
+        object_name: str,
+        data,
+        length: int,
+        content_type: str = "application/octet-stream",
+        metadata=None,
+        sse=None,
+        progress=None,
+        part_size: int = 0,
+        # num_parallel_uploads: int = 3,
+        # tags = None,
+        # retention = None,
+        # legal_hold: bool = False,
+    ):
+        """
+        Returns
+            newly added object
+        """
+        if object_name not in self.objects:
+            self.objects[object_name] = MockMinioObject(object_name)
+
+        obj = self.objects[object_name].put_object(
+            object_name=object_name,
+            data=data,
+            length=length,
+            content_type=content_type,
+            metadata=metadata,
+            sse=sse,
+            progress=progress,
+            part_size=part_size,
+            # num_parallel_uploads: int = 3,
+            # tags = None,
+            # retention = None,
+            # legal_hold: bool = False,
+            versioning=self.versioning,
+        )
+
+        return obj
+
+    def remove_object(self, object_name, version_id=None):
+        """ """
+        if object_name not in self.objects:
+            # object does not exist, so nothing to do
+            return
+        try:
+            if self.versioning.status == OFF:
+                if not version_id:
+                    del self.objects[object_name]
+                    return
+            return self.objects[object_name].remove_object(version_id, self.versioning)
+
+        except Exception as e:
+            logging.error("remove_object(): Exception")
+            logging.error(e)
+            raise
+
+    def get_object(self, object_name, version_id):
+        try:
+            the_object = self.objects[object_name]
+        except KeyError as exc:
+            raise S3Error(
+                message="The specified key does not exist.",
+                resource=f"/{self.bucket_name}/{object_name}",
+                request_id=None,
+                host_id=None,
+                response="mocked_response",
+                code=404,
+                bucket_name=self.bucket_name,
+                object_name=object_name,
+            ) from exc
+
+        try:
+            the_object_version = the_object.get_object(version_id, self.versioning)
+        except S3Error as e:
+            raise S3Error(
+                message=e.message,
+                response=e.response,
+                resource=f"/{self.bucket_name}/{object_name}",
+                host_id=None,
+                request_id=None,
+                code=e.code,
+                bucket_name=self.bucket_name,
+                object_name=object_name,
+            )
+        return the_object_version
+
+    def list_objects(
+        self,
+        prefix="",
+        recursive=False,
+        start_after="",
+        include_version=False,
+    ):
+        """
+        Returns
+            Iterator of MockMinioObjectVersions of the current bucket
+        """
+        # Initialization
+        # bucket_objects = []
+        seen_prefixes = set()
+
+        for object_name, obj in self.objects.items():
+            if object_name.startswith(prefix) and (
+                start_after == "" or object_name > start_after
+            ):
+                # Handle non-recursive listing by identifying and adding unique directory names
+                if not recursive:
+                    sub_path = object_name[len(prefix) :].strip("/")
+                    dir_end_idx = sub_path.find("/")
+                    if dir_end_idx != -1:
+                        dir_name = prefix + sub_path[: dir_end_idx + 1]
+                        if dir_name not in seen_prefixes:
+                            seen_prefixes.add(dir_name)
+                            yield Object(
+                                bucket_name=self.bucket_name, object_name=dir_name
+                            )
+                        # Skip further processing to prevent
+                        # adding the full object path
+                        continue
+                # Directly add the object for recursive listing
+                # or if it's a file in the current directory
+                if include_version:
+                    # Minio API always sort versions by time,
+                    # it also includes delete markers at the end newwst first
+                    versions_list = obj.list_versions()
+                    for version, obj_version in versions_list:
+                        yield Object(
+                            bucket_name=self.bucket_name,
+                            object_name=object_name,
+                            last_modified=obj_version.last_modified,
+                            version_id=version,
+                            is_latest="true" if obj_version.is_latest else "false",
+                            is_delete_marker=obj_version.is_delete_marker,
+                        )
+                else:
+                    obj_version = obj.get_latest()
+                    # only yield if the object is not a delete marker
+                    if not obj_version.is_delete_marker:
+                        yield Object(
+                            bucket_name=self.bucket_name,
+                            object_name=object_name,
+                            last_modified=obj_version.last_modified,
+                            version_id=None,
+                            is_latest=None,
+                            is_delete_marker=obj_version.is_delete_marker,
+                        )
+
 
 class MockMinioServer:
     """
@@ -100,7 +574,7 @@ def base_url(self):
         return self._base_url
 
     @property
-    def bucket(self):
+    def buckets(self):
         """Get the dictionary of buckets in the server."""
         return self._buckets
 
@@ -114,29 +588,29 @@ def __len__(self):
         return len(self._buckets)
 
     def keys(self):
-        """Returns the keys of the self._buckets dictionary"""
+        """Returns the keys of the self._buckets dictionary."""
         return self._buckets.keys()
 
     def values(self):
-        """Returns the values of the self._buckets dictionary"""
+        """Returns the values of the self._buckets dictionary."""
         return self._buckets.values()
 
     def items(self):
-        """Returns the items of the self._buckets dictionary"""
+        """Returns the items of the self._buckets dictionary."""
         return self._buckets.items()
 
     def get(self, key, default=None):
         """get a specific bucket,
-        or default if key is not in the self._buckets dictionary
+        or default if key is not in the self._buckets dictionary.
         """
         return self._buckets.get(key, default)
 
     def pop(self, key, default=None):
-        """pops a specific bucket"""
+        """pops a specific bucket."""
         return self._buckets.pop(key, default) if key in self._buckets else default
 
     def update(self, other):
-        """updates the self._buckets dictionary with another dictionary"""
+        """updates the self._buckets dictionary with another dictionary."""
         self._buckets.update(other)
 
     def __contains__(self, item):
@@ -272,7 +746,7 @@ def fget_object(
                 request. Defaults to None.
             sse (optional): Server-side encryption option. Defaults to None, as it's not used in
                 the mock.
-            version_id (str, optional): The version ID of the object to download. Defaults to
+            version_id (str | None, optional): The version ID of the object to download. Defaults to
                 None.
             extra_query_params (dict, optional): Additional query parameters for the request.
                 Defaults to None.
@@ -284,7 +758,14 @@ def fget_object(
         Returns:
             None: The method writes the object's data to a file and has no return value.
         """
-        the_object = self.get_object(bucket_name, object_name)
+        the_object = self.get_object(
+            bucket_name,
+            object_name,
+            version_id=version_id,
+            request_headers=request_headers,
+            sse=sse,
+            extra_query_params=extra_query_params,
+        )
         with open(file_path, "wb") as f:
             f.write(the_object.data)
 
@@ -295,7 +776,7 @@ def get_object(
         offset: int = 0,
         length: int = 0,
         request_headers=None,
-        ssec=None,
+        sse=None,
         version_id=None,
         extra_query_params=None,
     ):
@@ -313,23 +794,32 @@ def get_object(
             length (int, optional): The number of bytes of object data to retrieve. Defaults to 0,
                 which means the whole object.
             request_headers (dict, optional): Additional headers for the request. Defaults to None.
-            ssec (optional): Server-side encryption option. Defaults to None.
-            version_id (str, optional): The version ID of the object. Defaults to None.
+            sse (optional): Server-side encryption option. Defaults to None.
+            version_id (str | None, optional): The version ID of the object. Defaults to None.
             extra_query_params (dict, optional): Additional query parameters. Defaults to None.
 
         Returns:
             HTTPResponse: A response object containing the object data.
         """
         self._health_check()
-        the_object = self.buckets[bucket_name][object_name]
-        data = the_object.data
+
+        the_object_version = self.buckets[bucket_name].get_object(
+            object_name, version_id
+        )
+        if not the_object_version:
+            raise RuntimeError("Implementation Error")
+
+        data = the_object_version.data
+
         # Create a buffer containing the data
         if isinstance(data, io.BytesIO):
             body = copy.deepcopy(data)
-        if isinstance(data, bytes):
+        elif isinstance(data, bytes):
             body = data
-        else:
+        elif isinstance(data, str):
             body = io.BytesIO(data.encode("utf-8"))
+        else:
+            body = data
 
         conn = HTTPConnection("localhost")
         response = HTTPResponse(body=body, preload_content=False, connection=conn)
@@ -407,11 +897,11 @@ def put_object(
         metadata=None,
         sse=None,
         progress=None,
-        part_size: int = 0
+        part_size: int = 0,
         # num_parallel_uploads: int = 3,
         # tags = None,
         # retention = None,
-        # legal_hold: bool = False
+        # legal_hold: bool = False,
     ):
         """
         Simulates uploading an object to the mock Minio server.
@@ -434,6 +924,7 @@ def put_object(
         Returns:
             str: Confirmation message indicating successful upload.
         """
+
         self._health_check()
         if not self.bucket_exists(bucket_name):
             raise S3Error(
@@ -446,9 +937,22 @@ def put_object(
                 bucket_name=bucket_name,
                 object_name=None,
             )
-        self.buckets[bucket_name][object_name] = MockMinioObject(
-            object_name=object_name, data=data
+
+        _ = self.buckets[bucket_name].put_object(
+            object_name=object_name,
+            data=data,
+            length=length,
+            content_type=content_type,
+            metadata=metadata,
+            sse=sse,
+            progress=progress,
+            part_size=part_size,
+            # num_parallel_uploads: int = 3,
+            # tags = None,
+            # retention = None,
+            # legal_hold: bool = False,
         )
+
         return "Upload successful"
 
     def get_presigned_url(
@@ -477,14 +981,16 @@ def get_presigned_url(
                 Defaults to 7 days.
             response_headers (dict, optional): Headers to include in the response. Defaults to None.
             request_date (datetime, optional): The date of the request. Defaults to None.
-            version_id (str, optional): The version ID of the object. Defaults to None.
+            version_id (str | None, optional): The version ID of the object. Defaults to None.
             extra_query_params (dict, optional): Additional query parameters to include in the
                 presigned URL. Defaults to None.
 
         Returns:
             str: The presigned URL.
         """
-        return f"{self._base_url}/{bucket_name}/{object_name}"
+        if not version_id:
+            return f"{self._base_url}/{bucket_name}/{object_name}"
+        return f"{self._base_url}/{bucket_name}/{object_name}?versionId={version_id}"
 
     def presigned_put_object(
         self, bucket_name, object_name, expires=datetime.timedelta(days=7)
@@ -529,7 +1035,7 @@ def presigned_get_object(
                 Defaults to 7 days.
             response_headers (dict, optional): Headers to include in the response. Defaults to None.
             request_date (datetime, optional): The date of the request. Defaults to None.
-            version_id (str, optional): The version ID of the object. Defaults to None.
+            version_id (str | None, optional): The version ID of the object. Defaults to None.
             extra_query_params (dict, optional): Additional query parameters to include in the
                 presigned URL. Defaults to None.
 
@@ -603,14 +1109,60 @@ def make_bucket(self, bucket_name, location=None, object_lock=False):
             bool: True indicating the bucket was successfully created.
         """
         self._health_check()
-        self.buckets[bucket_name] = {
-            #  "__META__":
-            # {"name":bucket_name,
-            # "creation_date":datetime.datetime.utcnow()}
-        }
+        self.buckets[bucket_name] = MockMinioBucket(
+            bucket_name=bucket_name,
+            versioning=VersioningConfig(),
+            location=location,
+            object_lock=object_lock,
+        )
         return True
 
-    def list_objects(self, bucket_name, prefix="", recursive=False, start_after=""):
+    def set_bucket_versioning(self, bucket_name: str, config: VersioningConfig):
+        """Bucket versioning can be set to ENABLED or SUSPENDED, but not to
+        OFF (filtered out by VersioningConfig itself)
+        """
+        self._health_check()
+        if not self.bucket_exists(bucket_name):
+            raise S3Error(
+                message="bucket does not exist",
+                resource=bucket_name,
+                request_id=None,
+                host_id=None,
+                response="mocked_response",
+                code=404,
+                bucket_name=bucket_name,
+                object_name=None,
+            )
+        if not isinstance(config, VersioningConfig):
+            raise ValueError("config must be VersioningConfig type")
+        self.buckets[bucket_name].versioning = config
+
+    def get_bucket_versioning(self, bucket_name: str) -> VersioningConfig:
+        """Bucket versioning can be OFF (the initial value), ENABLED, or
+        SUSPENDED
+        """
+        self._health_check()
+        if not self.bucket_exists(bucket_name):
+            raise S3Error(
+                message="bucket does not exist",
+                resource=bucket_name,
+                request_id=None,
+                host_id=None,
+                response="mocked_response",
+                code=404,
+                bucket_name=bucket_name,
+                object_name=None,
+            )
+        return self.buckets[bucket_name].versioning
+
+    def list_objects(
+        self,
+        bucket_name,
+        prefix="",
+        recursive=False,
+        start_after="",
+        include_version=False,
+    ):
         """
         Lists objects in a bucket with the specified prefix and conditions.
 
@@ -621,41 +1173,13 @@ def list_objects(self, bucket_name, prefix="", recursive=False, start_after=""):
             prefix (str, optional): The prefix to filter objects by. Defaults to "".
             recursive (bool, optional): Whether to list objects recursively. Defaults to False.
             start_after (str, optional): The object name to start listing after. Defaults to "".
+            include_version (bool, optional): To include the objects versions
 
         Returns:
-            list: A list of object names that match the specified conditions.
+            list: A list of object names, or object names and versions,
+              that match the specified conditions.
         """
 
-        def _list_objects(
-            buckets, bucket_name, prefix="", recursive=False, start_after=""
-        ):
-            # Initialization
-            bucket = buckets[bucket_name]
-            # bucket_objects = []
-            seen_prefixes = set()
-
-            for obj_name in bucket.keys():
-                if obj_name.startswith(prefix) and (
-                    start_after == "" or obj_name > start_after
-                ):
-                    # Handle non-recursive listing by identifying and adding unique directory names
-                    if not recursive:
-                        sub_path = obj_name[len(prefix) :].strip("/")
-                        dir_end_idx = sub_path.find("/")
-                        if dir_end_idx != -1:
-                            dir_name = prefix + sub_path[: dir_end_idx + 1]
-                            if dir_name not in seen_prefixes:
-                                seen_prefixes.add(dir_name)
-                                yield Object(
-                                    bucket_name=bucket_name, object_name=dir_name
-                                )
-                                # bucket_objects.append()
-                            continue  # Skip further processing to prevent adding the full object path
-                    # Directly add the object for recursive listing or if it's a file in the current directory
-                    yield Object(bucket_name=bucket_name, object_name=obj_name)
-
-            # return bucket_objects
-
         try:
             if bucket_name not in self.buckets:
                 raise S3Error(
@@ -668,13 +1192,17 @@ def _list_objects(
                     bucket_name=bucket_name,
                     object_name=None,
                 )
-            return _list_objects(
-                self.buckets, bucket_name, prefix, recursive, start_after
+            return self.buckets[bucket_name].list_objects(
+                # self.buckets,
+                prefix,
+                recursive,
+                start_after,
+                include_version,
             )
         except Exception as e:
             raise e
 
-    def remove_object(self, bucket_name, object_name):
+    def remove_object(self, bucket_name, object_name, version_id=None):
         """
         Removes an object from a bucket in the mock Minio server.
 
@@ -683,18 +1211,15 @@ def remove_object(self, bucket_name, object_name):
         Args:
             bucket_name (str): The name of the bucket.
             object_name (str): The name of the object to remove.
+            version_id (str | None, optional): The version to delete.
 
         Returns:
             None: The method has no return value but indicates successful removal.
         """
         self._health_check()
-        try:
-            del self.buckets[bucket_name][object_name]
-            return
-        except Exception:
-            logging.error("remove_object(): Exception")
-            logging.error(self.buckets)
-            raise
+        return self.buckets[bucket_name].remove_object(
+            object_name, version_id=version_id
+        )
 
 
 @pytest.fixture
diff --git a/tests/test_minio_mock.py b/tests/test_minio_mock.py
index 86844f5..9fdd8cb 100644
--- a/tests/test_minio_mock.py
+++ b/tests/test_minio_mock.py
@@ -3,7 +3,55 @@
 import pytest
 import validators
 from minio import Minio
+from minio.commonconfig import ENABLED
 from minio.error import S3Error
+from minio.versioningconfig import OFF
+from minio.versioningconfig import SUSPENDED
+from minio.versioningconfig import VersioningConfig
+
+from pytest_minio_mock.plugin import MockMinioBucket
+from pytest_minio_mock.plugin import MockMinioObject
+
+
+@pytest.mark.UNIT
+class TestsMockMinioObject:
+    @pytest.mark.UNIT
+    def test_mock_minio_object_init(self):
+        mock_minio_object = MockMinioObject("test-object")
+        assert mock_minio_object.versions == {}
+
+
+@pytest.mark.UNIT
+class TestsMockMinioBucket:
+    @pytest.mark.UNIT
+    def test_mock_minio_bucket_init(self):
+        mock_minio_bucket = MockMinioBucket(
+            bucket_name="test-bucket", versioning=VersioningConfig()
+        )
+        assert mock_minio_bucket.bucket_name == "test-bucket"
+        assert mock_minio_bucket.versioning.status == OFF
+        assert mock_minio_bucket.objects == {}
+
+        versioning_config = VersioningConfig(ENABLED)
+        mock_minio_bucket = MockMinioBucket(
+            bucket_name="test-bucket", versioning=versioning_config
+        )
+        assert isinstance(mock_minio_bucket._versioning, VersioningConfig)
+        assert mock_minio_bucket.versioning.status == ENABLED
+
+    @pytest.mark.UNIT
+    def test_versioning(self):
+        mock_minio_bucket = MockMinioBucket(
+            bucket_name="test-bucket", versioning=VersioningConfig()
+        )
+        versioning_config = mock_minio_bucket.versioning
+        assert isinstance(versioning_config, VersioningConfig)
+        assert versioning_config.status == OFF
+        versioning_config = VersioningConfig(status=ENABLED)
+        mock_minio_bucket.versioning = versioning_config
+        versioning_config = mock_minio_bucket.versioning
+        assert isinstance(versioning_config, VersioningConfig)
+        assert versioning_config.status == ENABLED
 
 
 @pytest.mark.UNIT
@@ -15,36 +63,210 @@ def test_make_bucket(minio_mock):
     assert client.bucket_exists(bucket_name), "Bucket should exist after creation"
 
 
-@pytest.mark.UNIT
 @pytest.mark.API
-def test_adding_and_removing_objects(minio_mock):
+@pytest.mark.FUNC
+def test_putting_and_removing_objects_no_versionning(minio_mock):
+    # simple thing
     bucket_name = "test-bucket"
     object_name = "test-object"
     file_path = "tests/fixtures/maya.jpeg"
 
     client = Minio("http://local.host:9000")
     client.make_bucket(bucket_name)
-    client.make_bucket(bucket_name)
     client.fput_object(bucket_name, object_name, file_path)
 
     assert (
-        object_name in client.buckets[bucket_name]
+        object_name in client.buckets[bucket_name].objects
     ), "Object should be in the bucket after upload"
+    objects = list(client.list_objects(bucket_name))
+    assert len(objects) == 1
+    client.remove_object(bucket_name, object_name)
+    assert object_name not in client.buckets[bucket_name].objects
+    objects = list(client.list_objects(bucket_name))
+    assert len(objects) == 0
+
+    # even if include version is True nothing should change because versioning is OFF
+    objects = list(client.list_objects(bucket_name, include_version=True))
+    assert len(objects) == 0
+
+    # test retrieving object after it has been removed
+    with pytest.raises(S3Error) as error:
+        _ = client.get_object(bucket_name, object_name)
+    assert "The specified key does not exist" in str(error.value)
+
+
+@pytest.mark.API
+@pytest.mark.FUNC
+def test_putting_objects_with_versionning_enabled(minio_mock):
+    client = Minio("http://local.host:9000")
+    bucket_name = "test-bucket"
+    object_name = "test-object"
+    file_path = "tests/fixtures/maya.jpeg"
+    client.make_bucket(bucket_name)
+    # Versioning Enabled
+    client.set_bucket_versioning(bucket_name, VersioningConfig(ENABLED))
+    # Add two objects
+    client.fput_object(bucket_name, object_name, file_path)
+    client.fput_object(bucket_name, object_name, file_path)
+    # they should be two versions of the same object
+    # check list_objects with include_version=False returns only one object with is_latest=True
+    objects = list(client.list_objects(bucket_name, object_name, include_version=False))
+    assert len(objects) == 1
+    # check that versions are stored correctly and retrieved correctly
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 2
+
+
+@pytest.mark.API
+@pytest.mark.FUNC
+def test_removing_object_version_with_versionning_enabled(minio_mock):
+    client = Minio("http://local.host:9000")
+    bucket_name = "test-bucket"
+    object_name = "test-object"
+    file_path = "tests/fixtures/maya.jpeg"
+    client.make_bucket(bucket_name)
+
+    # Versioning Enabled
+    client.set_bucket_versioning(bucket_name, VersioningConfig(ENABLED))
+    # Add two objects
+    client.fput_object(bucket_name, object_name, file_path)
+    client.fput_object(bucket_name, object_name, file_path)
+
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    first_version = objects[0].version_id
+    last_version = objects[1].version_id
+
+    client.remove_object(bucket_name, object_name, version_id=first_version)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 1
+    assert objects[0].version_id == last_version
+    assert objects[0].is_latest == "true"
+
+    client.fput_object(bucket_name, object_name, file_path)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 2
+    first_version = objects[0].version_id
+    assert first_version != last_version
+
+
+@pytest.mark.API
+@pytest.mark.FUNC
+def test_putting_and_removing_and_listing_bjecst_with_versionning_enabled(minio_mock):
+    client = Minio("http://local.host:9000")
+    bucket_name = "test-bucket"
+    object_name = "test-object"
+    file_path = "tests/fixtures/maya.jpeg"
+    client.make_bucket(bucket_name)
+
+    # Versioning Enabled
+    client.set_bucket_versioning(bucket_name, VersioningConfig(ENABLED))
+    # Add two objects
+    client.fput_object(bucket_name, object_name, file_path)
+    client.fput_object(bucket_name, object_name, file_path)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 2
+    # removing the object with versioning enabled will add a delete marker
+    client.remove_object(bucket_name, object_name)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 3
+    assert objects[-1].is_delete_marker == True
+
+    # removing the object again will have no effect
+    client.remove_object(bucket_name, object_name)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 3
+
+    # listing an object marked for deletion will return an empty list
+    objects = list(client.list_objects(bucket_name, object_name))
+    assert len(objects) == 0
+
+    # putting a new version after deletion will add a new version
+    client.fput_object(bucket_name, object_name, file_path)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 4
+
+    objects = list(client.list_objects(bucket_name, object_name))
+    assert len(objects) == 1
+
+    # removing the object again with versioning enabled will add a new deletion marker
     client.remove_object(bucket_name, object_name)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 5
+
+    # trying to an object marked for deletion by version will raise an exception
+    with pytest.raises(S3Error) as error:
+        client.get_object(bucket_name, object_name, version_id=objects[3].version_id)
+    assert "not allowed against this resource" in str(error.value)
 
-    assert object_name not in client.buckets[bucket_name]
+    # trying to an object marked for deletion by version will raise an exception
+    with pytest.raises(S3Error) as error:
+        client.get_object(bucket_name, object_name, version_id=objects[4].version_id)
+    assert "not allowed against this resource" in str(error.value)
+
+    objects = list(client.list_objects(bucket_name, object_name))
+    assert len(objects) == 0
+
+
+@pytest.mark.API
+@pytest.mark.FUNC
+def test_versioned_objects_after_upload(minio_mock):
+    bucket_name = "test-bucket"
+    object_name = "test-object"
+    file_path = "tests/fixtures/maya.jpeg"
+
+    client = Minio("http://local.host:9000")
+    client.make_bucket(bucket_name)
+    client.fput_object(bucket_name, object_name, file_path)
+    client.set_bucket_versioning(bucket_name, VersioningConfig(ENABLED))
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 1
+    first_version = objects[0].version_id
+    assert first_version == "null"
+
+    client.fput_object(bucket_name, object_name, file_path)
+    client.fput_object(bucket_name, object_name, file_path)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    last_version = objects[1].version_id
+    assert len(objects) == 3
+    assert objects[-1].version_id == "null"
+    assert last_version is not None
+    client.set_bucket_versioning(bucket_name, VersioningConfig(SUSPENDED))
+
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+
+    client.remove_object(bucket_name, object_name, objects[0].version_id)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 2
+
+    client.remove_object(bucket_name, object_name)
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 2
+    assert objects[-1].is_delete_marker == True
+
+    client.remove_object(bucket_name, object_name, "null")
+    objects = list(client.list_objects(bucket_name, object_name, include_version=True))
+    assert len(objects) == 1
 
 
 @pytest.mark.UNIT
 @pytest.mark.API
-def test_file_download(minio_mock):
+@pytest.mark.FUNC
+@pytest.mark.parametrize("versioned", (True, False))
+def test_file_download(minio_mock, versioned):
     bucket_name = "test-bucket"
     object_name = "test-object"
     file_content = b"Test file content"
     length = sys.getsizeof(file_content)
     client = Minio("http://local.host:9000")
     client.make_bucket(bucket_name)
+    version = None
+    if versioned:
+        client.set_bucket_versioning(bucket_name, VersioningConfig(ENABLED))
     client.put_object(bucket_name, object_name, file_content, length)
+    if versioned:
+        version = list(
+            client.list_objects(bucket_name, object_name, include_version=True)
+        )[0].version_id
 
     response = client.get_object(bucket_name, object_name)
     downloaded_content = response.data
@@ -52,6 +274,13 @@ def test_file_download(minio_mock):
     assert (
         downloaded_content == file_content
     ), "Downloaded content should match the original"
+    if versioned:
+        response = client.get_object(bucket_name, object_name, version_id=version)
+        downloaded_content = response.data
+
+        assert (
+            downloaded_content == file_content
+        ), "Downloaded content should match the original"
 
 
 @pytest.mark.UNIT
@@ -65,17 +294,39 @@ def test_bucket_exists(minio_mock):
 
 @pytest.mark.UNIT
 @pytest.mark.API
-def test_get_presigned_url(minio_mock):
+def test_bucket_versioning(minio_mock):
+    bucket_name = "existing-bucket"
+    client = Minio("http://local.host:9000")
+    client.make_bucket(bucket_name)
+    assert client.get_bucket_versioning(bucket_name).status == "Off"
+    client.set_bucket_versioning(bucket_name, VersioningConfig(ENABLED))
+    assert client.get_bucket_versioning(bucket_name).status == "Enabled"
+    client.set_bucket_versioning(bucket_name, VersioningConfig("Suspended"))
+    assert client.get_bucket_versioning(bucket_name).status == "Suspended"
+
+
+@pytest.mark.UNIT
+@pytest.mark.API
+@pytest.mark.parametrize("versioned", (True, False))
+def test_get_presigned_url(minio_mock, versioned):
     bucket_name = "test-bucket"
     object_name = "test-object"
     file_path = "tests/fixtures/maya.jpeg"
 
     client = Minio("http://local.host:9000")
     client.make_bucket(bucket_name)
-    client.make_bucket(bucket_name)
+    version = None
+    if versioned:
+        client.set_bucket_versioning(bucket_name, VersioningConfig(ENABLED))
     client.fput_object(bucket_name, object_name, file_path)
-    url = client.get_presigned_url("GET", bucket_name, object_name)
+    if versioned:
+        version = list(
+            client.list_objects(bucket_name, object_name, include_version=True)
+        )[-1].version_id
+    url = client.get_presigned_url("GET", bucket_name, object_name, version_id=version)
     assert validators.url(url)
+    if version:
+        assert url.endswith(f"?versionId={version}")
 
 
 @pytest.mark.UNIT
@@ -125,7 +376,7 @@ def test_list_objects(minio_mock):
     client = Minio("http://local.host:9000")
 
     with pytest.raises(S3Error):
-        objects = client.list_objects("no-such-bucket")
+        _ = client.list_objects("no-such-bucket")
 
     bucket_name = "new-bucket"
     client.make_bucket(bucket_name)
@@ -138,8 +389,10 @@ def test_list_objects(minio_mock):
     client.put_object(bucket_name, "object4", data=b"object4 data", length=11)
 
     # Test recursive listing
-    objects_recursive = client.list_objects(bucket_name, prefix="a/", recursive=True)
-    # assert len(objects_recursive) == 3, "Expected 3 objects under 'a/' with recursion"
+    objects_recursive = list(
+        client.list_objects(bucket_name, prefix="a/", recursive=True)
+    )
+    assert len(objects_recursive) == 3, "Expected 3 objects under 'a/' with recursion"
     # Check that all expected paths are returned
     assert set(obj.object_name for obj in objects_recursive) == {
         "a/b/c/object1",