Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions config/clients/python/CHANGELOG.md.mustache
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Changelog

## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD)
- 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

### [{{packageVersion}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.9.6...{{packageVersion}}) (2025-10-06)

Expand Down
8 changes: 8 additions & 0 deletions config/clients/python/config.overrides.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions config/clients/python/template/README_calling_api.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,49 @@ body = ClientWriteRequest(
response = await fga_client.write(body, options)
```

###### Conflict Options for Write Operations

OpenFGA v1.10.0+ supports conflict options for write operations to handle duplicate writes and missing deletes gracefully.

**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,
# ClientWriteRequestOnMissingDeletes,
# ConflictOptions,
# )

options = {
"authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1",
"conflict": ConflictOptions(
on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE, # Available options: ERROR, IGNORE
on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE, # Available options: ERROR, 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",
),
],
)

response = await fga_client.write(body, options)
```

#### Relationship Queries

##### Check
Expand Down
15 changes: 7 additions & 8 deletions config/clients/python/template/model.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -117,28 +117,27 @@ 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}}
{{^isContainer}}
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}}
Expand Down
43 changes: 35 additions & 8 deletions config/clients/python/template/src/client/client.py.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,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
Expand Down Expand Up @@ -448,17 +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)

# 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:
on_duplicate = conflict_options.on_duplicate_writes.value
if conflict_options.on_missing_deletes:
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.get_writes_tuple_keys(on_duplicate=on_duplicate)
if body.deletes:
deletes_tuple_keys = body.get_deletes_tuple_keys(on_missing=on_missing)

{{#asyncio}}await {{/asyncio}}self._api.write(
WriteRequest(
Expand All @@ -471,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):
Expand Down Expand Up @@ -945,4 +972,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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -32,4 +38,8 @@ __all__ = [
"ClientWriteRequest",
"ClientWriteResponse",
"WriteTransactionOpts",
]
"ClientWriteRequestOnDuplicateWrites",
"ClientWriteRequestOnMissingDeletes",
"ConflictOptions",
"ClientWriteOptions",
]
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading