Skip to content

Commit cface85

Browse files
authored
feat(python-sdk): add support for write conflict settings (#643)
2 parents e2d2d4f + 99d9301 commit cface85

16 files changed

+810
-84
lines changed

.github/workflows/main.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ jobs:
136136
- name: Run All Tests
137137
run: make test-client-python
138138
env:
139-
OPEN_API_REF: c0b62b28b14d0d164d37a1f6bf19dc9d39e5769b
139+
OPEN_API_REF: 0ac19aac54f21f3c78970126b84b4c69c6e3b9a2
140140

141141
- name: Check for SDK changes
142142
run: |

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Main config
22
OPENFGA_DOCKER_TAG = v1
3-
OPEN_API_REF ?= e53c69cc55317404d02a6d8e418d626268f28a59
3+
OPEN_API_REF ?= 0ac19aac54f21f3c78970126b84b4c69c6e3b9a2
44
OPEN_API_URL = https://raw.githubusercontent.com/openfga/api/${OPEN_API_REF}/docs/openapiv2/apidocs.swagger.json
55
OPENAPI_GENERATOR_CLI_DOCKER_TAG ?= v6.4.0
66
NODE_DOCKER_TAG = 20-alpine

config/clients/python/CHANGELOG.md.mustache

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# Changelog
22

33
## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD)
4+
- feat: add support for conflict options for Write operations: (#235)
5+
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.
6+
See [Conflict Options for Write Operations](./README.md#conflict-options-for-write-operations) for more.
7+
- `on_duplicate` for handling duplicate tuple writes (ERROR or IGNORE)
8+
- `on_missing` for handling deletes of non-existent tuples (ERROR or IGNORE)
9+
- docs: added documentation for write conflict options in README
410

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

config/clients/python/config.overrides.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@
153153
"destinationFilename": "openfga_sdk/client/models/write_transaction_opts.py",
154154
"templateType": "SupportingFiles"
155155
},
156+
"src/client/models/write_conflict_opts.py.mustache": {
157+
"destinationFilename": "openfga_sdk/client/models/write_conflict_opts.py",
158+
"templateType": "SupportingFiles"
159+
},
160+
"src/client/models/write_options.py.mustache": {
161+
"destinationFilename": "openfga_sdk/client/models/write_options.py",
162+
"templateType": "SupportingFiles"
163+
},
156164
"/src/models/__init__.py.mustache": {
157165
"destinationFilename": "openfga_sdk/models/__init__.py",
158166
"templateType": "SupportingFiles"

config/clients/python/template/README_calling_api.mustache

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,49 @@ body = ClientWriteRequest(
452452
response = await fga_client.write(body, options)
453453
```
454454

455+
###### Conflict Options for Write Operations
456+
457+
OpenFGA v1.10.0+ supports conflict options for write operations to handle duplicate writes and missing deletes gracefully.
458+
459+
**Example: Ignore duplicate writes and missing deletes**
460+
461+
```python
462+
# from openfga_sdk import OpenFgaClient
463+
# from openfga_sdk.client.models import ClientTuple, ClientWriteRequest
464+
# from openfga_sdk.client.models.write_conflict_opts import (
465+
# ClientWriteRequestOnDuplicateWrites,
466+
# ClientWriteRequestOnMissingDeletes,
467+
# ConflictOptions,
468+
# )
469+
470+
options = {
471+
"authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1",
472+
"conflict": ConflictOptions(
473+
on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE, # Available options: ERROR, IGNORE
474+
on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE, # Available options: ERROR, IGNORE
475+
)
476+
}
477+
478+
body = ClientWriteRequest(
479+
writes=[
480+
ClientTuple(
481+
user="user:81684243-9356-4421-8fbf-a4f8d36aa31b",
482+
relation="viewer",
483+
object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a",
484+
),
485+
],
486+
deletes=[
487+
ClientTuple(
488+
user="user:81684243-9356-4421-8fbf-a4f8d36aa31b",
489+
relation="writer",
490+
object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a",
491+
),
492+
],
493+
)
494+
495+
response = await fga_client.write(body, options)
496+
```
497+
455498
#### Relationship Queries
456499

457500
##### Check

config/clients/python/template/model.mustache

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,28 +117,27 @@ class {{classname}}:
117117
{{#isArray}}
118118
if (self.local_vars_configuration.client_side_validation
119119
and not set({{{name}}}).issubset(set(allowed_values))):
120+
invalid_values = ", ".join(map(str, set({{{name}}}) - set(allowed_values)))
121+
valid_values = ", ".join(map(str, allowed_values))
120122
raise ValueError(
121-
"Invalid values for `{{{name}}}` [{0}], must be a subset of [{1}]"
122-
.format(", ".join(map(str, set({{{name}}}) - set(allowed_values))),
123-
", ".join(map(str, allowed_values)))
123+
f"Invalid values for `{{{name}}}` [{invalid_values}], must be a subset of [{valid_values}]"
124124
)
125125
{{/isArray}}
126126
{{#isMap}}
127127
if (self.local_vars_configuration.client_side_validation
128128
and not set({{{name}}}.keys()).issubset(set(allowed_values))):
129+
invalid_keys = ", ".join(map(str, set({{{name}}}.keys()) - set(allowed_values)))
130+
valid_values = ", ".join(map(str, allowed_values))
129131
raise ValueError(
130-
"Invalid keys in `{{{name}}}` [{0}], must be a subset of [{1}]"
131-
.format(", ".join(map(str, set({{{name}}}.keys()) - set(allowed_values))),
132-
", ".join(map(str, allowed_values)))
132+
f"Invalid keys in `{{{name}}}` [{invalid_keys}], must be a subset of [{valid_values}]"
133133
)
134134
{{/isMap}}
135135
{{/isContainer}}
136136
{{^isContainer}}
137137
allowed_values = [{{#isNullable}}None,{{/isNullable}}{{#allowableValues}}{{#values}}{{#isString}}"{{/isString}}{{{this}}}{{#isString}}"{{/isString}}{{^-last}}, {{/-last}}{{/values}}{{/allowableValues}}]
138138
if self.local_vars_configuration.client_side_validation and {{{name}}} not in allowed_values:
139139
raise ValueError(
140-
"Invalid value for `{{{name}}}` ({0}), must be one of {1}"
141-
.format({{{name}}}, allowed_values)
140+
f"Invalid value for `{{name}}` ({{{{{name}}}}}), must be one of {allowed_values}"
142141
)
143142
{{/isContainer}}
144143
{{/isEnum}}

config/clients/python/template/src/client/client.py.mustache

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ def options_to_transaction_info(options: dict[str, int | str | dict[str, int | s
112112
return options["transaction"]
113113
return WriteTransactionOpts()
114114

115+
def options_to_conflict_info(options: dict[str, int | str | dict[str, int | str]] | None = None):
116+
"""
117+
Return the conflict info
118+
"""
119+
if options is not None and options.get("conflict"):
120+
return options["conflict"]
121+
return None
122+
115123
def _check_errored(response: ClientBatchCheckClientResponse):
116124
"""
117125
Helper function to return whether the response is errored
@@ -448,17 +456,32 @@ class OpenFgaClient:
448456

449457
return batch_write_responses
450458

451-
{{#asyncio}}async {{/asyncio}}def _write_with_transaction(self, body: ClientWriteRequest, options: dict[str, int | str | dict[str, int | str]] | None = None):
459+
{{#asyncio}}async {{/asyncio}}def _write_with_transaction(
460+
self,
461+
body: ClientWriteRequest,
462+
options: dict[str, int | str | dict[str, int | str]] | None = None,
463+
):
452464
"""
453465
Write or deletes tuples
454466
"""
455467
kwargs = options_to_kwargs(options)
468+
conflict_options = options_to_conflict_info(options)
469+
470+
# Extract conflict options to pass to the tuple key methods
471+
on_duplicate = None
472+
on_missing = None
473+
if conflict_options:
474+
if conflict_options.on_duplicate_writes:
475+
on_duplicate = conflict_options.on_duplicate_writes.value
476+
if conflict_options.on_missing_deletes:
477+
on_missing = conflict_options.on_missing_deletes.value
478+
456479
writes_tuple_keys = None
457480
deletes_tuple_keys = None
458-
if body.writes_tuple_keys:
459-
writes_tuple_keys=body.writes_tuple_keys
460-
if body.deletes_tuple_keys:
461-
deletes_tuple_keys=body.deletes_tuple_keys
481+
if body.writes:
482+
writes_tuple_keys = body.get_writes_tuple_keys(on_duplicate=on_duplicate)
483+
if body.deletes:
484+
deletes_tuple_keys = body.get_deletes_tuple_keys(on_missing=on_missing)
462485

463486
{{#asyncio}}await {{/asyncio}}self._api.write(
464487
WriteRequest(
@@ -471,10 +494,14 @@ class OpenFgaClient:
471494
# any error will result in exception being thrown and not reached below code
472495
writes_response = None
473496
if body.writes:
474-
writes_response = [construct_write_single_response(i, True, None) for i in body.writes]
497+
writes_response = [
498+
construct_write_single_response(i, True, None) for i in body.writes
499+
]
475500
deletes_response = None
476501
if body.deletes:
477-
deletes_response = [construct_write_single_response(i, True, None) for i in body.deletes]
502+
deletes_response = [
503+
construct_write_single_response(i, True, None) for i in body.deletes
504+
]
478505
return ClientWriteResponse(writes=writes_response, deletes=deletes_response)
479506

480507
{{#asyncio}}async {{/asyncio}}def write(self, body: ClientWriteRequest, options: dict[str, int | str | dict[str, int | str]] | None = None):
@@ -945,4 +972,4 @@ class OpenFgaClient:
945972

946973
api_request_body = WriteAssertionsRequest([map_to_assertion(client_assertion) for client_assertion in body])
947974
api_response = {{#asyncio}}await {{/asyncio}}self._api.write_assertions(authorization_model_id, api_request_body, **kwargs)
948-
return api_response
975+
return api_response

config/clients/python/template/src/client/models/__init__.py.mustache

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ from {{packageName}}.client.models.tuple import ClientTuple
1515
from {{packageName}}.client.models.write_request import ClientWriteRequest
1616
from {{packageName}}.client.models.write_response import ClientWriteResponse
1717
from {{packageName}}.client.models.write_transaction_opts import WriteTransactionOpts
18+
from {{packageName}}.client.models.write_conflict_opts import (
19+
ClientWriteRequestOnDuplicateWrites,
20+
ClientWriteRequestOnMissingDeletes,
21+
ConflictOptions,
22+
)
23+
from {{packageName}}.client.models.write_options import ClientWriteOptions
1824

1925
__all__ = [
2026
"ClientAssertion",
@@ -32,4 +38,8 @@ __all__ = [
3238
"ClientWriteRequest",
3339
"ClientWriteResponse",
3440
"WriteTransactionOpts",
35-
]
41+
"ClientWriteRequestOnDuplicateWrites",
42+
"ClientWriteRequestOnMissingDeletes",
43+
"ConflictOptions",
44+
"ClientWriteOptions",
45+
]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{{>partial_header}}
2+
3+
from enum import Enum
4+
5+
6+
class ClientWriteRequestOnDuplicateWrites(str, Enum):
7+
ERROR = "error"
8+
IGNORE = "ignore"
9+
10+
11+
class ClientWriteRequestOnMissingDeletes(str, Enum):
12+
ERROR = "error"
13+
IGNORE = "ignore"
14+
15+
16+
class ConflictOptions:
17+
"""
18+
OpenFGA client write conflict options
19+
"""
20+
21+
def __init__(
22+
self,
23+
on_duplicate_writes: ClientWriteRequestOnDuplicateWrites | None = None,
24+
on_missing_deletes: ClientWriteRequestOnMissingDeletes | None = None,
25+
) -> None:
26+
self._on_duplicate_writes = on_duplicate_writes
27+
self._on_missing_deletes = on_missing_deletes
28+
29+
@property
30+
def on_duplicate_writes(self) -> ClientWriteRequestOnDuplicateWrites | None:
31+
"""
32+
Return on_duplicate_writes
33+
"""
34+
return self._on_duplicate_writes
35+
36+
@on_duplicate_writes.setter
37+
def on_duplicate_writes(
38+
self,
39+
value: ClientWriteRequestOnDuplicateWrites | None,
40+
) -> None:
41+
"""
42+
Set on_duplicate_writes
43+
"""
44+
self._on_duplicate_writes = value
45+
46+
@property
47+
def on_missing_deletes(self) -> ClientWriteRequestOnMissingDeletes | None:
48+
"""
49+
Return on_missing_deletes
50+
"""
51+
return self._on_missing_deletes
52+
53+
@on_missing_deletes.setter
54+
def on_missing_deletes(
55+
self,
56+
value: ClientWriteRequestOnMissingDeletes | None,
57+
) -> None:
58+
"""
59+
Set on_missing_deletes
60+
"""
61+
self._on_missing_deletes = value
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{{>partial_header}}
2+
3+
from {{packageName}}.client.models.write_conflict_opts import ConflictOptions
4+
from {{packageName}}.client.models.write_transaction_opts import WriteTransactionOpts
5+
6+
7+
class ClientWriteOptions:
8+
"""
9+
OpenFGA client write options
10+
"""
11+
12+
def __init__(
13+
self,
14+
authorization_model_id: str | None = None,
15+
transaction: WriteTransactionOpts | None = None,
16+
conflict: ConflictOptions | None = None,
17+
) -> None:
18+
self._authorization_model_id = authorization_model_id
19+
self._transaction = transaction
20+
self._conflict = conflict
21+
22+
@property
23+
def authorization_model_id(self) -> str | None:
24+
"""
25+
Return authorization_model_id
26+
"""
27+
return self._authorization_model_id
28+
29+
@authorization_model_id.setter
30+
def authorization_model_id(
31+
self,
32+
value: str | None,
33+
) -> None:
34+
"""
35+
Set authorization_model_id
36+
"""
37+
self._authorization_model_id = value
38+
39+
@property
40+
def transaction(self) -> WriteTransactionOpts | None:
41+
"""
42+
Return transaction
43+
"""
44+
return self._transaction
45+
46+
@transaction.setter
47+
def transaction(
48+
self,
49+
value: WriteTransactionOpts | None,
50+
) -> None:
51+
"""
52+
Set transaction
53+
"""
54+
self._transaction = value
55+
56+
@property
57+
def conflict(self) -> ConflictOptions | None:
58+
"""
59+
Return conflict
60+
"""
61+
return self._conflict
62+
63+
@conflict.setter
64+
def conflict(
65+
self,
66+
value: ConflictOptions | None,
67+
) -> None:
68+
"""
69+
Set conflict
70+
"""
71+
self._conflict = value

0 commit comments

Comments
 (0)