dpcharmlibs-interfaces 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ r"""Library to manage the relation for the data-platform products.
16
+
17
+ This V1 has been specified in
18
+ https://docs.google.com/document/d/1lnuonWnoQb36RWYwfHOBwU0VClLbawpTISXIC_yNKYo,
19
+ and should be backward compatible with v0 clients.
20
+
21
+ This library contains the Requires and Provides classes for handling the relation
22
+ between an application and multiple managed application supported by the data-team:
23
+ MySQL, Postgresql, MongoDB, Redis, Kafka, and Karapace.
24
+
25
+ #### Models
26
+
27
+ This library exposes basic default models that can be used in most cases.
28
+ If you need more complex models, you can subclass them.
29
+
30
+ ```python
31
+ from dpcharmlibs.interfaces import RequirerCommonModel, ExtraSecretStr
32
+
33
+ class ExtendedCommonModel(RequirerCommonModel):
34
+ operator_password: ExtraSecretStr
35
+ ```
36
+
37
+ Secret groups are handled using annotated types.
38
+ If you wish to add extra secret groups, please follow the following model.
39
+ The string metadata represents the secret group name, and `OptionalSecretStr` is a TypeAlias for
40
+ `SecretStr | None`. Finally, `SecretStr` represents a field validating the URI pattern `secret:.*`
41
+
42
+ ```python
43
+ MyGroupSecretStr = Annotated[OptionalSecretStr, Field(exclude=True, default=None), "mygroup"]
44
+ ```
45
+
46
+ Fields not specified as OptionalSecretStr and extended with a group name in the metadata will NOT
47
+ get serialised.
48
+
49
+
50
+ #### Requirer Charm
51
+
52
+ This library is a uniform interface to a selection of common database
53
+ metadata, with added custom events that add convenience to database management,
54
+ and methods to consume the application related data.
55
+
56
+
57
+ ```python
58
+ from dpcharmlibs.interfaces import (
59
+ RequirerCommonModel,
60
+ RequirerDataContractV1,
61
+ ResourceCreatedEvent,
62
+ ResourceEntityCreatedEvent,
63
+ ResourceProviderModel,
64
+ ResourceRequirerEventHandler,
65
+ )
66
+
67
+ class ClientCharm(CharmBase):
68
+ # Database charm that accepts connections from application charms.
69
+ def __init__(self, *args) -> None:
70
+ super().__init__(*args)
71
+
72
+ requests = [
73
+ RequirerCommonModel(
74
+ resource="clientdb",
75
+ ),
76
+ RequirerCommonModel(
77
+ resource="clientbis",
78
+ ),
79
+ RequirerCommonModel(
80
+ entity_type="USER",
81
+ )
82
+ ]
83
+ self.database = ResourceRequirerEventHandler(
84
+ self,"database", requests, response_model=ResourceProviderModel
85
+ )
86
+ self.framework.observe(self.database.on.resource_created, self._on_resource_created)
87
+ self.framework.observe(self.database.on.resource_entity_created, self._on_entity_created)
88
+
89
+ def _on_resource_created(self, event: ResourceCreatedEvent) -> None:
90
+ # Event triggered when a new database is created.
91
+ relation_id = event.relation.id
92
+ response = event.response # This is the response model
93
+
94
+ username = event.response.username
95
+ password = event.response.password
96
+ ...
97
+
98
+ def _on_entity_created(self, event: ResourceCreatedEvent) -> None:
99
+ # Event triggered when a new entity is created.
100
+ ...
101
+ ```
102
+
103
+ Compared to V0, this library makes heavy use of pydantic models, and allows for
104
+ multiple requests, specified as a list.
105
+ On the Requirer side, each response will trigger one custom event for that response.
106
+ This way, it allows for more strategic events to be emitted according to the request.
107
+
108
+ As show above, the library provides some custom events to handle specific situations,
109
+ which are listed below:
110
+ - resource_created: event emitted when the requested database is created.
111
+ - resource_entity_created: event emitted when the requested entity is created.
112
+ - endpoints_changed: event emitted when the read/write endpoints of the database have changed.
113
+ - read_only_endpoints_changed: event emitted when the read-only endpoints of the database
114
+ have changed. Event is not triggered if read/write endpoints changed too.
115
+
116
+ If it is needed to connect multiple database clusters to the same relation endpoint
117
+ the application charm can implement the same code as if it would connect to only
118
+ one database cluster (like the above code example).
119
+
120
+ To differentiate multiple clusters connected to the same relation endpoint
121
+ the application charm can use the name of the remote application:
122
+
123
+ ```python
124
+
125
+ def _on_resource_created(self, event: ResourceCreatedEvent) -> None:
126
+ # Get the remote app name of the cluster that triggered this event
127
+ cluster = event.relation.app.name
128
+ ```
129
+
130
+ It is also possible to provide an alias for each different database cluster/relation.
131
+
132
+ So, it is possible to differentiate the clusters in two ways.
133
+ The first is to use the remote application name, i.e., `event.relation.app.name`, as above.
134
+
135
+ The second way is to use different event handlers to handle each cluster events.
136
+ The implementation would be something like the following code:
137
+
138
+ ```python
139
+
140
+ from dpcharmlibs.interfaces import (
141
+ RequirerCommonModel,
142
+ RequirerDataContractV1,
143
+ ResourceCreatedEvent,
144
+ ResourceEntityCreatedEvent,
145
+ ResourceProviderModel,
146
+ ResourceRequirerEventHandler,
147
+ )
148
+
149
+ class ApplicationCharm(CharmBase):
150
+ # Application charm that connects to database charms.
151
+
152
+ def __init__(self, *args):
153
+ super().__init__(*args)
154
+
155
+ requests = [
156
+ RequirerCommonModel(
157
+ resource="clientdb",
158
+ ),
159
+ RequirerCommonModel(
160
+ resource="clientbis",
161
+ ),
162
+ ]
163
+ # Define the cluster aliases and one handler for each cluster database created event.
164
+ self.database = ResourceRequirerEventHandler(
165
+ self,
166
+ relation_name="database"
167
+ relations_aliases = ["cluster1", "cluster2"],
168
+ requests=
169
+ )
170
+ self.framework.observe(
171
+ self.database.on.cluster1_resource_created, self._on_cluster1_resource_created
172
+ )
173
+ self.framework.observe(
174
+ self.database.on.cluster2_resource_created, self._on_cluster2_resource_created
175
+ )
176
+
177
+ def _on_cluster1_resource_created(self, event: ResourceCreatedEvent) -> None:
178
+ # Handle the created database on the cluster named cluster1
179
+
180
+ # Create configuration file for app
181
+ config_file = self._render_app_config_file(
182
+ event.response.username,
183
+ event.response.password,
184
+ event.response.endpoints,
185
+ )
186
+ ...
187
+
188
+ def _on_cluster2_resource_created(self, event: ResourceCreatedEvent) -> None:
189
+ # Handle the created database on the cluster named cluster2
190
+
191
+ # Create configuration file for app
192
+ config_file = self._render_app_config_file(
193
+ event.response.username,
194
+ event.response.password,
195
+ event.response.endpoints,
196
+ )
197
+ ...
198
+ ```
199
+
200
+ ### Provider Charm
201
+
202
+ Following an example of using the ResourceRequestedEvent, in the context of the
203
+ database charm code:
204
+
205
+ ```python
206
+ from dpcharmlibs.interfaces import (
207
+ ResourceProviderEventHandler,
208
+ ResourceProviderModel,
209
+ ResourceRequestedEvent,
210
+ RequirerCommonModel,
211
+ )
212
+
213
+ class SampleCharm(CharmBase):
214
+
215
+ def __init__(self, *args):
216
+ super().__init__(*args)
217
+ # Charm events defined in the database provides charm library.
218
+ self.provided_database = ResourceProviderEventHandler(self, "database", RequirerCommonModel)
219
+ self.framework.observe(self.provided_database.on.resource_requested,
220
+ self._on_resource_requested)
221
+ # Database generic helper
222
+ self.database = DatabaseHelper()
223
+
224
+ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None:
225
+ # Handle the event triggered by a new database requested in the relation
226
+ # Retrieve the database name using the charm library.
227
+ db_name = event.request.resource
228
+ # generate a new user credential
229
+ username = self.database.generate_user(event.request.request_id)
230
+ password = self.database.generate_password(event.request.request_id)
231
+ # set the credentials for the relation
232
+ response = ResourceProviderModel(
233
+ salt=event.request.salt,
234
+ request_id=event.request.request_id,
235
+ resource=db_name,
236
+ username=username,
237
+ password=password,
238
+ ...
239
+ )
240
+ self.provided_database.set_response(event.relation.id, response)
241
+ ```
242
+
243
+ As shown above, the library provides a custom event (resource_requested) to handle
244
+ the situation when an application charm requests a new database to be created.
245
+ It's preferred to subscribe to this event instead of relation changed event to avoid
246
+ creating a new database when other information other than a database name is
247
+ exchanged in the relation databag.
248
+
249
+ """
250
+
251
+ from ._version import __version__ as __version__
252
+ from .diff import (
253
+ Diff,
254
+ diff,
255
+ resource_added,
256
+ store_new_data,
257
+ )
258
+ from .errors import (
259
+ DataInterfacesError,
260
+ IllegalOperationError,
261
+ SecretAlreadyExistsError,
262
+ SecretError,
263
+ SecretsUnavailableError,
264
+ )
265
+ from .events import (
266
+ AuthenticationUpdatedEvent,
267
+ BulkResourcesRequestedEvent,
268
+ MtlsCertUpdatedEvent,
269
+ ResourceCreatedEvent,
270
+ ResourceEndpointsChangedEvent,
271
+ ResourceEntityCreatedEvent,
272
+ ResourceEntityPermissionsChangedEvent,
273
+ ResourceEntityRequestedEvent,
274
+ ResourceProviderEvent,
275
+ ResourceProvidesEvents,
276
+ ResourceReadOnlyEndpointsChangedEvent,
277
+ ResourceRequestedEvent,
278
+ ResourceRequirerEvent,
279
+ ResourceRequiresEvents,
280
+ StatusEventBase,
281
+ StatusRaisedEvent,
282
+ StatusResolvedEvent,
283
+ )
284
+ from .handlers import (
285
+ EventHandlers,
286
+ ResourceProviderEventHandler,
287
+ ResourceRequirerEventHandler,
288
+ )
289
+ from .models import (
290
+ SECRET_PREFIX,
291
+ BaseCommonModel,
292
+ CommonModel,
293
+ DataContract,
294
+ DataContractV0,
295
+ DataContractV1,
296
+ EntityPermissionModel,
297
+ KafkaRequestModel,
298
+ KafkaResponseModel,
299
+ PeerModel,
300
+ ProviderCommonModel,
301
+ RelationStatus,
302
+ RequirerCommonModel,
303
+ RequirerDataContract,
304
+ RequirerDataContractType,
305
+ RequirerDataContractV0,
306
+ RequirerDataContractV1,
307
+ ResourceProviderModel,
308
+ )
309
+ from .repository import (
310
+ AbstractRepository,
311
+ OpsOtherPeerUnitRepository,
312
+ OpsPeerRepository,
313
+ OpsPeerUnitRepository,
314
+ OpsRelationRepository,
315
+ OpsRepository,
316
+ )
317
+ from .repository_interfaces import (
318
+ OpsOtherPeerUnitRepositoryInterface,
319
+ OpsPeerRepositoryInterface,
320
+ OpsPeerUnitRepositoryInterface,
321
+ OpsRelationRepositoryInterface,
322
+ RepositoryInterface,
323
+ build_model,
324
+ write_model,
325
+ )
326
+ from .secrets import (
327
+ CachedSecret,
328
+ SecretCache,
329
+ )
330
+ from .types import (
331
+ EntitySecretStr,
332
+ ExtraSecretStr,
333
+ MtlsSecretStr,
334
+ OptionalPathLike,
335
+ OptionalSecretBool,
336
+ OptionalSecrets,
337
+ OptionalSecretStr,
338
+ RelationStatusDict,
339
+ Scope,
340
+ SecretGroup,
341
+ SecretString,
342
+ TlsSecretBool,
343
+ TlsSecretStr,
344
+ UserSecretStr,
345
+ )
346
+ from .utils import (
347
+ ensure_leader_for_app,
348
+ gen_hash,
349
+ gen_salt,
350
+ get_encoded_dict,
351
+ )
352
+
353
+ __all__ = [
354
+ 'SECRET_PREFIX',
355
+ 'AbstractRepository',
356
+ 'AuthenticationUpdatedEvent',
357
+ 'BaseCommonModel',
358
+ 'BulkResourcesRequestedEvent',
359
+ 'CachedSecret',
360
+ 'CommonModel',
361
+ 'DataContract',
362
+ 'DataContractV0',
363
+ 'DataContractV1',
364
+ 'DataInterfacesError',
365
+ 'Diff',
366
+ 'EntityPermissionModel',
367
+ 'EntitySecretStr',
368
+ 'EventHandlers',
369
+ 'ExtraSecretStr',
370
+ 'IllegalOperationError',
371
+ 'KafkaRequestModel',
372
+ 'KafkaResponseModel',
373
+ 'MtlsCertUpdatedEvent',
374
+ 'MtlsSecretStr',
375
+ 'OpsOtherPeerUnitRepository',
376
+ 'OpsOtherPeerUnitRepositoryInterface',
377
+ 'OpsPeerRepository',
378
+ 'OpsPeerRepositoryInterface',
379
+ 'OpsPeerUnitRepository',
380
+ 'OpsPeerUnitRepositoryInterface',
381
+ 'OpsRelationRepository',
382
+ 'OpsRelationRepositoryInterface',
383
+ 'OpsRepository',
384
+ 'OptionalPathLike',
385
+ 'OptionalSecretBool',
386
+ 'OptionalSecretStr',
387
+ 'OptionalSecrets',
388
+ 'PeerModel',
389
+ 'ProviderCommonModel',
390
+ 'RelationStatus',
391
+ 'RelationStatusDict',
392
+ 'RepositoryInterface',
393
+ 'RequirerCommonModel',
394
+ 'RequirerDataContract',
395
+ 'RequirerDataContractType',
396
+ 'RequirerDataContractV0',
397
+ 'RequirerDataContractV1',
398
+ 'ResourceCreatedEvent',
399
+ 'ResourceEndpointsChangedEvent',
400
+ 'ResourceEntityCreatedEvent',
401
+ 'ResourceEntityPermissionsChangedEvent',
402
+ 'ResourceEntityRequestedEvent',
403
+ 'ResourceProviderEvent',
404
+ 'ResourceProviderEventHandler',
405
+ 'ResourceProviderModel',
406
+ 'ResourceProvidesEvents',
407
+ 'ResourceReadOnlyEndpointsChangedEvent',
408
+ 'ResourceRequestedEvent',
409
+ 'ResourceRequirerEvent',
410
+ 'ResourceRequirerEventHandler',
411
+ 'ResourceRequiresEvents',
412
+ 'Scope',
413
+ 'SecretAlreadyExistsError',
414
+ 'SecretCache',
415
+ 'SecretError',
416
+ 'SecretGroup',
417
+ 'SecretString',
418
+ 'SecretsUnavailableError',
419
+ 'StatusEventBase',
420
+ 'StatusRaisedEvent',
421
+ 'StatusResolvedEvent',
422
+ 'TlsSecretBool',
423
+ 'TlsSecretStr',
424
+ 'UserSecretStr',
425
+ 'build_model',
426
+ 'diff',
427
+ 'ensure_leader_for_app',
428
+ 'gen_hash',
429
+ 'gen_salt',
430
+ 'get_encoded_dict',
431
+ 'resource_added',
432
+ 'store_new_data',
433
+ 'write_model',
434
+ ]
@@ -0,0 +1,15 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ __version__ = '1.0.0'
@@ -0,0 +1,94 @@
1
+ # Copyright 2026 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Logic to compute diffs and modelling for diffs."""
15
+
16
+ import json
17
+ from typing import Any, NamedTuple
18
+
19
+ from ops.model import Application, Relation, Unit
20
+
21
+ from dpcharmlibs.interfaces.utils import RESOURCE_ALIASES
22
+
23
+
24
+ class Diff(NamedTuple):
25
+ """A tuple for storing the diff between two data mappings.
26
+
27
+ added - keys that were added
28
+ changed - keys that still exist but have new values
29
+ deleted - key that were deleted
30
+ """
31
+
32
+ added: set[str]
33
+ changed: set[str]
34
+ deleted: set[str]
35
+
36
+
37
+ def diff(old_data: dict[str, str] | None, new_data: dict[str, str]) -> Diff:
38
+ """Retrieves the diff of the data in the relation changed databag for v1.
39
+
40
+ Args:
41
+ old_data: dictionary of the stored data before the event.
42
+ new_data: dictionary of the received data to compute the diff.
43
+
44
+ Returns:
45
+ a Diff instance containing the added, deleted and changed
46
+ keys from the event relation databag.
47
+ """
48
+ old_data = old_data or {}
49
+
50
+ # These are the keys that were added to the databag and triggered this event.
51
+ added = new_data.keys() - old_data.keys()
52
+ # These are the keys that were removed from the databag and triggered this event.
53
+ deleted = old_data.keys() - new_data.keys()
54
+ # These are the keys that already existed in the databag,
55
+ # but had their values changed.
56
+ changed = {key for key in old_data.keys() & new_data.keys() if old_data[key] != new_data[key]}
57
+ # Return the diff with all possible changes.
58
+ return Diff(added, changed, deleted)
59
+
60
+
61
+ def resource_added(diff: Diff, aliases: list[str] | None = None) -> bool:
62
+ """Ensures that one of the aliased resources has been added."""
63
+ all_aliases = RESOURCE_ALIASES + ['resource'] + (aliases or [])
64
+ return any(item in diff.added for item in all_aliases)
65
+
66
+
67
+ def store_new_data(
68
+ relation: Relation,
69
+ component: Unit | Application,
70
+ new_data: dict[str, str],
71
+ short_uuid: str | None = None,
72
+ global_data: dict[str, Any] | None = None,
73
+ ):
74
+ """Stores the new data in the databag for diff computation.
75
+
76
+ Args:
77
+ relation: The relation considered to write data to
78
+ component: The component databag to write data to
79
+ new_data: a dictionary containing the data to write
80
+ short_uuid: Only present in V1, the request-id of that data to write.
81
+ global_data: request-independent, global state data to be written.
82
+ """
83
+ global_data = global_data or {}
84
+ global_data = {k: v for k, v in global_data.items() if v}
85
+ # First, the case for V0
86
+ if not short_uuid:
87
+ relation.data[component].update({'data': json.dumps(new_data | global_data)})
88
+ # Then the case for V1, where we have a ShortUUID
89
+ else:
90
+ data = json.loads(relation.data[component].get('data', '{}')) | global_data
91
+ if not isinstance(data, dict):
92
+ raise ValueError
93
+ data[short_uuid] = new_data
94
+ relation.data[component].update({'data': json.dumps(data)})
@@ -0,0 +1,34 @@
1
+ # Copyright 2026 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Exceptions."""
15
+
16
+
17
+ class DataInterfacesError(Exception):
18
+ """Common ancestor for DataInterfaces related exceptions."""
19
+
20
+
21
+ class SecretError(DataInterfacesError):
22
+ """Common ancestor for Secrets related exceptions."""
23
+
24
+
25
+ class SecretAlreadyExistsError(SecretError):
26
+ """A secret that was to be added already exists."""
27
+
28
+
29
+ class SecretsUnavailableError(SecretError):
30
+ """Secrets aren't yet available for Juju version used."""
31
+
32
+
33
+ class IllegalOperationError(DataInterfacesError):
34
+ """To be used when an operation is not allowed to be performed."""