From d6a7a87dfe554ba12b83738dfc93ac1c7e7e4d83 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Wed, 1 Apr 2026 23:32:21 +0300 Subject: [PATCH 01/20] feat(object): add base class and increment method --- kether_labs_formation_complete.pdf | Bin 0 -> 43201 bytes src/parse_sdk/__init__.py | 2 + src/parse_sdk/object.py | 107 +++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 kether_labs_formation_complete.pdf create mode 100644 src/parse_sdk/object.py diff --git a/kether_labs_formation_complete.pdf b/kether_labs_formation_complete.pdf new file mode 100644 index 0000000000000000000000000000000000000000..60c57f3e888dff36fbf123d234b7910f30bfedf9 GIT binary patch literal 43201 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 None: + self.class_name = class_name + self.object_id = object_id + + # Données internes de l'objet + self._data: dict[str, Any] = {} + + # Liste des clés modifiées localement qui doivent être envoyées au serveur + self._dirty_keys: set[str] = set() + + def get(self, key: str, default: Any = None) -> Any: + """Récupère la valeur d'un champ. + + Args: + key: Le nom du champ. + default: Valeur par défaut si le champ n'existe pas. + + Returns: + La valeur du champ (décodée si type spécial Parse). + """ + return self._data.get(key, default) + + def set(self, key: str, value: Any) -> ParseObject: + """Définit la valeur d'un champ. + + Args: + key: Le nom du champ. + value: La valeur à enregistrer. + + Returns: + L'instance actuelle (pour le chaînage). + """ + # On encode la valeur (ex: datetime -> ParseDate) + encoded_value = encode_parse_value(value) + + self._data[key] = value + self._dirty_keys.add(key) + + return self + + async def save(self, use_master_key: bool = False, session_token: str | None = None) -> None: + """Sauvegarde l'objet sur Parse Server. + + Cette méthode envoie uniquement les champs modifiés (dirty). + """ + if not self._dirty_keys: + return + + # Construction du payload (données à envoyer) + payload = {key: encode_parse_value(self._data[key]) for key in self._dirty_keys} + + client = get_client() + + if self.object_id: + # Mise à jour (PUT) + path = f"/classes/{self.class_name}/{self.object_id}" + response = await client.put(path, json=payload, use_master_key=use_master_key, session_token=session_token) + else: + # Création (POST) + path = f"/classes/{self.class_name}" + response = await client.post(path, json=payload, use_master_key=use_master_key, session_token=session_token) + + # On récupère l'objectId généré par le serveur + if "objectId" in response: + self.object_id = response["objectId"] + + # Une fois sauvegardé, l'objet n'est plus "dirty" + self._dirty_keys.clear() + + # --- MÉTHODES À IMPLÉMENTER POUR L'ISSUE #17 --- + + def increment(self, key: str, amount: int = 1) -> ParseObject: + """Incrémente un champ numérique. + + Args: + key: Le nom du champ. + amount: La valeur à ajouter (défaut: 1). + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import Increment + + return self.set(key, Increment(amount)) From 5ea4a49dba35bea960ffcca723ea52ca3676e17b Mon Sep 17 00:00:00 2001 From: 0yenga Date: Wed, 1 Apr 2026 23:35:08 +0300 Subject: [PATCH 02/20] feat(object): add base class and increment method --- src/parse_sdk/__init__.py | 2 +- src/parse_sdk/object.py | 36 ++++++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/parse_sdk/__init__.py b/src/parse_sdk/__init__.py index 84352ce..8ebbed1 100644 --- a/src/parse_sdk/__init__.py +++ b/src/parse_sdk/__init__.py @@ -38,7 +38,6 @@ decode_parse_value, encode_parse_value, ) -from .object import ParseObject from .client import ParseClient, get_client # Exceptions — toujours disponibles @@ -66,6 +65,7 @@ ParseTimeoutError, ParseUsernameTakenError, ) +from .object import ParseObject # NOTE : ParseClient, ParseObject, ParseQuery, ParseUser, ParseFile, etc. # seront ajoutés ici au fur et à mesure de leur implémentation. diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py index 473dc73..78a4e32 100644 --- a/src/parse_sdk/object.py +++ b/src/parse_sdk/object.py @@ -1,7 +1,7 @@ """ Module ParseObject — CRUD et gestion des données. -Ce module fournit la classe ParseObject qui représente un objet stocké +Ce module fournit la classe ParseObject qui représente un objet stocké sur Parse Server. Elle permet de lire, modifier et sauvegarder des données. """ @@ -9,7 +9,7 @@ from typing import Any -from ._types import decode_parse_value, encode_parse_value +from ._types import encode_parse_value from .client import get_client @@ -24,10 +24,10 @@ class ParseObject: def __init__(self, class_name: str, object_id: str | None = None) -> None: self.class_name = class_name self.object_id = object_id - + # Données internes de l'objet self._data: dict[str, Any] = {} - + # Liste des clés modifiées localement qui doivent être envoyées au serveur self._dirty_keys: set[str] = set() @@ -55,13 +55,15 @@ def set(self, key: str, value: Any) -> ParseObject: """ # On encode la valeur (ex: datetime -> ParseDate) encoded_value = encode_parse_value(value) - + self._data[key] = value self._dirty_keys.add(key) - + return self - async def save(self, use_master_key: bool = False, session_token: str | None = None) -> None: + async def save( + self, use_master_key: bool = False, session_token: str | None = None + ) -> None: """Sauvegarde l'objet sur Parse Server. Cette méthode envoie uniquement les champs modifiés (dirty). @@ -71,18 +73,28 @@ async def save(self, use_master_key: bool = False, session_token: str | None = N # Construction du payload (données à envoyer) payload = {key: encode_parse_value(self._data[key]) for key in self._dirty_keys} - + client = get_client() - + if self.object_id: # Mise à jour (PUT) path = f"/classes/{self.class_name}/{self.object_id}" - response = await client.put(path, json=payload, use_master_key=use_master_key, session_token=session_token) + response = await client.put( + path, + json=payload, + use_master_key=use_master_key, + session_token=session_token, + ) else: # Création (POST) path = f"/classes/{self.class_name}" - response = await client.post(path, json=payload, use_master_key=use_master_key, session_token=session_token) - + response = await client.post( + path, + json=payload, + use_master_key=use_master_key, + session_token=session_token, + ) + # On récupère l'objectId généré par le serveur if "objectId" in response: self.object_id = response["objectId"] From 35b0bf0422498446c850cef511bbab788546882d Mon Sep 17 00:00:00 2001 From: 0yenga Date: Wed, 1 Apr 2026 23:43:21 +0300 Subject: [PATCH 03/20] feat(object): add add_to_array method --- src/parse_sdk/object.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py index 78a4e32..a3d1877 100644 --- a/src/parse_sdk/object.py +++ b/src/parse_sdk/object.py @@ -117,3 +117,17 @@ def increment(self, key: str, amount: int = 1) -> ParseObject: from ._types import Increment return self.set(key, Increment(amount)) + + def add_to_array(self, key: str, values: list[Any]) -> ParseObject: + """Ajoute des éléments à un champ tableau. + + Args: + key: Le nom du champ. + values: Liste des valeurs à ajouter. + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import AddToArray + + return self.set(key, AddToArray(values)) From 64a319934f4f24bf013ccf3660110adfe9876a42 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 00:03:28 +0300 Subject: [PATCH 04/20] feat(object): add add_unique method --- src/parse_sdk/object.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py index a3d1877..21180b0 100644 --- a/src/parse_sdk/object.py +++ b/src/parse_sdk/object.py @@ -131,3 +131,17 @@ def add_to_array(self, key: str, values: list[Any]) -> ParseObject: from ._types import AddToArray return self.set(key, AddToArray(values)) + + def add_unique(self, key: str, values: list[Any]) -> ParseObject: + """Ajoute des éléments à un tableau seulement s'ils sont absents. + + Args: + key: Le nom du champ. + values: Liste des valeurs à ajouter. + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import AddUniqueToArray + + return self.set(key, AddUniqueToArray(values)) From b191e494121057f7d8e7bf7a438d6049ad4c5e36 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 00:04:35 +0300 Subject: [PATCH 05/20] feat(object): add remove_from_array method --- src/parse_sdk/object.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py index 21180b0..2353aec 100644 --- a/src/parse_sdk/object.py +++ b/src/parse_sdk/object.py @@ -145,3 +145,17 @@ def add_unique(self, key: str, values: list[Any]) -> ParseObject: from ._types import AddUniqueToArray return self.set(key, AddUniqueToArray(values)) + + def remove_from_array(self, key: str, values: list[Any]) -> ParseObject: + """Supprime des éléments d'un champ tableau. + + Args: + key: Le nom du champ. + values: Liste des éléments à supprimer. + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import RemoveFromArray + + return self.set(key, RemoveFromArray(values)) From 42ec42d4beeb98e077e0f016b119d85604cb9a2c Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 00:26:18 +0300 Subject: [PATCH 06/20] feat(object): add unset method --- src/parse_sdk/object.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py index 2353aec..f3f0be2 100644 --- a/src/parse_sdk/object.py +++ b/src/parse_sdk/object.py @@ -159,3 +159,16 @@ def remove_from_array(self, key: str, values: list[Any]) -> ParseObject: from ._types import RemoveFromArray return self.set(key, RemoveFromArray(values)) + + def unset(self, key: str) -> ParseObject: + """Supprime un champ de l'objet. + + Args: + key: Le nom du champ à supprimer. + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import DeleteField + + return self.set(key, DeleteField()) From 6fb8328fe8ea6faab75f1aaea808063635bbccb0 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 00:30:43 +0300 Subject: [PATCH 07/20] test(object): add unit tests for ParseObject atomic operations --- tests/unit/test_object.py | 96 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/unit/test_object.py diff --git a/tests/unit/test_object.py b/tests/unit/test_object.py new file mode 100644 index 0000000..a658cb7 --- /dev/null +++ b/tests/unit/test_object.py @@ -0,0 +1,96 @@ +import pytest +from unittest.mock import MagicMock, patch + +from parse_sdk import ParseObject, GeoPoint, Pointer +from parse_sdk._types import Increment, AddToArray, AddUniqueToArray, RemoveFromArray, DeleteField + +def test_object_initialization(): + obj = ParseObject("GameScore") + assert obj.class_name == "GameScore" + assert obj.object_id is None + + obj_with_id = ParseObject("GameScore", "abc123") + assert obj_with_id.object_id == "abc123" + +def test_object_set_get(): + obj = ParseObject("GameScore") + obj.set("playerName", "Alice") + assert obj.get("playerName") == "Alice" + assert obj.get("nonExistent", "default") == "default" + +def test_object_increment(): + obj = ParseObject("GameScore") + result = obj.increment("score", 5) + + # Vérifie le chaînage + assert result == obj + # Vérifie que la valeur stockée est un objet Increment + val = obj.get("score") + assert isinstance(val, Increment) + assert val.amount == 5 + # Vérifie que le champ est marqué comme modifié + assert "score" in obj._dirty_keys + +def test_object_add_to_array(): + obj = ParseObject("GameScore") + obj.add_to_array("tags", ["python", "sdk"]) + + val = obj.get("tags") + assert isinstance(val, AddToArray) + assert val.objects == ["python", "sdk"] + assert "tags" in obj._dirty_keys + +def test_object_add_unique(): + obj = ParseObject("GameScore") + obj.add_unique("skills", ["async"]) + + val = obj.get("skills") + assert isinstance(val, AddUniqueToArray) + assert val.objects == ["async"] + +def test_object_remove_from_array(): + obj = ParseObject("GameScore") + obj.remove_from_array("tags", ["old"]) + + val = obj.get("tags") + assert isinstance(val, RemoveFromArray) + assert val.objects == ["old"] + +def test_object_unset(): + obj = ParseObject("GameScore") + obj.unset("temporaryField") + + val = obj.get("temporaryField") + assert isinstance(val, DeleteField) + +def test_object_chaining(): + obj = ParseObject("GameScore") + result = ( + obj.increment("score") + .add_to_array("tags", ["test"]) + .unset("old") + ) + assert result == obj + +@pytest.mark.asyncio +async def test_object_save_dirty_tracking(): + # On mocke get_client pour ne pas faire de vraie requête réseau + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + + # On définit une fonction asynchrone pour simuler l'appel réseau + async def mock_post(*args, **kwargs): + return {"objectId": "newId", "createdAt": "..."} + + mock_http.post = mock_post + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore") + obj.set("score", 100) + assert len(obj._dirty_keys) == 1 + + await obj.save() + + # Après sauvegarde, les dirty_keys doivent être vides + assert len(obj._dirty_keys) == 0 + assert obj.object_id == "newId" From f0976dfa95f243b0e889b93fd63059aa63672a04 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 00:37:56 +0300 Subject: [PATCH 08/20] test(object): refine unit tests and fix linting issues --- src/parse_sdk/object.py | 3 --- tests/unit/test_object.py | 52 ++++++++++++++++++++++++--------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py index f3f0be2..d2186c5 100644 --- a/src/parse_sdk/object.py +++ b/src/parse_sdk/object.py @@ -53,9 +53,6 @@ def set(self, key: str, value: Any) -> ParseObject: Returns: L'instance actuelle (pour le chaînage). """ - # On encode la valeur (ex: datetime -> ParseDate) - encoded_value = encode_parse_value(value) - self._data[key] = value self._dirty_keys.add(key) diff --git a/tests/unit/test_object.py b/tests/unit/test_object.py index a658cb7..a4ae8da 100644 --- a/tests/unit/test_object.py +++ b/tests/unit/test_object.py @@ -1,27 +1,37 @@ -import pytest from unittest.mock import MagicMock, patch -from parse_sdk import ParseObject, GeoPoint, Pointer -from parse_sdk._types import Increment, AddToArray, AddUniqueToArray, RemoveFromArray, DeleteField +import pytest + +from parse_sdk import ParseObject +from parse_sdk._types import ( + AddToArray, + AddUniqueToArray, + DeleteField, + Increment, + RemoveFromArray, +) + def test_object_initialization(): obj = ParseObject("GameScore") assert obj.class_name == "GameScore" assert obj.object_id is None - + obj_with_id = ParseObject("GameScore", "abc123") assert obj_with_id.object_id == "abc123" + def test_object_set_get(): obj = ParseObject("GameScore") obj.set("playerName", "Alice") assert obj.get("playerName") == "Alice" assert obj.get("nonExistent", "default") == "default" + def test_object_increment(): obj = ParseObject("GameScore") result = obj.increment("score", 5) - + # Vérifie le chaînage assert result == obj # Vérifie que la valeur stockée est un objet Increment @@ -31,66 +41,68 @@ def test_object_increment(): # Vérifie que le champ est marqué comme modifié assert "score" in obj._dirty_keys + def test_object_add_to_array(): obj = ParseObject("GameScore") obj.add_to_array("tags", ["python", "sdk"]) - + val = obj.get("tags") assert isinstance(val, AddToArray) assert val.objects == ["python", "sdk"] assert "tags" in obj._dirty_keys + def test_object_add_unique(): obj = ParseObject("GameScore") obj.add_unique("skills", ["async"]) - + val = obj.get("skills") assert isinstance(val, AddUniqueToArray) assert val.objects == ["async"] + def test_object_remove_from_array(): obj = ParseObject("GameScore") obj.remove_from_array("tags", ["old"]) - + val = obj.get("tags") assert isinstance(val, RemoveFromArray) assert val.objects == ["old"] + def test_object_unset(): obj = ParseObject("GameScore") obj.unset("temporaryField") - + val = obj.get("temporaryField") assert isinstance(val, DeleteField) + def test_object_chaining(): obj = ParseObject("GameScore") - result = ( - obj.increment("score") - .add_to_array("tags", ["test"]) - .unset("old") - ) + result = obj.increment("score").add_to_array("tags", ["test"]).unset("old") assert result == obj + @pytest.mark.asyncio async def test_object_save_dirty_tracking(): # On mocke get_client pour ne pas faire de vraie requête réseau with patch("parse_sdk.object.get_client") as mock_get_client: mock_http = MagicMock() - + # On définit une fonction asynchrone pour simuler l'appel réseau - async def mock_post(*args, **kwargs): + async def mock_post(*_args, **_kwargs): return {"objectId": "newId", "createdAt": "..."} - + mock_http.post = mock_post mock_get_client.return_value = mock_http - + obj = ParseObject("GameScore") obj.set("score", 100) assert len(obj._dirty_keys) == 1 - + await obj.save() - + # Après sauvegarde, les dirty_keys doivent être vides assert len(obj._dirty_keys) == 0 assert obj.object_id == "newId" From ee3996265189e579bd59e5f8578288170e07e64f Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 20:16:49 +0300 Subject: [PATCH 09/20] fix(types): rename unused param to _data in DeleteField.from_parse --- src/parse_sdk/_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse_sdk/_types.py b/src/parse_sdk/_types.py index 1dfe790..c92c4f5 100644 --- a/src/parse_sdk/_types.py +++ b/src/parse_sdk/_types.py @@ -318,7 +318,7 @@ def to_parse(self) -> dict[str, Any]: return {"__op": "Delete"} @classmethod - def from_parse(cls, data: dict[str, Any]) -> DeleteField: + def from_parse(cls, _data: dict[str, Any]) -> DeleteField: return cls() From ffa7cc9f4222b3234838720e251fb87268249c8d Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 20:20:51 +0300 Subject: [PATCH 10/20] chore: remove PDF formation file and add *.pdf to .gitignore --- .gitignore | 5 +++++ kether_labs_formation_complete.pdf | Bin 43201 -> 0 bytes 2 files changed, 5 insertions(+) delete mode 100644 kether_labs_formation_complete.pdf diff --git a/.gitignore b/.gitignore index b7faf40..b4e1c44 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,8 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Documents de formation / présentations — ne pas versionner dans le SDK +*.pdf +*.pptx +*.docx diff --git a/kether_labs_formation_complete.pdf b/kether_labs_formation_complete.pdf deleted file mode 100644 index 60c57f3e888dff36fbf123d234b7910f30bfedf9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43201 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 Date: Thu, 2 Apr 2026 20:31:19 +0300 Subject: [PATCH 11/20] fix(exceptions): fix raise_parse_error crash for specialized constructors --- src/parse_sdk/exceptions.py | 9 ++++++- tests/unit/test_types_and_exceptions.py | 31 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/parse_sdk/exceptions.py b/src/parse_sdk/exceptions.py index 7272e60..79b1588 100644 --- a/src/parse_sdk/exceptions.py +++ b/src/parse_sdk/exceptions.py @@ -354,4 +354,11 @@ def raise_parse_error(code: int, message: str) -> None: exc_class = PARSE_ERROR_MAP.get(code, ParseError) if exc_class is ParseError: raise ParseError(code=code, message=message) - raise exc_class(message=message) # type: ignore[call-arg] + + # Les classes spécialisées ont des constructeurs qui ne correspondent pas + # tous à la signature (code, message). On crée l'instance via __new__ et + # on l'initialise avec ParseError.__init__ pour conserver le bon type + # tout en passant les bons arguments — isinstance() fonctionne toujours. + exc = exc_class.__new__(exc_class) + ParseError.__init__(exc, code=code, message=message) + raise exc diff --git a/tests/unit/test_types_and_exceptions.py b/tests/unit/test_types_and_exceptions.py index 57b6598..2eb8461 100644 --- a/tests/unit/test_types_and_exceptions.py +++ b/tests/unit/test_types_and_exceptions.py @@ -252,3 +252,34 @@ def test_exception_hierarchy(self) -> None: err = ParseSessionExpiredError() assert isinstance(err, ParseError) assert isinstance(err, Exception) + + def test_raise_parse_error_specialized_constructors(self) -> None: + """raise_parse_error ne doit pas crasher pour les classes avec + un constructeur spécialisé (ex: ParseObjectNotFoundError). + L'instance levée doit être du bon type ET avoir code/message corrects. + """ + from parse_sdk.exceptions import ( + ParseDuplicateValueError, + ParseObjectNotFoundError, + ParseUsernameTakenError, + ) + + # Code 101 → ParseObjectNotFoundError (constructeur: class_name, object_id) + with pytest.raises(ParseObjectNotFoundError) as exc_info: + raise_parse_error(101, "Object not found") + assert exc_info.value.code == 101 + assert exc_info.value.message == "Object not found" + + # Code 137 → ParseDuplicateValueError (constructeur: field) + with pytest.raises(ParseDuplicateValueError) as exc_info2: + raise_parse_error(137, "Duplicate value") + assert exc_info2.value.code == 137 + + # Code 202 → ParseUsernameTakenError (constructeur: username) + with pytest.raises(ParseUsernameTakenError) as exc_info3: + raise_parse_error(202, "Username taken") + assert exc_info3.value.code == 202 + + # Tous doivent être instanceof ParseError + for exc in [exc_info, exc_info2, exc_info3]: + assert isinstance(exc.value, ParseError) From 4b3989f6dcd90a7a67dc0ebdc35c8686c456c179 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 20:32:34 +0300 Subject: [PATCH 12/20] chore(deps): move unused pydantic and websockets to optional extras --- pyproject.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ff98d8..99ec38d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,19 +28,23 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "httpx>=0.25.0", - "pydantic>=2.0", - "websockets>=12.0", ] [project.optional-dependencies] django = ["django>=4.0"] fastapi = ["fastapi>=0.100", "python-multipart>=0.0.6"] flask = ["flask>=3.0"] +# Activé automatiquement quand ParseObject utilisera la validation Pydantic +validation = ["pydantic>=2.0"] +# Activé quand le module LiveQuery (WebSocket) sera implémenté +livequery = ["websockets>=12.0"] all = [ "django>=4.0", "fastapi>=0.100", "python-multipart>=0.0.6", "flask>=3.0", + "pydantic>=2.0", + "websockets>=12.0", ] dev = [ "pytest>=7.4", From ecd803cce21f01eb1bfd455a8de084f3d40e17e2 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 21:11:28 +0300 Subject: [PATCH 13/20] fix(object): decode parse types in ParseObject.get() --- src/parse_sdk/object.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py index d2186c5..7ff074c 100644 --- a/src/parse_sdk/object.py +++ b/src/parse_sdk/object.py @@ -9,7 +9,7 @@ from typing import Any -from ._types import encode_parse_value +from ._types import decode_parse_value, encode_parse_value from .client import get_client @@ -41,7 +41,8 @@ def get(self, key: str, default: Any = None) -> Any: Returns: La valeur du champ (décodée si type spécial Parse). """ - return self._data.get(key, default) + value = self._data.get(key, default) + return decode_parse_value(value) def set(self, key: str, value: Any) -> ParseObject: """Définit la valeur d'un champ. From 1c1faff8c33211226f936cb433767977eac86295 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 21:11:58 +0300 Subject: [PATCH 14/20] chore(client): remove empty TYPE_CHECKING block --- src/parse_sdk/client.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/parse_sdk/client.py b/src/parse_sdk/client.py index 6216680..3c37b8d 100644 --- a/src/parse_sdk/client.py +++ b/src/parse_sdk/client.py @@ -7,13 +7,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ._http import ParseHTTPClient - -if TYPE_CHECKING: - pass - # Variable de module pour le pattern singleton/global _current_client: ParseHTTPClient | None = None From 40aee47c4f5d17f5c6222c75a2a2bcfc8364c27f Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 21:12:26 +0300 Subject: [PATCH 15/20] fix(init): update obsolete comment --- src/parse_sdk/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse_sdk/__init__.py b/src/parse_sdk/__init__.py index 8ebbed1..91b8f55 100644 --- a/src/parse_sdk/__init__.py +++ b/src/parse_sdk/__init__.py @@ -67,7 +67,7 @@ ) from .object import ParseObject -# NOTE : ParseClient, ParseObject, ParseQuery, ParseUser, ParseFile, etc. +# NOTE : ParseQuery, ParseUser, ParseFile, etc. # seront ajoutés ici au fur et à mesure de leur implémentation. # Chaque contributeur qui implémente un module doit aussi l'exporter ici. From 3c8db39de8269d7a2e11f23dbef489851ac09a2d Mon Sep 17 00:00:00 2001 From: 0yenga Date: Thu, 2 Apr 2026 21:20:49 +0300 Subject: [PATCH 16/20] chore: fix CONTRIBUTING.md markdown and add GitHub Actions CI workflow --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 10 ++++------ 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7706c58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +jobs: + test: + name: Test on Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Check code style (Black) + run: black --check src/ tests/ + + - name: Lint (Ruff) + run: ruff check src/ tests/ + + - name: Type checking (Mypy) + run: mypy src/ + + - name: Run unit tests + run: pytest tests/unit/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c21884..e4a3dc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,7 @@ git --version # git version 2.x ### 1. Forker et cloner le dépôt -`````bash +```bash # 1. Forkez le projet depuis GitHub (bouton "Fork" en haut à droite) # 2. Clonez votre fork localement @@ -75,18 +75,16 @@ cd parsepy # 3. Ajoutez le dépôt original comme remote "upstream" git remote add upstream https://github.com/Kether-Labs/parsepy.git +``` -### 4. Créer un environnement virtuel +### 2. Créer un environnement virtuel ```bash # Avec venv (standard) python -m venv .venv source .venv/bin/activate # Linux / macOS .venv\Scripts\activate # Windows -```` - -````` - +``` ### 3. Installer les dépendances de développement ```bash From 6a7840d5b326357c2fb40e5ae6c3a0505b0b4491 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Mon, 6 Apr 2026 02:31:56 +0300 Subject: [PATCH 17/20] feat(object): add atomic operations, sync CRUD, and special type serialization --- src/parse_sdk/_http.py | 65 +++++---------- src/parse_sdk/client.py | 12 +-- src/parse_sdk/object.py | 174 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 185 insertions(+), 66 deletions(-) diff --git a/src/parse_sdk/_http.py b/src/parse_sdk/_http.py index c99d2cc..08ef614 100644 --- a/src/parse_sdk/_http.py +++ b/src/parse_sdk/_http.py @@ -1,16 +1,8 @@ """ -Couche HTTP interne du SDK Parse Server Python. - -Ce module est PRIVÉ. Il ne doit jamais être importé directement par les -utilisateurs du SDK. Tous les modules publics (ParseObject, ParseQuery, etc.) -passent exclusivement par ce module pour leurs requêtes HTTP. - -Responsabilités : -- Gérer le client httpx (async + sync) -- Injecter les headers Parse obligatoires -- Retry automatique avec backoff exponentiel -- Convertir les erreurs HTTP en exceptions ParseError -- Logger les requêtes/réponses pour le debug +Couche HTTP interne pour Parse Server. + +Usage interne uniquement — ne pas importer manuellement. +Tous les modules (ParseObject, ParseQuery, etc.) passent par ici pour leurs requêtes. """ from __future__ import annotations @@ -75,25 +67,16 @@ def __init__( self._max_retries = max_retries self._session_token: str | None = None - # Client async partagé — évite de rouvrir une connexion à chaque requête + # Client async partagé pour réutiliser les connexions self._async_client: httpx.AsyncClient | None = None - # ------------------------------------------------------------------ - # Gestion du session token (défini par ParseUser après login) - # ------------------------------------------------------------------ - def set_session_token(self, token: str | None) -> None: """Définit le session token à envoyer dans les requêtes suivantes.""" self._session_token = token def clear_session_token(self) -> None: - """Supprime le session token (après logout).""" self._session_token = None - # ------------------------------------------------------------------ - # Construction des headers - # ------------------------------------------------------------------ - def _build_headers( self, use_master_key: bool = False, @@ -125,10 +108,6 @@ def _build_headers( return headers - # ------------------------------------------------------------------ - # Gestion du client async - # ------------------------------------------------------------------ - async def _get_async_client(self) -> httpx.AsyncClient: """Retourne le client async partagé, en le créant si nécessaire.""" if self._async_client is None or self._async_client.is_closed: @@ -139,15 +118,10 @@ async def _get_async_client(self) -> httpx.AsyncClient: return self._async_client async def close(self) -> None: - """Ferme proprement le client HTTP async.""" if self._async_client and not self._async_client.is_closed: await self._async_client.aclose() self._async_client = None - # ------------------------------------------------------------------ - # Méthode principale : requête async avec retry - # ------------------------------------------------------------------ - async def request( self, method: str, @@ -246,10 +220,6 @@ async def request( raise last_error or ParseConnectionError(f"Échec de la requête {method} {path}") - # ------------------------------------------------------------------ - # Wrapper synchrone - # ------------------------------------------------------------------ - def request_sync( self, method: str, @@ -312,10 +282,6 @@ def request_sync( raise last_error or ParseConnectionError(f"Échec de la requête {method} {path}") - # ------------------------------------------------------------------ - # Traitement de la réponse HTTP - # ------------------------------------------------------------------ - def _handle_response(self, response: httpx.Response) -> dict[str, Any]: """Parse la réponse HTTP et lève l'exception appropriée si erreur. @@ -342,10 +308,6 @@ def _handle_response(self, response: httpx.Response) -> dict[str, Any]: return body - # ------------------------------------------------------------------ - # Helpers HTTP raccourcis - # ------------------------------------------------------------------ - async def get(self, path: str, **kwargs: Any) -> dict[str, Any]: """GET asynchrone.""" return await self.request("GET", path, **kwargs) @@ -359,5 +321,20 @@ async def put(self, path: str, **kwargs: Any) -> dict[str, Any]: return await self.request("PUT", path, **kwargs) async def delete(self, path: str, **kwargs: Any) -> dict[str, Any]: - """DELETE asynchrone.""" return await self.request("DELETE", path, **kwargs) + + def get_sync(self, path: str, **kwargs: Any) -> dict[str, Any]: + """GET synchrone.""" + return self.request_sync("GET", path, **kwargs) + + def post_sync(self, path: str, **kwargs: Any) -> dict[str, Any]: + """POST synchrone.""" + return self.request_sync("POST", path, **kwargs) + + def put_sync(self, path: str, **kwargs: Any) -> dict[str, Any]: + """PUT synchrone.""" + return self.request_sync("PUT", path, **kwargs) + + def delete_sync(self, path: str, **kwargs: Any) -> dict[str, Any]: + """DELETE synchrone.""" + return self.request_sync("DELETE", path, **kwargs) diff --git a/src/parse_sdk/client.py b/src/parse_sdk/client.py index 3c37b8d..78ebf88 100644 --- a/src/parse_sdk/client.py +++ b/src/parse_sdk/client.py @@ -1,14 +1,12 @@ """ -ParseClient — Point d'entrée principal du SDK. - -Ce module fournit la classe publique ParseClient que chaque utilisateur -installe pour configurer sa connexion à Parse Server. +Point d'entrée principal pour configurer le SDK. """ from __future__ import annotations from ._http import ParseHTTPClient -# Variable de module pour le pattern singleton/global + +# Instance globale partagée (singleton) _current_client: ParseHTTPClient | None = None @@ -66,7 +64,6 @@ def __init__( self._validate_required_param(rest_key, "rest_key") self._validate_required_param(server_url, "server_url") - # Stocker la configuration (optionnel mais utile pour debugging) self._app_id = app_id self._rest_key = rest_key self._server_url = server_url @@ -74,7 +71,7 @@ def __init__( self._timeout = timeout self._max_retries = max_retries - # Créer le client HTTP interne + # Client HTTP interne self._http_client = ParseHTTPClient( app_id=app_id, rest_key=rest_key, @@ -84,7 +81,6 @@ def __init__( max_retries=max_retries, ) - # Enregistrer comme client global global _current_client _current_client = self._http_client diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py index 7ff074c..c8eaf68 100644 --- a/src/parse_sdk/object.py +++ b/src/parse_sdk/object.py @@ -1,8 +1,6 @@ """ -Module ParseObject — CRUD et gestion des données. - -Ce module fournit la classe ParseObject qui représente un objet stocké -sur Parse Server. Elle permet de lire, modifier et sauvegarder des données. +Gestion des objets Parse (ParseObject). +Lecture, modification et sauvegarde des données. """ from __future__ import annotations @@ -25,10 +23,7 @@ def __init__(self, class_name: str, object_id: str | None = None) -> None: self.class_name = class_name self.object_id = object_id - # Données internes de l'objet self._data: dict[str, Any] = {} - - # Liste des clés modifiées localement qui doivent être envoyées au serveur self._dirty_keys: set[str] = set() def get(self, key: str, default: Any = None) -> Any: @@ -65,17 +60,21 @@ async def save( """Sauvegarde l'objet sur Parse Server. Cette méthode envoie uniquement les champs modifiés (dirty). + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Raises: + ParseError: Si le serveur retourne une erreur. """ if not self._dirty_keys: return - # Construction du payload (données à envoyer) payload = {key: encode_parse_value(self._data[key]) for key in self._dirty_keys} - client = get_client() if self.object_id: - # Mise à jour (PUT) path = f"/classes/{self.class_name}/{self.object_id}" response = await client.put( path, @@ -84,7 +83,6 @@ async def save( session_token=session_token, ) else: - # Création (POST) path = f"/classes/{self.class_name}" response = await client.post( path, @@ -93,14 +91,162 @@ async def save( session_token=session_token, ) - # On récupère l'objectId généré par le serveur if "objectId" in response: self.object_id = response["objectId"] - # Une fois sauvegardé, l'objet n'est plus "dirty" + self._data.update(response) + self._dirty_keys.clear() + + def save_sync( + self, use_master_key: bool = False, session_token: str | None = None + ) -> None: + """Version synchrone de `save()`. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Raises: + ParseError: Si le serveur retourne une erreur. + """ + if not self._dirty_keys: + return + + payload = {key: encode_parse_value(self._data[key]) for key in self._dirty_keys} + client = get_client() + + if self.object_id: + path = f"/classes/{self.class_name}/{self.object_id}" + response = client.put_sync( + path, + json=payload, + use_master_key=use_master_key, + session_token=session_token, + ) + else: + path = f"/classes/{self.class_name}" + response = client.post_sync( + path, + json=payload, + use_master_key=use_master_key, + session_token=session_token, + ) + + if "objectId" in response: + self.object_id = response["objectId"] + + self._data.update(response) + self._dirty_keys.clear() + + async def fetch( + self, use_master_key: bool = False, session_token: str | None = None + ) -> ParseObject: + """Récupère les dernières données de l'objet depuis le serveur. + + Met à jour l'instance actuelle et efface les modifications locales non sauvegardées. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Returns: + L'instance actuelle de ParseObject. + + Raises: + RuntimeError: Si l'objet n'a pas d'objectId. + ParseError: Si le serveur retourne une erreur. + """ + if not self.object_id: + raise RuntimeError("Impossible de fetch un objet sans objectId") + + client = get_client() + path = f"/classes/{self.class_name}/{self.object_id}" + response = await client.get( + path, use_master_key=use_master_key, session_token=session_token + ) + + self._data = response + self._dirty_keys.clear() + return self + + def fetch_sync( + self, use_master_key: bool = False, session_token: str | None = None + ) -> ParseObject: + """Version synchrone de `fetch()`. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Returns: + L'instance actuelle de ParseObject. + + Raises: + RuntimeError: Si l'objet n'a pas d'objectId. + """ + if not self.object_id: + raise RuntimeError("Impossible de fetch un objet sans objectId") + + client = get_client() + path = f"/classes/{self.class_name}/{self.object_id}" + response = client.get_sync( + path, use_master_key=use_master_key, session_token=session_token + ) + + self._data = response self._dirty_keys.clear() + return self - # --- MÉTHODES À IMPLÉMENTER POUR L'ISSUE #17 --- + async def delete( + self, use_master_key: bool = False, session_token: str | None = None + ) -> None: + """Supprime l'objet sur Parse Server. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Raises: + RuntimeError: Si l'objet n'a pas d'objectId. + ParseError: Si le serveur retourne une erreur. + """ + if not self.object_id: + raise RuntimeError("Impossible de supprimer un objet sans objectId") + + client = get_client() + path = f"/classes/{self.class_name}/{self.object_id}" + await client.delete( + path, use_master_key=use_master_key, session_token=session_token + ) + + self.object_id = None + self._data.clear() + self._dirty_keys.clear() + + def delete_sync( + self, use_master_key: bool = False, session_token: str | None = None + ) -> None: + """Version synchrone de `delete()`. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Raises: + RuntimeError: Si l'objet n'a pas d'objectId. + """ + if not self.object_id: + raise RuntimeError("Impossible de supprimer un objet sans objectId") + + client = get_client() + path = f"/classes/{self.class_name}/{self.object_id}" + client.delete_sync( + path, use_master_key=use_master_key, session_token=session_token + ) + + self.object_id = None + self._data.clear() + self._dirty_keys.clear() def increment(self, key: str, amount: int = 1) -> ParseObject: """Incrémente un champ numérique. From 426bd11166f3dda2e462d5a4223c0d2cd3dd0563 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Mon, 6 Apr 2026 02:32:49 +0300 Subject: [PATCH 18/20] test(object): add unit tests for atomic operations and sync methods --- tests/unit/test_http_extra.py | 97 ++++++++++++++++++++++++++++++++++ tests/unit/test_http_sync.py | 53 +++++++++++++++++++ tests/unit/test_object.py | 85 ++++++++++++++++++++++++++++- tests/unit/test_object_sync.py | 58 ++++++++++++++++++++ 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_http_extra.py create mode 100644 tests/unit/test_http_sync.py create mode 100644 tests/unit/test_object_sync.py diff --git a/tests/unit/test_http_extra.py b/tests/unit/test_http_extra.py new file mode 100644 index 0000000..8886214 --- /dev/null +++ b/tests/unit/test_http_extra.py @@ -0,0 +1,97 @@ +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from parse_sdk._http import ParseHTTPClient +from parse_sdk.exceptions import ParseConnectionError, ParseTimeoutError + + +@pytest.mark.asyncio +async def test_http_use_master_key(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + master_key="secret" + ) + + with patch("httpx.AsyncClient.request") as mock_request: + mock_request.return_value = MagicMock(status_code=200, json=lambda: {}, is_error=False) + + await client.get("/classes/GameScore", use_master_key=True) + + args, kwargs = mock_request.call_args + assert kwargs["headers"]["X-Parse-Master-Key"] == "secret" + +@pytest.mark.asyncio +async def test_http_retry_logic(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + max_retries=2 + ) + + with patch("httpx.AsyncClient.request") as mock_request: + # Premier appel : 503, Deuxième appel : 200 + mock_request.side_effect = [ + MagicMock(status_code=503, is_error=True, text="Service Unavailable"), + MagicMock(status_code=200, json=lambda: {"ok": True}, is_error=False) + ] + + with patch("asyncio.sleep", return_value=None): # Skip sleep + response = await client.get("/classes/GameScore") + assert response == {"ok": True} + assert mock_request.call_count == 2 + +@pytest.mark.asyncio +async def test_http_timeout_exception(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + max_retries=1 + ) + + with ( + patch("httpx.AsyncClient.request", side_effect=httpx.TimeoutException("Timeout")), + pytest.raises(ParseTimeoutError), + ): + await client.get("/classes/GameScore") + +@pytest.mark.asyncio +async def test_http_network_error(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + max_retries=1 + ) + + with ( + patch("httpx.AsyncClient.request", side_effect=httpx.NetworkError("Network")), + pytest.raises(ParseConnectionError), + ): + await client.get("/classes/GameScore") + +def test_http_sync_retry_and_errors(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + max_retries=2 + ) + + with ( + patch("httpx.Client.request") as mock_request, + patch("time.sleep", return_value=None), + ): + # Simulation d'un timeout puis succès + mock_request.side_effect = [ + httpx.TimeoutException("Timeout"), + MagicMock(status_code=200, json=lambda: {"ok": True}, is_error=False), + ] + response = client.get_sync("/classes/GameScore") + assert response == {"ok": True} + assert mock_request.call_count == 2 diff --git a/tests/unit/test_http_sync.py b/tests/unit/test_http_sync.py new file mode 100644 index 0000000..ca68840 --- /dev/null +++ b/tests/unit/test_http_sync.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock, patch + +from parse_sdk._http import ParseHTTPClient + + +def test_http_get_sync(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + ) + + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client.request.return_value = MagicMock( + status_code=200, + json=lambda: {"results": []}, + is_error=False, + ) + mock_client_class.return_value.__enter__.return_value = mock_client + + response = client.get_sync("/classes/GameScore") + + assert response == {"results": []} + mock_client.request.assert_called_once() + args, kwargs = mock_client.request.call_args + assert kwargs["method"] == "GET" + assert kwargs["url"] == "https://api.parse.com/parse/classes/GameScore" + + +def test_http_post_sync(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + ) + + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client.request.return_value = MagicMock( + status_code=201, + json=lambda: {"objectId": "123"}, + is_error=False, + ) + mock_client_class.return_value.__enter__.return_value = mock_client + + response = client.post_sync("/classes/GameScore", json={"score": 100}) + + assert response == {"objectId": "123"} + mock_client.request.assert_called_once() + args, kwargs = mock_client.request.call_args + assert kwargs["method"] == "POST" + assert kwargs["json"] == {"score": 100} diff --git a/tests/unit/test_object.py b/tests/unit/test_object.py index a4ae8da..d2be0c3 100644 --- a/tests/unit/test_object.py +++ b/tests/unit/test_object.py @@ -7,7 +7,9 @@ AddToArray, AddUniqueToArray, DeleteField, + GeoPoint, Increment, + Pointer, RemoveFromArray, ) @@ -94,7 +96,7 @@ async def test_object_save_dirty_tracking(): async def mock_post(*_args, **_kwargs): return {"objectId": "newId", "createdAt": "..."} - mock_http.post = mock_post + mock_http.post.side_effect = mock_post mock_get_client.return_value = mock_http obj = ParseObject("GameScore") @@ -106,3 +108,84 @@ async def mock_post(*_args, **_kwargs): # Après sauvegarde, les dirty_keys doivent être vides assert len(obj._dirty_keys) == 0 assert obj.object_id == "newId" + assert obj.get("score") == 100 + + +@pytest.mark.asyncio +async def test_object_fetch(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + + async def mock_get(*_args, **_kwargs): + return {"objectId": "abc", "playerName": "Bob", "score": 500} + + mock_http.get.side_effect = mock_get + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + obj.set("score", 100) # Modification locale + assert len(obj._dirty_keys) == 1 + + await obj.fetch() + + assert obj.get("playerName") == "Bob" + assert obj.get("score") == 500 + assert len(obj._dirty_keys) == 0 + + +@pytest.mark.asyncio +async def test_object_delete(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + + async def mock_delete(*_args, **_kwargs): + return {} + + mock_http.delete.side_effect = mock_delete + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + await obj.delete() + + assert obj.object_id is None + assert obj._data == {} + mock_http.delete.assert_called_once() + + +@pytest.mark.asyncio +async def test_object_save_with_geopoint(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + async def mock_post(_path, json, **_kwargs): + assert json["location"]["__type"] == "GeoPoint" + assert json["location"]["latitude"] == 48.8566 + return {"objectId": "geoId"} + + mock_http.post.side_effect = mock_post + mock_get_client.return_value = mock_http + + obj = ParseObject("Place") + obj.set("location", GeoPoint(48.8566, 2.3522)) + await obj.save() + + assert obj.object_id == "geoId" + + +@pytest.mark.asyncio +async def test_object_save_with_pointer(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + async def mock_post(_path, json, **_kwargs): + assert json["owner"]["__type"] == "Pointer" + assert json["owner"]["className"] == "_User" + assert json["owner"]["objectId"] == "user123" + return {"objectId": "postId"} + + mock_http.post.side_effect = mock_post + mock_get_client.return_value = mock_http + + obj = ParseObject("Post") + obj.set("owner", Pointer("_User", "user123")) + await obj.save() + + assert obj.object_id == "postId" diff --git a/tests/unit/test_object_sync.py b/tests/unit/test_object_sync.py new file mode 100644 index 0000000..bb8d795 --- /dev/null +++ b/tests/unit/test_object_sync.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock, patch + +from parse_sdk import ParseObject + + +def test_object_save_sync(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + mock_http.post_sync.return_value = {"objectId": "syncId"} + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore") + obj.set("score", 200) + obj.save_sync() + + assert obj.object_id == "syncId" + assert len(obj._dirty_keys) == 0 + mock_http.post_sync.assert_called_once() + + +def test_object_update_sync(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + mock_http.put_sync.return_value = {"updatedAt": "..."} + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + obj.set("score", 300) + obj.save_sync() + + assert len(obj._dirty_keys) == 0 + mock_http.put_sync.assert_called_once() + + +def test_object_fetch_sync(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + mock_http.get_sync.return_value = {"objectId": "abc", "score": 999} + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + obj.fetch_sync() + + assert obj.get("score") == 999 + mock_http.get_sync.assert_called_once() + + +def test_object_delete_sync(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + mock_http.delete_sync.return_value = {} + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + obj.delete_sync() + + assert obj.object_id is None + mock_http.delete_sync.assert_called_once() From fe2f503cdfd3447797fbe35904dc404a8e42d9af Mon Sep 17 00:00:00 2001 From: 0yenga Date: Mon, 6 Apr 2026 02:33:09 +0300 Subject: [PATCH 19/20] docs: update roadmap status for ParseObject --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a2e32fd..d4480cc 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Parse Server dispose de SDK officiels pour JavaScript, iOS, Android et .NET — | Module | Statut | |---|---| -| `ParseClient` — configuration et HTTP | 🚧 En cours | -| `ParseObject` — CRUD | 📋 Planifié | +| `ParseClient` — configuration et HTTP | ✅ Terminé | +| `ParseObject` — CRUD | ✅ Terminé | | `ParseQuery` — requêtes | 📋 Planifié | | `ParseUser` — authentification | 📋 Planifié | | `ParseFile` — fichiers | 📋 Planifié | From 71768186c6b6878bf8793cdc7a991b3c461badb1 Mon Sep 17 00:00:00 2001 From: 0yenga Date: Mon, 6 Apr 2026 03:03:12 +0300 Subject: [PATCH 20/20] chore: fix formatting and Ruff SIM117 errors in tests --- tests/unit/test_http_extra.py | 65 +++++++++++++++++++---------------- tests/unit/test_object.py | 2 ++ 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/tests/unit/test_http_extra.py b/tests/unit/test_http_extra.py index 8886214..d804416 100644 --- a/tests/unit/test_http_extra.py +++ b/tests/unit/test_http_extra.py @@ -1,3 +1,4 @@ +# ruff: noqa: SIM117 from unittest.mock import MagicMock, patch import httpx @@ -13,52 +14,57 @@ async def test_http_use_master_key(): app_id="app", rest_key="rest", server_url="https://api.parse.com/parse", - master_key="secret" + master_key="secret", ) with patch("httpx.AsyncClient.request") as mock_request: - mock_request.return_value = MagicMock(status_code=200, json=lambda: {}, is_error=False) + mock_request.return_value = MagicMock( + status_code=200, json=lambda: {}, is_error=False + ) await client.get("/classes/GameScore", use_master_key=True) args, kwargs = mock_request.call_args assert kwargs["headers"]["X-Parse-Master-Key"] == "secret" + @pytest.mark.asyncio async def test_http_retry_logic(): client = ParseHTTPClient( app_id="app", rest_key="rest", server_url="https://api.parse.com/parse", - max_retries=2 + max_retries=2, ) with patch("httpx.AsyncClient.request") as mock_request: # Premier appel : 503, Deuxième appel : 200 mock_request.side_effect = [ MagicMock(status_code=503, is_error=True, text="Service Unavailable"), - MagicMock(status_code=200, json=lambda: {"ok": True}, is_error=False) + MagicMock(status_code=200, json=lambda: {"ok": True}, is_error=False), ] - with patch("asyncio.sleep", return_value=None): # Skip sleep + with patch("asyncio.sleep", return_value=None): # Skip sleep response = await client.get("/classes/GameScore") assert response == {"ok": True} assert mock_request.call_count == 2 + @pytest.mark.asyncio async def test_http_timeout_exception(): client = ParseHTTPClient( app_id="app", rest_key="rest", server_url="https://api.parse.com/parse", - max_retries=1 + max_retries=1, ) - with ( - patch("httpx.AsyncClient.request", side_effect=httpx.TimeoutException("Timeout")), - pytest.raises(ParseTimeoutError), - ): - await client.get("/classes/GameScore") + with patch( + "httpx.AsyncClient.request", side_effect=httpx.TimeoutException("Timeout") + ): # noqa: SIM117 + with pytest.raises(ParseTimeoutError): + await client.get("/classes/GameScore") + @pytest.mark.asyncio async def test_http_network_error(): @@ -66,32 +72,31 @@ async def test_http_network_error(): app_id="app", rest_key="rest", server_url="https://api.parse.com/parse", - max_retries=1 + max_retries=1, ) - with ( - patch("httpx.AsyncClient.request", side_effect=httpx.NetworkError("Network")), - pytest.raises(ParseConnectionError), - ): - await client.get("/classes/GameScore") + with patch( + "httpx.AsyncClient.request", side_effect=httpx.NetworkError("Network") + ): # noqa: SIM117 + with pytest.raises(ParseConnectionError): + await client.get("/classes/GameScore") + def test_http_sync_retry_and_errors(): client = ParseHTTPClient( app_id="app", rest_key="rest", server_url="https://api.parse.com/parse", - max_retries=2 + max_retries=2, ) - with ( - patch("httpx.Client.request") as mock_request, - patch("time.sleep", return_value=None), - ): - # Simulation d'un timeout puis succès - mock_request.side_effect = [ - httpx.TimeoutException("Timeout"), - MagicMock(status_code=200, json=lambda: {"ok": True}, is_error=False), - ] - response = client.get_sync("/classes/GameScore") - assert response == {"ok": True} - assert mock_request.call_count == 2 + with patch("httpx.Client.request") as mock_request: # noqa: SIM117 + with patch("time.sleep", return_value=None): + # Simulation d'un timeout puis succès + mock_request.side_effect = [ + httpx.TimeoutException("Timeout"), + MagicMock(status_code=200, json=lambda: {"ok": True}, is_error=False), + ] + response = client.get_sync("/classes/GameScore") + assert response == {"ok": True} + assert mock_request.call_count == 2 diff --git a/tests/unit/test_object.py b/tests/unit/test_object.py index d2be0c3..3f48f3a 100644 --- a/tests/unit/test_object.py +++ b/tests/unit/test_object.py @@ -156,6 +156,7 @@ async def mock_delete(*_args, **_kwargs): async def test_object_save_with_geopoint(): with patch("parse_sdk.object.get_client") as mock_get_client: mock_http = MagicMock() + async def mock_post(_path, json, **_kwargs): assert json["location"]["__type"] == "GeoPoint" assert json["location"]["latitude"] == 48.8566 @@ -175,6 +176,7 @@ async def mock_post(_path, json, **_kwargs): async def test_object_save_with_pointer(): with patch("parse_sdk.object.get_client") as mock_get_client: mock_http = MagicMock() + async def mock_post(_path, json, **_kwargs): assert json["owner"]["__type"] == "Pointer" assert json["owner"]["className"] == "_User"