IDKit 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.
idkit/identifier.py ADDED
@@ -0,0 +1,440 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from typing import Any, Iterator, Self
6
+
7
+ from .exceptions import IdentifierParseError, IdentifierValidationError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Identifier[G: StrEnum, S: StrEnum, R: StrEnum]:
12
+ """
13
+ Immutable typed identifier value.
14
+
15
+ The identifier format is:
16
+
17
+ Group::Source[-Component][+role]
18
+
19
+ The same source enum is used for both the required ``Source`` segment and
20
+ the optional ``Component`` segment. Instances support fluent construction
21
+ through attribute access when created from an ``IdentifierRoot``.
22
+
23
+ Examples:
24
+ system::runtime
25
+ system::runtime-agent
26
+ system::runtime-agent+analyzer
27
+ """
28
+
29
+ group: G
30
+ source: S | None = None
31
+ component: S | None = None
32
+ role: R | None = None
33
+
34
+ source_enum: type[S] | None = field(default=None, repr=False, compare=False)
35
+ role_enum: type[R] | None = field(default=None, repr=False, compare=False)
36
+
37
+ def __post_init__(self) -> None:
38
+ if self.component is not None and self.source is None:
39
+ raise IdentifierValidationError(
40
+ "Identifier cannot have a component without a source."
41
+ )
42
+
43
+ def __getattr__(self, name: str) -> Identifier[G, S, R]:
44
+ if self.source_enum and name in self.source_enum.__members__:
45
+ if self.source is None or self.component is None:
46
+ return self.with_source_or_component(self.source_enum[name])
47
+
48
+ if self.role_enum and name in self.role_enum.__members__:
49
+ return self.with_role(self.role_enum[name])
50
+
51
+ raise AttributeError(name)
52
+
53
+ @property
54
+ def has_source(self) -> bool:
55
+ return self.source is not None
56
+
57
+ @property
58
+ def has_component(self) -> bool:
59
+ return self.component is not None
60
+
61
+ @property
62
+ def has_role(self) -> bool:
63
+ return self.role is not None
64
+
65
+ @property
66
+ def has_action(self) -> bool:
67
+ return self.has_role
68
+
69
+ @property
70
+ def is_complete(self) -> bool:
71
+ return self.source is not None and self.role is not None
72
+
73
+ def require_source(self) -> Self:
74
+ if self.source is None:
75
+ raise IdentifierValidationError("Identifier requires a source.")
76
+ return self
77
+
78
+ def require_role(self) -> Self:
79
+ if self.role is None:
80
+ raise IdentifierValidationError("Identifier requires an role.")
81
+ return self
82
+
83
+ def require_action(self) -> Self:
84
+ return self.require_role()
85
+
86
+ def require_complete(self) -> Self:
87
+ self.require_source()
88
+ self.require_role()
89
+ return self
90
+
91
+ def with_source(self, source: S) -> Self:
92
+ if self.source is not None:
93
+ raise IdentifierValidationError("Identifier already has a source.")
94
+
95
+ return type(self)(
96
+ group=self.group,
97
+ source=source,
98
+ component=self.component,
99
+ role=self.role,
100
+ source_enum=self.source_enum,
101
+ role_enum=self.role_enum,
102
+ )
103
+
104
+ def with_component(self, component: S) -> Self:
105
+ if self.source is None:
106
+ raise IdentifierValidationError("Cannot set component before source.")
107
+
108
+ if self.component is not None:
109
+ raise IdentifierValidationError("Identifier already has a component.")
110
+
111
+ return type(self)(
112
+ group=self.group,
113
+ source=self.source,
114
+ component=component,
115
+ role=self.role,
116
+ source_enum=self.source_enum,
117
+ role_enum=self.role_enum,
118
+ )
119
+
120
+ def with_role(self, role: R) -> Self:
121
+ if self.role is not None:
122
+ raise IdentifierValidationError("Identifier already has an role.")
123
+
124
+ return type(self)(
125
+ group=self.group,
126
+ source=self.source,
127
+ component=self.component,
128
+ role=role,
129
+ source_enum=self.source_enum,
130
+ role_enum=self.role_enum,
131
+ )
132
+
133
+ def with_source_or_component(self, source: S) -> Self:
134
+ if self.source is None:
135
+ return self.with_source(source)
136
+
137
+ if self.component is None:
138
+ return self.with_component(source)
139
+
140
+ raise IdentifierValidationError(
141
+ "Identifier already has both source and component."
142
+ )
143
+
144
+ def without_role(self) -> Self:
145
+ return type(self)(
146
+ group=self.group,
147
+ source=self.source,
148
+ component=self.component,
149
+ role=None,
150
+ source_enum=self.source_enum,
151
+ role_enum=self.role_enum,
152
+ )
153
+
154
+ def without_component(self) -> Self:
155
+ return type(self)(
156
+ group=self.group,
157
+ source=self.source,
158
+ component=None,
159
+ role=self.role,
160
+ source_enum=self.source_enum,
161
+ role_enum=self.role_enum,
162
+ )
163
+
164
+ def without_source(self) -> Self:
165
+ return type(self)(
166
+ group=self.group,
167
+ source=None,
168
+ component=None,
169
+ role=self.role,
170
+ source_enum=self.source_enum,
171
+ role_enum=self.role_enum,
172
+ )
173
+
174
+ @property
175
+ def parent(self) -> Self:
176
+ if self.role is not None:
177
+ return self.without_role()
178
+
179
+ if self.component is not None:
180
+ return self.without_component()
181
+
182
+ if self.source is not None:
183
+ return type(self)(
184
+ group=self.group,
185
+ source_enum=self.source_enum,
186
+ role_enum=self.role_enum,
187
+ )
188
+
189
+ return self
190
+
191
+ @property
192
+ def parents(self) -> tuple[Self, ...]:
193
+ items: list[Self] = []
194
+ current = self
195
+
196
+ while current.parent != current:
197
+ current = current.parent
198
+ items.append(current)
199
+
200
+ return tuple(items)
201
+
202
+ def matches(self, other: Identifier[G, S, R] | str) -> bool:
203
+ """
204
+ Prefix-style hierarchy match.
205
+
206
+ ID.system.runtime.agent.analyzer.matches(ID.system.runtime) == True
207
+ """
208
+ if isinstance(other, str):
209
+ if self.source_enum is None or self.role_enum is None:
210
+ return self.value == other
211
+
212
+ try:
213
+ other = type(self).parse(
214
+ other,
215
+ group_enum=type(self.group),
216
+ source_enum=self.source_enum,
217
+ role_enum=self.role_enum,
218
+ )
219
+ except IdentifierParseError:
220
+ return False
221
+
222
+ if self.group != other.group:
223
+ return False
224
+
225
+ if other.source is not None and self.source != other.source:
226
+ return False
227
+
228
+ if other.component is not None and self.component != other.component:
229
+ return False
230
+
231
+ if other.role is not None and self.role != other.role:
232
+ return False
233
+
234
+ return True
235
+
236
+ @property
237
+ def parts(self) -> tuple[G, S | None, S | None, R | None]:
238
+ return self.group, self.source, self.component, self.role
239
+
240
+ @property
241
+ def string_parts(self) -> tuple[str, ...]:
242
+ return tuple(
243
+ part.value
244
+ for part in (self.group, self.source, self.component, self.role)
245
+ if part is not None
246
+ )
247
+
248
+ @property
249
+ def namespace(self) -> str:
250
+ if self.source is None:
251
+ return self.group.value
252
+
253
+ value = f"{self.group.value}::{self.source.value}"
254
+
255
+ if self.component is not None:
256
+ value += f"-{self.component.value}"
257
+
258
+ return value
259
+
260
+ @property
261
+ def qualified(self) -> str:
262
+ if self.role is None:
263
+ return self.namespace
264
+
265
+ return f"{self.namespace}+{self.role.value}"
266
+
267
+ @property
268
+ def value(self) -> str:
269
+ return self.qualified
270
+
271
+ @property
272
+ def path(self) -> str:
273
+ return "/".join(self.string_parts)
274
+
275
+ @property
276
+ def slug(self) -> str:
277
+ return "-".join(self.string_parts)
278
+
279
+ @property
280
+ def metric_key(self) -> str:
281
+ return ".".join(self.string_parts)
282
+
283
+ @property
284
+ def cache_key(self) -> str:
285
+ return self.value
286
+
287
+ @property
288
+ def event_topic(self) -> str:
289
+ return self.path
290
+
291
+ def to_dict(self) -> dict[str, Any]:
292
+ return {
293
+ "group": self.group.value,
294
+ "source": self.source.value if self.source else None,
295
+ "component": self.component.value if self.component else None,
296
+ "role": self.role.value if self.role else None,
297
+ "namespace": self.namespace,
298
+ "qualified": self.qualified,
299
+ "path": self.path,
300
+ "slug": self.slug,
301
+ "metric_key": self.metric_key,
302
+ "cache_key": self.cache_key,
303
+ "event_topic": self.event_topic,
304
+ "value": self.value,
305
+ }
306
+
307
+ def __iter__(self) -> Iterator[StrEnum]:
308
+ yield self.group
309
+
310
+ if self.source is not None:
311
+ yield self.source
312
+
313
+ if self.component is not None:
314
+ yield self.component
315
+
316
+ if self.role is not None:
317
+ yield self.role
318
+
319
+ def __str__(self) -> str:
320
+ return self.value
321
+
322
+ def __repr__(self) -> str:
323
+ return f"Identifier('{self.value}')"
324
+
325
+ def __eq__(self, other: object) -> bool:
326
+ if isinstance(other, Identifier):
327
+ return self.parts == other.parts
328
+
329
+ if isinstance(other, str):
330
+ return self.value == other
331
+
332
+ return NotImplemented
333
+
334
+ def __lt__(self, other: object) -> bool:
335
+ if isinstance(other, Identifier):
336
+ return self.value < other.value
337
+
338
+ if isinstance(other, str):
339
+ return self.value < other
340
+
341
+ return NotImplemented
342
+
343
+ def __hash__(self) -> int:
344
+ return hash(self.parts)
345
+
346
+ @classmethod
347
+ def parse(
348
+ cls,
349
+ value: str,
350
+ *,
351
+ group_enum: type[G],
352
+ source_enum: type[S],
353
+ role_enum: type[R],
354
+ ) -> Identifier[G, S, R]:
355
+ if not value:
356
+ raise IdentifierParseError("Identifier cannot be empty.")
357
+
358
+ if "::" not in value:
359
+ try:
360
+ return cls(
361
+ group=group_enum(value),
362
+ source_enum=source_enum,
363
+ role_enum=role_enum,
364
+ )
365
+ except ValueError as exc:
366
+ raise IdentifierParseError(
367
+ f"Invalid identifier '{value}'. Expected format: Group::Source[-Component][+role]."
368
+ ) from exc
369
+
370
+ try:
371
+ group_part, rest = value.split("::", 1)
372
+ except ValueError as exc:
373
+ raise IdentifierParseError(
374
+ f"Invalid identifier '{value}'. Expected format: Group::Source[-Component][+role]."
375
+ ) from exc
376
+
377
+ if not group_part:
378
+ raise IdentifierParseError("Group cannot be empty.")
379
+
380
+ try:
381
+ parsed_group = group_enum(group_part)
382
+ except ValueError as exc:
383
+ raise IdentifierParseError(f"Invalid group '{group_part}'.") from exc
384
+
385
+ if not rest:
386
+ raise IdentifierParseError("Source cannot be empty.")
387
+
388
+ parsed_role: R | None = None
389
+
390
+ if "+" in rest:
391
+ rest, role_part = rest.split("+", 1)
392
+
393
+ if not role_part:
394
+ raise IdentifierParseError("role cannot be empty.")
395
+
396
+ try:
397
+ parsed_role = role_enum(role_part)
398
+ except ValueError as exc:
399
+ raise IdentifierParseError(f"Invalid role '{role_part}'.") from exc
400
+
401
+ if not rest:
402
+ raise IdentifierParseError("Source cannot be empty.")
403
+
404
+ parsed_component: S | None = None
405
+
406
+ if "-" in rest:
407
+ source_part, component_part = rest.split("-", 1)
408
+
409
+ if not source_part:
410
+ raise IdentifierParseError("Source cannot be empty.")
411
+
412
+ if not component_part:
413
+ raise IdentifierParseError("Component cannot be empty.")
414
+
415
+ try:
416
+ parsed_source = source_enum(source_part)
417
+ except ValueError as exc:
418
+ raise IdentifierParseError(f"Invalid source '{source_part}'.") from exc
419
+
420
+ try:
421
+ parsed_component = source_enum(component_part)
422
+ except ValueError as exc:
423
+ raise IdentifierParseError(
424
+ f"Invalid component '{component_part}'."
425
+ ) from exc
426
+
427
+ else:
428
+ try:
429
+ parsed_source = source_enum(rest)
430
+ except ValueError as exc:
431
+ raise IdentifierParseError(f"Invalid source '{rest}'.") from exc
432
+
433
+ return cls(
434
+ group=parsed_group,
435
+ source=parsed_source,
436
+ component=parsed_component,
437
+ role=parsed_role,
438
+ source_enum=source_enum,
439
+ role_enum=role_enum,
440
+ )
idkit/idkit.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from .identifier import Identifier
4
+ from .protocols import Identifiable, SupportsIdentifier
5
+ from .root import IdentifierRoot
6
+ from .enums import (
7
+ IdentifierGroup,
8
+ IdentifierOperation,
9
+ IdentifierRole,
10
+ IdentifierSource,
11
+ )
12
+
13
+
14
+ type IDKitIdentifier = Identifier[IdentifierGroup, IdentifierSource, IdentifierRole]
15
+
16
+ type IDKitOperationIdentifier = Identifier[
17
+ IdentifierGroup, IdentifierSource, IdentifierOperation
18
+ ]
19
+
20
+ type IDKitIdentifierRoot = IdentifierRoot[
21
+ IdentifierGroup, IdentifierSource, IdentifierRole
22
+ ]
23
+
24
+ type IDKitIdentifierLike = SupportsIdentifier[
25
+ IdentifierGroup, IdentifierSource, IdentifierRole
26
+ ]
27
+
28
+ type IDKitIdentifiable = Identifiable[IdentifierGroup, IdentifierSource, IdentifierRole]
29
+
30
+ type AppIdentifier = IDKitIdentifier
31
+ type AppIdentifierRoot = IDKitIdentifierRoot
32
+ type AppIdentifierLike = IDKitIdentifierLike
33
+ type AppIdentifiable = IDKitIdentifiable
34
+
35
+
36
+ ID: IDKitIdentifierRoot = IdentifierRoot(
37
+ group=IdentifierGroup,
38
+ source=IdentifierSource,
39
+ role=IdentifierRole,
40
+ )
41
+
42
+
43
+ __all__ = [
44
+ "ID",
45
+ "AppIdentifier",
46
+ "AppIdentifierRoot",
47
+ "AppIdentifierLike",
48
+ "AppIdentifiable",
49
+ "IDKitIdentifier",
50
+ "IDKitOperationIdentifier",
51
+ "IDKitIdentifierRoot",
52
+ "IDKitIdentifierLike",
53
+ "IDKitIdentifiable",
54
+ "IdentifierGroup",
55
+ "IdentifierSource",
56
+ "IdentifierRole",
57
+ "IdentifierOperation",
58
+ ]
idkit/protocols.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Any, Protocol, Self
5
+
6
+
7
+ class SupportsIdentifier[G: StrEnum, S: StrEnum, R: StrEnum](Protocol):
8
+ """Structural interface implemented by identifier-like values.
9
+
10
+ Use this protocol when a function only needs the public identifier API and
11
+ should accept either ``Identifier`` instances or compatible implementations.
12
+ """
13
+
14
+ @property
15
+ def group(self) -> G: ...
16
+
17
+ @property
18
+ def source(self) -> S | None: ...
19
+
20
+ @property
21
+ def component(self) -> S | None: ...
22
+
23
+ @property
24
+ def role(self) -> R | None: ...
25
+
26
+ @property
27
+ def parts(self) -> tuple[G, S | None, S | None, R | None]: ...
28
+
29
+ @property
30
+ def string_parts(self) -> tuple[str, ...]: ...
31
+
32
+ @property
33
+ def namespace(self) -> str: ...
34
+
35
+ @property
36
+ def qualified(self) -> str: ...
37
+
38
+ @property
39
+ def value(self) -> str: ...
40
+
41
+ @property
42
+ def path(self) -> str: ...
43
+
44
+ @property
45
+ def slug(self) -> str: ...
46
+
47
+ @property
48
+ def metric_key(self) -> str: ...
49
+
50
+ @property
51
+ def cache_key(self) -> str: ...
52
+
53
+ @property
54
+ def event_topic(self) -> str: ...
55
+
56
+ @property
57
+ def has_source(self) -> bool: ...
58
+
59
+ @property
60
+ def has_component(self) -> bool: ...
61
+
62
+ @property
63
+ def has_role(self) -> bool: ...
64
+
65
+ @property
66
+ def has_action(self) -> bool: ...
67
+
68
+ @property
69
+ def is_complete(self) -> bool: ...
70
+
71
+ def require_source(self) -> Self: ...
72
+
73
+ def require_role(self) -> Self: ...
74
+
75
+ def require_action(self) -> Self: ...
76
+
77
+ def require_complete(self) -> Self: ...
78
+
79
+ def to_dict(self) -> dict[str, Any]: ...
80
+
81
+
82
+ class Identifiable[G: StrEnum, S: StrEnum, R: StrEnum](Protocol):
83
+ """Structural interface for objects that expose an ``identifier`` property."""
84
+
85
+ @property
86
+ def identifier(self) -> SupportsIdentifier[G, S, R]: ...
idkit/py.typed ADDED
@@ -0,0 +1 @@
1
+
idkit/root.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+ from .identifier import Identifier
6
+
7
+
8
+ class IdentifierRoot[G: StrEnum, S: StrEnum, R: StrEnum]:
9
+ """Entry point for building and parsing typed identifiers.
10
+
11
+ An ``IdentifierRoot`` binds the enum types that define the valid groups,
12
+ sources/components, and actions for a package. Attribute access on the root
13
+ starts a new identifier from a group name, for example ``ID.system``.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ group: type[G],
20
+ source: type[S],
21
+ role: type[R],
22
+ ):
23
+ self.group = group
24
+ self.source = source
25
+ self.role = role
26
+
27
+ def __getattr__(self, name: str) -> Identifier[G, S, R]:
28
+ if name in self.group.__members__:
29
+ return Identifier(
30
+ group=self.group[name],
31
+ source_enum=self.source,
32
+ role_enum=self.role,
33
+ )
34
+
35
+ raise AttributeError(name)
36
+
37
+ def parse(self, value: str) -> Identifier[G, S, R]:
38
+ return Identifier.parse(
39
+ value,
40
+ group_enum=self.group,
41
+ source_enum=self.source,
42
+ role_enum=self.role,
43
+ )
44
+
45
+
46
+ IDRoot = IdentifierRoot
47
+ """Short alias for `IdentifierRoot`"""