From a1f451d7da6c2f45ad85dc5ccffe519c8336d165 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Oct 2025 11:40:57 +0530 Subject: [PATCH 01/15] feat: generator changes for idempotency py sdk --- Makefile | 2 +- config/clients/python/config.overrides.json | 8 + .../template/src/client/client.py.mustache | 25 ++- .../src/client/models/__init__.py.mustache | 12 +- .../models/write_conflict_opts.py.mustache | 61 ++++++ .../client/models/write_options.py.mustache | 71 +++++++ .../client/models/write_request.py.mustache | 36 +++- .../src/sync/client/client.py.mustache | 25 ++- .../test/client/client_test.py.mustache | 181 ++++++++++++++++++ .../test/sync/client/client_test.py.mustache | 181 ++++++++++++++++++ 10 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 config/clients/python/template/src/client/models/write_conflict_opts.py.mustache create mode 100644 config/clients/python/template/src/client/models/write_options.py.mustache diff --git a/Makefile b/Makefile index 2bdb907a6..ed80677ed 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Main config OPENFGA_DOCKER_TAG = v1 -OPEN_API_REF ?= e53c69cc55317404d02a6d8e418d626268f28a59 +OPEN_API_REF ?= 0ac19aac54f21f3c78970126b84b4c69c6e3b9a2 OPEN_API_URL = https://raw.githubusercontent.com/openfga/api/${OPEN_API_REF}/docs/openapiv2/apidocs.swagger.json OPENAPI_GENERATOR_CLI_DOCKER_TAG ?= v6.4.0 NODE_DOCKER_TAG = 20-alpine diff --git a/config/clients/python/config.overrides.json b/config/clients/python/config.overrides.json index 8311a0ca0..8b80511d8 100644 --- a/config/clients/python/config.overrides.json +++ b/config/clients/python/config.overrides.json @@ -153,6 +153,14 @@ "destinationFilename": "openfga_sdk/client/models/write_transaction_opts.py", "templateType": "SupportingFiles" }, + "src/client/models/write_conflict_opts.py.mustache": { + "destinationFilename": "openfga_sdk/client/models/write_conflict_opts.py", + "templateType": "SupportingFiles" + }, + "src/client/models/write_options.py.mustache": { + "destinationFilename": "openfga_sdk/client/models/write_options.py", + "templateType": "SupportingFiles" + }, "/src/models/__init__.py.mustache": { "destinationFilename": "openfga_sdk/models/__init__.py", "templateType": "SupportingFiles" diff --git a/config/clients/python/template/src/client/client.py.mustache b/config/clients/python/template/src/client/client.py.mustache index d7551d929..4b2669c45 100644 --- a/config/clients/python/template/src/client/client.py.mustache +++ b/config/clients/python/template/src/client/client.py.mustache @@ -27,6 +27,12 @@ from {{packageName}}.client.models.list_relations_request import ClientListRelat from {{packageName}}.client.models.list_users_request import ClientListUsersRequest from {{packageName}}.client.models.write_single_response import construct_write_single_response from {{packageName}}.client.models.write_transaction_opts import WriteTransactionOpts +from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnDuplicateWrites, + ClientWriteRequestOnMissingDeletes, + ConflictOptions, +) +from {{packageName}}.client.models.write_options import ClientWriteOptions from {{packageName}}.client.models.read_changes_request import ClientReadChangesRequest from {{packageName}}.exceptions import ( AuthenticationError, @@ -112,6 +118,14 @@ def options_to_transaction_info(options: dict[str, int | str | dict[str, int | s return options["transaction"] return WriteTransactionOpts() +def options_to_conflict_info(options: dict[str, int | str | dict[str, int | str]] | None = None): + """ + Return the conflict info + """ + if options is not None and options.get("conflict"): + return options["conflict"] + return None + def _check_errored(response: ClientBatchCheckClientResponse): """ Helper function to return whether the response is errored @@ -453,6 +467,15 @@ class OpenFgaClient: Write or deletes tuples """ kwargs = options_to_kwargs(options) + conflict_options = options_to_conflict_info(options) + + # Set conflict options on the body if provided + if conflict_options: + if conflict_options.on_duplicate_writes: + body.on_duplicate = conflict_options.on_duplicate_writes.value + if conflict_options.on_missing_deletes: + body.on_missing = conflict_options.on_missing_deletes.value + writes_tuple_keys = None deletes_tuple_keys = None if body.writes_tuple_keys: @@ -945,4 +968,4 @@ class OpenFgaClient: api_request_body = WriteAssertionsRequest([map_to_assertion(client_assertion) for client_assertion in body]) api_response = {{#asyncio}}await {{/asyncio}}self._api.write_assertions(authorization_model_id, api_request_body, **kwargs) - return api_response + return api_response \ No newline at end of file diff --git a/config/clients/python/template/src/client/models/__init__.py.mustache b/config/clients/python/template/src/client/models/__init__.py.mustache index 7edc3e503..7ece718f3 100644 --- a/config/clients/python/template/src/client/models/__init__.py.mustache +++ b/config/clients/python/template/src/client/models/__init__.py.mustache @@ -15,6 +15,12 @@ from {{packageName}}.client.models.tuple import ClientTuple from {{packageName}}.client.models.write_request import ClientWriteRequest from {{packageName}}.client.models.write_response import ClientWriteResponse from {{packageName}}.client.models.write_transaction_opts import WriteTransactionOpts +from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnDuplicateWrites, + ClientWriteRequestOnMissingDeletes, + ConflictOptions, +) +from {{packageName}}.client.models.write_options import ClientWriteOptions __all__ = [ "ClientAssertion", @@ -32,4 +38,8 @@ __all__ = [ "ClientWriteRequest", "ClientWriteResponse", "WriteTransactionOpts", -] + "ClientWriteRequestOnDuplicateWrites", + "ClientWriteRequestOnMissingDeletes", + "ConflictOptions", + "ClientWriteOptions", +] \ No newline at end of file diff --git a/config/clients/python/template/src/client/models/write_conflict_opts.py.mustache b/config/clients/python/template/src/client/models/write_conflict_opts.py.mustache new file mode 100644 index 000000000..20b32734e --- /dev/null +++ b/config/clients/python/template/src/client/models/write_conflict_opts.py.mustache @@ -0,0 +1,61 @@ +{{>partial_header}} + +from enum import Enum + + +class ClientWriteRequestOnDuplicateWrites(str, Enum): + ERROR = "error" + IGNORE = "ignore" + + +class ClientWriteRequestOnMissingDeletes(str, Enum): + ERROR = "error" + IGNORE = "ignore" + + +class ConflictOptions: + """ + OpenFGA client write conflict options + """ + + def __init__( + self, + on_duplicate_writes: ClientWriteRequestOnDuplicateWrites | None = None, + on_missing_deletes: ClientWriteRequestOnMissingDeletes | None = None, + ) -> None: + self._on_duplicate_writes = on_duplicate_writes + self._on_missing_deletes = on_missing_deletes + + @property + def on_duplicate_writes(self) -> ClientWriteRequestOnDuplicateWrites | None: + """ + Return on_duplicate_writes + """ + return self._on_duplicate_writes + + @on_duplicate_writes.setter + def on_duplicate_writes( + self, + value: ClientWriteRequestOnDuplicateWrites | None, + ) -> None: + """ + Set on_duplicate_writes + """ + self._on_duplicate_writes = value + + @property + def on_missing_deletes(self) -> ClientWriteRequestOnMissingDeletes | None: + """ + Return on_missing_deletes + """ + return self._on_missing_deletes + + @on_missing_deletes.setter + def on_missing_deletes( + self, + value: ClientWriteRequestOnMissingDeletes | None, + ) -> None: + """ + Set on_missing_deletes + """ + self._on_missing_deletes = value \ No newline at end of file diff --git a/config/clients/python/template/src/client/models/write_options.py.mustache b/config/clients/python/template/src/client/models/write_options.py.mustache new file mode 100644 index 000000000..06cf9378c --- /dev/null +++ b/config/clients/python/template/src/client/models/write_options.py.mustache @@ -0,0 +1,71 @@ +{{>partial_header}} + +from {{packageName}}.client.models.write_conflict_opts import ConflictOptions +from {{packageName}}.client.models.write_transaction_opts import WriteTransactionOpts + + +class ClientWriteOptions: + """ + OpenFGA client write options + """ + + def __init__( + self, + authorization_model_id: str | None = None, + transaction: WriteTransactionOpts | None = None, + conflict: ConflictOptions | None = None, + ) -> None: + self._authorization_model_id = authorization_model_id + self._transaction = transaction + self._conflict = conflict + + @property + def authorization_model_id(self) -> str | None: + """ + Return authorization_model_id + """ + return self._authorization_model_id + + @authorization_model_id.setter + def authorization_model_id( + self, + value: str | None, + ) -> None: + """ + Set authorization_model_id + """ + self._authorization_model_id = value + + @property + def transaction(self) -> WriteTransactionOpts | None: + """ + Return transaction + """ + return self._transaction + + @transaction.setter + def transaction( + self, + value: WriteTransactionOpts | None, + ) -> None: + """ + Set transaction + """ + self._transaction = value + + @property + def conflict(self) -> ConflictOptions | None: + """ + Return conflict + """ + return self._conflict + + @conflict.setter + def conflict( + self, + value: ConflictOptions | None, + ) -> None: + """ + Set conflict + """ + self._conflict = value \ No newline at end of file diff --git a/config/clients/python/template/src/client/models/write_request.py.mustache b/config/clients/python/template/src/client/models/write_request.py.mustache index bfba1e970..cc57c0d66 100644 --- a/config/clients/python/template/src/client/models/write_request.py.mustache +++ b/config/clients/python/template/src/client/models/write_request.py.mustache @@ -14,9 +14,13 @@ class ClientWriteRequest: self, writes: list[ClientTuple] | None = None, deletes: list[ClientTuple] | None = None, + on_duplicate: str | None = None, + on_missing: str | None = None, ) -> None: self._writes = writes self._deletes = deletes + self._on_duplicate = on_duplicate + self._on_missing = on_missing @property def writes(self) -> list[ClientTuple] | None: @@ -46,6 +50,34 @@ class ClientWriteRequest: """ self._deletes = value + @property + def on_duplicate(self) -> str | None: + """ + Return on_duplicate + """ + return self._on_duplicate + + @on_duplicate.setter + def on_duplicate(self, value: str | None) -> None: + """ + Set on_duplicate + """ + self._on_duplicate = value + + @property + def on_missing(self) -> str | None: + """ + Return on_missing + """ + return self._on_missing + + @on_missing.setter + def on_missing(self, value: str | None) -> None: + """ + Set on_missing + """ + self._on_missing = value + @property def writes_tuple_keys(self) -> WriteRequestWrites | None: """ @@ -59,7 +91,7 @@ class ClientWriteRequest: if keys is None: return None - return WriteRequestWrites(tuple_keys=keys) + return WriteRequestWrites(tuple_keys=keys, on_duplicate=self._on_duplicate) @property def deletes_tuple_keys(self) -> WriteRequestDeletes | None: @@ -74,4 +106,4 @@ class ClientWriteRequest: if keys is None: return None - return WriteRequestDeletes(tuple_keys=keys) + return WriteRequestDeletes(tuple_keys=keys, on_missing=self._on_missing) \ No newline at end of file diff --git a/config/clients/python/template/src/sync/client/client.py.mustache b/config/clients/python/template/src/sync/client/client.py.mustache index cc285cf07..434ddd39b 100644 --- a/config/clients/python/template/src/sync/client/client.py.mustache +++ b/config/clients/python/template/src/sync/client/client.py.mustache @@ -24,6 +24,12 @@ from {{packageName}}.client.models.list_relations_request import ClientListRelat from {{packageName}}.client.models.list_users_request import ClientListUsersRequest from {{packageName}}.client.models.write_single_response import construct_write_single_response from {{packageName}}.client.models.write_transaction_opts import WriteTransactionOpts +from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnDuplicateWrites, + ClientWriteRequestOnMissingDeletes, + ConflictOptions, +) +from {{packageName}}.client.models.write_options import ClientWriteOptions from {{packageName}}.client.models.read_changes_request import ClientReadChangesRequest from {{packageName}}.exceptions import ( AuthenticationError, @@ -112,6 +118,14 @@ def options_to_transaction_info(options: dict[str, int | str | dict[str, int | s return options["transaction"] return WriteTransactionOpts() +def options_to_conflict_info(options: dict[str, int | str | dict[str, int | str]] | None = None): + """ + Return the conflict info + """ + if options is not None and options.get("conflict"): + return options["conflict"] + return None + def _check_errored(response: ClientBatchCheckClientResponse): """ Helper function to return whether the response is errored @@ -438,6 +452,15 @@ class OpenFgaClient: Write or deletes tuples """ kwargs = options_to_kwargs(options) + conflict_options = options_to_conflict_info(options) + + # Set conflict options on the body if provided + if conflict_options: + if conflict_options.on_duplicate_writes: + body.on_duplicate = conflict_options.on_duplicate_writes.value + if conflict_options.on_missing_deletes: + body.on_missing = conflict_options.on_missing_deletes.value + writes_tuple_keys = None deletes_tuple_keys = None if body.writes_tuple_keys: @@ -919,4 +942,4 @@ class OpenFgaClient: api_request_body = WriteAssertionsRequest([map_to_assertion(client_assertion) for client_assertion in body]) api_response = self._api.write_assertions(authorization_model_id, api_request_body, **kwargs) - return api_response + return api_response \ No newline at end of file diff --git a/config/clients/python/template/test/client/client_test.py.mustache b/config/clients/python/template/test/client/client_test.py.mustache index b7de74e97..f45905129 100644 --- a/config/clients/python/template/test/client/client_test.py.mustache +++ b/config/clients/python/template/test/client/client_test.py.mustache @@ -3939,3 +3939,184 @@ class TestClientConfigurationHeaders: config.headers["X-New"] = "new-value" assert "X-New" not in copied_config.headers + + @patch.object(rest.RESTClientObject, "request") + async def test_write_with_conflict_options_ignore_duplicates(self, mock_request): + """Test case for write with conflict options - ignore duplicates""" + from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnDuplicateWrites, + ConflictOptions, + ) + + response_body = "{}" + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + body = ClientWriteRequest( + writes=[ + ClientTuple( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ], + ) + await api_client.write( + body, + options={ + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + "conflict": ConflictOptions( + on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE + ), + }, + ) + mock_request.assert_called_once_with( + "POST", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write", + headers=ANY, + query_params=[], + post_params=[], + body={ + "writes": { + "tuple_keys": [ + { + "object": "document:2021-budget", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + }, + ], + "on_duplicate": "ignore", + }, + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + }, + _preload_content=ANY, + _request_timeout=None, + ) + + @patch.object(rest.RESTClientObject, "request") + async def test_write_with_conflict_options_ignore_missing_deletes(self, mock_request): + """Test case for write with conflict options - ignore missing deletes""" + from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnMissingDeletes, + ConflictOptions, + ) + + response_body = "{}" + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + body = ClientWriteRequest( + deletes=[ + ClientTuple( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ) + ], + ) + await api_client.write( + body, + options={ + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + "conflict": ConflictOptions( + on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE + ), + }, + ) + mock_request.assert_called_once_with( + "POST", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write", + headers=ANY, + query_params=[], + post_params=[], + body={ + "deletes": { + "tuple_keys": [ + { + "object": "document:2021-budget", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + }, + ], + "on_missing": "ignore", + }, + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + }, + _preload_content=ANY, + _request_timeout=None, + ) + + @patch.object(rest.RESTClientObject, "request") + async def test_write_with_conflict_options_both(self, mock_request): + """Test case for write with both conflict options""" + from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnDuplicateWrites, + ClientWriteRequestOnMissingDeletes, + ConflictOptions, + ) + + response_body = "{}" + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + body = ClientWriteRequest( + writes=[ + ClientTuple( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ], + deletes=[ + ClientTuple( + object="document:2021-report", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31c", + ) + ], + ) + await api_client.write( + body, + options={ + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + "conflict": ConflictOptions( + on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE, + on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE, + ), + }, + ) + mock_request.assert_called_once_with( + "POST", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write", + headers=ANY, + query_params=[], + post_params=[], + body={ + "writes": { + "tuple_keys": [ + { + "object": "document:2021-budget", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + }, + ], + "on_duplicate": "ignore", + }, + "deletes": { + "tuple_keys": [ + { + "object": "document:2021-report", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", + }, + ], + "on_missing": "ignore", + }, + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + }, + _preload_content=ANY, + _request_timeout=None, + ) \ No newline at end of file diff --git a/config/clients/python/template/test/sync/client/client_test.py.mustache b/config/clients/python/template/test/sync/client/client_test.py.mustache index 814637469..cab7925f8 100644 --- a/config/clients/python/template/test/sync/client/client_test.py.mustache +++ b/config/clients/python/template/test/sync/client/client_test.py.mustache @@ -3938,3 +3938,184 @@ class TestSyncClientConfigurationHeaders: config.headers["X-New"] = "new-value" assert "X-New" not in copied_config.headers + + @patch.object(rest.RESTClientObject, "request") + def test_sync_write_with_conflict_options_ignore_duplicates(self, mock_request): + """Test case for write with conflict options - ignore duplicates""" + from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnDuplicateWrites, + ConflictOptions, + ) + + response_body = "{}" + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + body = ClientWriteRequest( + writes=[ + ClientTuple( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ], + ) + api_client.write( + body, + options={ + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + "conflict": ConflictOptions( + on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE + ), + }, + ) + mock_request.assert_called_once_with( + "POST", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write", + headers=ANY, + query_params=[], + post_params=[], + body={ + "writes": { + "tuple_keys": [ + { + "object": "document:2021-budget", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + }, + ], + "on_duplicate": "ignore", + }, + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + }, + _preload_content=ANY, + _request_timeout=None, + ) + + @patch.object(rest.RESTClientObject, "request") + def test_sync_write_with_conflict_options_ignore_missing_deletes(self, mock_request): + """Test case for write with conflict options - ignore missing deletes""" + from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnMissingDeletes, + ConflictOptions, + ) + + response_body = "{}" + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + body = ClientWriteRequest( + deletes=[ + ClientTuple( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ) + ], + ) + api_client.write( + body, + options={ + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + "conflict": ConflictOptions( + on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE + ), + }, + ) + mock_request.assert_called_once_with( + "POST", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write", + headers=ANY, + query_params=[], + post_params=[], + body={ + "deletes": { + "tuple_keys": [ + { + "object": "document:2021-budget", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + }, + ], + "on_missing": "ignore", + }, + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + }, + _preload_content=ANY, + _request_timeout=None, + ) + + @patch.object(rest.RESTClientObject, "request") + def test_sync_write_with_conflict_options_both(self, mock_request): + """Test case for write with both conflict options""" + from {{packageName}}.client.models.write_conflict_opts import ( + ClientWriteRequestOnDuplicateWrites, + ClientWriteRequestOnMissingDeletes, + ConflictOptions, + ) + + response_body = "{}" + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + body = ClientWriteRequest( + writes=[ + ClientTuple( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ], + deletes=[ + ClientTuple( + object="document:2021-report", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31c", + ) + ], + ) + api_client.write( + body, + options={ + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + "conflict": ConflictOptions( + on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE, + on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE, + ), + }, + ) + mock_request.assert_called_once_with( + "POST", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write", + headers=ANY, + query_params=[], + post_params=[], + body={ + "writes": { + "tuple_keys": [ + { + "object": "document:2021-budget", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + }, + ], + "on_duplicate": "ignore", + }, + "deletes": { + "tuple_keys": [ + { + "object": "document:2021-report", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", + }, + ], + "on_missing": "ignore", + }, + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + }, + _preload_content=ANY, + _request_timeout=None, + ) \ No newline at end of file From 07a87d8a46ca9eed781dc4723f7b8175f796e8ce Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Oct 2025 13:05:02 +0530 Subject: [PATCH 02/15] fix: default args for on missing and on deletes test fix --- .../clients/python/template/test/api_test.py.mustache | 6 ++++-- .../template/test/client/client_test.py.mustache | 10 +++++++++- .../python/template/test/sync/api_test.py.mustache | 6 ++++-- .../template/test/sync/client/client_test.py.mustache | 10 +++++++++- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/config/clients/python/template/test/api_test.py.mustache b/config/clients/python/template/test/api_test.py.mustache index a7d424552..efe6dec0f 100644 --- a/config/clients/python/template/test/api_test.py.mustache +++ b/config/clients/python/template/test/api_test.py.mustache @@ -873,7 +873,8 @@ class TestOpenFgaApi(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_duplicate": "error", }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -927,7 +928,8 @@ class TestOpenFgaApi(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_missing": "error", }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, diff --git a/config/clients/python/template/test/client/client_test.py.mustache b/config/clients/python/template/test/client/client_test.py.mustache index f45905129..cb918b4fc 100644 --- a/config/clients/python/template/test/client/client_test.py.mustache +++ b/config/clients/python/template/test/client/client_test.py.mustache @@ -3875,9 +3875,17 @@ def client_configuration(): ) -class TestClientConfigurationHeaders: +class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): """Tests for ClientConfiguration headers parameter""" + def setUp(self): + self.configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) + + def tearDown(self): + pass + def test_client_configuration_headers_default_none(self, client_configuration): """Test that headers default to an empty dict in ClientConfiguration""" assert client_configuration.headers == {} diff --git a/config/clients/python/template/test/sync/api_test.py.mustache b/config/clients/python/template/test/sync/api_test.py.mustache index 7030f61b6..24c670150 100644 --- a/config/clients/python/template/test/sync/api_test.py.mustache +++ b/config/clients/python/template/test/sync/api_test.py.mustache @@ -940,7 +940,8 @@ class TestOpenFgaApiSync(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_duplicate": "error", }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -994,7 +995,8 @@ class TestOpenFgaApiSync(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_missing": "error", }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, diff --git a/config/clients/python/template/test/sync/client/client_test.py.mustache b/config/clients/python/template/test/sync/client/client_test.py.mustache index cab7925f8..d3aa1b84b 100644 --- a/config/clients/python/template/test/sync/client/client_test.py.mustache +++ b/config/clients/python/template/test/sync/client/client_test.py.mustache @@ -3866,9 +3866,17 @@ def client_configuration(): ) -class TestSyncClientConfigurationHeaders: +class TestSyncClientConfigurationHeaders(IsolatedAsyncioTestCase): """Tests for ClientConfiguration headers parameter in sync client""" + def setUp(self): + self.configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) + + def tearDown(self): + pass + def test_sync_client_configuration_headers_default_none(self, client_configuration): """Test that headers default to an empty dict in ClientConfiguration""" assert client_configuration.headers == {} From 8014c8f97e0ef57de07803695c55509346977983 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Oct 2025 13:36:17 +0530 Subject: [PATCH 03/15] feat: remove setUp script for test --- .../test/client/client_test.py.mustache | 22 +++++++++++-------- .../test/sync/client/client_test.py.mustache | 22 +++++++++---------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/config/clients/python/template/test/client/client_test.py.mustache b/config/clients/python/template/test/client/client_test.py.mustache index cb918b4fc..b1dcddb75 100644 --- a/config/clients/python/template/test/client/client_test.py.mustache +++ b/config/clients/python/template/test/client/client_test.py.mustache @@ -3875,17 +3875,9 @@ def client_configuration(): ) -class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): +class TestClientConfigurationHeaders: """Tests for ClientConfiguration headers parameter""" - def setUp(self): - self.configuration = ClientConfiguration( - api_url="http://api.fga.example", - ) - - def tearDown(self): - pass - def test_client_configuration_headers_default_none(self, client_configuration): """Test that headers default to an empty dict in ClientConfiguration""" assert client_configuration.headers == {} @@ -3949,6 +3941,7 @@ class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): assert "X-New" not in copied_config.headers @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio async def test_write_with_conflict_options_ignore_duplicates(self, mock_request): """Test case for write with conflict options - ignore duplicates""" from {{packageName}}.client.models.write_conflict_opts import ( @@ -3958,6 +3951,9 @@ class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): response_body = "{}" mock_request.return_value = mock_response(response_body, 200) + self.configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) configuration = self.configuration configuration.store_id = store_id async with OpenFgaClient(configuration) as api_client: @@ -4003,6 +3999,7 @@ class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): ) @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio async def test_write_with_conflict_options_ignore_missing_deletes(self, mock_request): """Test case for write with conflict options - ignore missing deletes""" from {{packageName}}.client.models.write_conflict_opts import ( @@ -4012,6 +4009,9 @@ class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): response_body = "{}" mock_request.return_value = mock_response(response_body, 200) + self.configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) configuration = self.configuration configuration.store_id = store_id async with OpenFgaClient(configuration) as api_client: @@ -4057,6 +4057,7 @@ class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): ) @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio async def test_write_with_conflict_options_both(self, mock_request): """Test case for write with both conflict options""" from {{packageName}}.client.models.write_conflict_opts import ( @@ -4067,6 +4068,9 @@ class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): response_body = "{}" mock_request.return_value = mock_response(response_body, 200) + self.configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) configuration = self.configuration configuration.store_id = store_id async with OpenFgaClient(configuration) as api_client: diff --git a/config/clients/python/template/test/sync/client/client_test.py.mustache b/config/clients/python/template/test/sync/client/client_test.py.mustache index d3aa1b84b..ee3508dab 100644 --- a/config/clients/python/template/test/sync/client/client_test.py.mustache +++ b/config/clients/python/template/test/sync/client/client_test.py.mustache @@ -3866,17 +3866,9 @@ def client_configuration(): ) -class TestSyncClientConfigurationHeaders(IsolatedAsyncioTestCase): +class TestSyncClientConfigurationHeaders: """Tests for ClientConfiguration headers parameter in sync client""" - def setUp(self): - self.configuration = ClientConfiguration( - api_url="http://api.fga.example", - ) - - def tearDown(self): - pass - def test_sync_client_configuration_headers_default_none(self, client_configuration): """Test that headers default to an empty dict in ClientConfiguration""" assert client_configuration.headers == {} @@ -3957,7 +3949,9 @@ class TestSyncClientConfigurationHeaders(IsolatedAsyncioTestCase): response_body = "{}" mock_request.return_value = mock_response(response_body, 200) - configuration = self.configuration + configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) configuration.store_id = store_id with OpenFgaClient(configuration) as api_client: body = ClientWriteRequest( @@ -4011,7 +4005,9 @@ class TestSyncClientConfigurationHeaders(IsolatedAsyncioTestCase): response_body = "{}" mock_request.return_value = mock_response(response_body, 200) - configuration = self.configuration + configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) configuration.store_id = store_id with OpenFgaClient(configuration) as api_client: body = ClientWriteRequest( @@ -4066,7 +4062,9 @@ class TestSyncClientConfigurationHeaders(IsolatedAsyncioTestCase): response_body = "{}" mock_request.return_value = mock_response(response_body, 200) - configuration = self.configuration + configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) configuration.store_id = store_id with OpenFgaClient(configuration) as api_client: body = ClientWriteRequest( From 050476d8786de70245a9db6dfe25d97115e6e197 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Oct 2025 13:42:48 +0530 Subject: [PATCH 04/15] fix: lint issues remove unused imports --- .../clients/python/template/src/client/client.py.mustache | 6 ------ .../python/template/src/sync/client/client.py.mustache | 6 ------ 2 files changed, 12 deletions(-) diff --git a/config/clients/python/template/src/client/client.py.mustache b/config/clients/python/template/src/client/client.py.mustache index 4b2669c45..c82d4cdb0 100644 --- a/config/clients/python/template/src/client/client.py.mustache +++ b/config/clients/python/template/src/client/client.py.mustache @@ -27,12 +27,6 @@ from {{packageName}}.client.models.list_relations_request import ClientListRelat from {{packageName}}.client.models.list_users_request import ClientListUsersRequest from {{packageName}}.client.models.write_single_response import construct_write_single_response from {{packageName}}.client.models.write_transaction_opts import WriteTransactionOpts -from {{packageName}}.client.models.write_conflict_opts import ( - ClientWriteRequestOnDuplicateWrites, - ClientWriteRequestOnMissingDeletes, - ConflictOptions, -) -from {{packageName}}.client.models.write_options import ClientWriteOptions from {{packageName}}.client.models.read_changes_request import ClientReadChangesRequest from {{packageName}}.exceptions import ( AuthenticationError, diff --git a/config/clients/python/template/src/sync/client/client.py.mustache b/config/clients/python/template/src/sync/client/client.py.mustache index 434ddd39b..c7d53668f 100644 --- a/config/clients/python/template/src/sync/client/client.py.mustache +++ b/config/clients/python/template/src/sync/client/client.py.mustache @@ -24,12 +24,6 @@ from {{packageName}}.client.models.list_relations_request import ClientListRelat from {{packageName}}.client.models.list_users_request import ClientListUsersRequest from {{packageName}}.client.models.write_single_response import construct_write_single_response from {{packageName}}.client.models.write_transaction_opts import WriteTransactionOpts -from {{packageName}}.client.models.write_conflict_opts import ( - ClientWriteRequestOnDuplicateWrites, - ClientWriteRequestOnMissingDeletes, - ConflictOptions, -) -from {{packageName}}.client.models.write_options import ClientWriteOptions from {{packageName}}.client.models.read_changes_request import ClientReadChangesRequest from {{packageName}}.exceptions import ( AuthenticationError, From a9106474a7c59db2cca154d12a9535b34f19364a Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Oct 2025 16:57:10 +0530 Subject: [PATCH 05/15] feat: add README and changelog changes --- config/clients/python/CHANGELOG.md.mustache | 4 + .../template/README_calling_api.mustache | 127 ++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/config/clients/python/CHANGELOG.md.mustache b/config/clients/python/CHANGELOG.md.mustache index af05b7739..6337c1b8d 100644 --- a/config/clients/python/CHANGELOG.md.mustache +++ b/config/clients/python/CHANGELOG.md.mustache @@ -1,6 +1,10 @@ # Changelog ## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD) +- feat: The SDK supports OpenFGA v1.10.0+ conflict options: + - `on_duplicate` for handling duplicate tuple writes (ERROR or IGNORE) + - `on_missing` for handling deletes of non-existent tuples (ERROR or IGNORE) +- docs: added documentation for write conflict options in README ### [{{packageVersion}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.9.6...{{packageVersion}}) (2025-10-06) diff --git a/config/clients/python/template/README_calling_api.mustache b/config/clients/python/template/README_calling_api.mustache index 83ddf5e3d..4c214d91f 100644 --- a/config/clients/python/template/README_calling_api.mustache +++ b/config/clients/python/template/README_calling_api.mustache @@ -452,6 +452,133 @@ body = ClientWriteRequest( response = await fga_client.write(body, options) ``` +###### Conflict Options + +OpenFGA v1.10.0 introduced support for write conflict options to handle duplicate writes and missing deletes gracefully. These options help avoid unnecessary error handling logic in client code. + +**Available Options:** + +- `on_duplicate` - Controls behavior when writing a tuple that already exists: + - `ERROR` (default): Returns an error if an identical tuple already exists + - `IGNORE`: Silently ignores duplicate writes (treats as no-op) + +- `on_missing` - Controls behavior when deleting a tuple that doesn't exist: + - `ERROR` (default): Returns an error if the tuple doesn't exist + - `IGNORE`: Silently ignores deletes of non-existent tuples (treats as no-op) + +**Example: Ignoring duplicate writes** + +```python +# from openfga_sdk import OpenFgaClient +# from openfga_sdk.client.models import ClientTuple, ClientWriteRequest +# from openfga_sdk.client.models.write_conflict_opts import ( +# ClientWriteRequestOnDuplicateWrites, +# ConflictOptions, +# ) + +# Initialize the fga_client +# fga_client = OpenFgaClient(configuration) + +options = { + "authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1", + "conflict": ConflictOptions( + on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE + ) +} + +body = ClientWriteRequest( + writes=[ + ClientTuple( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="viewer", + object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + ), + ], +) + +# This will succeed even if the tuple already exists +response = await fga_client.write(body, options) +``` + +**Example: Ignoring missing deletes** + +```python +# from openfga_sdk import OpenFgaClient +# from openfga_sdk.client.models import ClientTuple, ClientWriteRequest +# from openfga_sdk.client.models.write_conflict_opts import ( +# ClientWriteRequestOnMissingDeletes, +# ConflictOptions, +# ) + +# Initialize the fga_client +# fga_client = OpenFgaClient(configuration) + +options = { + "authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1", + "conflict": ConflictOptions( + on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE + ) +} + +body = ClientWriteRequest( + deletes=[ + ClientTuple( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="writer", + object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + ), + ], +) + +# This will succeed even if the tuple doesn't exist +response = await fga_client.write(body, options) +``` + +**Example: Using both conflict options together** + +```python +# from openfga_sdk import OpenFgaClient +# from openfga_sdk.client.models import ClientTuple, ClientWriteRequest +# from openfga_sdk.client.models.write_conflict_opts import ( +# ClientWriteRequestOnDuplicateWrites, +# ClientWriteRequestOnMissingDeletes, +# ConflictOptions, +# ) + +# Initialize the fga_client +# fga_client = OpenFgaClient(configuration) + +options = { + "authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1", + "conflict": ConflictOptions( + on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE, + on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE, + ) +} + +body = ClientWriteRequest( + writes=[ + ClientTuple( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="viewer", + object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + ), + ], + deletes=[ + ClientTuple( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="writer", + object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + ), + ], +) + +# Both operations will succeed regardless of tuple existence +response = await fga_client.write(body, options) +``` + +For a complete working example, see the [conflict-options example](https://github.com/openfga/python-sdk/tree/main/example/conflict-options). + #### Relationship Queries ##### Check From 3b64c2ea8c1aa00589db2fafc44222998d5259eb Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Oct 2025 22:22:00 +0530 Subject: [PATCH 06/15] fix: ruff lint error --- Makefile | 2 +- config/clients/python/template/model.mustache | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index ed80677ed..93e04fc97 100644 --- a/Makefile +++ b/Makefile @@ -152,7 +152,7 @@ test-client-python: build-client-python make run-in-docker sdk_language=python image=ghcr.io/astral-sh/uv:python${PYTHON_DOCKER_TAG}-alpine command="/bin/sh -c 'export UV_LINK_MODE=copy && \ uv sync && \ uv run pytest --cov-report term-missing --cov=openfga_sdk test/ && \ - uv run ruff check .'" + uv run ruff check . --fix'" ### Java .PHONY: tag-client-java diff --git a/config/clients/python/template/model.mustache b/config/clients/python/template/model.mustache index 734690595..cf3854124 100644 --- a/config/clients/python/template/model.mustache +++ b/config/clients/python/template/model.mustache @@ -117,19 +117,19 @@ class {{classname}}: {{#isArray}} if (self.local_vars_configuration.client_side_validation and not set({{{name}}}).issubset(set(allowed_values))): + invalid_values = ", ".join(map(str, set({{{name}}}) - set(allowed_values))) + valid_values = ", ".join(map(str, allowed_values)) raise ValueError( - "Invalid values for `{{{name}}}` [{0}], must be a subset of [{1}]" - .format(", ".join(map(str, set({{{name}}}) - set(allowed_values))), - ", ".join(map(str, allowed_values))) + f"Invalid values for `{{{name}}}` [{invalid_values}], must be a subset of [{valid_values}]" ) {{/isArray}} {{#isMap}} if (self.local_vars_configuration.client_side_validation and not set({{{name}}}.keys()).issubset(set(allowed_values))): + invalid_keys = ", ".join(map(str, set({{{name}}}.keys()) - set(allowed_values))) + valid_values = ", ".join(map(str, allowed_values)) raise ValueError( - "Invalid keys in `{{{name}}}` [{0}], must be a subset of [{1}]" - .format(", ".join(map(str, set({{{name}}}.keys()) - set(allowed_values))), - ", ".join(map(str, allowed_values))) + f"Invalid keys in `{{{name}}}` [{invalid_keys}], must be a subset of [{valid_values}]" ) {{/isMap}} {{/isContainer}} @@ -137,8 +137,7 @@ class {{classname}}: allowed_values = [{{#isNullable}}None,{{/isNullable}}{{#allowableValues}}{{#values}}{{#isString}}"{{/isString}}{{{this}}}{{#isString}}"{{/isString}}{{^-last}}, {{/-last}}{{/values}}{{/allowableValues}}] if self.local_vars_configuration.client_side_validation and {{{name}}} not in allowed_values: raise ValueError( - "Invalid value for `{{{name}}}` ({0}), must be one of {1}" - .format({{{name}}}, allowed_values) + f"Invalid value for `{{{name}}}` ({{{name}}}), must be one of {allowed_values}" ) {{/isContainer}} {{/isEnum}} From 8395a2a24c9f4c862d53a1d04a774dbc206b2a9d Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Oct 2025 22:26:08 +0530 Subject: [PATCH 07/15] fix: revert mf change --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 93e04fc97..ed80677ed 100644 --- a/Makefile +++ b/Makefile @@ -152,7 +152,7 @@ test-client-python: build-client-python make run-in-docker sdk_language=python image=ghcr.io/astral-sh/uv:python${PYTHON_DOCKER_TAG}-alpine command="/bin/sh -c 'export UV_LINK_MODE=copy && \ uv sync && \ uv run pytest --cov-report term-missing --cov=openfga_sdk test/ && \ - uv run ruff check . --fix'" + uv run ruff check .'" ### Java .PHONY: tag-client-java From 1385edbcfccbfd3cd4c429c98c5cf0c59c10f80a Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Oct 2025 22:29:14 +0530 Subject: [PATCH 08/15] fix: use correct open API ref --- .github/workflows/main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 61ca8d55c..5ffee1f58 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -136,7 +136,7 @@ jobs: - name: Run All Tests run: make test-client-python env: - OPEN_API_REF: c0b62b28b14d0d164d37a1f6bf19dc9d39e5769b + OPEN_API_REF: 0ac19aac54f21f3c78970126b84b4c69c6e3b9a2 - name: Check for SDK changes run: | From 9d4ed8b339713c7be744ce13f9d49301ffa88e2d Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 17 Oct 2025 21:26:56 +0530 Subject: [PATCH 09/15] feat: readme and changelog changes reverse sync from sdk --- config/clients/python/CHANGELOG.md.mustache | 4 +- .../template/README_calling_api.mustache | 94 +------------------ .../client/models/write_request.py.mustache | 50 +++------- 3 files changed, 20 insertions(+), 128 deletions(-) diff --git a/config/clients/python/CHANGELOG.md.mustache b/config/clients/python/CHANGELOG.md.mustache index 6337c1b8d..dc7d99495 100644 --- a/config/clients/python/CHANGELOG.md.mustache +++ b/config/clients/python/CHANGELOG.md.mustache @@ -1,7 +1,9 @@ # Changelog ## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD) -- feat: The SDK supports OpenFGA v1.10.0+ conflict options: +- feat: add support for conflict options for Write operations: (#235) + The client now supports setting `ConflictOptions` on `ClientWriteOptions` to control behavior when writing duplicate tuples or deleting non-existent tuples. This feature requires OpenFGA server [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. + See [Conflict Options for Write Operations](./README.md#conflict-options-for-write-operations) for more. - `on_duplicate` for handling duplicate tuple writes (ERROR or IGNORE) - `on_missing` for handling deletes of non-existent tuples (ERROR or IGNORE) - docs: added documentation for write conflict options in README diff --git a/config/clients/python/template/README_calling_api.mustache b/config/clients/python/template/README_calling_api.mustache index 4c214d91f..e7348c67e 100644 --- a/config/clients/python/template/README_calling_api.mustache +++ b/config/clients/python/template/README_calling_api.mustache @@ -452,107 +452,26 @@ body = ClientWriteRequest( response = await fga_client.write(body, options) ``` -###### Conflict Options +###### Conflict Options for Write Operations -OpenFGA v1.10.0 introduced support for write conflict options to handle duplicate writes and missing deletes gracefully. These options help avoid unnecessary error handling logic in client code. +OpenFGA v1.10.0+ supports conflict options for write operations to handle duplicate writes and missing deletes gracefully. -**Available Options:** - -- `on_duplicate` - Controls behavior when writing a tuple that already exists: - - `ERROR` (default): Returns an error if an identical tuple already exists - - `IGNORE`: Silently ignores duplicate writes (treats as no-op) - -- `on_missing` - Controls behavior when deleting a tuple that doesn't exist: - - `ERROR` (default): Returns an error if the tuple doesn't exist - - `IGNORE`: Silently ignores deletes of non-existent tuples (treats as no-op) - -**Example: Ignoring duplicate writes** +**Example: Ignore duplicate writes and missing deletes** ```python # from openfga_sdk import OpenFgaClient # from openfga_sdk.client.models import ClientTuple, ClientWriteRequest # from openfga_sdk.client.models.write_conflict_opts import ( # ClientWriteRequestOnDuplicateWrites, -# ConflictOptions, -# ) - -# Initialize the fga_client -# fga_client = OpenFgaClient(configuration) - -options = { - "authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1", - "conflict": ConflictOptions( - on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE - ) -} - -body = ClientWriteRequest( - writes=[ - ClientTuple( - user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", - relation="viewer", - object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", - ), - ], -) - -# This will succeed even if the tuple already exists -response = await fga_client.write(body, options) -``` - -**Example: Ignoring missing deletes** - -```python -# from openfga_sdk import OpenFgaClient -# from openfga_sdk.client.models import ClientTuple, ClientWriteRequest -# from openfga_sdk.client.models.write_conflict_opts import ( # ClientWriteRequestOnMissingDeletes, # ConflictOptions, # ) -# Initialize the fga_client -# fga_client = OpenFgaClient(configuration) - -options = { - "authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1", - "conflict": ConflictOptions( - on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE - ) -} - -body = ClientWriteRequest( - deletes=[ - ClientTuple( - user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", - relation="writer", - object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", - ), - ], -) - -# This will succeed even if the tuple doesn't exist -response = await fga_client.write(body, options) -``` - -**Example: Using both conflict options together** - -```python -# from openfga_sdk import OpenFgaClient -# from openfga_sdk.client.models import ClientTuple, ClientWriteRequest -# from openfga_sdk.client.models.write_conflict_opts import ( -# ClientWriteRequestOnDuplicateWrites, -# ClientWriteRequestOnMissingDeletes, -# ConflictOptions, -# ) - -# Initialize the fga_client -# fga_client = OpenFgaClient(configuration) - options = { "authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1", "conflict": ConflictOptions( - on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE, - on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE, + on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE, # Available options: ERROR, IGNORE + on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE, # Available options: ERROR, IGNORE ) } @@ -573,12 +492,9 @@ body = ClientWriteRequest( ], ) -# Both operations will succeed regardless of tuple existence response = await fga_client.write(body, options) ``` -For a complete working example, see the [conflict-options example](https://github.com/openfga/python-sdk/tree/main/example/conflict-options). - #### Relationship Queries ##### Check diff --git a/config/clients/python/template/src/client/models/write_request.py.mustache b/config/clients/python/template/src/client/models/write_request.py.mustache index cc57c0d66..f6b74cf68 100644 --- a/config/clients/python/template/src/client/models/write_request.py.mustache +++ b/config/clients/python/template/src/client/models/write_request.py.mustache @@ -14,13 +14,9 @@ class ClientWriteRequest: self, writes: list[ClientTuple] | None = None, deletes: list[ClientTuple] | None = None, - on_duplicate: str | None = None, - on_missing: str | None = None, ) -> None: self._writes = writes self._deletes = deletes - self._on_duplicate = on_duplicate - self._on_missing = on_missing @property def writes(self) -> list[ClientTuple] | None: @@ -50,36 +46,9 @@ class ClientWriteRequest: """ self._deletes = value - @property - def on_duplicate(self) -> str | None: - """ - Return on_duplicate - """ - return self._on_duplicate - - @on_duplicate.setter - def on_duplicate(self, value: str | None) -> None: - """ - Set on_duplicate - """ - self._on_duplicate = value - - @property - def on_missing(self) -> str | None: - """ - Return on_missing - """ - return self._on_missing - - @on_missing.setter - def on_missing(self, value: str | None) -> None: - """ - Set on_missing - """ - self._on_missing = value - - @property - def writes_tuple_keys(self) -> WriteRequestWrites | None: + def writes_tuple_keys( + self, on_duplicate: str | None = None + ) -> WriteRequestWrites | None: """ Return the writes as tuple keys """ @@ -91,10 +60,13 @@ class ClientWriteRequest: if keys is None: return None - return WriteRequestWrites(tuple_keys=keys, on_duplicate=self._on_duplicate) + if on_duplicate is not None: + return WriteRequestWrites(tuple_keys=keys, on_duplicate=on_duplicate) + return WriteRequestWrites(tuple_keys=keys) - @property - def deletes_tuple_keys(self) -> WriteRequestDeletes | None: + def deletes_tuple_keys( + self, on_missing: str | None = None + ) -> WriteRequestDeletes | None: """ Return the delete as tuple keys """ @@ -106,4 +78,6 @@ class ClientWriteRequest: if keys is None: return None - return WriteRequestDeletes(tuple_keys=keys, on_missing=self._on_missing) \ No newline at end of file + if on_missing is not None: + return WriteRequestDeletes(tuple_keys=keys, on_missing=on_missing) + return WriteRequestDeletes(tuple_keys=keys) From ab6718af896671bd635083c2afc943bb3a69cb6d Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 17 Oct 2025 22:19:54 +0530 Subject: [PATCH 10/15] feat: client file reverse sync --- .../template/src/client/client.py.mustache | 30 ++++++++++++------- .../src/sync/client/client.py.mustache | 30 ++++++++++++------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/config/clients/python/template/src/client/client.py.mustache b/config/clients/python/template/src/client/client.py.mustache index c82d4cdb0..56a838af4 100644 --- a/config/clients/python/template/src/client/client.py.mustache +++ b/config/clients/python/template/src/client/client.py.mustache @@ -456,26 +456,32 @@ class OpenFgaClient: return batch_write_responses - {{#asyncio}}async {{/asyncio}}def _write_with_transaction(self, body: ClientWriteRequest, options: dict[str, int | str | dict[str, int | str]] | None = None): + {{#asyncio}}async {{/asyncio}}def _write_with_transaction( + self, + body: ClientWriteRequest, + options: dict[str, int | str | dict[str, int | str]] | None = None, + ): """ Write or deletes tuples """ kwargs = options_to_kwargs(options) conflict_options = options_to_conflict_info(options) - # Set conflict options on the body if provided + # Extract conflict options to pass to the tuple key methods + on_duplicate = None + on_missing = None if conflict_options: if conflict_options.on_duplicate_writes: - body.on_duplicate = conflict_options.on_duplicate_writes.value + on_duplicate = conflict_options.on_duplicate_writes.value if conflict_options.on_missing_deletes: - body.on_missing = conflict_options.on_missing_deletes.value + on_missing = conflict_options.on_missing_deletes.value writes_tuple_keys = None deletes_tuple_keys = None - if body.writes_tuple_keys: - writes_tuple_keys=body.writes_tuple_keys - if body.deletes_tuple_keys: - deletes_tuple_keys=body.deletes_tuple_keys + if body.writes: + writes_tuple_keys = body.writes_tuple_keys(on_duplicate=on_duplicate) + if body.deletes: + deletes_tuple_keys = body.deletes_tuple_keys(on_missing=on_missing) {{#asyncio}}await {{/asyncio}}self._api.write( WriteRequest( @@ -488,10 +494,14 @@ class OpenFgaClient: # any error will result in exception being thrown and not reached below code writes_response = None if body.writes: - writes_response = [construct_write_single_response(i, True, None) for i in body.writes] + writes_response = [ + construct_write_single_response(i, True, None) for i in body.writes + ] deletes_response = None if body.deletes: - deletes_response = [construct_write_single_response(i, True, None) for i in body.deletes] + deletes_response = [ + construct_write_single_response(i, True, None) for i in body.deletes + ] return ClientWriteResponse(writes=writes_response, deletes=deletes_response) {{#asyncio}}async {{/asyncio}}def write(self, body: ClientWriteRequest, options: dict[str, int | str | dict[str, int | str]] | None = None): diff --git a/config/clients/python/template/src/sync/client/client.py.mustache b/config/clients/python/template/src/sync/client/client.py.mustache index c7d53668f..6abcf62dd 100644 --- a/config/clients/python/template/src/sync/client/client.py.mustache +++ b/config/clients/python/template/src/sync/client/client.py.mustache @@ -441,26 +441,32 @@ class OpenFgaClient: return batch_write_responses - def _write_with_transaction(self, body: ClientWriteRequest, options: dict[str, int | str | dict[str, int | str]] | None = None): + def _write_with_transaction( + self, + body: ClientWriteRequest, + options: dict[str, int | str | dict[str, int | str]] | None = None, + ): """ Write or deletes tuples """ kwargs = options_to_kwargs(options) conflict_options = options_to_conflict_info(options) - # Set conflict options on the body if provided + # Extract conflict options to pass to the tuple key methods + on_duplicate = None + on_missing = None if conflict_options: if conflict_options.on_duplicate_writes: - body.on_duplicate = conflict_options.on_duplicate_writes.value + on_duplicate = conflict_options.on_duplicate_writes.value if conflict_options.on_missing_deletes: - body.on_missing = conflict_options.on_missing_deletes.value + on_missing = conflict_options.on_missing_deletes.value writes_tuple_keys = None deletes_tuple_keys = None - if body.writes_tuple_keys: - writes_tuple_keys=body.writes_tuple_keys - if body.deletes_tuple_keys: - deletes_tuple_keys=body.deletes_tuple_keys + if body.writes: + writes_tuple_keys = body.writes_tuple_keys(on_duplicate=on_duplicate) + if body.deletes: + deletes_tuple_keys = body.deletes_tuple_keys(on_missing=on_missing) self._api.write( WriteRequest( @@ -473,10 +479,14 @@ class OpenFgaClient: # any error will result in exception being thrown and not reached below code writes_response = None if body.writes: - writes_response = [construct_write_single_response(i, True, None) for i in body.writes] + writes_response = [ + construct_write_single_response(i, True, None) for i in body.writes + ] deletes_response = None if body.deletes: - deletes_response = [construct_write_single_response(i, True, None) for i in body.deletes] + deletes_response = [ + construct_write_single_response(i, True, None) for i in body.deletes + ] return ClientWriteResponse(writes=writes_response, deletes=deletes_response) def write(self, body: ClientWriteRequest, options: dict[str, int | str | dict[str, int | str]] | None = None): From ceb366c33533ef2f95e30cd1725de97e6de0b162 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 17 Oct 2025 22:48:41 +0530 Subject: [PATCH 11/15] fix: client tests mustache template reverse sync --- .../test/client/client_test.py.mustache | 91 +++++++++++++------ .../test/sync/client/client_test.py.mustache | 73 ++++++++++----- 2 files changed, 111 insertions(+), 53 deletions(-) diff --git a/config/clients/python/template/test/client/client_test.py.mustache b/config/clients/python/template/test/client/client_test.py.mustache index b1dcddb75..f2e000bee 100644 --- a/config/clients/python/template/test/client/client_test.py.mustache +++ b/config/clients/python/template/test/client/client_test.py.mustache @@ -1016,7 +1016,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", }, - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1061,7 +1062,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_missing": "error", }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1161,7 +1163,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1182,7 +1185,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1203,7 +1207,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1303,7 +1308,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1324,7 +1330,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1345,7 +1352,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1449,7 +1457,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", }, - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1470,7 +1479,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1584,7 +1594,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1605,7 +1616,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1626,7 +1638,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1680,7 +1693,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_missing": "error", }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1743,7 +1757,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", }, - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1806,7 +1821,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", }, - ] + ], + "on_missing": "error", }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1862,7 +1878,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "object": "document:2021-budget", } - ] + ], + "on_duplicate": "error", }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -2173,7 +2190,9 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): if user == "user:81684243-9356-4421-8fbf-a4f8d36aa31b": return mock_response('{"allowed": true, "resolution": "1234"}', 200) elif user == "user:81684243-9356-4421-8fbf-a4f8d36aa31c": - raise ValidationException(http_resp=http_mock_response(response_body, 400)) + raise ValidationException( + http_resp=http_mock_response(response_body, 400) + ) elif user == "user:81684243-9356-4421-8fbf-a4f8d36aa31d": return mock_response('{"allowed": false, "resolution": "1234"}', 200) return mock_response('{"allowed": false, "resolution": "1234"}', 200) @@ -3395,8 +3414,12 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): # Verify all four headers exist with correct values self.assertEqual(result["headers"]["X-SDK-Version"], "1.0.0") # Default was set - self.assertEqual(result["headers"]["X-Request-ID"], "my-custom-request-id") # Custom overrode default - self.assertEqual(result["headers"]["X-Tenant-ID"], "tenant-123") # Custom preserved + self.assertEqual( + result["headers"]["X-Request-ID"], "my-custom-request-id" + ) # Custom overrode default + self.assertEqual( + result["headers"]["X-Tenant-ID"], "tenant-123" + ) # Custom preserved self.assertEqual(len(result["headers"]), 3) # Exactly 3 headers def test_set_heading_if_not_set_with_empty_string_value(self): @@ -3419,7 +3442,9 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): """Test that headers with special characters are handled correctly.""" options = {"headers": {"X-Custom-Header": "value-with-special!@#$%^&*()_+"}} result = set_heading_if_not_set(options, "X-Custom-Header", "default") - self.assertEqual(result["headers"]["X-Custom-Header"], "value-with-special!@#$%^&*()_+") + self.assertEqual( + result["headers"]["X-Custom-Header"], "value-with-special!@#$%^&*()_+" + ) def test_set_heading_if_not_set_case_sensitivity(self): """Test that header names are treated as case-sensitive by the helper function.""" @@ -3555,7 +3580,9 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): async with OpenFgaClient(configuration) as api_client: custom_options = {"headers": {"X-List-Objects-Id": "list-obj-999"}} - api_response = await api_client.list_objects(body=body, options=custom_options) + api_response = await api_client.list_objects( + body=body, options=custom_options + ) call_args = mock_request.call_args headers = call_args[1]["headers"] @@ -3580,7 +3607,9 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): async with OpenFgaClient(configuration) as api_client: custom_options = {"headers": {"X-List-Users-Id": "list-users-777"}} - api_response = await api_client.list_users(body=body, options=custom_options) + api_response = await api_client.list_users( + body=body, options=custom_options + ) call_args = mock_request.call_args headers = call_args[1]["headers"] @@ -3622,11 +3651,15 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): async with OpenFgaClient(configuration) as api_client: # First call with custom header 1 check_options = {"headers": {"X-Request-Id": "check-request-111"}} - check_response = await api_client.check(body=check_body, options=check_options) + check_response = await api_client.check( + body=check_body, options=check_options + ) # Second call with custom header 2 expand_options = {"headers": {"X-Request-Id": "expand-request-222"}} - expand_response = await api_client.expand(body=expand_body, options=expand_options) + expand_response = await api_client.expand( + body=expand_body, options=expand_options + ) # Verify first call had correct header first_call_args = mock_request.call_args_list[0] @@ -4000,7 +4033,9 @@ class TestClientConfigurationHeaders: @patch.object(rest.RESTClientObject, "request") @pytest.mark.asyncio - async def test_write_with_conflict_options_ignore_missing_deletes(self, mock_request): + async def test_write_with_conflict_options_ignore_missing_deletes( + self, mock_request + ): """Test case for write with conflict options - ignore missing deletes""" from {{packageName}}.client.models.write_conflict_opts import ( ClientWriteRequestOnMissingDeletes, @@ -4060,7 +4095,7 @@ class TestClientConfigurationHeaders: @pytest.mark.asyncio async def test_write_with_conflict_options_both(self, mock_request): """Test case for write with both conflict options""" - from {{packageName}}.client.models.write_conflict_opts import ( + from openfga_sdk.client.models.write_conflict_opts import ( ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConflictOptions, @@ -4131,4 +4166,4 @@ class TestClientConfigurationHeaders: }, _preload_content=ANY, _request_timeout=None, - ) \ No newline at end of file + ) diff --git a/config/clients/python/template/test/sync/client/client_test.py.mustache b/config/clients/python/template/test/sync/client/client_test.py.mustache index ee3508dab..a3ab59811 100644 --- a/config/clients/python/template/test/sync/client/client_test.py.mustache +++ b/config/clients/python/template/test/sync/client/client_test.py.mustache @@ -1014,7 +1014,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", }, - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1059,7 +1060,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_missing": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1159,7 +1161,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1180,7 +1183,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1201,7 +1205,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1301,7 +1306,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1322,7 +1328,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1343,7 +1350,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1448,7 +1456,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", }, - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1469,7 +1478,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1583,7 +1593,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1604,7 +1615,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31c", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1625,7 +1637,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1679,7 +1692,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", } - ] + ], + "on_missing": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1742,7 +1756,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", }, - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1805,7 +1820,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31d", }, - ] + ], + "on_missing": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -1861,7 +1877,8 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): "relation": "reader", "object": "document:2021-budget", } - ] + ], + "on_duplicate": "error" }, "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", }, @@ -2176,7 +2193,9 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): if user == "user:81684243-9356-4421-8fbf-a4f8d36aa31b": return mock_response('{"allowed": true, "resolution": "1234"}', 200) elif user == "user:81684243-9356-4421-8fbf-a4f8d36aa31c": - raise ValidationException(http_resp=http_mock_response(response_body, 400)) + raise ValidationException( + http_resp=http_mock_response(response_body, 400) + ) elif user == "user:81684243-9356-4421-8fbf-a4f8d36aa31d": return mock_response('{"allowed": false, "resolution": "1234"}', 200) return mock_response('{"allowed": false, "resolution": "1234"}', 200) @@ -3397,8 +3416,12 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): # Verify all four headers exist with correct values self.assertEqual(result["headers"]["X-SDK-Version"], "1.0.0") # Default was set - self.assertEqual(result["headers"]["X-Request-ID"], "my-custom-request-id") # Custom overrode default - self.assertEqual(result["headers"]["X-Tenant-ID"], "tenant-123") # Custom preserved + self.assertEqual( + result["headers"]["X-Request-ID"], "my-custom-request-id" + ) # Custom overrode default + self.assertEqual( + result["headers"]["X-Tenant-ID"], "tenant-123" + ) # Custom preserved self.assertEqual(len(result["headers"]), 3) # Exactly 3 headers def test_set_heading_if_not_set_with_empty_string_value(self): @@ -3481,9 +3504,7 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): with OpenFgaClient(configuration) as api_client: # Add custom header that should override any defaults - custom_options = { - "headers": {"X-Custom-Request-Id": "custom-request-123"} - } + custom_options = {"headers": {"X-Custom-Request-Id": "custom-request-123"}} api_response = api_client.check(body=body, options=custom_options) # Verify the API was called and extract the headers @@ -3996,7 +4017,9 @@ class TestSyncClientConfigurationHeaders: ) @patch.object(rest.RESTClientObject, "request") - def test_sync_write_with_conflict_options_ignore_missing_deletes(self, mock_request): + def test_sync_write_with_conflict_options_ignore_missing_deletes( + self, mock_request + ): """Test case for write with conflict options - ignore missing deletes""" from {{packageName}}.client.models.write_conflict_opts import ( ClientWriteRequestOnMissingDeletes, @@ -4124,4 +4147,4 @@ class TestSyncClientConfigurationHeaders: }, _preload_content=ANY, _request_timeout=None, - ) \ No newline at end of file + ) From b82b67cc90be25357e7f6841433a6c73d1401eb4 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 17 Oct 2025 22:58:11 +0530 Subject: [PATCH 12/15] feat: apply committable copilot suggestions Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- config/clients/python/CHANGELOG.md.mustache | 2 +- config/clients/python/template/model.mustache | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/clients/python/CHANGELOG.md.mustache b/config/clients/python/CHANGELOG.md.mustache index dc7d99495..b8871a42f 100644 --- a/config/clients/python/CHANGELOG.md.mustache +++ b/config/clients/python/CHANGELOG.md.mustache @@ -1,7 +1,7 @@ # Changelog ## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD) -- feat: add support for conflict options for Write operations: (#235) +- feat: add support for conflict options for Write operations: (#610) The client now supports setting `ConflictOptions` on `ClientWriteOptions` to control behavior when writing duplicate tuples or deleting non-existent tuples. This feature requires OpenFGA server [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. See [Conflict Options for Write Operations](./README.md#conflict-options-for-write-operations) for more. - `on_duplicate` for handling duplicate tuple writes (ERROR or IGNORE) diff --git a/config/clients/python/template/model.mustache b/config/clients/python/template/model.mustache index cf3854124..6eec067f9 100644 --- a/config/clients/python/template/model.mustache +++ b/config/clients/python/template/model.mustache @@ -137,7 +137,7 @@ class {{classname}}: allowed_values = [{{#isNullable}}None,{{/isNullable}}{{#allowableValues}}{{#values}}{{#isString}}"{{/isString}}{{{this}}}{{#isString}}"{{/isString}}{{^-last}}, {{/-last}}{{/values}}{{/allowableValues}}] if self.local_vars_configuration.client_side_validation and {{{name}}} not in allowed_values: raise ValueError( - f"Invalid value for `{{{name}}}` ({{{name}}}), must be one of {allowed_values}" + f"Invalid value for `{{name}}` ({{{{{name}}}}}), must be one of {allowed_values}" ) {{/isContainer}} {{/isEnum}} From 410dd8562b678b68d4250d1c9042694ca79c0c35 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 17 Oct 2025 23:03:25 +0530 Subject: [PATCH 13/15] fix: revert changelog issue # --- config/clients/python/CHANGELOG.md.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/clients/python/CHANGELOG.md.mustache b/config/clients/python/CHANGELOG.md.mustache index b8871a42f..dc7d99495 100644 --- a/config/clients/python/CHANGELOG.md.mustache +++ b/config/clients/python/CHANGELOG.md.mustache @@ -1,7 +1,7 @@ # Changelog ## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD) -- feat: add support for conflict options for Write operations: (#610) +- feat: add support for conflict options for Write operations: (#235) The client now supports setting `ConflictOptions` on `ClientWriteOptions` to control behavior when writing duplicate tuples or deleting non-existent tuples. This feature requires OpenFGA server [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. See [Conflict Options for Write Operations](./README.md#conflict-options-for-write-operations) for more. - `on_duplicate` for handling duplicate tuple writes (ERROR or IGNORE) From e444a15965b316db8a9088dcc320def9e8f789cf Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 17 Oct 2025 23:04:40 +0530 Subject: [PATCH 14/15] feat: use packageName template instead of HC Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../clients/python/template/test/client/client_test.py.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/clients/python/template/test/client/client_test.py.mustache b/config/clients/python/template/test/client/client_test.py.mustache index f2e000bee..8de35455e 100644 --- a/config/clients/python/template/test/client/client_test.py.mustache +++ b/config/clients/python/template/test/client/client_test.py.mustache @@ -4095,7 +4095,7 @@ class TestClientConfigurationHeaders: @pytest.mark.asyncio async def test_write_with_conflict_options_both(self, mock_request): """Test case for write with both conflict options""" - from openfga_sdk.client.models.write_conflict_opts import ( + from {{packageName}}.client.models.write_conflict_opts import ( ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConflictOptions, From 99d9301b26311d5d77095e2822e0b2f92a09471f Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Sat, 18 Oct 2025 01:14:53 +0530 Subject: [PATCH 15/15] feat: address copilot comment, backward compatible --- .../template/src/client/client.py.mustache | 4 +-- .../client/models/write_request.py.mustache | 36 ++++++++++++++++--- .../src/sync/client/client.py.mustache | 4 +-- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/config/clients/python/template/src/client/client.py.mustache b/config/clients/python/template/src/client/client.py.mustache index 56a838af4..e7a6e15a2 100644 --- a/config/clients/python/template/src/client/client.py.mustache +++ b/config/clients/python/template/src/client/client.py.mustache @@ -479,9 +479,9 @@ class OpenFgaClient: writes_tuple_keys = None deletes_tuple_keys = None if body.writes: - writes_tuple_keys = body.writes_tuple_keys(on_duplicate=on_duplicate) + writes_tuple_keys = body.get_writes_tuple_keys(on_duplicate=on_duplicate) if body.deletes: - deletes_tuple_keys = body.deletes_tuple_keys(on_missing=on_missing) + deletes_tuple_keys = body.get_deletes_tuple_keys(on_missing=on_missing) {{#asyncio}}await {{/asyncio}}self._api.write( WriteRequest( diff --git a/config/clients/python/template/src/client/models/write_request.py.mustache b/config/clients/python/template/src/client/models/write_request.py.mustache index f6b74cf68..cc4311f2e 100644 --- a/config/clients/python/template/src/client/models/write_request.py.mustache +++ b/config/clients/python/template/src/client/models/write_request.py.mustache @@ -46,11 +46,31 @@ class ClientWriteRequest: """ self._deletes = value - def writes_tuple_keys( + @property + def writes_tuple_keys(self) -> WriteRequestWrites | None: + """ + Return the writes as tuple keys (backward compatibility property) + """ + return self.get_writes_tuple_keys() + + @property + def deletes_tuple_keys(self) -> WriteRequestDeletes | None: + """ + Return the deletes as tuple keys (backward compatibility property) + """ + return self.get_deletes_tuple_keys() + + def get_writes_tuple_keys( self, on_duplicate: str | None = None ) -> WriteRequestWrites | None: """ - Return the writes as tuple keys + Return the writes as tuple keys with optional conflict handling + + Args: + on_duplicate: Optional conflict resolution strategy for duplicate writes + + Returns: + WriteRequestWrites object with tuple keys and optional on_duplicate setting """ if self._writes is None: return None @@ -64,11 +84,17 @@ class ClientWriteRequest: return WriteRequestWrites(tuple_keys=keys, on_duplicate=on_duplicate) return WriteRequestWrites(tuple_keys=keys) - def deletes_tuple_keys( + def get_deletes_tuple_keys( self, on_missing: str | None = None ) -> WriteRequestDeletes | None: """ - Return the delete as tuple keys + Return the deletes as tuple keys with optional conflict handling + + Args: + on_missing: Optional conflict resolution strategy for missing deletes + + Returns: + WriteRequestDeletes object with tuple keys and optional on_missing setting """ if self._deletes is None: return None @@ -80,4 +106,4 @@ class ClientWriteRequest: if on_missing is not None: return WriteRequestDeletes(tuple_keys=keys, on_missing=on_missing) - return WriteRequestDeletes(tuple_keys=keys) + return WriteRequestDeletes(tuple_keys=keys) \ No newline at end of file diff --git a/config/clients/python/template/src/sync/client/client.py.mustache b/config/clients/python/template/src/sync/client/client.py.mustache index 6abcf62dd..a9e170a0c 100644 --- a/config/clients/python/template/src/sync/client/client.py.mustache +++ b/config/clients/python/template/src/sync/client/client.py.mustache @@ -464,9 +464,9 @@ class OpenFgaClient: writes_tuple_keys = None deletes_tuple_keys = None if body.writes: - writes_tuple_keys = body.writes_tuple_keys(on_duplicate=on_duplicate) + writes_tuple_keys = body.get_writes_tuple_keys(on_duplicate=on_duplicate) if body.deletes: - deletes_tuple_keys = body.deletes_tuple_keys(on_missing=on_missing) + deletes_tuple_keys = body.get_deletes_tuple_keys(on_missing=on_missing) self._api.write( WriteRequest(