charmlibs-interfaces-ldap 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,158 @@
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
+
15
+ """The charmlibs.interfaces.ldap package.
16
+
17
+ Juju Charm Library for the `ldap` Juju Interface
18
+ ================================================
19
+
20
+ This Juju charm library contains the Provider and Requirer classes for handling
21
+ the ``ldap`` interface.
22
+
23
+ Requirer Charm
24
+ --------------
25
+
26
+ The requirer charm is expected to:
27
+
28
+ - Provide information for the provider charm to deliver LDAP related
29
+ information in the juju integration, in order to communicate with the LDAP
30
+ server and authenticate LDAP operations
31
+ - Listen to the custom juju event ``LdapReadyEvent`` to obtain the LDAP
32
+ related information from the integration
33
+ - Listen to the custom juju event ``LdapUnavailableEvent`` to handle the
34
+ situation when the LDAP integration is broken
35
+
36
+ .. code-block:: python
37
+
38
+ from charmlibs.interfaces.ldap import (
39
+ LdapRequirer,
40
+ LdapReadyEvent,
41
+ LdapUnavailableEvent,
42
+ )
43
+
44
+ class RequirerCharm(CharmBase):
45
+ # LDAP requirer charm that integrates with an LDAP provider charm.
46
+
47
+ def __init__(self, *args):
48
+ super().__init__(*args)
49
+
50
+ self.ldap_requirer = LdapRequirer(self)
51
+ self.framework.observe(
52
+ self.ldap_requirer.on.ldap_ready,
53
+ self._on_ldap_ready,
54
+ )
55
+ self.framework.observe(
56
+ self.ldap_requirer.on.ldap_unavailable,
57
+ self._on_ldap_unavailable,
58
+ )
59
+
60
+ def _on_ldap_ready(self, event: LdapReadyEvent) -> None:
61
+ # Consume the LDAP related information
62
+ ldap_data = self.ldap_requirer.consume_ldap_relation_data(
63
+ relation=event.relation,
64
+ )
65
+
66
+ # Configure the LDAP requirer charm
67
+ ...
68
+
69
+ def _on_ldap_unavailable(self, event: LdapUnavailableEvent) -> None:
70
+ # Handle the situation where the LDAP integration is broken
71
+ ...
72
+
73
+ As shown above, the library offers custom juju events to handle specific
74
+ situations, which are listed below:
75
+
76
+ - ``ldap_ready``: event emitted when the LDAP related information is ready for
77
+ requirer charm to use.
78
+ - ``ldap_unavailable``: event emitted when the LDAP integration is broken.
79
+
80
+ Additionally, the requirer charmed operator needs to declare the ``ldap``
81
+ interface in the ``metadata.yaml``:
82
+
83
+ .. code-block:: yaml
84
+
85
+ requires:
86
+ ldap:
87
+ interface: ldap
88
+
89
+ Provider Charm
90
+ --------------
91
+
92
+ The provider charm is expected to:
93
+
94
+ - Use the information provided by the requirer charm to provide LDAP related
95
+ information for the requirer charm to connect and authenticate to the LDAP
96
+ server
97
+ - Listen to the custom juju event ``LdapRequestedEvent`` to offer LDAP related
98
+ information in the integration
99
+
100
+ .. code-block:: python
101
+
102
+ from charmlibs.interfaces.ldap import (
103
+ LdapProvider,
104
+ LdapRequestedEvent,
105
+ )
106
+
107
+ class ProviderCharm(CharmBase):
108
+ # LDAP provider charm.
109
+
110
+ def __init__(self, *args):
111
+ super().__init__(*args)
112
+
113
+ self.ldap_provider = LdapProvider(self)
114
+ self.framework.observe(
115
+ self.ldap_provider.on.ldap_requested,
116
+ self._on_ldap_requested,
117
+ )
118
+
119
+ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None:
120
+ # Consume the information provided by the requirer charm
121
+ requirer_data = event.data
122
+
123
+ # Prepare the LDAP related information using the requirer's data
124
+ ldap_data = ...
125
+
126
+ # Update the integration data
127
+ self.ldap_provider.update_relations_app_data(
128
+ relation.id,
129
+ ldap_data,
130
+ )
131
+
132
+ As shown above, the library offers custom juju events to handle specific
133
+ situations, which are listed below:
134
+
135
+ - ``ldap_requested``: event emitted when the requirer charm is requesting the
136
+ LDAP related information in order to connect and authenticate to the LDAP server.
137
+ """
138
+
139
+ from ._ldap import (
140
+ LdapProvider,
141
+ LdapProviderData,
142
+ LdapReadyEvent,
143
+ LdapRequestedEvent,
144
+ LdapRequirer,
145
+ LdapRequirerData,
146
+ LdapUnavailableEvent,
147
+ )
148
+ from ._version import __version__ as __version__
149
+
150
+ __all__ = [
151
+ 'LdapProvider',
152
+ 'LdapProviderData',
153
+ 'LdapReadyEvent',
154
+ 'LdapRequestedEvent',
155
+ 'LdapRequirer',
156
+ 'LdapRequirerData',
157
+ 'LdapUnavailableEvent',
158
+ ]
@@ -0,0 +1,411 @@
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
+
15
+
16
+ """LDAP interface implementation.
17
+
18
+ Migrated from charms.glauth_k8s.v0.ldap (v0.13).
19
+ """
20
+
21
+ import json
22
+ from collections.abc import Callable
23
+ from functools import wraps
24
+ from string import Template
25
+ from typing import Any, Literal, Optional, Union
26
+
27
+ import ops
28
+ from ops.charm import (
29
+ CharmBase,
30
+ RelationBrokenEvent,
31
+ RelationChangedEvent,
32
+ RelationCreatedEvent,
33
+ RelationEvent,
34
+ )
35
+ from ops.framework import EventSource, Handle, Object, ObjectEvents
36
+ from ops.model import Relation, SecretNotFoundError
37
+ from pydantic import (
38
+ BaseModel,
39
+ Field,
40
+ StrictBool,
41
+ ValidationError,
42
+ field_serializer,
43
+ field_validator,
44
+ )
45
+
46
+ DEFAULT_RELATION_NAME = 'ldap'
47
+ BIND_ACCOUNT_SECRET_LABEL_TEMPLATE = Template('relation-$relation_id-bind-account-secret')
48
+
49
+
50
+ def leader_unit(func: Callable) -> Callable:
51
+ @wraps(func)
52
+ def wrapper(
53
+ obj: Union['LdapProvider', 'LdapRequirer'], *args: Any, **kwargs: Any
54
+ ) -> Any | None:
55
+ if not obj.unit.is_leader():
56
+ return None
57
+
58
+ return func(obj, *args, **kwargs)
59
+
60
+ return wrapper
61
+
62
+
63
+ @leader_unit
64
+ def _update_relation_app_databag(
65
+ ldap: Union['LdapProvider', 'LdapRequirer'], relation: Relation, data: dict
66
+ ) -> None:
67
+ if relation is None:
68
+ return
69
+
70
+ data = {k: str(v) if v else '' for k, v in data.items()}
71
+ relation.data[ldap.app].update(data)
72
+
73
+
74
+ class Secret:
75
+ def __init__(self, secret: ops.Secret = None) -> None:
76
+ self._secret: ops.Secret = secret
77
+
78
+ @property
79
+ def uri(self) -> str:
80
+ return self._secret.id if self._secret else ''
81
+
82
+ @classmethod
83
+ def load(
84
+ cls,
85
+ charm: CharmBase,
86
+ label: str,
87
+ ) -> Optional['Secret']:
88
+ try:
89
+ secret = charm.model.get_secret(label=label)
90
+ except SecretNotFoundError:
91
+ return None
92
+
93
+ return Secret(secret)
94
+
95
+ @classmethod
96
+ def create_or_update(cls, charm: CharmBase, label: str, content: dict[str, str]) -> 'Secret':
97
+ try:
98
+ secret = charm.model.get_secret(label=label)
99
+ secret.set_content(content=content)
100
+ except SecretNotFoundError:
101
+ secret = charm.app.add_secret(label=label, content=content)
102
+
103
+ return Secret(secret)
104
+
105
+ def grant(self, relation: Relation) -> None:
106
+ self._secret.grant(relation)
107
+
108
+ def remove(self) -> None:
109
+ self._secret.remove_all_revisions()
110
+
111
+
112
+ class LdapProviderBaseData(BaseModel):
113
+ urls: list[str] = Field(frozen=True)
114
+ ldaps_urls: list[str] = Field(frozen=True)
115
+ base_dn: str = Field(frozen=True)
116
+ starttls: StrictBool = Field(frozen=True)
117
+
118
+ @field_validator('urls', mode='before')
119
+ @classmethod
120
+ def validate_ldap_urls(cls, vs: list[str] | str) -> list[str]:
121
+ if isinstance(vs, str):
122
+ vs = json.loads(vs)
123
+ if isinstance(vs, str):
124
+ vs = [vs]
125
+
126
+ for v in vs:
127
+ if not v.startswith('ldap://'):
128
+ raise ValidationError.from_exception_data('Invalid LDAP URL scheme.')
129
+
130
+ return vs
131
+
132
+ @field_validator('ldaps_urls', mode='before')
133
+ @classmethod
134
+ def validate_ldaps_urls(cls, vs: list[str] | str) -> list[str]:
135
+ if isinstance(vs, str):
136
+ vs = json.loads(vs)
137
+ if isinstance(vs, str):
138
+ vs = [vs]
139
+
140
+ for v in vs:
141
+ if not v.startswith('ldaps://'):
142
+ raise ValidationError.from_exception_data('Invalid LDAPS URL scheme.')
143
+
144
+ return vs
145
+
146
+ @field_serializer('urls', 'ldaps_urls')
147
+ def serialize_list(self, urls: list[str]) -> str:
148
+ return str(json.dumps(urls))
149
+
150
+ @field_validator('starttls', mode='before')
151
+ @classmethod
152
+ def deserialize_bool(cls, v: str | bool) -> bool:
153
+ if isinstance(v, str):
154
+ return v.casefold() == 'true'
155
+
156
+ return v
157
+
158
+ @field_serializer('starttls')
159
+ def serialize_bool(self, starttls: bool) -> str:
160
+ return str(starttls)
161
+
162
+
163
+ class LdapProviderData(LdapProviderBaseData):
164
+ bind_dn: str = Field(frozen=True)
165
+ bind_password: str = Field(exclude=True)
166
+ bind_password_secret: str | None = None
167
+ auth_method: Literal['simple'] = Field(frozen=True)
168
+
169
+
170
+ class LdapRequirerData(BaseModel):
171
+ user: str = Field(frozen=True)
172
+ group: str = Field(frozen=True)
173
+
174
+
175
+ class LdapRequestedEvent(RelationEvent):
176
+ """An event emitted when the LDAP integration is built."""
177
+
178
+ def __init__(self, handle: Handle, relation: Relation) -> None:
179
+ super().__init__(handle, relation, relation.app)
180
+
181
+ @property
182
+ def data(self) -> LdapRequirerData | None:
183
+ relation_data = self.relation.data.get(self.relation.app)
184
+ return LdapRequirerData(**relation_data) if relation_data else None
185
+
186
+
187
+ class LdapProviderEvents(ObjectEvents):
188
+ ldap_requested = EventSource(LdapRequestedEvent)
189
+
190
+
191
+ class LdapReadyEvent(RelationEvent):
192
+ """An event when the LDAP related information is ready."""
193
+
194
+
195
+ class LdapUnavailableEvent(RelationEvent):
196
+ """An event when the LDAP integration is unavailable."""
197
+
198
+
199
+ class LdapRequirerEvents(ObjectEvents):
200
+ ldap_ready = EventSource(LdapReadyEvent)
201
+ ldap_unavailable = EventSource(LdapUnavailableEvent)
202
+
203
+
204
+ class _LdapInterface(Object):
205
+ def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None:
206
+ super().__init__(charm, relation_name)
207
+
208
+ self.charm = charm
209
+ self.app = charm.app
210
+ self.unit = charm.unit
211
+ self._relation_name = relation_name
212
+
213
+ @property
214
+ def relations(self) -> list[Relation]:
215
+ """The list of Relation instances associated with this relation_name."""
216
+ return [
217
+ relation
218
+ for relation in self.charm.model.relations[self._relation_name]
219
+ if self._is_relation_active(relation)
220
+ ]
221
+
222
+ @staticmethod
223
+ def _is_relation_active(relation: Relation) -> bool:
224
+ """Whether the relation is active based on contained data."""
225
+ try:
226
+ _ = repr(relation.data)
227
+ return True
228
+ except (RuntimeError, ops.ModelError):
229
+ return False
230
+
231
+
232
+ class LdapProvider(_LdapInterface):
233
+ on = LdapProviderEvents()
234
+
235
+ def __init__(
236
+ self,
237
+ charm: CharmBase,
238
+ relation_name: str = DEFAULT_RELATION_NAME,
239
+ ) -> None:
240
+ super().__init__(charm, relation_name)
241
+
242
+ self.framework.observe(
243
+ self.charm.on[self._relation_name].relation_changed,
244
+ self._on_relation_changed,
245
+ )
246
+ self.framework.observe(
247
+ self.charm.on[self._relation_name].relation_broken,
248
+ self._on_relation_broken,
249
+ )
250
+
251
+ @leader_unit
252
+ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
253
+ """Handle the event emitted when the requirer charm provides the necessary data."""
254
+ self.on.ldap_requested.emit(event.relation)
255
+
256
+ @leader_unit
257
+ def _on_relation_broken(self, event: RelationBrokenEvent) -> None:
258
+ """Handle the event emitted when the LDAP integration is broken."""
259
+ secret = Secret.load(
260
+ self.charm,
261
+ label=BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=event.relation.id),
262
+ )
263
+ if secret:
264
+ secret.remove()
265
+
266
+ def get_bind_password(self, relation_id: int) -> str | None:
267
+ """Retrieve the bind account password for a given integration."""
268
+ try:
269
+ secret = self.charm.model.get_secret(
270
+ label=BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=relation_id)
271
+ )
272
+ except SecretNotFoundError:
273
+ return None
274
+ return secret.get_content().get('password')
275
+
276
+ def update_relations_app_data(
277
+ self,
278
+ data: LdapProviderBaseData | LdapProviderData,
279
+ /,
280
+ relation_id: int | None = None,
281
+ ) -> None:
282
+ """An API for the provider charm to provide the LDAP related information."""
283
+ if not (relations := self.charm.model.relations.get(self._relation_name)):
284
+ return
285
+
286
+ if relation_id is not None and isinstance(data, LdapProviderData):
287
+ relations = [relation for relation in relations if relation.id == relation_id]
288
+ secret = Secret.create_or_update(
289
+ self.charm,
290
+ BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=relation_id),
291
+ {'password': data.bind_password},
292
+ )
293
+ secret.grant(relations[0])
294
+ data.bind_password_secret = secret.uri
295
+
296
+ for relation in relations:
297
+ _update_relation_app_databag(self.charm, relation, data.model_dump())
298
+
299
+
300
+ class LdapRequirer(_LdapInterface):
301
+ """An LDAP requirer to consume data delivered by an LDAP provider charm."""
302
+
303
+ on = LdapRequirerEvents()
304
+
305
+ def __init__(
306
+ self,
307
+ charm: CharmBase,
308
+ relation_name: str = DEFAULT_RELATION_NAME,
309
+ *,
310
+ data: LdapRequirerData | None = None,
311
+ ) -> None:
312
+ super().__init__(charm, relation_name)
313
+
314
+ self._data = data
315
+
316
+ self.framework.observe(
317
+ self.charm.on[self._relation_name].relation_created,
318
+ self._on_ldap_relation_created,
319
+ )
320
+ self.framework.observe(
321
+ self.charm.on[self._relation_name].relation_changed,
322
+ self._on_ldap_relation_changed,
323
+ )
324
+ self.framework.observe(
325
+ self.charm.on[self._relation_name].relation_broken,
326
+ self._on_ldap_relation_broken,
327
+ )
328
+
329
+ def _on_ldap_relation_created(self, event: RelationCreatedEvent) -> None:
330
+ """Handle the event emitted when an LDAP integration is created."""
331
+ user = self._data.user if self._data else self.app.name
332
+ group = self._data.group if self._data else self.model.name
333
+ _update_relation_app_databag(self.charm, event.relation, {'user': user, 'group': group})
334
+
335
+ def _on_ldap_relation_changed(self, event: RelationChangedEvent) -> None:
336
+ """Handle the event emitted when the LDAP related information is ready."""
337
+ provider_app = event.relation.app
338
+
339
+ if not (provider_data := event.relation.data.get(provider_app)):
340
+ return
341
+
342
+ provider_data = dict(provider_data)
343
+ if self._load_provider_data(provider_data):
344
+ self.on.ldap_ready.emit(event.relation)
345
+
346
+ def _on_ldap_relation_broken(self, event: RelationBrokenEvent) -> None:
347
+ """Handle the event emitted when the LDAP integration is broken."""
348
+ self.on.ldap_unavailable.emit(event.relation)
349
+
350
+ def _load_provider_data(self, provider_data: dict) -> LdapProviderData | None:
351
+ try:
352
+ secret_id = provider_data.get('bind_password_secret')
353
+ secret = self.charm.model.get_secret(id=secret_id)
354
+ provider_data['bind_password'] = secret.get_content().get('password')
355
+ return LdapProviderData(**provider_data)
356
+ except (ops.ModelError, ops.SecretNotFoundError, TypeError, ValidationError):
357
+ return None
358
+
359
+ def consume_ldap_relation_data(
360
+ self,
361
+ /,
362
+ relation: Relation | None = None,
363
+ relation_id: int | None = None,
364
+ ) -> LdapProviderData | None:
365
+ """Consume the LDAP related information from the application databag."""
366
+ if not relation:
367
+ relation = self.charm.model.get_relation(self._relation_name, relation_id)
368
+
369
+ if not relation:
370
+ return None
371
+
372
+ provider_data = dict(relation.data.get(relation.app))
373
+ if not provider_data:
374
+ return None
375
+
376
+ return self._load_provider_data(provider_data)
377
+
378
+ def _ready_for_relation(self, relation: Relation) -> bool:
379
+ if not relation.app:
380
+ return False
381
+
382
+ return 'urls' in relation.data[relation.app] and 'bind_dn' in relation.data[relation.app]
383
+
384
+ def ready(self, relation_id: int | None = None) -> bool:
385
+ """Check if the resource has been created.
386
+
387
+ This function can be used to check if the Provider answered with data in the charm code
388
+ when outside an event callback.
389
+
390
+ Args:
391
+ relation_id (int, optional): When provided the check is done only for the relation id
392
+ provided, otherwise the check is done for all relations
393
+
394
+ Returns:
395
+ True or False
396
+
397
+ Raises:
398
+ IndexError: If relation_id is provided but that relation does not exist
399
+ """
400
+ if relation_id is None:
401
+ return (
402
+ all(self._ready_for_relation(relation) for relation in self.relations)
403
+ if self.relations
404
+ else False
405
+ )
406
+
407
+ try:
408
+ relation = next(relation for relation in self.relations if relation.id == relation_id)
409
+ return self._ready_for_relation(relation)
410
+ except StopIteration:
411
+ raise IndexError(f'relation id {relation_id} cannot be accessed') from None
@@ -0,0 +1,15 @@
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
+
15
+ __version__ = '1.0.0'
File without changes
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: charmlibs-interfaces-ldap
3
+ Version: 1.0.0
4
+ Summary: The charmlibs.interfaces.ldap package.
5
+ Project-URL: Documentation, https://canonical.com/juju/docs/charmlibs/reference/charmlibs/interfaces/ldap
6
+ Project-URL: Repository, https://github.com/canonical/charmlibs/tree/main/interfaces/ldap
7
+ Project-URL: Issues, https://github.com/canonical/charmlibs/issues
8
+ Project-URL: Changelog, https://github.com/canonical/charmlibs/blob/main/interfaces/ldap/CHANGELOG.md
9
+ Author: Identity Team at Canonical
10
+ License-Expression: Apache-2.0
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: ops<4,>=2.23.1
17
+ Requires-Dist: pydantic>=2.13.4
18
+ Description-Content-Type: text/markdown
19
+
20
+ # charmlibs.interfaces.ldap
21
+
22
+ The `ldap` interface library.
23
+
24
+ To install, add `charmlibs-interfaces-ldap` to your Python dependencies. Then in your Python code, import as:
25
+
26
+ ```py
27
+ from charmlibs.interfaces import ldap
28
+ ```
29
+
30
+ See the [reference documentation](https://canonical.com/juju/docs/charmlibs/reference/charmlibs/interfaces/ldap) for more.
@@ -0,0 +1,7 @@
1
+ charmlibs/interfaces/ldap/__init__.py,sha256=1dUTfbVaS6zwrwD7dqSGzWx8fn4JQJqy9mE3A3MOV7M,4879
2
+ charmlibs/interfaces/ldap/_ldap.py,sha256=QN65iaceZFNi0FLu9sWyjZLlY8x2--xOxiRaHBivsd4,13416
3
+ charmlibs/interfaces/ldap/_version.py,sha256=mimKCOrPlb1vtVJVsxk3us5vTy0K8n3WoU3GISe6wFE,597
4
+ charmlibs/interfaces/ldap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ charmlibs_interfaces_ldap-1.0.0.dist-info/METADATA,sha256=tN_wEViigEQ_z9nH58uql3oeK1hVP31izZofQV8C4Nk,1213
6
+ charmlibs_interfaces_ldap-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ charmlibs_interfaces_ldap-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any