Repository: settorac-nina/bubble-api (language: python)

Issue:
0.4.0 review



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml
new file mode 100644
index 0000000..11ee0ee
--- /dev/null
+++ b/.github/workflows/cicd.yaml
@@ -0,0 +1,37 @@
+name: CI / CD
+
+on: [pull_request, workflow_dispatch]
+
+jobs:
+  test:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: true
+      matrix:
+        os: ["ubuntu-latest", "windows-latest", "macos-latest"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
+
+    steps:
+      - name: Checkout source
+        uses: actions/checkout@v3
+
+      - name: Setup python
+        uses: actions/setup-python@v3
+        with:
+          python-version: ${{ matrix.python-version }}
+          architecture: x64
+
+      - name: Install
+        run: |
+          python -m pip install --upgrade pip
+          pip install -e .
+          pip install -r requirements.test.txt
+
+      - name: Run tests
+        run: pytest tests/unit --no-cov
+
+      - name: Install build
+        run: pip install build
+
+      - name: Build package
+        run: python -m build
diff --git a/.gitignore b/.gitignore
index cb39f34..aee7cb5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,4 +129,5 @@ dmypy.json
 .pyre/
 
 local/
-Makefile
\ No newline at end of file
+Makefile
+.idea
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..c978c2c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,37 @@
+repos:
+-   repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v2.3.0
+    hooks:
+    -   id: check-yaml
+    -   id: end-of-file-fixer
+    -   id: trailing-whitespace
+    -   id: name-tests-test
+    -   id: check-ast
+    -   id: debug-statements
+
+-   repo: https://github.com/hadialqattan/pycln
+    rev: v2.1.5
+    hooks:
+    -   id: pycln
+
+-   repo: https://github.com/pycqa/isort
+    rev: 5.12.0
+    hooks:
+      - id: isort
+
+-   repo: https://github.com/asottile/pyupgrade
+    rev: v3.4.0
+    hooks:
+    -   id: pyupgrade
+        args: [--py39-plus]
+
+-   repo: https://github.com/psf/black
+    rev: 22.10.0
+    hooks:
+    -   id: black
+
+-   repo: https://github.com/PyCQA/flake8
+    rev: 6.0.0
+    hooks:
+    - id: flake8
+      language_version: python3
diff --git a/LICENSE b/LICENSE
index ab49413..80c1dd1 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2023 settorac-nina
+Copyright (c) 2023 Dylan Nina & Mathis Bourdin
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index eb2ea4f..d1ee2c2 100644
--- a/README.md
+++ b/README.md
@@ -1,148 +1,87 @@
-# bubble-api
-Interactions with Bubble.io in Python made easy
+<div align="center">
+    <h1><code>Bubble Api</code></h1>
+    <p><strong>Interactions with Bubble.io API in Python made easy !</strong></p>
+    <img alt="Python Badge" src="https://img.shields.io/badge/-Python-D6D6D6?logo=python"/>
+    <img alt="PiPY Badge" src="https://img.shields.io/pypi/v/bubble-api"/>
+    <img alt="Ci/CD Badge" src="https://github.com/settorac-nina/bubble-api/actions/workflows/cicd.yaml/badge.svg?branch=main)"/>
+    <img src="https://img.shields.io/badge/Made%20With-Love-ef7d16.svg"/>
+</div>
 
-If you have any recommendations or if you want to participate in the project, do not hesitate to contact me at 
-settorac.nina@gmail.com
+---
 
-I have not yet written any documentation due to lack of time. 
-However, I tried to make the code as simple and readable as possible.
+## Installing
 
-Examples
-````
-from bubble_api.bubble import Bubble, Constraint
+The easiest way to install it is through **pipy** :
 
+```shell
+pip install bubble-api
+```
 
-# Instantiate Bubble instance
-bubble = Bubble(
-    api_key="API_KEY",  # Put your API key here. You can also use a user token
-    base_url="https://DOMAIN.com",  # Do not include app version and api/1.1 here
-    bubble_version="test",  # Request to development branch
-    n_retries=4,
-    # Wait 0 second after first fail, 2 after second one, 4 after third on, 8 after forth, ...
-    base_wait_time=2,
-    exponential_backoff=True
-)
+## How to use it
 
-# Make a simple GET request
-get_data_resp = bubble.make_request(
-    bubble_type="user",
-    limit=2,
-    cursor=15,
-    columns_selected=["first_name", "last_name", "_id"]  # If not set, return all columns
-)
+At the moment, there is no documentation for this library.
+You can, however, rely on the integration tests we use to ensure proper integration with Bubble.
+You can find them [here](tests/integration).
 
-users = get_data_resp.results
-remaining = get_data_resp.remaining
-count = get_data_resp.count
-
-# Make a request with constraints and pagination automatically handled and data returned in list
-full_data_resp = bubble.make_full_request(
-    bubble_type="user",
-    sort_field="first_name",
-    descending=False,
-    columns_selected=["first_name", "last_name", "_id"],  # If not set, return all columns
-    constraints=[
-        Constraint(
-            key="last_name",
-            constraint_type="equals",
-            value="Dupont"
-        )
-    ]
-)
+### Creating a BubbleWrapper instance :
 
-dupont_users = full_data_resp.results
-
-# Make a request with constraints and pagination automatically handled and data returned saved in a file
-full_data_resp_2 = bubble.make_full_request(
-    bubble_type="user",
-    sort_field="first_name",
-    descending=False,
-    columns_selected=["first_name", "last_name", "_id"],  # If not set, return all columns
-    constraints=[
-        Constraint(
-            key="last_name",
-            constraint_type="equals",
-            value="Dupont"
-        )
-    ],
-    path_to_file="dupont_users.csv"
-)
+```python
+from bubble_api import BubbleWrapper
 
-path_to_file = full_data_resp_2.path_to_file
-
-# Make a request with constraints and pagination automatically handled and data returned saved in a file and use
-# threads to go faster (setting threads to 2 doesn't mean that it will be twice as fast)
-full_data_resp_3 = bubble.make_full_request(
-    bubble_type="user",
-    sort_field="first_name",
-    descending=False,
-    columns_selected=["first_name", "last_name", "_id"],  # If not set, return all columns
-    constraints=[
-        Constraint(
-            key="last_name",
-            constraint_type="equals",
-            value="Dupont"
-        )
-    ],
-    path_to_file="dupont_users_with_threads.csv",
-    n_threads=2
+bubble_wrapper = BubbleWrapper(
+    base_url="https://cuure.com",
+    api_token="YOUR_API_TOKEN",
+    bubble_version="live"
 )
+```
 
-path_to_file_with_threads = full_data_resp_3.path_to_file
+### Interact with bubble data :
 
-# Count number of items
-n_items = bubble.count_items(
-    bubble_type="user"
-)
+From the `BubbleWrapper` instance you can now interact with the data api easily.
 
-# Get object using its unique id
-selected_user = bubble.get_object_by_id(
-    bubble_type="user",
-    unique_id="1655199935778x975916702499961900",
-    columns_selected=["first_name", "last_name", "_id"]  # If not set, return all columns
+```python
+object_data = bubble_wrapper.get(
+    "table_name",
+    bubble_id="bubble_object_id",
 )
+```
+And so on with all the basic interactions with the api: **delete**, **update**, **replace**, ...
 
-# You can also get an object using another column
-# You should only use columns with unique constraint
-selected_order = bubble.get_object_by_id(
-    bubble_type="order",
-    column_id="ref",
-    unique_id="100000",
-    columns_selected=["ref", "_id"]  # If not set, return all columns
-)
+You can also use constraints for getting or deleting objects.
+This is done through a list of `Contraint` object you can declare by operations with the `Field` class.
 
-# You can decide to get a failure in case the result is not unique or if nothing is found
-selected_dupont = bubble.get_object_by_id(
-    bubble_type="user",
-    column_id="last_name",
-    unique_id="Dupont",
-    fail_if_multiple_results=True,
-    fail_if_not_found=True
-)
+```python
+from bubble_api import BubbleWrapper, Field
+from datetime import datetime
 
-# Create an object
-order_unique_id = bubble.create_object(
-    bubble_type="order",
-    params={
-        "ref": "100001"
-    }
-)
+bubble_wrapper = BubbleWrapper( ... )
 
-# Update an object
-user_unique_id = bubble.update_object(
-    bubble_type="user",
-    params={
-        "first_name": "Pierre"
-    },
-    unique_id="1655199935778x975916702499961900"
-)
+constraints = [
+    Field("name") == "Bob",
+    Field("Created") > datetime(2023, 1, 1),
+]
 
-# Trigger WF
-resp = bubble.make_wf_request(
-    wf_name="my_wf",
-    params={
-        "customer": "123x321"
-    }
+data = bubble_wrapper.get(
+    "table_name",
+    constraints=constraints
 )
+```
+
+All the fields names and constraints values formatting is handle by the library.
+
+## Incoming features
+
+- [ ] Big request optimization using remaining approximation (exclude_remaining)
+- [ ] Multithreading
+- [ ] Default fields (Modified Date, unique id, Created Date, ...)
+- [ ] Lifetime object management (update, refresh, replace)
+- [ ] Documentation
+
+## Authors
+
+* **Dylan Nina** - *Initial work* - [settorac-nina](https://github.com/settorac-nina)
+* **Mathis Bourdin** - *Contributor* - [mathisbrdn](https://github.com/mathisbrdn)
+
+## License
 
-````
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
diff --git a/bubble_api/__init__.py b/bubble_api/__init__.py
new file mode 100644
index 0000000..7f1c5f7
--- /dev/null
+++ b/bubble_api/__init__.py
@@ -0,0 +1,3 @@
+from .constraint import Constraint
+from .field import Field
+from .wrapper import BubbleWrapper
diff --git a/bubble_api/constraint.py b/bubble_api/constraint.py
new file mode 100644
index 0000000..cb4b9d5
--- /dev/null
+++ b/bubble_api/constraint.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import datetime
+from collections.abc import Iterable
+from numbers import Number
+from typing import Any
+
+
+class Constraint:
+    def __init__(self, key: str, constraint_type: str, value: Any | None = None):
+        self.key = key
+        self.constraint_type = constraint_type
+        self.value = self.format_constraint_value(value)
+
+    @staticmethod
+    def format_constraint_value(value: Any) -> str | list[str] | None:
+        if value is None:
+            return None
+        if isinstance(value, str):
+            return value
+        if isinstance(value, Number):
+            return str(value)
+        if isinstance(value, datetime.datetime):
+            return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+        if isinstance(value, datetime.date):
+            return value.strftime("%Y-%m-%d")
+        if isinstance(value, Iterable):
+            return [Constraint.format_constraint_value(v) for v in value]
+        return value
+
+    def to_dict(self) -> dict:
+        res = {
+            "key": self.key,
+            "constraint_type": self.constraint_type,
+        }
+
+        if self.value:
+            res["value"] = self.value
+        return res
diff --git a/bubble_api/field.py b/bubble_api/field.py
new file mode 100644
index 0000000..723d297
--- /dev/null
+++ b/bubble_api/field.py
@@ -0,0 +1,111 @@
+from .constraint import Constraint
+
+
+class Field:
+    def __init__(self, field_name: str):
+        self.field_name = self.format_field_name(field_name)
+
+    @staticmethod
+    def format_field_name(field_name: str):
+        return field_name.lower().replace(" ", "")
+
+    def equals(self, other):
+        return Constraint(
+            self.field_name,
+            "equals",
+            other,
+        )
+
+    def not_equals(self, other):
+        return Constraint(
+            self.field_name,
+            "not equal",
+            other,
+        )
+
+    __eq__ = equals
+    __ne__ = not_equals
+
+    def is_empty(self):
+        return Constraint(
+            self.field_name,
+            "is_empty",
+        )
+
+    def is_not_empty(self):
+        return Constraint(
+            self.field_name,
+            "is_not_empty",
+        )
+
+    def text_contains(self, text):
+        return Constraint(
+            self.field_name,
+            "text contains",
+            text,
+        )
+
+    def not_text_contains(self, text):
+        return Constraint(
+            self.field_name,
+            "not text contains",
+            text,
+        )
+
+    def greater_than(self, value):
+        return Constraint(
+            self.field_name,
+            "greater than",
+            value,
+        )
+
+    def less_than(self, value):
+        return Constraint(
+            self.field_name,
+            "less than",
+            value,
+        )
+
+    __gt__ = greater_than
+    __lt__ = less_than
+
+    def __ge__(self, value):
+        raise NotImplementedError
+
+    def __le__(self, value):
+        raise NotImplementedError
+
+    def is_in(self, value):
+        return Constraint(
+            self.field_name,
+            "in",
+            value,
+        )
+
+    def is_not_in(self, value):
+        return Constraint(
+            self.field_name,
+            "not in",
+            value,
+        )
+
+    def contains(self, value):
+        return Constraint(
+            self.field_name,
+            "contains",
+            value,
+        )
+
+    def not_contains(self, value):
+        return Constraint(
+            self.field_name,
+            "not contains",
+            value,
+        )
+
+    def geographic_search(self, value):
+        return Constraint(
+            self.field_name,
+            "geographic_search",
+            value,
+        )
diff --git a/bubble_api/wrapper.py b/bubble_api/wrapper.py
new file mode 100644
index 0000000..a9f400d
--- /dev/null
+++ b/bubble_api/wrapper.py
@@ -0,0 +1,285 @@
+from __future__ import annotations
+
+import json
+import time
+from collections.abc import Iterable
+from itertools import islice
+
+import requests
+
+from .constraint import Constraint
+from .field import Field
+
+API_VERSION = "1.1"
+
+
+class BubbleWrapper:
+    def __init__(
+        self,
+        base_url: str,
+        api_token: str | None = None,
+        bubble_version: str = "test",
+        *args,
+        **kwargs,
+    ):
+        if bubble_version != "live":
+            base_url = f"{base_url}/version-{bubble_version}"
+        self.base_url = f"{base_url}/api/{API_VERSION}"
+        self.api_token = api_token
+
+    def _get_headers(self) -> dict | None:
+        return (
+            {
+                "Authorization": f"Bearer {self.api_token}",
+            }
+            if self.api_token is not None
+            else None
+        )
+
+    @staticmethod
+    def _format_constraints(
+        constraints: Constraint | Iterable[Constraint] | None = None,
+    ) -> str:
+        if constraints is None:
+            constraints = list()
+        if isinstance(constraints, Constraint):
+            constraints = [constraints]
+        return json.dumps([constraint.to_dict() for constraint in constraints])
+
+    @staticmethod
+    def format_bubble_type(bubble_type: str):
+        return bubble_type.lower().replace(" ", "")
+
+    def make_request(
+        self,
+        nb_retries: int = 3,
+        sleep_time: float = 0.2,
+        exponential_backoff: bool = False,
+        **kwargs,
+    ) -> requests.Response:
+        if kwargs.get("headers") is None:
+            kwargs["headers"] = self._get_headers()
+
+        response = requests.request(**kwargs)
+
+        if response.status_code // 100 == 2:
+            return response
+
+        if nb_retries == 0 or response.status_code // 100 == 4:
+            print(response.text)
+            response.raise_for_status()
+
+        time.sleep(sleep_time)
+
+        if exponential_backoff:
+            sleep_time *= 2
+
+        return self.make_request(
+            nb_retries=nb_retries - 1,
+            sleep_time=sleep_time,
+            exponential_backoff=exponential_backoff,
+            **kwargs,
+        )
+
+    def get(
+        self,
+        bubble_type: str,
+        bubble_id: str | None = None,
+        column_name: str | None = None,
+        constraints: list[Constraint] | None = None,
+        **kwargs,
+    ):
+        bubble_type = self.format_bubble_type(bubble_type)
+        if bubble_id is not None:
+            return self.get_by_id(bubble_type, bubble_id, column_name, **kwargs)
+        return self.get_objects(bubble_type, constraints, **kwargs)
+
+    def create(self, bubble_type: str, fields: dict | list[dict] = None, **kwargs):
+        bubble_type = self.format_bubble_type(bubble_type)
+        if isinstance(fields, list):
+            return self.create_bulk(bubble_type, fields=fields, **kwargs)
+        return self.create_object(bubble_type, fields, **kwargs)
+
+    def delete(
+        self,
+        bubble_type: str,
+        bubble_id: str | Iterable[str] | None = None,
+        constraints: Constraint | Iterable[Constraint] | None = None,
+        **kwargs,
+    ):
+        bubble_type = self.format_bubble_type(bubble_type)
+        if bubble_id is not None and constraints is not None:
+            raise TypeError("You can't specify both bubble_id and constraints.")
+        if isinstance(bubble_id, str):
+            return self.delete_by_id(bubble_type, bubble_id, **kwargs)
+        if isinstance(bubble_id, Iterable):
+            return self.delete_by_ids(bubble_type, bubble_id, **kwargs)
+        if constraints is not None:
+            return self.delete_objects(bubble_type, constraints, **kwargs)
+        raise Warning(
+            "You must specify at least one of bubble_id, bubble_ids or constraints.",
+            "If you intend to delete the whole table, please use the delete_all method.",
+        )
+
+    def get_by_id(self, bubble_type, bubble_id, column_name=None, **kwargs):
+        bubble_type = self.format_bubble_type(bubble_type)
+        if column_name is not None:
+            objs = self.get_objects(
+                bubble_type,
+                [Field(column_name) == bubble_id],
+            )
+            return objs[0] if len(objs) > 0 else None
+
+        url = f"{self.base_url}/obj/{bubble_type}/{bubble_id}"
+
+        resp = self.make_request(method="GET", url=url, **kwargs)
+
+        return resp.json()["response"]
+
+    def create_object(self, bubble_type: str, fields: dict | None = None, **kwargs):
+        bubble_type = self.format_bubble_type(bubble_type)
+        url = f"{self.base_url}/obj/{bubble_type}"
+
+        resp = self.make_request(method="POST", url=url, json=fields, **kwargs)
+
+        return resp.json()["id"]
+
+    def update_object(self, bubble_type: str, bubble_id: str, fields: dict, **kwargs):
+        bubble_type = self.format_bubble_type(bubble_type)
+        url = f"{self.base_url}/obj/{bubble_type}/{bubble_id}"
+
+        self.make_request(method="PATCH", url=url, json=fields, **kwargs)
+
+    def replace_object(self, bubble_type: str, bubble_id: str, fields: dict, **kwargs):
+        bubble_type = self.format_bubble_type(bubble_type)
+        url = f"{self.base_url}/obj/{bubble_type}/{bubble_id}"
+
+        self.make_request(method="PUT", url=url, json=fields, **kwargs)
+
+    def delete_by_id(self, bubble_type: str, bubble_id: str, **kwargs):
+        bubble_type = self.format_bubble_type(bubble_type)
+        url = f"{self.base_url}/obj/{bubble_type}/{bubble_id}"
+
+        self.make_request(method="DELETE", url=url, **kwargs)
+
+    def delete_by_ids(self, bubble_type: str, ids: Iterable[str], **kwargs):
+        bubble_type = self.format_bubble_type(bubble_type)
+        for _id in ids:
+            self.delete_by_id(bubble_type, _id, **kwargs)
+
+    def delete_objects(
+        self,
+        bubble_type: str,
+        constraints: Constraint | Iterable[Constraint] | None = None,
+        **kwargs,
+    ):
+        bubble_type = self.format_bubble_type(bubble_type)
+        self.delete_by_ids(
+            bubble_type,
+            (
+                obj["_id"]
+                for obj in self.get_objects_gen(bubble_type, constraints, **kwargs)
+            ),
+            **kwargs,
+        )
+
+    def delete_all(self, bubble_type, **kwargs):
+        bubble_type = self.format_bubble_type(bubble_type)
+        self.delete_objects(bubble_type, list(), **kwargs)
+
+    def create_bulk(self, bubble_type: str, fields: list[dict], **kwargs) -> list:
+        bubble_type = self.format_bubble_type(bubble_type)
+        url = f"{self.base_url}/obj/{bubble_type}/bulk"
+        headers = {
+            **self._get_headers(),
+            "Content-Type": "text/plain",
+        }
+
+        resp = self.make_request(
+            method="POST",
+            url=url,
+            data="\n".join(json.dumps(f) for f in fields),
+            headers=headers,
+            **kwargs,
+        )
+
+        return [json.loads(r) for r in resp.text.split("\n")]
+
+    def count_objects(
+        self,
+        bubble_type: str,
+        constraints: Constraint | Iterable[Constraint] | None = None,
+        **kwargs,
+    ):
+        bubble_type = self.format_bubble_type(bubble_type)
+        url = f"{self.base_url}/obj/{bubble_type}"
+        constraints = self._format_constraints(constraints)
+
+        params = {
+            "constraints": constraints,
+            "cursor": 0,
+            "limit": 1,
+        }
+
+        resp = self.make_request(method="GET", url=url, params=params, **kwargs)
+        json_resp = resp.json()["response"]
+
+        return json_resp["count"] + json_resp["remaining"]
+
+    def get_objects_gen(
+        self,
+        bubble_type: str,
+        constraints: Constraint | Iterable[Constraint] | None = None,
+        sort_field: str = None,
+        descending: bool = False,
+        limit: int = 100,
+        **kwargs,
+    ):
+        bubble_type = self.format_bubble_type(bubble_type)
+        url = f"{self.base_url}/obj/{bubble_type}"
+
+        constraints = self._format_constraints(constraints)
+
+        if isinstance(sort_field, Field):
+            sort_field = sort_field.field_name
+
+        params = {
+            "constraints": constraints,
+            "cursor": 0,
+            "limit": limit,
+            "sort_field": sort_field,
+            "descending": descending,
+        }
+
+        while True:
+            resp = self.make_request(method="GET", url=url, params=params, **kwargs)
+            json_resp = resp.json()["response"]
+            yield from json_resp["results"]
+
+            params["cursor"] = json_resp["cursor"] + json_resp["count"]
+
+            if json_resp["remaining"] == 0:
+                break
+
+    def get_objects(
+        self,
+        bubble_type: str,
+        constraints: Constraint | Iterable[Constraint] | None = None,
+        max_objects: int | None = None,
+        **kwargs,
+    ):
+        bubble_type = self.format_bubble_type(bubble_type)
+        return list(
+            islice(
+                self.get_objects_gen(bubble_type, constraints, **kwargs), max_objects
+            )
+        )
+
+    def run_workflow(
+        self, wf_name: str, params: dict | None = None, method: str = "POST", **kwargs
+    ):
+        url = f"{self.base_url}/wf/{wf_name}"
+
+        resp = self.make_request(method=method, url=url, json=params, **kwargs)
+
+        return resp.json()["response"]
diff --git a/pyproject.toml b/pyproject.toml
index a8dfcb9..beb2b78 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,27 +1,35 @@
 [build-system]
-requires = ["hatchling"]
-build-backend = "hatchling.build"
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
 
 [project]
 name = "bubble_api"
-version = "0.3.1"
+version = "0.4.0"
 authors = [
-  { name="Dylan NINA", email="settorac.nina@gmail.com" },
+    { name="Dylan NINA", email="settorac.nina@gmail.com" },
+    { name="Mathis Bourdin", email="mathisbrdn@gmail.com" },
 ]
-description = "Interactions with Bubble.io in Python made easy"
+description = "Interactions with Bubble.io API in Python made easy !"
 readme = "README.md"
 requires-python = ">=3.8"
 classifiers = [
+    "Programming Language :: Python",
     "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
     "License :: OSI Approved :: MIT License",
     "Operating System :: OS Independent",
 ]
+
 dependencies = [
-    'requests >= 2.28.1, < 2.29.0',
-    'pydantic >= 1.10.4, < 1.11.0',
-    'pandas >= 1.5.2, < 1.6.0',
+    'requests',
 ]
 
+[tool.setuptools]
+packages = ["bubble_api"]
+
 [project.urls]
 "Homepage" = "https://github.com/settorac-nina/bubble-api"
 "Bug Tracker" = "https://github.com/settorac-nina/bubble-api/issues"
diff --git a/requirements.dev.txt b/requirements.dev.txt
new file mode 100644
index 0000000..74824e2
--- /dev/null
+++ b/requirements.dev.txt
@@ -0,0 +1,4 @@
+setuptools>=61
+wheel
+twine
+pre-commit
diff --git a/requirements.txt b/requirements.txt
index ab80c34..f229360 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,1 @@
-requests>=2.28.1,<2.29.0
-pydantic>=1.10.4,<1.11.0
-pandas>=1.5.2,<1.6.0
\ No newline at end of file
+requests
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..fc3489f
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,3 @@
+[flake8]
+exclude = __init__.py
+max-line-length = 120
diff --git a/src/bubble_api/bubble.py b/src/bubble_api/bubble.py
deleted file mode 100644
index 358d032..0000000
--- a/src/bubble_api/bubble.py
+++ /dev/null
@@ -1,1284 +0,0 @@
-import time
-import requests
-import json
-import pandas as pd
-from requests.exceptions import ConnectionError
-from requests.exceptions import ReadTimeout
-from pydantic import BaseModel, validator
-from pydantic import Field
-from pydantic import root_validator
-from pydantic import validate_arguments
-from pydantic.typing import Annotated
-from typing import Optional, List, Literal, Any
-from threading import Thread
-from datetime import datetime
-
-
-BUBBLE_API_VERSION = "1.1"
-
-# Number of retries
-DEF_RETRIES = 0
-MIN_RETRIES = 0
-MAX_RETRIES = 10
-
-# Wait time between request retries
-DEF_WAIT_TIME = 0
-MIN_WAIT_TIME = 0
-
-# Verbose level
-DEF_VERB_LEV = 1
-MIN_VERB_LEV = 0
-MAX_VERB_LEV = 2
-
-
-def save_result_in_dict(save_in_dict=None, save_in_key=None):
-    """
-    This decorator is used to save the result of the decorated function in a dictionary
-    """
-    if save_in_dict is not None and save_in_key is None:
-        raise ValueError("save_in_key must be provided if save_result_in_dict is provided")
-
-    def decorator(function):
-        def new_function(*args, **kwargs):
-            result = function(*args, **kwargs)
-            if save_in_dict is not None:
-                save_in_dict[save_in_key] = result
-            return result
-        return new_function
-    return decorator
-
-
-class Constraint(BaseModel):
-    """
-    See https://manual.bubble.io/core-resources/api/data-api#search-constraints for more details
-    """
-    key: str
-    constraint_type: Literal[
-        "equals",
-        "not equal",
-        "is_empty",
-        "is_not_empty",
-        "text contains",
-        "not text contains",
-        "greater than",
-        "less than",
-        "in",
-        "not int",
-        "contains",
-        "not contains",
-        "empty",
-        "not empty",
-        "geographic_search"
-    ]
-    value: Any = None
-    
-    @validator('value')
-    def datetime_to_string(cls, value):
-        if isinstance(value, datetime):
-            return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
-        return value
-
-
-class GetDataResp(BaseModel):
-    results: List[dict]
-    remaining: int = 0
-    count: int
-
-    def __str__(self):
-        return self.json(indent=4)
-
-
-class GetFullDataResp(BaseModel):
-    results: Optional[List[dict]] = None
-    path_to_file: Optional[str] = None
-
-    def __str__(self):
-        return self.json(indent=4)
-
-
-class Bubble(BaseModel):
-
-    base_url: str = Field(
-        ...,
-        description="URL to your Bubble website (https://{DOMAIN})"
-    )
-    bubble_version: Optional[str] = Field(
-        "live",
-        description="Version of your website to use. Set test for Development branch"
-    )
-    api_key: Optional[str] = Field(
-        None,
-        description="API key. It can also be a user token"
-    )
-    n_retries: int = Field(
-        DEF_RETRIES,
-        ge=MIN_RETRIES,
-        le=MAX_RETRIES,
-        description="Maximum number of retries in case of request failure"
-    )
-    base_wait_time: int = Field(
-        DEF_WAIT_TIME,
-        ge=MIN_WAIT_TIME,
-        description="Time to wait between retries in seconds. "
-                    "If set to 0, do not wait between retries. "
-                    "Must be greater than 0 if exponential_backoff is True"
-    )
-    exponential_backoff: bool = Field(
-        False,
-        description="Whether to use exponential backoff for time between retries. "
-                    "If exponential_backoff is True, no wait time after first try and then wait time is "
-                    "(base_wait_time^n) where n >= 1 is the retry index"
-    )
-    verbose_level: int = Field(
-        DEF_VERB_LEV,
-        ge=MIN_VERB_LEV,
-        le=MAX_VERB_LEV,
-        description="If set to 0. Do not print anything. "
-                    "If set to 1, print information in case of failure. Default and recommended value for "
-                    "production environments"
-                    "If set to 2, print lots of information. Not recommended in production environments"
-    )
-    timeout: Optional[int] = Field(
-        None,
-        ge=1,
-        description="Use this parameter to set request timeout in seconds. "
-                    "If no response is received after the given time a ConnectionError is raised. "
-                    "Be extremely careful when you use this parameter and modify or create objects in your database. "
-                    "A timeout does not necessarily mean that the object has not been modified or created"
-    )
-
-    @root_validator()
-    def exponential_backoff_time(cls, values):
-        if values.get("exponential_backoff", False) and values.get("base_wait_time", 0) <= 1:
-            raise ValueError("base_wait_time must be greater than 1 if exponential_backoff is True")
-        return values
-
-    def __str__(self):
-        if self.api_key is not None:
-            # The whole key is printed if its length is less than 3
-            # (not an issue because it means it's not a real key)
-            api_key_info = self.api_key[:3] + "*" * max(len(self.api_key) - 3, 0)
-
-        else:
-            api_key_info = "No API key provided"
-
-        return f"Bubble instance ({api_key_info})"
-
-    @validate_arguments
-    def make_request(
-            self,
-            bubble_type: str,
-            unique_id: Optional[str] = None,
-            cursor: Optional[Annotated[int, Field(ge=0)]] = 0,
-            limit: Optional[Annotated[int, Field(ge=1, le=100)]] = 100,
-            sort_field: Optional[str] = None,
-            descending: bool = False,
-            constraints: Optional[List[Constraint]] = None,
-            columns_selected: Optional[List[str]] = None,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None,
-            exclude_remaining: Optional[bool] = False
-    ) -> GetDataResp:
-        """
-        This function is used to make GET requests to Bubble and handle errors and retries.
-        (!) It doesn't handle pagination (!)
-        See https://manual.bubble.io/core-resources/api/data-api#getting-a-list-of-things-and-search for more details
-        :param bubble_type: Name of the table to request
-        :param unique_id: Unique id of the object to retrieve. If provided, ignore search constraints, and pagination
-        parameters. To directly get object without pagination information, use method get_object_by_id
-        :param cursor: Rank of the first item in the list to fetch
-        :param limit: Maximum number of items in response
-        :param sort_field: Field to use for sorting. No sort if not provided
-        :param descending: True for descending sorting, False for ascending sorting
-        :param constraints: List of Constraint
-        :param columns_selected: List of columns to return in response. If not provided, return all columns
-        :param n_retries: Overrides parent value. Maximum number of retries in case of request failure.
-        If None, use self.n_retries
-        :param base_wait_time: Overrides parent value. Time to wait between retries in seconds.
-        If None, use self.base_wait_time
-        :param exponential_backoff: Overrides parent value. Whether to use exponential backoff for time between retries.
-        If None, use self.exponential_backoff
-        :param verbose_level: Overrides parent value. Verbose level.
-        If None, use self.verbose_level
-        :param timeout: Overrides parent value. Use this parameter to set request timeout in seconds. Set 0 to remove
-        timeout. (!) None will not erase the parent value (!).
-        :param exclude_remaining: If set to True, remaining is an estimation and not the exact count
-        If None, use self.timeout
-        """
-        if n_retries is None:
-            n_retries = self.n_retries
-        if base_wait_time is None:
-            base_wait_time = self.base_wait_time
-        if exponential_backoff is None:
-            exponential_backoff = self.exponential_backoff
-        if verbose_level is None:
-            verbose_level = self.verbose_level
-        if timeout is None:
-            timeout = self.timeout
-        if timeout == 0:
-            timeout = None
-
-        if exponential_backoff and base_wait_time <= 1:
-            raise ValueError("base_wait_time must be greater than 1 if exponential_backoff is True")
-
-        base_url = f"{self.base_url}/version-{self.bubble_version}" if self.bubble_version != "live" else \
-            self.base_url
-
-        if unique_id is not None:
-            full_url = f"{base_url}/api/{BUBBLE_API_VERSION}/obj/{bubble_type}/{unique_id}"
-            params = {}
-
-        else:
-            full_url = f"{base_url}/api/{BUBBLE_API_VERSION}/obj/{bubble_type}"
-            params = {
-                "limit": limit,
-                "cursor": cursor,
-                "constraints": json.dumps([c.dict() for c in constraints]) if constraints is not None else None,
-                "sort_field": sort_field,
-                "descending": descending,
-                "exclude_remaining": exclude_remaining
-            }
-
-        if verbose_level >= 2:
-            print("GET request URL : ", full_url)
-            print("GET request parameters : ", json.dumps(params, indent=4))
-
-        headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key is not None else None
-
-        break_while = False
-        retry_index = 0
-        resp = None
-        no_resp_error = "Unknown error"
-
-        while retry_index <= n_retries and not break_while:
-            try:
-                if (retry_index >= 1 or verbose_level >= 2) and verbose_level >= 1:
-                    print(f"GET request - Retry index : {retry_index}/{n_retries}")
-
-                resp = requests.get(
-                    full_url,
-                    headers=headers,
-                    params=params,
-                    timeout=timeout
-                )
-
-                if resp.ok:
-                    break_while = True
-                    if verbose_level >= 2:
-                        print("GET request - Success - Response content :", str(resp.content))
-
-                    json_resp = resp.json()
-                    response = json_resp.get("response", {})
-                    if unique_id is not None:
-                        results = [response]
-                        remaining = 0
-                        resp_count = 1
-
-                    else:
-                        results = response.get("results", [])
-                        remaining = response.get("remaining", 0)
-                        resp_count = response.get("count", 0)
-
-                    if columns_selected is not None and len(columns_selected) > 0:
-                        results = [{column: result.get(column) for column in columns_selected} for result in results]
-
-                    return GetDataResp(
-                        results=results,
-                        remaining=remaining,
-                        count=resp_count
-                    )
-
-                elif resp.status_code in [400, 404]:
-                    break_while = True
-                    if resp.status_code == 404 and unique_id is not None:
-                        return GetDataResp(
-                            results=[],
-                            remaining=0,
-                            count=0
-                        )
-
-                    if verbose_level >= 1:
-                        print(f"GET request - {resp.status_code} - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "body" in json_resp:
-                        raise ValueError(json.dumps(json_resp.get("body"), indent=4))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                elif resp.status_code == 401:  # Unauthorized
-                    break_while = True
-                    if verbose_level >= 1:
-                        print(f"GET request - 401 - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "translation" in json_resp:
-                        raise ValueError(json_resp.get("translation"))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                else:
-                    if verbose_level >= 1:
-                        print(f"GET request - {resp.status_code} - {str(resp.content)}")
-
-            except AttributeError as ae:  # If resp.json() fails
-                if verbose_level >= 1:
-                    print("GET request - AttributeError", str(ae))
-
-            except ConnectionError as ce:
-                no_resp_error = "ConnectionError"
-                if verbose_level >= 1:
-                    print("GET request - ConnectionError :", str(ce))
-
-            except ReadTimeout as rto:
-                no_resp_error = "ReadTimeout"
-                if verbose_level >= 1:
-                    print("GET request - ReadTimeout :", str(rto))
-
-            finally:
-                if not break_while and retry_index < n_retries:
-                    if exponential_backoff:
-                        sleep_time = 0 if retry_index == 0 else base_wait_time ** retry_index
-                    else:
-                        sleep_time = base_wait_time
-
-                    if verbose_level >= 1 and sleep_time > 0:
-                        print(f"GET request - Wait {sleep_time} second(s)")
-
-                    time.sleep(sleep_time)
-
-                retry_index += 1
-
-        # All requests failed
-        if n_retries == 0:
-            request_failed = "The request failed"
-        else:
-            request_failed = "All requests failed"
-
-        if verbose_level >= 1:
-            if resp is not None:
-                print(f"GET request - {request_failed} - Last response content : {str(resp.content)}")
-            else:
-                print(f"GET request - {request_failed} - Response is None ({no_resp_error})")
-
-        if resp is not None:
-            raise ValueError(f"{request_failed} - Last response content : {str(resp.content)}")
-        else:  # Can happen if all requests lead to a ConnectionError or ReadTimeout
-            raise ValueError(f"{request_failed} - Response is None ({no_resp_error})")
-
-    def count_items(
-            self,
-            bubble_type: str,
-            constraints: Optional[List[Constraint]] = None,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None
-    ) -> int:
-        """
-        This function is used to count the number of items returned by a query but does not actually get these items
-        :param bubble_type: Name of the table to request
-        :param constraints: List of Constraint
-        :param n_retries: Overrides parent value. Maximum number of retries in case of request failure.
-        If None, use self.n_retries
-        :param base_wait_time: Overrides parent value. Time to wait between retries in seconds.
-        If None, use self.base_wait_time
-        :param exponential_backoff: Overrides parent value. Whether to use exponential backoff for time between retries.
-        If None, use self.exponential_backoff
-        :param verbose_level: Overrides parent value. Verbose level.
-        If None, use self.verbose_level
-        :param timeout: Overrides parent value. Use this parameter to set request timeout in seconds. Set 0 to remove
-        timeout. (!) None will not erase the parent value (!).
-        If None, use self.timeout
-        """
-        get_data_resp = self.make_request(
-            bubble_type=bubble_type,
-            cursor=0,
-            limit=1,
-            constraints=constraints,
-            n_retries=n_retries,
-            base_wait_time=base_wait_time,
-            exponential_backoff=exponential_backoff,
-            verbose_level=verbose_level,
-            timeout=timeout
-        )
-
-        return len(get_data_resp.results) + get_data_resp.remaining
-
-    def make_full_request(
-            self,
-            bubble_type: str,
-            limit: Optional[Annotated[int, Field(ge=1, le=100)]] = 100,
-            sort_field: Optional[str] = None,
-            descending: bool = False,
-            constraints: Optional[List[Constraint]] = None,
-            columns_selected: Optional[List[str]] = None,
-            path_to_file: Optional[str] = None,
-            file_format: Literal["CSV"] = "CSV",
-            n_threads: Annotated[int, Field(ge=0)] = 0,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None
-    ) -> GetFullDataResp:
-        """
-        This function is used to make GET requests to Bubble and handle errors, retries and pagination.
-        The data will be stored in the file provided in path_to_file if any. If no file is provided, the data will
-        be returned in a list. In this case, you need to make sure not to query too much data
-        :param bubble_type: Name of the table to request
-        :param limit: Maximum number of items in response
-        :param sort_field: Field to use for sorting. No sort if not provided
-        :param descending: True for descending sorting, False for ascending sorting
-        :param constraints: List of Constraint
-        :param columns_selected: List of columns to return in response. If not provided, return all columns
-        :param n_retries: Overrides parent value. Maximum number of retries in case of request failure.
-        If None, use self.n_retries
-        :param path_to_file: Path to a file where to save data
-        :param file_format: Format to use to save data. Only CSV is supported
-        :param n_threads: Number of simultaneous requests to make at most. If set to 0, do not use threads
-        :param base_wait_time: Overrides parent value. Time to wait between retries in seconds.
-        If None, use self.base_wait_time
-        :param exponential_backoff: Overrides parent value. Whether to use exponential backoff for time between retries.
-        If None, use self.exponential_backoff
-        :param verbose_level: Overrides parent value. Verbose level.
-        If None, use self.verbose_level
-        :param timeout: Overrides parent value. Use this parameter to set request timeout in seconds. Set 0 to remove
-        timeout. (!) None will not erase the parent value (!).
-        If None, use self.timeout
-        """
-        cursor = 0
-        full_results = []
-        if n_threads == 0:
-            remaining = 1
-            first_request = True
-            while remaining > 0:
-                get_data_resp = self.make_request(
-                    bubble_type=bubble_type,
-                    cursor=cursor,
-                    limit=limit,
-                    sort_field=sort_field,
-                    descending=descending,
-                    constraints=constraints,
-                    columns_selected=columns_selected,
-                    n_retries=n_retries,
-                    base_wait_time=base_wait_time,
-                    exponential_backoff=exponential_backoff,
-                    verbose_level=verbose_level,
-                    timeout=timeout
-                )
-
-                if path_to_file is None:
-                    full_results.extend(get_data_resp.results)
-
-                else:
-                    if file_format == "CSV":
-                        df = pd.DataFrame(get_data_resp.results)
-                        df.to_csv(
-                            path_to_file,
-                            mode="w" if first_request else "a",
-                            header=first_request,
-                            index=False
-                        )
-                    else:
-                        raise ValueError(f"{file_format} is not a valid value for file_format")
-
-                remaining = get_data_resp.remaining
-                cursor += get_data_resp.count
-                first_request = False
-
-        else:
-            n_items = self.count_items(
-                bubble_type=bubble_type,
-                constraints=constraints,
-                n_retries=n_retries,
-                base_wait_time=base_wait_time,
-                exponential_backoff=exponential_backoff,
-                verbose_level=verbose_level,
-                timeout=timeout
-            )
-            n_total_threads = (n_items - 1) // limit + 1
-            n_batches = (n_total_threads - 1) // n_threads + 1
-            for b in range(n_batches):
-                n_remaining_threads = n_total_threads - (b * n_threads)
-                threads = []
-                result_dict = dict()
-                for t in range(min(n_threads, n_remaining_threads)):
-                    thread = Thread(
-                        target=save_result_in_dict(result_dict, str(b * n_threads + t))(self.make_request),
-                        kwargs={
-                            "bubble_type": bubble_type,
-                            "cursor": cursor,
-                            "limit": limit,
-                            "sort_field": sort_field,
-                            "descending": descending,
-                            "constraints": constraints,
-                            "columns_selected": columns_selected,
-                            "n_retries": n_retries,
-                            "base_wait_time": base_wait_time,
-                            "exponential_backoff": exponential_backoff,
-                            "verbose_level": verbose_level,
-                            "timeout": timeout
-                        }
-                    )
-                    thread.start()
-                    threads.append(thread)
-
-                    cursor += limit
-
-                # Wait for threads to end
-                for thread in threads:
-                    thread.join()
-
-                for t in range(min(n_threads, n_remaining_threads)):
-                    get_data_resp = result_dict.get(str(b * n_threads + t))
-                    if get_data_resp is None:
-                        raise ValueError("Could not fetch data")
-
-                    if path_to_file is None:
-                        full_results.extend(get_data_resp.results)
-
-                    else:
-                        if file_format == "CSV":
-                            df = pd.DataFrame(get_data_resp.results)
-                            df.to_csv(
-                                path_to_file,
-                                mode="w" if b * n_threads + t == 0 else "a",
-                                header=b * n_threads + t == 0,
-                                index=False
-                            )
-                        else:
-                            raise ValueError(f"{file_format} is not a valid value for file_format")
-
-        return GetFullDataResp(
-            results=None if path_to_file is not None else full_results,
-            path_to_file=path_to_file
-        )
-
-    def get_object_by_id(
-            self,
-            bubble_type: str,
-            unique_id: str,
-            column_id: str = "_id",
-            fail_if_multiple_results: bool = False,
-            fail_if_not_found: bool = False,
-            columns_selected: Optional[List[str]] = None,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None
-    ) -> Optional[dict]:
-        """
-        This function is used to retrieve an object using an identifier. By default, it uses _id. You can change
-        this behavior providing column_id. You should only use columns with unique constraint.
-        If multiple objects can be found, only one is randomly returned. Note that using _id is quicker
-        :param bubble_type: Name of the table to request
-        :param unique_id: If of the object to fetch
-        :param column_id: The column to search
-        :param fail_if_multiple_results: If set to True, fail if more than one object is found
-        :param fail_if_not_found: If set to True, fail if no object if found
-        :param columns_selected: List of columns to return in response. If not provided, return all columns
-        :param n_retries: Overrides parent value. Maximum number of retries in case of request failure.
-        If None, use self.n_retries
-        :param base_wait_time: Overrides parent value. Time to wait between retries in seconds.
-        If None, use self.base_wait_time
-        :param exponential_backoff: Overrides parent value. Whether to use exponential backoff for time between retries.
-        If None, use self.exponential_backoff
-        :param verbose_level: Overrides parent value. Verbose level.
-        If None, use self.verbose_level
-        :param timeout: Overrides parent value. Use this parameter to set request timeout in seconds. Set 0 to remove
-        timeout. (!) None will not erase the parent value (!).
-        If None, use self.timeout
-        """
-        if column_id in ["_id", "unique_id"]:
-            get_data_resp = self.make_request(
-                bubble_type=bubble_type,
-                unique_id=unique_id,
-                columns_selected=columns_selected,
-                n_retries=n_retries,
-                base_wait_time=base_wait_time,
-                exponential_backoff=exponential_backoff,
-                verbose_level=verbose_level,
-                timeout=timeout
-            )
-
-        else:
-            constraints = [
-                Constraint(
-                    key=column_id,
-                    constraint_type="equals",
-                    value=unique_id
-                )
-            ]
-
-            get_data_resp = self.make_request(
-                bubble_type=bubble_type,
-                cursor=0,
-                limit=1,
-                constraints=constraints,
-                columns_selected=columns_selected,
-                n_retries=n_retries,
-                base_wait_time=base_wait_time,
-                exponential_backoff=exponential_backoff,
-                verbose_level=verbose_level,
-                timeout=timeout,
-                exclude_remaining=False if fail_if_multiple_results else True
-            )
-
-        if fail_if_multiple_results and get_data_resp.remaining > 0:
-            raise ValueError("More than one object found")
-
-        results = get_data_resp.results
-
-        if fail_if_not_found and len(results) == 0:
-            raise ValueError(f"Object not found in table {bubble_type} ({column_id} = {unique_id})")
-
-        return results[0] if len(results) > 0 else None
-
-    def update_object(
-            self,
-            bubble_type: str,
-            unique_id: str,
-            params: dict,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None
-    ) -> str:
-        """
-        This function is used to make PATCH requests to Bubble and handle errors and retries.
-        https://manual.bubble.io/core-resources/api/data-api#modify-a-thing-by-id for more details
-        :param bubble_type: Name of the table to request
-        :param unique_id: Unique id of the object to update
-        :param params: Dictionary with updated attributes. Columns that are not in this dictionary won't be updated
-        :param n_retries: Overrides parent value. Maximum number of retries in case of request failure.
-        If None, use self.n_retries
-        :param base_wait_time: Overrides parent value. Time to wait between retries in seconds.
-        If None, use self.base_wait_time
-        :param exponential_backoff: Overrides parent value. Whether to use exponential backoff for time between retries.
-        If None, use self.exponential_backoff
-        :param verbose_level: Overrides parent value. Verbose level.
-        If None, use self.verbose_level
-        :param timeout: Overrides parent value. Use this parameter to set request timeout in seconds. Set 0 to remove
-        timeout. (!) None will not erase the parent value (!).
-        If None, use self.timeout
-        """
-        if n_retries is None:
-            n_retries = self.n_retries
-        if base_wait_time is None:
-            base_wait_time = self.base_wait_time
-        if exponential_backoff is None:
-            exponential_backoff = self.exponential_backoff
-        if verbose_level is None:
-            verbose_level = self.verbose_level
-        if timeout is None:
-            timeout = self.timeout
-        if timeout == 0:
-            timeout = None
-
-        if exponential_backoff and base_wait_time <= 1:
-            raise ValueError("base_wait_time must be greater than 1 if exponential_backoff is True")
-
-        base_url = f"{self.base_url}/version-{self.bubble_version}" if self.bubble_version != "live" else \
-            self.base_url
-
-        full_url = f"{base_url}/api/{BUBBLE_API_VERSION}/obj/{bubble_type}/{unique_id}"
-
-        if verbose_level >= 2:
-            print("PATCH request URL : ", full_url)
-            print("PATCH request parameters : ", json.dumps(params, indent=4))
-
-        headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key is not None else None
-
-        break_while = False
-        retry_index = 0
-        resp = None
-        no_resp_error = "Unknown error"
-
-        while retry_index <= n_retries and not break_while:
-            try:
-                if (retry_index >= 1 or verbose_level >= 2) and verbose_level >= 1:
-                    print(f"PATCH request - Retry index : {retry_index}/{n_retries}")
-
-                resp = requests.patch(
-                    full_url,
-                    headers=headers,
-                    json=params,
-                    timeout=timeout
-                )
-
-                if resp.ok:
-                    if verbose_level >= 2:
-                        print(f"PATCH request - Object {unique_id} of table {bubble_type} updated")
-
-                    return unique_id
-
-                elif resp.status_code in [400, 404]:
-                    break_while = True
-                    if verbose_level >= 1:
-                        print(f"PATCH request - {resp.status_code} - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "body" in json_resp:
-                        raise ValueError(json.dumps(json_resp.get("body"), indent=4))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                elif resp.status_code == 401:  # Unauthorized
-                    break_while = True
-                    if verbose_level >= 1:
-                        print(f"PATCH request - 401 - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "translation" in json_resp:
-                        raise ValueError(json_resp.get("translation"))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                else:
-                    if verbose_level >= 1:
-                        print(f"PATCH request - {resp.status_code} - {str(resp.content)}")
-
-            except AttributeError as ae:  # If resp.json() fails
-                if verbose_level >= 1:
-                    print("PATCH request - AttributeError", str(ae))
-
-            except ConnectionError as ce:
-                no_resp_error = "ConnectionError"
-                if verbose_level >= 1:
-                    print("PATCH request - ConnectionError :", str(ce))
-
-            except ReadTimeout as rto:
-                no_resp_error = "ReadTimeout"
-                if verbose_level >= 1:
-                    print("PATCH request - ReadTimeout :", str(rto))
-
-            finally:
-                if not break_while and retry_index < n_retries:
-                    if exponential_backoff:
-                        sleep_time = 0 if retry_index == 0 else base_wait_time ** retry_index
-                    else:
-                        sleep_time = base_wait_time
-
-                    if verbose_level >= 1 and sleep_time > 0:
-                        print(f"PATCH request - Wait {sleep_time} second(s)")
-
-                    time.sleep(sleep_time)
-
-                retry_index += 1
-
-        # All requests failed
-        if n_retries == 0:
-            request_failed = "The request failed"
-        else:
-            request_failed = "All requests failed"
-
-        if verbose_level >= 1:
-            if resp is not None:
-                print(f"PATCH request - {request_failed} - Last response content : {str(resp.content)}")
-            else:
-                print(f"PATCH request - {request_failed} - Response is None ({no_resp_error})")
-
-        if resp is not None:
-            raise ValueError(f"{request_failed} - Last response content : {str(resp.content)}")
-        else:  # Can happen if all requests lead to a ConnectionError or ReadTimeout
-            raise ValueError(f"{request_failed} - Response is None ({no_resp_error})")
-
-    def create_object(
-            self,
-            bubble_type: str,
-            params: dict,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None
-    ) -> str:
-        """
-        This function is used to make POST requests to Bubble and handle errors and retries.
-        https://manual.bubble.io/core-resources/api/data-api#create-a-new-thing for more details
-        :param bubble_type: Name of the table to request
-        :param params: Dictionary with updated attributes. Columns that are not in this dictionary won't be updated
-        :param n_retries: Overrides parent value. Maximum number of retries in case of request failure.
-        If None, use self.n_retries
-        :param base_wait_time: Overrides parent value. Time to wait between retries in seconds.
-        If None, use self.base_wait_time
-        :param exponential_backoff: Overrides parent value. Whether to use exponential backoff for time between retries.
-        If None, use self.exponential_backoff
-        :param verbose_level: Overrides parent value. Verbose level.
-        If None, use self.verbose_level
-        :param timeout: Overrides parent value. Use this parameter to set request timeout in seconds. Set 0 to remove
-        timeout. (!) None will not erase the parent value (!).
-        If None, use self.timeout
-        """
-        if n_retries is None:
-            n_retries = self.n_retries
-        if base_wait_time is None:
-            base_wait_time = self.base_wait_time
-        if exponential_backoff is None:
-            exponential_backoff = self.exponential_backoff
-        if verbose_level is None:
-            verbose_level = self.verbose_level
-        if timeout is None:
-            timeout = self.timeout
-        if timeout == 0:
-            timeout = None
-
-        if exponential_backoff and base_wait_time <= 1:
-            raise ValueError("base_wait_time must be greater than 1 if exponential_backoff is True")
-
-        base_url = f"{self.base_url}/version-{self.bubble_version}" if self.bubble_version != "live" else \
-            self.base_url
-
-        full_url = f"{base_url}/api/{BUBBLE_API_VERSION}/obj/{bubble_type}"
-
-        if verbose_level >= 2:
-            print("POST request URL : ", full_url)
-
-        headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key is not None else None
-
-        if verbose_level >= 2:
-            print("POST request parameters : ", json.dumps(params, indent=4))
-
-        break_while = False
-        retry_index = 0
-        resp = None
-        no_resp_error = "Unknown error"
-
-        while retry_index <= n_retries and not break_while:
-            try:
-                if (retry_index >= 1 or verbose_level >= 2) and verbose_level >= 1:
-                    print(f"POST request - Retry index : {retry_index}/{n_retries}")
-
-                resp = requests.post(
-                    full_url,
-                    headers=headers,
-                    json=params,
-                    timeout=timeout
-                )
-
-                if resp.ok:
-                    if verbose_level >= 2:
-                        print(f"POST request - {resp.status_code} - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    return json_resp.get("id")
-
-                elif resp.status_code in [400, 404]:
-                    break_while = True
-                    if verbose_level >= 1:
-                        print(f"POST request - {resp.status_code} - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "body" in json_resp:
-                        raise ValueError(json.dumps(json_resp.get("body"), indent=4))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                elif resp.status_code == 401:  # Unauthorized
-                    break_while = True
-                    if verbose_level >= 1:
-                        print(f"POST request - 401 - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "translation" in json_resp:
-                        raise ValueError(json_resp.get("translation"))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                else:
-                    if verbose_level >= 1:
-                        print(f"POST request - {resp.status_code} - {str(resp.content)}")
-
-            except AttributeError as ae:  # If resp.json() fails
-                if verbose_level >= 1:
-                    print("POST request - AttributeError", str(ae))
-
-            except ConnectionError as ce:
-                no_resp_error = "ConnectionError"
-                if verbose_level >= 1:
-                    print("POST request - ConnectionError :", str(ce))
-
-            except ReadTimeout as rto:
-                no_resp_error = "ReadTimeout"
-                if verbose_level >= 1:
-                    print("POST request - ReadTimeout :", str(rto))
-
-            finally:
-                if not break_while and retry_index < n_retries:
-                    if exponential_backoff:
-                        sleep_time = 0 if retry_index == 0 else base_wait_time ** retry_index
-                    else:
-                        sleep_time = base_wait_time
-
-                    if verbose_level >= 1 and sleep_time > 0:
-                        print(f"POST request - Wait {sleep_time} second(s)")
-
-                    time.sleep(sleep_time)
-
-                retry_index += 1
-
-        # All requests failed
-        if n_retries == 0:
-            request_failed = "The request failed"
-        else:
-            request_failed = "All requests failed"
-
-        if verbose_level >= 1:
-            if resp is not None:
-                print(f"POST request - {request_failed} - Last response content : {str(resp.content)}")
-            else:
-                print(f"POST request - {request_failed} - Response is None ({no_resp_error})")
-
-        if resp is not None:
-            raise ValueError(f"{request_failed} - Last response content : {str(resp.content)}")
-        else:  # Can happen if all requests lead to a ConnectionError or ReadTimeout
-            raise ValueError(f"{request_failed} - Response is None ({no_resp_error})")
-
-    @validate_arguments
-    def make_wf_request(
-            self,
-            wf_name: str,
-            params: Optional[dict] = None,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None
-    ) -> GetDataResp:
-        """
-        This function is used to make POST requests to Bubble and handle errors and retries.
-        :param wf_name: Name of the workflow to execute
-        :param params: Argument to provide to the workflow
-        :param n_retries: Overrides parent value. Maximum number of retries in case of request failure.
-        If None, use self.n_retries
-        :param base_wait_time: Overrides parent value. Time to wait between retries in seconds.
-        If None, use self.base_wait_time
-        :param exponential_backoff: Overrides parent value. Whether to use exponential backoff for time between retries.
-        If None, use self.exponential_backoff
-        :param verbose_level: Overrides parent value. Verbose level.
-        If None, use self.verbose_level
-        :param timeout: Overrides parent value. Use this parameter to set request timeout in seconds. Set 0 to remove
-        timeout. (!) None will not erase the parent value (!).
-        """
-        if n_retries is None:
-            n_retries = self.n_retries
-        if base_wait_time is None:
-            base_wait_time = self.base_wait_time
-        if exponential_backoff is None:
-            exponential_backoff = self.exponential_backoff
-        if verbose_level is None:
-            verbose_level = self.verbose_level
-        if timeout is None:
-            timeout = self.timeout
-        if timeout == 0:
-            timeout = None
-
-        if exponential_backoff and base_wait_time <= 1:
-            raise ValueError("base_wait_time must be greater than 1 if exponential_backoff is True")
-
-        base_url = f"{self.base_url}/version-{self.bubble_version}" if self.bubble_version != "live" else \
-            self.base_url
-
-        full_url = f"{base_url}/api/{BUBBLE_API_VERSION}/wf/{wf_name}"
-
-        if verbose_level >= 2:
-            print("POST request URL : ", full_url)
-            print("POST request parameters : ", json.dumps(params, indent=4))
-
-        headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key is not None else None
-
-        break_while = False
-        retry_index = 0
-        resp = None
-        no_resp_error = "Unknown error"
-
-        while retry_index <= n_retries and not break_while:
-            try:
-                if (retry_index >= 1 or verbose_level >= 2) and verbose_level >= 1:
-                    print(f"POST request - Retry index : {retry_index}/{n_retries}")
-
-                resp = requests.post(
-                    full_url,
-                    headers=headers,
-                    json=params,
-                    timeout=timeout
-                )
-
-                if resp.ok:
-                    break_while = True
-                    if verbose_level >= 2:
-                        print("POST request - Success - Response content :", str(resp.content))
-
-                    json_resp = resp.json()
-                    return json_resp
-
-                elif resp.status_code in [400, 404]:
-                    break_while = True
-                    if verbose_level >= 1:
-                        print(f"POST request - {resp.status_code} - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "body" in json_resp:
-                        raise ValueError(json.dumps(json_resp.get("body"), indent=4))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                elif resp.status_code == 401:  # Unauthorized
-                    break_while = True
-                    if verbose_level >= 1:
-                        print(f"POST request - 401 - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "translation" in json_resp:
-                        raise ValueError(json_resp.get("translation"))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                else:
-                    if verbose_level >= 1:
-                        print(f"POST request - {resp.status_code} - {str(resp.content)}")
-
-            except AttributeError as ae:  # If resp.json() fails
-                if verbose_level >= 1:
-                    print("POST request - AttributeError", str(ae))
-
-            except ConnectionError as ce:
-                no_resp_error = "ConnectionError"
-                if verbose_level >= 1:
-                    print("POST request - ConnectionError :", str(ce))
-
-            except ReadTimeout as rto:
-                no_resp_error = "ReadTimeout"
-                if verbose_level >= 1:
-                    print("POST request - ReadTimeout :", str(rto))
-
-            finally:
-                if not break_while and retry_index < n_retries:
-                    if exponential_backoff:
-                        sleep_time = 0 if retry_index == 0 else base_wait_time ** retry_index
-                    else:
-                        sleep_time = base_wait_time
-
-                    if verbose_level >= 1 and sleep_time > 0:
-                        print(f"POST request - Wait {sleep_time} second(s)")
-
-                    time.sleep(sleep_time)
-
-                retry_index += 1
-
-        # All requests failed
-        if n_retries == 0:
-            request_failed = "The request failed"
-        else:
-            request_failed = "All requests failed"
-
-        if verbose_level >= 1:
-            if resp is not None:
-                print(f"POST request - {request_failed} - Last response content : {str(resp.content)}")
-            else:
-                print(f"POST request - {request_failed} - Response is None ({no_resp_error})")
-
-        if resp is not None:
-            raise ValueError(f"{request_failed} - Last response content : {str(resp.content)}")
-        else:  # Can happen if all requests lead to a ConnectionError or ReadTimeout
-            raise ValueError(f"{request_failed} - Response is None ({no_resp_error})")
-
-    @validate_arguments
-    def make_delete_request_by__id(
-            self,
-            bubble_type: str,
-            unique_id: str,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None,
-            exclude_remaining: Optional[bool] = False
-    ) -> GetDataResp:
-        
-        if n_retries is None:
-            n_retries = self.n_retries
-        if base_wait_time is None:
-            base_wait_time = self.base_wait_time
-        if exponential_backoff is None:
-            exponential_backoff = self.exponential_backoff
-        if verbose_level is None:
-            verbose_level = self.verbose_level
-        if timeout is None:
-            timeout = self.timeout
-        if timeout == 0:
-            timeout = None   
-
-        if exponential_backoff and base_wait_time <= 1:
-            raise ValueError("base_wait_time must be greater than 1 if exponential_backoff is True")
-
-        base_url = f"{self.base_url}/version-{self.bubble_version}" if self.bubble_version != "live" else \
-            self.base_url
-
-        full_url = f"{base_url}/api/{BUBBLE_API_VERSION}/obj/{bubble_type}/{unique_id}"
-        params = {}
-
-        if verbose_level >= 2:
-            print("DELETE request URL : ", full_url)
-
-        headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key is not None else None
-
-        break_while = False
-        retry_index = 0
-        resp = None
-        no_resp_error = "Unknown error"
-
-        while retry_index <= n_retries and not break_while:
-            try:
-                if (retry_index >= 1 or verbose_level >= 2) and verbose_level >= 1:
-                    print(f"DELET request - Retry index : {retry_index}/{n_retries}")
-
-                resp = requests.delete(
-                    full_url,
-                    headers=headers,
-                    params=params,
-                    timeout=timeout
-                )
-                print(resp.status_code, resp.ok)
-
-                if resp.ok:
-                    break_while = True
-                    return GetDataResp(
-                        results=[],
-                        remaining=0,
-                        count=0
-                    )
-
-                elif resp.status_code in [400, 404]:
-                    break_while = True
-                    if resp.status_code == 404 and unique_id is not None:
-                        return GetDataResp(
-                            results=[],
-                            remaining=0,
-                            count=0
-                        )
-
-                    if verbose_level >= 1:
-                        print(f"DELETE request - {resp.status_code} - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "body" in json_resp:
-                        raise ValueError(json.dumps(json_resp.get("body"), indent=4))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                elif resp.status_code == 401:  # Unauthorized
-                    break_while = True
-                    if verbose_level >= 1:
-                        print(f"DELETE request - 401 - {str(resp.content)}")
-
-                    json_resp = resp.json()
-                    if "translation" in json_resp:
-                        raise ValueError(json_resp.get("translation"))
-                    else:
-                        raise ValueError(f"Failed with status code {resp.status_code}")
-
-                else:
-                    if verbose_level >= 1:
-                        print(f"DELETE request - {resp.status_code} - {str(resp.content)}")
-
-            except AttributeError as ae:  # If resp.json() fails
-                if verbose_level >= 1:
-                    print("DELETE request - AttributeError", str(ae))
-
-            except ConnectionError as ce:
-                no_resp_error = "ConnectionError"
-                if verbose_level >= 1:
-                    print("DELETE request - ConnectionError :", str(ce))
-
-            except ReadTimeout as rto:
-                no_resp_error = "ReadTimeout"
-                if verbose_level >= 1:
-                    print("DELETE request - ReadTimeout :", str(rto))
-
-            finally:
-                if not break_while and retry_index < n_retries:
-                    if exponential_backoff:
-                        sleep_time = 0 if retry_index == 0 else base_wait_time ** retry_index
-                    else:
-                        sleep_time = base_wait_time
-
-                    if verbose_level >= 1 and sleep_time > 0:
-                        print(f"DELETE request - Wait {sleep_time} second(s)")
-
-                    time.sleep(sleep_time)
-
-                retry_index += 1
-
-        # All requests failed
-        if n_retries == 0:
-            request_failed = "The request failed"
-        else:
-            request_failed = "All requests failed"
-
-        if verbose_level >= 1:
-            if resp is not None:
-                print(f"DELETE request - {request_failed} - Last response content : {str(resp.content)}")
-            else:
-                print(f"DELETE request - {request_failed} - Response is None ({no_resp_error})")
-
-        if resp is not None:
-            raise ValueError(f"{request_failed} - Last response content : {str(resp.content)}")
-        else:  # Can happen if all requests lead to a ConnectionError or ReadTimeout
-            raise ValueError(f"{request_failed} - Response is None ({no_resp_error})")
-        
-    def delete_with_constraints(
-            self,
-            bubble_type: str,
-            constraints: Optional[List[Constraint]] = None,
-            n_retries: Optional[Annotated[int, Field(ge=MIN_RETRIES, le=MAX_RETRIES)]] = None,
-            base_wait_time: Optional[Annotated[int, Field(ge=MIN_WAIT_TIME)]] = None,
-            exponential_backoff: Optional[bool] = None,
-            verbose_level: Optional[Annotated[int, Field(ge=MIN_VERB_LEV, le=MAX_VERB_LEV)]] = None,
-            timeout: Optional[Annotated[int, Field(ge=0)]] = None,
-            exclude_remaining: Optional[bool] = False
-    ):
-        
-        if n_retries is None:
-            n_retries = self.n_retries
-        if base_wait_time is None:
-            base_wait_time = self.base_wait_time
-        if exponential_backoff is None:
-            exponential_backoff = self.exponential_backoff
-        if verbose_level is None:
-            verbose_level = self.verbose_level
-        if timeout is None:
-            timeout = self.timeout
-        if timeout == 0:
-            timeout = None 
-            
-        make_request = lambda: self.make_request(
-            bubble_type=bubble_type,
-            constraints=constraints,
-            n_retries=n_retries,
-            base_wait_time=base_wait_time,
-            exponential_backoff=exponential_backoff,
-            verbose_level=verbose_level,
-            timeout=timeout,
-            exclude_remaining=exclude_remaining
-        )
-        
-        while objs_to_delete := make_request().results:
-            for obj in objs_to_delete:
-                self.make_delete_request_by__id(
-                    bubble_type=bubble_type,
-                    unique_id=obj["_id"],
-                    n_retries=n_retries,
-                    base_wait_time=base_wait_time,
-                    exponential_backoff=exponential_backoff,
-                    verbose_level=verbose_level,
-                    timeout=timeout,
-                    exclude_remaining=exclude_remaining
-                )


Test patch:
diff --git a/requirements.test.txt b/requirements.test.txt
new file mode 100644
index 0000000..57679e4
--- /dev/null
+++ b/requirements.test.txt
@@ -0,0 +1,4 @@
+pytest
+pytest-cov==4.0.0
+pytest-spec==3.2.0
+requests_mock
diff --git a/src/bubble_api/__init__.py b/tests/__init__.py
similarity index 100%
rename from src/bubble_api/__init__.py
rename to tests/__init__.py
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration/methods_test.py b/tests/integration/methods_test.py
new file mode 100644
index 0000000..847f3eb
--- /dev/null
+++ b/tests/integration/methods_test.py
@@ -0,0 +1,202 @@
+import os
+
+import pytest
+import requests
+
+from bubble_api import BubbleWrapper, Field
+
+
+@pytest.fixture(scope="session")
+def bubble_wrapper():
+    return BubbleWrapper(
+        base_url="https://cuure.com",
+        api_token=os.environ["BUBBLE_API_KEY"],
+        bubble_version="test",
+    )
+
+
+def clean_test_data(bubble_wrapper):
+    bubble_wrapper.delete(
+        "appfeedback", constraints=Field("app_version").text_contains("test")
+    )
+
+
+@pytest.fixture(scope="session", autouse=True)
+def cleaning_feedback_data(bubble_wrapper):
+    clean_test_data(bubble_wrapper)
+    yield
+    clean_test_data(bubble_wrapper)
+
+
+@pytest.mark.integration
+def test__create_object(bubble_wrapper):
+    object_id = bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 5, "app_version": "2.2.0 test"},
+    )
+
+    assert isinstance(object_id, str)
+
+
+@pytest.mark.integration
+def test__get_object_by_id(bubble_wrapper):
+    object_id = bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 5, "app_version": "2.2.1 test"},
+    )
+
+    feedback = bubble_wrapper.get_by_id(
+        "appfeedback",
+        object_id,
+    )
+
+    assert isinstance(feedback, dict)
+    assert feedback["app_version"] == "2.2.1 test"
+
+
+@pytest.mark.integration
+def test__delete_object_by_id(bubble_wrapper):
+    object_id = bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 5, "app_version": "2.2.2 test"},
+    )
+
+    obj = bubble_wrapper.get_by_id(
+        "appfeedback",
+        object_id,
+    )
+
+    assert obj["_id"] == object_id
+
+    bubble_wrapper.delete_by_id(
+        "appfeedback",
+        object_id,
+    )
+
+    with pytest.raises(requests.exceptions.HTTPError) as exc_info:
+        bubble_wrapper.get_by_id(
+            "appfeedback",
+            object_id,
+        )
+
+    assert "404 Client Error: Not Found" in str(exc_info.value)
+
+
+@pytest.mark.integration
+def test__update_object(bubble_wrapper):
+    object_id = bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 5, "app_version": "2.2.3 test"},
+    )
+
+    bubble_wrapper.update_object(
+        "appfeedback",
+        object_id,
+        {"rating": 4, "app_version": "2.2.3 test"},
+    )
+
+    feedback = bubble_wrapper.get_by_id(
+        "appfeedback",
+        object_id,
+    )
+
+    assert isinstance(feedback, dict)
+    assert feedback["rating"] == 4
+    assert feedback["app_version"] == "2.2.3 test"
+
+
+@pytest.mark.integration
+def test__replace_object(bubble_wrapper):
+    object_id = bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 5, "app_version": "2.2.4 test"},
+    )
+
+    bubble_wrapper.replace_object(
+        "appfeedback",
+        object_id,
+        {"app_version": "2.3.4 test"},
+    )
+
+    feedback = bubble_wrapper.get_by_id(
+        "appfeedback",
+        object_id,
+    )
+
+    assert isinstance(feedback, dict)
+    assert "rating" not in feedback
+    assert feedback["app_version"] == "2.3.4 test"
+    assert feedback["_id"] == object_id
+
+
+@pytest.mark.integration
+def test__count_objects_with_constraints(bubble_wrapper):
+    bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 5, "app_version": "2.2.5 test"},
+    )
+
+    bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 4, "app_version": "2.2.5 test"},
+    )
+
+    bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 3, "app_version": "2.2.5 test"},
+    )
+
+    res = bubble_wrapper.count_objects(
+        "appfeedback",
+        Field("app_version") == "2.2.5 test",
+    )
+
+    assert res == 3
+
+
+@pytest.mark.integration
+def test__get_objects_with_constraints(bubble_wrapper):
+    id_12 = bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 12, "app_version": "2.2.6 test"},
+    )
+
+    id_11 = bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 11, "app_version": "2.2.6 test"},
+    )
+
+    bubble_wrapper.create(
+        "appfeedback",
+        {"rating": 10, "app_version": "2.2.6 test"},
+    )
+
+    res = bubble_wrapper.get_objects(
+        "appfeedback",
+        [Field("rating") > 10, Field("app_version").text_contains(" test")],
+    )
+
+    assert {r["_id"] for r in res} == {id_11, id_12}
+
+
+@pytest.mark.integration
+def test__create_bulk(bubble_wrapper):
+    bulk_res = bubble_wrapper.create_bulk(
+        "appfeedback",
+        [
+            {"rating": 9, "app_version": "2.2.7 test"},
+            {"rating": 8, "app_version": "2.2.7 test"},
+            {"rating": 7, "app_version": "2.2.7 test"},
+        ],
+    )
+
+    assert all(r["status"] == "success" for r in bulk_res)
+
+    res = bubble_wrapper.get_objects(
+        "appfeedback",
+        Field("app_version") == "2.2.7 test",
+    )
+
+    assert len(res) == 3
+    assert {r["_id"] for r in res} == {r["id"] for r in bulk_res}
+    assert {r["rating"] for r in res} == {7, 8, 9}
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/unit/constraint_test.py b/tests/unit/constraint_test.py
new file mode 100644
index 0000000..dd74f37
--- /dev/null
+++ b/tests/unit/constraint_test.py
@@ -0,0 +1,59 @@
+from datetime import date, datetime
+
+from bubble_api.constraint import Constraint
+
+
+def test__constraints_to_dict__with_value():
+    constraint = Constraint("key", "operator", "value")
+    res = constraint.to_dict()
+
+    assert res == {
+        "key": "key",
+        "constraint_type": "operator",
+        "value": "value",
+    }
+
+
+def test__constraints_to_dict__without_value():
+    constraint = Constraint("key", "operator")
+    res = constraint.to_dict()
+
+    assert res == {"key": "key", "constraint_type": "operator"}
+
+
+def test__constraints_format_none():
+    constraint = Constraint("key", "operator", None)
+
+    assert constraint.value is None
+
+
+def test__constraints_format_string():
+    constraint = Constraint("key", "operator", "value")
+
+    assert constraint.value == "value"
+
+
+def test__constraints_format_int():
+    constraint = Constraint("key", "operator", 8)
+
+    assert constraint.value == "8"
+
+
+def test__constraints_format_float():
+    constraint = Constraint("key", "operator", 8.6)
+
+    assert constraint.value == "8.6"
+
+
+def test__constraints_format_date():
+    constraint = Constraint("key", "operator", date(2023, 5, 19))
+
+    assert constraint.value == "2023-05-19"
+
+
+def test__constraints_format_datetime():
+    constraint = Constraint(
+        "key", "operator", datetime(2023, 5, 19, 22, 47, 46, 477590)
+    )
+
+    assert constraint.value == "2023-05-19T22:47:46.477590Z"
diff --git a/tests/unit/field_test.py b/tests/unit/field_test.py
new file mode 100644
index 0000000..9b43a3a
--- /dev/null
+++ b/tests/unit/field_test.py
@@ -0,0 +1,177 @@
+from datetime import datetime
+
+from bubble_api.constraint import Constraint
+from bubble_api.field import Field
+
+
+def test__field_name_format():
+    field = Field("Rental Unit")
+
+    assert field.field_name == "rentalunit"
+
+
+def test__field_to_constraint__equals():
+    res = Field("key") == "value"
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "equals",
+        "value": "value",
+    }
+
+
+def test__field_to_constraint__not_equals():
+    res = Field("key") != "value"
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "not equal",
+        "value": "value",
+    }
+
+
+def test__field_to_constraint__is_empty():
+    res = Field("key").is_empty()
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "is_empty",
+    }
+
+
+def test__field_to_constraint__is_not_empty():
+    res = Field("key").is_not_empty()
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "is_not_empty",
+    }
+
+
+def test__field_to_constraint__text_contains():
+    res = Field("key").text_contains("sub_text")
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "text contains",
+        "value": "sub_text",
+    }
+
+
+def test__field_to_constraint__not_text_contains():
+    res = Field("key").not_text_contains("sub_text")
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "not text contains",
+        "value": "sub_text",
+    }
+
+
+def test__field_to_constraint__greater_than():
+    res = Field("key") > "value"
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "greater than",
+        "value": "value",
+    }
+
+
+def test__field_to_constraint__greater_than_reversed():
+    res = "value" < Field("key")
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "greater than",
+        "value": "value",
+    }
+
+
+def test__field_to_constraint__less_than():
+    res = Field("key") < "value"
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "less than",
+        "value": "value",
+    }
+
+
+def test__field_to_constraint__less_than_reversed():
+    res = "value" > Field("key")
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "less than",
+        "value": "value",
+    }
+
+
+def test__field_to_constraint__is_in():
+    res = Field("key").is_in(
+        [datetime(2023, 7, 20).date(), datetime(2023, 7, 21).date()]
+    )
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "in",
+        "value": ["2023-07-20", "2023-07-21"],
+    }
+
+
+def test__field_to_constraint__is_not_in():
+    res = Field("key").is_not_in(
+        [datetime(2023, 7, 20).date(), datetime(2023, 7, 21).date()]
+    )
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "not in",
+        "value": ["2023-07-20", "2023-07-21"],
+    }
+
+
+def test__field_to_constraint__contains():
+    res = Field("key").contains("value")
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "contains",
+        "value": "value",
+    }
+
+
+def test__field_to_constraint__not_contains():
+    res = Field("key").not_contains("value")
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "not contains",
+        "value": "value",
+    }
+
+
+def test__field_to_constraint__geographic_search():
+    res = Field("key").geographic_search("value")
+
+    assert isinstance(res, Constraint)
+    assert res.to_dict() == {
+        "key": "key",
+        "constraint_type": "geographic_search",
+        "value": "value",
+    }
diff --git a/tests/unit/wrapper_test.py b/tests/unit/wrapper_test.py
new file mode 100644
index 0000000..aeec80a
--- /dev/null
+++ b/tests/unit/wrapper_test.py
@@ -0,0 +1,344 @@
+import json
+from time import perf_counter
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+import requests
+import requests_mock
+
+from bubble_api import BubbleWrapper, Field
+
+BASE_URL_EXAMPLE = "https://example.com"
+OBJ_API_URL_EXAMPLE = f"{BASE_URL_EXAMPLE}/version-test/api/1.1/obj"
+
+DELTA_TIME_ERROR = 0.01
+
+
+def extract_url_params(url):
+    parse_result = urlparse(url)
+    return parse_qs(str(parse_result.query))
+
+
+@pytest.fixture(scope="session")
+def bubble_wrapper():
+    return BubbleWrapper(
+        base_url=BASE_URL_EXAMPLE,
+        api_token="API_KEY",
+        bubble_version="test",
+    )
+
+
+@pytest.fixture
+def mocker():
+    with requests_mock.Mocker() as mocker:
+        yield mocker
+
+
+def test__bubble_get_by_id(bubble_wrapper, mocker):
+    example_table = "example_table"
+    example_id = "example_id"
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}/{example_id}"
+    mocker.get(expected_url, json={"response": {"data": "test"}})
+
+    bubble_wrapper.get_by_id(example_table, example_id)
+
+    assert mocker.called_once
+    assert mocker.last_request.method == "GET"
+    assert mocker.last_request.url == expected_url
+    assert mocker.last_request.body is None
+
+
+def test__bubble_create_object(bubble_wrapper, mocker):
+    example_table = "example_table"
+    example_id = "example_id"
+    example_object = {"field_1": 0, "field_2": 4.5}
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}"
+
+    mocker.post(expected_url, json={"id": example_id})
+
+    object_id = bubble_wrapper.create(
+        example_table,
+        example_object,
+    )
+
+    assert object_id == example_id
+    assert mocker.called_once
+    assert mocker.last_request.method == "POST"
+    assert mocker.last_request.url == expected_url
+    assert mocker.last_request.json() == example_object
+
+
+def test__delete_object_by_id(bubble_wrapper, mocker):
+    example_table = "example_table"
+    example_id = "example_id"
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}/{example_id}"
+
+    mocker.delete(expected_url)
+
+    bubble_wrapper.delete_by_id(
+        example_table,
+        example_id,
+    )
+
+    assert mocker.called_once
+    assert mocker.last_request.method == "DELETE"
+    assert mocker.last_request.url == expected_url
+    assert mocker.last_request.body is None
+
+
+def test__bubble_update_object(bubble_wrapper, mocker):
+    example_table = "example_table"
+    example_id = "example_id"
+    example_fields = {"field_1": 0, "field_2": 4.5}
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}/{example_id}"
+
+    mocker.patch(expected_url)
+
+    bubble_wrapper.update_object(
+        example_table,
+        example_id,
+        example_fields,
+    )
+
+    assert mocker.called_once
+    assert mocker.last_request.method == "PATCH"
+    assert mocker.last_request.url == expected_url
+    assert mocker.last_request.json() == example_fields
+
+
+def test__bubble_replace_object(bubble_wrapper, mocker):
+    example_table = "example_table"
+    example_id = "example_id"
+    example_fields = {"field_1": 0, "field_2": 4.5}
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}/{example_id}"
+
+    mocker.put(expected_url)
+
+    bubble_wrapper.replace_object(
+        example_table,
+        example_id,
+        example_fields,
+    )
+
+    assert mocker.called_once
+    assert mocker.last_request.method == "PUT"
+    assert mocker.last_request.url == expected_url
+    assert mocker.last_request.json() == example_fields
+
+
+def test__bubble_count_objects(bubble_wrapper, mocker):
+    example_table = "example_table"
+    constraints = [
+        Field("Name") == "Bob",
+        Field("Age") > 18,
+    ]
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}"
+
+    mocker.get(
+        expected_url,
+        json={
+            "response": {
+                "data": {},
+                "count": 1,
+                "remaining": 149,
+            }
+        },
+    )
+
+    count = bubble_wrapper.count_objects(example_table, constraints)
+
+    assert count == 150
+    assert mocker.called_once
+    assert mocker.last_request.method == "GET"
+    assert mocker.last_request.url.startswith(expected_url)
+
+    last_request_params = extract_url_params(mocker.last_request.url)
+    assert [con.to_dict() for con in constraints] == json.loads(
+        last_request_params["constraints"][0]
+    )
+    assert json.loads(last_request_params["limit"][0]) == 1
+    assert json.loads(last_request_params["cursor"][0]) == 0
+    assert mocker.last_request.body is None
+
+
+def test__bubble_retry_server_error(bubble_wrapper, mocker):
+    example_table = "example_table"
+    example_id = "example_id"
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}/{example_id}"
+    mocker.get(
+        expected_url,
+        [
+            {"text": "Server Error", "status_code": 500},
+            {"json": {"response": {"data": "test"}, "status_code": 200}},
+        ],
+    )
+
+    bubble_wrapper.get_by_id(example_table, example_id)
+
+    assert mocker.call_count == 2
+    assert mocker.last_request.method == "GET"
+    assert mocker.last_request.url == expected_url
+    assert mocker.last_request.body is None
+
+
+def test__bubble_retry_correct_number_of_time(bubble_wrapper, mocker):
+    example_table = "example_table"
+    example_id = "example_id"
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}/{example_id}"
+    mocker.get(
+        expected_url,
+        text="Server Error",
+        status_code=500,
+    )
+
+    with pytest.raises(requests.exceptions.HTTPError) as exc_info:
+        bubble_wrapper.get_by_id(example_table, example_id, nb_retries=3)
+
+    assert "Server Error" in str(exc_info.value)
+
+    assert mocker.call_count == 4
+    assert mocker.last_request.method == "GET"
+    assert mocker.last_request.url == expected_url
+    assert mocker.last_request.body is None
+
+
+def test__bubble_wait_correct_amount_of_time(bubble_wrapper, mocker):
+    sleep_time = 0.5
+    nb_retries = 5
+
+    example_table = "example_table"
+    example_id = "example_id"
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}/{example_id}"
+    mocker.get(
+        expected_url,
+        text="Server Error",
+        status_code=500,
+    )
+
+    start = perf_counter()
+    with pytest.raises(requests.exceptions.HTTPError) as exc_info:
+        bubble_wrapper.get_by_id(
+            example_table, example_id, nb_retries=nb_retries, sleep_time=sleep_time
+        )
+    total_time = perf_counter() - start
+
+    assert "Server Error" in str(exc_info.value)
+
+    assert mocker.call_count == nb_retries + 1
+    assert total_time > sleep_time * nb_retries - DELTA_TIME_ERROR
+
+
+def test__bubble_wait_correct_amount_of_time_with_exponential_backoff(
+    bubble_wrapper, mocker
+):
+    sleep_time = 0.1
+    nb_retries = 5
+    expected_wait = sum(sleep_time * 2**k for k in range(nb_retries))
+
+    example_table = "example_table"
+    example_id = "example_id"
+    expected_url = f"{OBJ_API_URL_EXAMPLE}/{example_table}/{example_id}"
+    mocker.get(
+        expected_url,
+        text="Server Error",
+        status_code=500,
+    )
+
+    start = perf_counter()
+    with pytest.raises(requests.exceptions.HTTPError) as exc_info:
+        bubble_wrapper.get_by_id(
+            example_table,
+            example_id,
+            nb_retries=nb_retries,
+            sleep_time=sleep_time,
+            exponential_backoff=True,
+        )
+    total_time = perf_counter() - start
+
+    assert "Server Error" in str(exc_info.value)
+
+    assert mocker.call_count == nb_retries + 1
+    assert total_time > expected_wait - DELTA_TIME_ERROR
+
+
+@patch("bubble_api.BubbleWrapper.get_by_id")
+def test__use_get_by_id__when__id_is_given(get_by_id, bubble_wrapper):
+    bubble_type = "test_type"
+    _id = "test_id"
+
+    _ = bubble_wrapper.get(bubble_type, bubble_id=_id)
+
+    assert get_by_id.called_called_with(bubble_type, _id)
+
+
+@patch("bubble_api.BubbleWrapper.get_objects")
+def test__use_get_objects__when__constraints_are_given(get_objects, bubble_wrapper):
+    bubble_type = "test_type"
+    constraints = [
+        Field("Name") == "Bob",
+        Field("Age") > 18,
+    ]
+
+    _ = bubble_wrapper.get(bubble_type, constriants=constraints)
+
+    assert get_objects.called_called_with(bubble_type, constraints)
+
+
+@patch("bubble_api.BubbleWrapper.create_object")
+def test__use_create_object__when__one_object_is_given(create_object, bubble_wrapper):
+    bubble_type = "test_type"
+    fields = {"field_a": "abc"}
+
+    _ = bubble_wrapper.create(bubble_type, fields)
+
+    assert create_object.called_called_with(bubble_type, fields)
+
+
+@patch("bubble_api.BubbleWrapper.create_bulk")
+def test__use_create_bulk__when__several_objects_are_given(create_bulk, bubble_wrapper):
+    bubble_type = "test_type"
+    fields = [{"field_a": "abc"}, {"field_a": "def"}]
+
+    _ = bubble_wrapper.create(bubble_type, fields)
+
+    assert create_bulk.called_called_with(bubble_type, fields)
+
+
+@patch("bubble_api.BubbleWrapper.delete_by_id")
+def test__use_delete_by_id__when__one_id_is_given(delete_by_id, bubble_wrapper):
+    bubble_type = "test_type"
+    _id = "test_id"
+
+    bubble_wrapper.delete(bubble_type, _id)
+
+    assert delete_by_id.called_called_with(bubble_type, _id)
+
+
+@patch("bubble_api.BubbleWrapper.delete_by_ids")
+def test__use_delete_by_ids__when__several_ids_are_given(delete_by_ids, bubble_wrapper):
+    bubble_type = "test_type"
+    _ids = ["test_id_1", "test_id_2", "test_id_3"]
+
+    bubble_wrapper.delete(bubble_type, _ids)
+
+    assert delete_by_ids.called_called_with(bubble_type, _ids)
+
+
+@patch("bubble_api.BubbleWrapper.delete_objects")
+def test__use_delete_objects__when__constraints_are_given(
+    delete_objects, bubble_wrapper
+):
+    bubble_type = "test_type"
+    constraints = [Field("Name") == "Bob", Field("Age") > 18]
+
+    bubble_wrapper.delete(bubble_type, constraints=constraints)
+
+    assert delete_objects.called_called_with(bubble_type, constraints)
+
+
+def test__raise_warning__when__only_type_is_given(bubble_wrapper):
+    bubble_type = "test_type"
+
+    with pytest.raises(Warning):
+        bubble_wrapper.delete(bubble_type)