unique_toolkit 1.2.1__py3-none-any.whl → 1.3.1__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,936 @@
1
+ """
2
+ React JSON Schema Form (RJSF) metadata tags for Pydantic models.
3
+
4
+ This module provides utilities for generating React JSON Schema Form uiSchemas
5
+ from Pydantic models using type annotations. It allows developers to specify
6
+ UI metadata directly in their Pydantic model definitions using Annotated types.
7
+
8
+ Key components:
9
+ - RJSFMetaTag: Factory class for creating RJSF metadata tags
10
+ - ui_schema_for_model(): Main function to generate uiSchema from Pydantic models
11
+ - Helper functions for processing type annotations and metadata
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Annotated, Any, Union, get_args, get_origin
17
+
18
+ from pydantic import BaseModel
19
+ from typing_extensions import get_type_hints
20
+
21
+
22
+ # --------- Base Metadata carrier ----------
23
+ class RJSFMetaTag:
24
+ def __init__(self, attrs: dict[str, Any] | None = None):
25
+ """Initialize with either a dict or keyword arguments.
26
+
27
+ Args:
28
+ attrs: Dictionary of attributes (preferred)
29
+ **kwargs: Keyword arguments (for backward compatibility)
30
+ """
31
+ self.attrs = attrs if attrs is not None else {}
32
+
33
+ # --------- Widget Type Subclasses ----------
34
+
35
+ class BooleanWidget:
36
+ """Widgets for boolean fields: radio, select, checkbox (default)."""
37
+
38
+ @classmethod
39
+ def radio(
40
+ cls,
41
+ *,
42
+ disabled: bool = False,
43
+ title: str | None = None,
44
+ description: str | None = None,
45
+ help: str | None = None,
46
+ **kwargs: Any,
47
+ ) -> RJSFMetaTag:
48
+ """Create a radio button group for boolean values."""
49
+ attrs = {
50
+ "ui:widget": "radio",
51
+ "ui:disabled": disabled,
52
+ "ui:title": title,
53
+ "ui:description": description,
54
+ "ui:help": help,
55
+ **kwargs,
56
+ }
57
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
58
+
59
+ @classmethod
60
+ def select(
61
+ cls,
62
+ *,
63
+ disabled: bool = False,
64
+ title: str | None = None,
65
+ description: str | None = None,
66
+ help: str | None = None,
67
+ **kwargs: Any,
68
+ ) -> RJSFMetaTag:
69
+ """Create a select dropdown for boolean values."""
70
+ attrs = {
71
+ "ui:widget": "select",
72
+ "ui:disabled": disabled,
73
+ "ui:title": title,
74
+ "ui:description": description,
75
+ "ui:help": help,
76
+ **kwargs,
77
+ }
78
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
79
+
80
+ @classmethod
81
+ def checkbox(
82
+ cls,
83
+ *,
84
+ disabled: bool = False,
85
+ title: str | None = None,
86
+ description: str | None = None,
87
+ help: str | None = None,
88
+ **kwargs: Any,
89
+ ) -> RJSFMetaTag:
90
+ """Create a checkbox for boolean values (default widget)."""
91
+ attrs = {
92
+ "ui:widget": "checkbox",
93
+ "ui:disabled": disabled,
94
+ "ui:title": title,
95
+ "ui:description": description,
96
+ "ui:help": help,
97
+ **kwargs,
98
+ }
99
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
100
+
101
+ class StringWidget:
102
+ """Widgets for string fields: text, textarea, password, color, email, url, date, datetime, time, file."""
103
+
104
+ @classmethod
105
+ def textfield(
106
+ cls,
107
+ *,
108
+ placeholder: str | None = None,
109
+ disabled: bool = False,
110
+ readonly: bool = False,
111
+ autofocus: bool = False,
112
+ title: str | None = None,
113
+ description: str | None = None,
114
+ help: str | None = None,
115
+ class_names: str | None = None,
116
+ **kwargs: Any,
117
+ ) -> RJSFMetaTag:
118
+ """Create a text field (default for strings)."""
119
+ attrs: dict[str, Any] = {
120
+ "ui:widget": "text",
121
+ "ui:placeholder": placeholder,
122
+ "ui:disabled": disabled,
123
+ "ui:readonly": readonly,
124
+ "ui:autofocus": autofocus,
125
+ "ui:title": title,
126
+ "ui:description": description,
127
+ "ui:help": help,
128
+ "ui:classNames": class_names,
129
+ **kwargs,
130
+ }
131
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
132
+
133
+ @classmethod
134
+ def textarea(
135
+ cls,
136
+ *,
137
+ placeholder: str | None = None,
138
+ disabled: bool = False,
139
+ readonly: bool = False,
140
+ rows: int | None = None,
141
+ title: str | None = None,
142
+ description: str | None = None,
143
+ help: str | None = None,
144
+ **kwargs: Any,
145
+ ) -> RJSFMetaTag:
146
+ """Create a textarea field."""
147
+ attrs: dict[str, Any] = {
148
+ "ui:widget": "textarea",
149
+ "ui:placeholder": placeholder,
150
+ "ui:disabled": disabled,
151
+ "ui:readonly": readonly,
152
+ "ui:options": {"rows": rows} if rows else None,
153
+ "ui:title": title,
154
+ "ui:description": description,
155
+ "ui:help": help,
156
+ **kwargs,
157
+ }
158
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
159
+
160
+ @classmethod
161
+ def password(
162
+ cls,
163
+ *,
164
+ placeholder: str | None = None,
165
+ disabled: bool = False,
166
+ readonly: bool = False,
167
+ title: str | None = None,
168
+ description: str | None = None,
169
+ help: str | None = None,
170
+ **kwargs: Any,
171
+ ) -> RJSFMetaTag:
172
+ """Create a password field."""
173
+ attrs = {
174
+ "ui:widget": "password",
175
+ "ui:placeholder": placeholder,
176
+ "ui:disabled": disabled,
177
+ "ui:readonly": readonly,
178
+ "ui:title": title,
179
+ "ui:description": description,
180
+ "ui:help": help,
181
+ **kwargs,
182
+ }
183
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
184
+
185
+ @classmethod
186
+ def color(
187
+ cls,
188
+ *,
189
+ disabled: bool = False,
190
+ title: str | None = None,
191
+ description: str | None = None,
192
+ help: str | None = None,
193
+ **kwargs: Any,
194
+ ) -> RJSFMetaTag:
195
+ """Create a color picker field."""
196
+ attrs = {
197
+ "ui:widget": "color",
198
+ "ui:disabled": disabled,
199
+ "ui:title": title,
200
+ "ui:description": description,
201
+ "ui:help": help,
202
+ **kwargs,
203
+ }
204
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
205
+
206
+ @classmethod
207
+ def email(
208
+ cls,
209
+ *,
210
+ placeholder: str | None = None,
211
+ disabled: bool = False,
212
+ readonly: bool = False,
213
+ title: str | None = None,
214
+ description: str | None = None,
215
+ help: str | None = None,
216
+ **kwargs: Any,
217
+ ) -> RJSFMetaTag:
218
+ """Create an email field."""
219
+ attrs = {
220
+ "ui:widget": "email",
221
+ "ui:placeholder": placeholder,
222
+ "ui:disabled": disabled,
223
+ "ui:readonly": readonly,
224
+ "ui:title": title,
225
+ "ui:description": description,
226
+ "ui:help": help,
227
+ **kwargs,
228
+ }
229
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
230
+
231
+ @classmethod
232
+ def url(
233
+ cls,
234
+ *,
235
+ placeholder: str | None = None,
236
+ disabled: bool = False,
237
+ readonly: bool = False,
238
+ title: str | None = None,
239
+ description: str | None = None,
240
+ help: str | None = None,
241
+ **kwargs: Any,
242
+ ) -> RJSFMetaTag:
243
+ """Create a URL field."""
244
+ attrs = {
245
+ "ui:widget": "uri",
246
+ "ui:placeholder": placeholder,
247
+ "ui:disabled": disabled,
248
+ "ui:readonly": readonly,
249
+ "ui:title": title,
250
+ "ui:description": description,
251
+ "ui:help": help,
252
+ **kwargs,
253
+ }
254
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
255
+
256
+ @classmethod
257
+ def date(
258
+ cls,
259
+ *,
260
+ disabled: bool = False,
261
+ title: str | None = None,
262
+ description: str | None = None,
263
+ help: str | None = None,
264
+ **kwargs: Any,
265
+ ) -> RJSFMetaTag:
266
+ """Create a date field."""
267
+ attrs = {
268
+ "ui:widget": "date",
269
+ "ui:disabled": disabled,
270
+ "ui:title": title,
271
+ "ui:description": description,
272
+ "ui:help": help,
273
+ **kwargs,
274
+ }
275
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
276
+
277
+ @classmethod
278
+ def datetime(
279
+ cls,
280
+ *,
281
+ disabled: bool = False,
282
+ title: str | None = None,
283
+ description: str | None = None,
284
+ help: str | None = None,
285
+ **kwargs: Any,
286
+ ) -> RJSFMetaTag:
287
+ """Create a datetime field."""
288
+ attrs = {
289
+ "ui:widget": "datetime",
290
+ "ui:disabled": disabled,
291
+ "ui:title": title,
292
+ "ui:description": description,
293
+ "ui:help": help,
294
+ **kwargs,
295
+ }
296
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
297
+
298
+ @classmethod
299
+ def time(
300
+ cls,
301
+ *,
302
+ disabled: bool = False,
303
+ title: str | None = None,
304
+ description: str | None = None,
305
+ help: str | None = None,
306
+ **kwargs: Any,
307
+ ) -> RJSFMetaTag:
308
+ """Create a time field."""
309
+ attrs = {
310
+ "ui:widget": "time",
311
+ "ui:disabled": disabled,
312
+ "ui:title": title,
313
+ "ui:description": description,
314
+ "ui:help": help,
315
+ **kwargs,
316
+ }
317
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
318
+
319
+ @classmethod
320
+ def file(
321
+ cls,
322
+ *,
323
+ disabled: bool = False,
324
+ accept: str | None = None,
325
+ title: str | None = None,
326
+ description: str | None = None,
327
+ help: str | None = None,
328
+ **kwargs: Any,
329
+ ) -> RJSFMetaTag:
330
+ """Create a file upload field."""
331
+ attrs = {
332
+ "ui:widget": "file",
333
+ "ui:disabled": disabled,
334
+ "ui:options": {"accept": accept} if accept else None,
335
+ "ui:title": title,
336
+ "ui:description": description,
337
+ "ui:help": help,
338
+ **kwargs,
339
+ }
340
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
341
+
342
+ class NumberWidget:
343
+ """Widgets for number and integer fields: updown, range, radio (with enum)."""
344
+
345
+ @classmethod
346
+ def updown(
347
+ cls,
348
+ *,
349
+ placeholder: str | None = None,
350
+ disabled: bool = False,
351
+ readonly: bool = False,
352
+ min: int | float | None = None,
353
+ max: int | float | None = None,
354
+ step: int | float | None = None,
355
+ title: str | None = None,
356
+ description: str | None = None,
357
+ help: str | None = None,
358
+ **kwargs: Any,
359
+ ) -> RJSFMetaTag:
360
+ """Create a number updown field (default for numbers)."""
361
+ options: dict[str, Any] = {
362
+ "min": min,
363
+ "max": max,
364
+ "step": step,
365
+ }
366
+ options = {k: v for k, v in options.items() if v is not None}
367
+
368
+ attrs = {
369
+ "ui:widget": "updown",
370
+ "ui:placeholder": placeholder,
371
+ "ui:disabled": disabled,
372
+ "ui:readonly": readonly,
373
+ "ui:options": options if options else None,
374
+ "ui:title": title,
375
+ "ui:description": description,
376
+ "ui:help": help,
377
+ **kwargs,
378
+ }
379
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
380
+
381
+ @classmethod
382
+ def range(
383
+ cls,
384
+ *,
385
+ disabled: bool = False,
386
+ min: int | float | None = None,
387
+ max: int | float | None = None,
388
+ step: int | float | None = None,
389
+ title: str | None = None,
390
+ description: str | None = None,
391
+ help: str | None = None,
392
+ **kwargs: Any,
393
+ ) -> RJSFMetaTag:
394
+ """Create a range slider field."""
395
+ options: dict[str, Any] = {
396
+ "min": min,
397
+ "max": max,
398
+ "step": step,
399
+ }
400
+ options = {k: v for k, v in options.items() if v is not None}
401
+
402
+ attrs = {
403
+ "ui:widget": "range",
404
+ "ui:disabled": str(disabled).lower() if disabled else None,
405
+ "ui:options": options if options else None,
406
+ "ui:title": title,
407
+ "ui:description": description,
408
+ "ui:help": help,
409
+ **kwargs,
410
+ }
411
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
412
+
413
+ @classmethod
414
+ def radio(
415
+ cls,
416
+ *,
417
+ disabled: bool = False,
418
+ title: str | None = None,
419
+ description: str | None = None,
420
+ help: str | None = None,
421
+ **kwargs: Any,
422
+ ) -> RJSFMetaTag:
423
+ """Create radio buttons for number enum values."""
424
+ attrs = {
425
+ "ui:widget": "radio",
426
+ "ui:disabled": disabled,
427
+ "ui:title": title,
428
+ "ui:description": description,
429
+ "ui:help": help,
430
+ **kwargs,
431
+ }
432
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
433
+
434
+ class ArrayWidget:
435
+ """Widgets for array fields: checkboxes, select, radio."""
436
+
437
+ @classmethod
438
+ def checkboxes(
439
+ cls,
440
+ *,
441
+ disabled: bool = False,
442
+ title: str | None = None,
443
+ description: str | None = None,
444
+ help: str | None = None,
445
+ **kwargs: Any,
446
+ ) -> RJSFMetaTag:
447
+ """Create checkboxes for array values."""
448
+ attrs = {
449
+ "ui:widget": "checkboxes",
450
+ "ui:disabled": disabled,
451
+ "ui:title": title,
452
+ "ui:description": description,
453
+ "ui:help": help,
454
+ **kwargs,
455
+ }
456
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
457
+
458
+ @classmethod
459
+ def select(
460
+ cls,
461
+ *,
462
+ disabled: bool = False,
463
+ title: str | None = None,
464
+ description: str | None = None,
465
+ help: str | None = None,
466
+ **kwargs: Any,
467
+ ) -> RJSFMetaTag:
468
+ """Create a select dropdown for array values."""
469
+ attrs = {
470
+ "ui:widget": "select",
471
+ "ui:disabled": disabled,
472
+ "ui:title": title,
473
+ "ui:description": description,
474
+ "ui:help": help,
475
+ **kwargs,
476
+ }
477
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
478
+
479
+ @classmethod
480
+ def radio(
481
+ cls,
482
+ *,
483
+ disabled: bool = False,
484
+ title: str | None = None,
485
+ description: str | None = None,
486
+ help: str | None = None,
487
+ **kwargs: Any,
488
+ ) -> RJSFMetaTag:
489
+ """Create radio buttons for array values."""
490
+ attrs = {
491
+ "ui:widget": "radio",
492
+ "ui:disabled": disabled,
493
+ "ui:title": title,
494
+ "ui:description": description,
495
+ "ui:help": help,
496
+ **kwargs,
497
+ }
498
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
499
+
500
+ class ObjectWidget:
501
+ """Widgets for object fields: expandable, collapsible."""
502
+
503
+ @classmethod
504
+ def expandable(
505
+ cls,
506
+ *,
507
+ title: str | None = None,
508
+ description: str | None = None,
509
+ help: str | None = None,
510
+ **kwargs: Any,
511
+ ) -> RJSFMetaTag:
512
+ """Create an expandable object field."""
513
+ attrs = {
514
+ "ui:expandable": True,
515
+ "ui:title": title,
516
+ "ui:description": description,
517
+ "ui:help": help,
518
+ **kwargs,
519
+ }
520
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
521
+
522
+ @classmethod
523
+ def collapsible(
524
+ cls,
525
+ *,
526
+ title: str | None = None,
527
+ description: str | None = None,
528
+ help: str | None = None,
529
+ **kwargs: Any,
530
+ ) -> RJSFMetaTag:
531
+ """Create a collapsible object field."""
532
+ attrs = {
533
+ "ui:collapsible": True,
534
+ "ui:title": title,
535
+ "ui:description": description,
536
+ "ui:help": help,
537
+ **kwargs,
538
+ }
539
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
540
+
541
+ class SpecialWidget:
542
+ """Special widgets: hidden, custom fields."""
543
+
544
+ @classmethod
545
+ def hidden(
546
+ cls,
547
+ **kwargs: Any,
548
+ ) -> RJSFMetaTag:
549
+ """Create a hidden field."""
550
+ attrs = {
551
+ "ui:widget": "hidden",
552
+ **kwargs,
553
+ }
554
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
555
+
556
+ @classmethod
557
+ def custom_field(
558
+ cls,
559
+ field_name: str,
560
+ *,
561
+ title: str | None = None,
562
+ description: str | None = None,
563
+ help: str | None = None,
564
+ **kwargs: Any,
565
+ ) -> RJSFMetaTag:
566
+ """Create a custom field."""
567
+ attrs = {
568
+ "ui:field": field_name,
569
+ "ui:title": title,
570
+ "ui:description": description,
571
+ "ui:help": help,
572
+ **kwargs,
573
+ }
574
+ return RJSFMetaTag({k: v for k, v in attrs.items() if v is not None})
575
+
576
+ # --------- Composer Methods ----------
577
+
578
+ @classmethod
579
+ def Optional(
580
+ cls,
581
+ widget_tag: RJSFMetaTag,
582
+ /,
583
+ title: str | None = None,
584
+ description: str | None = None,
585
+ help: str | None = None,
586
+ disabled: bool = False,
587
+ readonly: bool = False,
588
+ optional_title: str | None = None,
589
+ **kwargs: Any,
590
+ ) -> RJSFMetaTag:
591
+ """
592
+ Create an Optional widget that applies the given widget to the non-None type.
593
+
594
+ Args:
595
+ widget_tag: The widget tag to apply to the Optional type
596
+
597
+ Returns:
598
+ RJSFMetaTag with the widget applied to the Optional type
599
+ """
600
+
601
+ union_attrs = {
602
+ "ui:title": title,
603
+ "ui:description": description,
604
+ "ui:help": help,
605
+ "ui:disabled": disabled,
606
+ "ui:readonly": readonly,
607
+ **kwargs,
608
+ }
609
+
610
+ union_attrs = {k: v for k, v in union_attrs.items() if v is not None}
611
+
612
+ optional_attrs = {
613
+ "ui:title": optional_title,
614
+ }
615
+ optional_attrs = {k: v for k, v in optional_attrs.items() if v is not None}
616
+ return RJSFMetaTag(
617
+ {
618
+ **union_attrs,
619
+ "anyOf": [{**widget_tag.attrs}, {**optional_attrs, "type": "null"}],
620
+ }
621
+ )
622
+
623
+ @classmethod
624
+ def Union(
625
+ cls,
626
+ widgets: list[RJSFMetaTag],
627
+ /,
628
+ title: str | None = None,
629
+ description: str | None = None,
630
+ help: str | None = None,
631
+ disabled: bool = False,
632
+ readonly: bool = False,
633
+ **kwargs: Any,
634
+ ) -> RJSFMetaTag:
635
+ """
636
+ Create a Union widget that applies different widgets to each branch of the Union.
637
+
638
+ According to RJSF docs, anyOf should be at the same level as the field,
639
+ not nested inside the field metadata.
640
+
641
+ Args:
642
+ widgets: List of widget tags for each branch of the Union type
643
+ title: Title for the Union field
644
+ description: Description for the Union field
645
+ help: Help text for the Union field
646
+ disabled: Whether the Union field is disabled
647
+ readonly: Whether the Union field is readonly
648
+ **kwargs: Additional Union-level metadata
649
+
650
+ Returns:
651
+ RJSFMetaTag with anyOf structure containing widgets for each branch
652
+ """
653
+
654
+ if len(widgets) == 1:
655
+ raise ValueError(
656
+ "Union types require multiple widgets (one per branch). "
657
+ "For single widget usage, consider using Optional() or a regular widget. "
658
+ f"Received only 1 widget: {widgets[0].attrs.get('ui:widget', 'unknown')}"
659
+ )
660
+
661
+ # Create anyOf structure with the provided widgets
662
+ any_of_branches = []
663
+ for widget in widgets:
664
+ any_of_branches.append(widget.attrs)
665
+
666
+ # Start with Union-level metadata
667
+ union_attrs = {
668
+ "ui:title": title,
669
+ "ui:description": description,
670
+ "ui:help": help,
671
+ "ui:disabled": disabled,
672
+ "ui:readonly": readonly,
673
+ **kwargs,
674
+ }
675
+
676
+ # Add any additional kwargs
677
+ for key, value in kwargs.items():
678
+ if value is not None:
679
+ union_attrs[f"ui:{key}" if not key.startswith("ui:") else key] = value
680
+
681
+ union_attrs = {k: v for k, v in union_attrs.items() if v is not None}
682
+ return RJSFMetaTag({**union_attrs, "anyOf": any_of_branches})
683
+
684
+
685
+ # --------- Helpers ----------
686
+ _NONE_TYPES = {type(None)}
687
+ """
688
+ Set containing the None type for use in Union type processing.
689
+ Used by _unwrap_optional to identify and filter out None types from Union annotations.
690
+ """
691
+
692
+
693
+ def _strip_annotated(ann: Any) -> tuple[Any, list[Any]]:
694
+ """
695
+ Extract the base type and metadata from an Annotated type.
696
+
697
+ This function unwraps a single level of Annotated[...] to get the base type
698
+ and any metadata annotations. For non-Annotated types, it returns the
699
+ type unchanged with an empty metadata list.
700
+
701
+ Args:
702
+ ann: The type annotation to process (may be Annotated or not)
703
+
704
+ Returns:
705
+ A tuple of (base_type, metadata_list) where:
706
+ - base_type: The underlying type (e.g., str, int, list[str])
707
+ - metadata_list: List of metadata objects (RJSFMetaTag instances, etc.)
708
+ """
709
+ if get_origin(ann) is Annotated:
710
+ base, *extras = get_args(ann)
711
+ return base, extras
712
+ return ann, []
713
+
714
+
715
+ def _collect_metatags(extras: list[Any]) -> dict[str, Any]:
716
+ """
717
+ Extract and merge RJSF metadata from a list of annotation extras.
718
+
719
+ This function processes a list of metadata objects (from Annotated[...])
720
+ and extracts all RJSFMetaTag instances, merging their attributes into
721
+ a single dictionary. Later tags override earlier ones for duplicate keys.
722
+
723
+ Args:
724
+ extras: List of metadata objects from Annotated type annotations
725
+
726
+ Returns:
727
+ A dictionary containing all merged RJSF metadata attributes
728
+ """
729
+ out: dict[str, Any] = {}
730
+ for x in extras:
731
+ if isinstance(x, RJSFMetaTag):
732
+ out.update(x.attrs)
733
+ return out
734
+
735
+
736
+ def _unwrap_optional(ann: Any) -> Any:
737
+ """
738
+ Unwrap Optional[Type] or Union[Type, None] to just Type.
739
+
740
+ This function simplifies Union types that contain None by removing the
741
+ None option and returning the non-None type. If there are multiple
742
+ non-None types, the original Union is returned unchanged.
743
+
744
+ Args:
745
+ ann: The type annotation to process
746
+
747
+ Returns:
748
+ The unwrapped type if it was Optional/Union with None, otherwise
749
+ the original type unchanged
750
+ """
751
+ if get_origin(ann) is Union:
752
+ args = [a for a in get_args(ann) if a not in _NONE_TYPES]
753
+ if len(args) == 1:
754
+ return args[0]
755
+ return ann
756
+
757
+
758
+ def _walk_annotated_chain(ann: Any) -> tuple[Any, dict[str, Any]]:
759
+ """
760
+ Recursively unwrap nested Annotated types and collect all metadata.
761
+
762
+ This function processes deeply nested Annotated[...] types by walking
763
+ through the chain and collecting all RJSF metadata from each level.
764
+ It handles cases like Annotated[Annotated[Type, meta1], meta2].
765
+
766
+ Args:
767
+ ann: The type annotation to process (may be deeply nested)
768
+
769
+ Returns:
770
+ A tuple of (final_base_type, merged_metadata_dict) where:
771
+ - final_base_type: The innermost non-Annotated type
772
+ - merged_metadata_dict: All RJSF metadata merged from all levels
773
+ """
774
+ merged: dict[str, Any] = {}
775
+ cur = ann
776
+ while get_origin(cur) is Annotated:
777
+ base, extras = _strip_annotated(cur)
778
+ merged.update(_collect_metatags(extras))
779
+ cur = base
780
+ return cur, merged
781
+
782
+
783
+ def _is_pyd_model(t: Any) -> bool:
784
+ try:
785
+ return isinstance(t, type) and issubclass(t, BaseModel)
786
+ except TypeError:
787
+ return False
788
+
789
+
790
+ # --------- Build RJSF-style uiSchema dict from a model *type* ----------
791
+ def ui_schema_for_model(model_cls: type[BaseModel]) -> dict[str, Any]:
792
+ """
793
+ Generate a React JSON Schema Form (RJSF) uiSchema from a Pydantic model.
794
+
795
+ This function analyzes a Pydantic BaseModel class and extracts all RJSF metadata
796
+ from field annotations to create a complete uiSchema dictionary. The resulting
797
+ schema can be used directly with React JSON Schema Form components.
798
+
799
+ The function handles:
800
+ - Simple fields with RJSF metadata annotations
801
+ - Nested Pydantic models (inline expansion)
802
+ - List/array fields with item-level metadata
803
+ - Dictionary fields with value-type metadata
804
+ - Union types with multiple branches (anyOf)
805
+ - Optional fields (Union with None)
806
+
807
+ Args:
808
+ model_cls: A Pydantic BaseModel subclass to analyze
809
+
810
+ Returns:
811
+ A dictionary representing the RJSF uiSchema with the structure:
812
+ {
813
+ "field_name": {
814
+ "ui:widget": "text",
815
+ "ui:placeholder": "Enter value",
816
+ # ... other RJSF metadata
817
+ # For nested models, fields are inlined:
818
+ "nested_field": { ... },
819
+ # For arrays:
820
+ "items": { ... },
821
+ # For dicts:
822
+ "additionalProperties": { ... },
823
+ # For unions:
824
+ "anyOf": [{ ... }, { ... }]
825
+ }
826
+ }
827
+
828
+ Raises:
829
+ TypeError: If model_cls is not a Pydantic BaseModel subclass
830
+ """
831
+ if not _is_pyd_model(model_cls):
832
+ raise TypeError(f"{model_cls!r} is not a Pydantic BaseModel subclass")
833
+
834
+ hints = get_type_hints(model_cls, include_extras=True)
835
+ ui: dict[str, Any] = {}
836
+
837
+ for fname, ann in hints.items():
838
+ node: dict[str, Any] = {}
839
+
840
+ base, meta = _walk_annotated_chain(ann)
841
+ base = _unwrap_optional(base)
842
+ # Start with field-level metadata (flat dict)
843
+ if meta:
844
+ node.update(meta)
845
+
846
+ origin = get_origin(base)
847
+
848
+ # Nested model -> inline children
849
+ if _is_pyd_model(base):
850
+ node.update(ui_schema_for_model(base))
851
+
852
+ # Array-like -> items
853
+ elif origin in (list, set, tuple):
854
+ (item_type, *_) = get_args(base) or (Any,)
855
+ item_base, item_meta = _walk_annotated_chain(item_type)
856
+ item_base = _unwrap_optional(item_base)
857
+
858
+ item_node: dict[str, Any] = {}
859
+ if item_meta:
860
+ item_node.update(item_meta)
861
+ if _is_pyd_model(item_base):
862
+ item_node.update(ui_schema_for_model(item_base))
863
+ node["items"] = item_node
864
+
865
+ # Dict -> additionalProperties (value side)
866
+ elif origin is dict:
867
+ key_t, val_t = get_args(base) or (Any, Any)
868
+ val_base, val_meta = _walk_annotated_chain(val_t)
869
+ val_base = _unwrap_optional(val_base)
870
+
871
+ val_node: dict[str, Any] = {}
872
+ if val_meta:
873
+ val_node.update(val_meta)
874
+ if _is_pyd_model(val_base):
875
+ val_node.update(ui_schema_for_model(val_base))
876
+ node["additionalProperties"] = val_node
877
+
878
+ # Union -> anyOf branches
879
+ elif origin is Union:
880
+ branches = []
881
+ for alt in get_args(base):
882
+ if alt in _NONE_TYPES:
883
+ continue
884
+ alt_b, alt_meta = _walk_annotated_chain(alt)
885
+ branch: dict[str, Any] = {}
886
+ if alt_meta:
887
+ branch.update(alt_meta)
888
+ if _is_pyd_model(alt_b):
889
+ branch.update(ui_schema_for_model(alt_b))
890
+ branches.append(branch)
891
+ if branches:
892
+ # Check if the metadata already has an anyOf structure (from Union composer)
893
+ if "anyOf" in node:
894
+ # Use the anyOf from the metadata (from Union composer)
895
+ pass # Keep the existing anyOf from the Union composer
896
+ else:
897
+ # Create anyOf structure for Union types
898
+ node["anyOf"] = branches
899
+
900
+ # Scalars: node already has metadata if any
901
+
902
+ ui[fname] = node
903
+
904
+ return ui
905
+
906
+
907
+ # --------- Example ---------
908
+ if __name__ == "__main__":
909
+
910
+ class Address(BaseModel):
911
+ street: Annotated[str, RJSFMetaTag.StringWidget.textfield(placeholder="Street")]
912
+ zip: Annotated[str, RJSFMetaTag.NumberWidget.updown(placeholder="12345")]
913
+
914
+ class User(BaseModel):
915
+ id: Annotated[int, RJSFMetaTag.NumberWidget.updown(disabled=True)]
916
+ name: Annotated[
917
+ str,
918
+ RJSFMetaTag.StringWidget.textfield(
919
+ placeholder="Enter your name", autofocus=True
920
+ ),
921
+ ]
922
+ address: Annotated[
923
+ Address, RJSFMetaTag.SpecialWidget.custom_field("AddressField")
924
+ ]
925
+ tags: Annotated[
926
+ list[Annotated[str, RJSFMetaTag.ArrayWidget.checkboxes()]],
927
+ RJSFMetaTag.ArrayWidget.checkboxes(title="Tags"),
928
+ ]
929
+ prefs: dict[str, Annotated[int, RJSFMetaTag.NumberWidget.range(min=0, max=100)]]
930
+ alt: Union[
931
+ Annotated[Address, RJSFMetaTag.ObjectWidget.expandable(role="home")], None
932
+ ]
933
+
934
+ import json
935
+
936
+ print(json.dumps(ui_schema_for_model(User), indent=7))
@@ -26,15 +26,41 @@ class ContentMetadata(BaseModel):
26
26
 
27
27
  class ContentChunk(BaseModel):
28
28
  model_config = model_config
29
- id: str
30
- text: str
31
- order: int
32
- key: str | None = None
33
- chunk_id: str | None = None
34
- url: str | None = None
35
- title: str | None = None
36
- start_page: int | None = None
37
- end_page: int | None = None
29
+ id: str = Field(
30
+ default="",
31
+ description="The id of the content this chunk belongs to. The id starts with 'cont_' followed by an alphanumeric string of length 24.",
32
+ examples=["cont_abcdefgehijklmnopqrstuvwx"],
33
+ )
34
+ text: str = Field(default="", description="The text content of the chunk.")
35
+ order: int = Field(
36
+ default=0,
37
+ description="The order of the chunk in the original content. Concatenating the chunks in order will give the original content.",
38
+ )
39
+ key: str | None = Field(
40
+ default=None,
41
+ description="The key of the chunk. For document chunks this is the the filename",
42
+ )
43
+ chunk_id: str | None = Field(
44
+ default=None,
45
+ description="The id of the chunk. The id starts with 'chunk_' followed by an alphanumeric string of length 24.",
46
+ examples=["chunk_abcdefgehijklmnopqrstuv"],
47
+ )
48
+ url: str | None = Field(
49
+ default=None,
50
+ description="For chunk retrieved from the web this is the url of the chunk.",
51
+ )
52
+ title: str | None = Field(
53
+ default=None,
54
+ description="The title of the chunk. For document chunks this is the title of the document.",
55
+ )
56
+ start_page: int | None = Field(
57
+ default=None,
58
+ description="The start page of the chunk. For document chunks this is the start page of the document.",
59
+ )
60
+ end_page: int | None = Field(
61
+ default=None,
62
+ description="The end page of the chunk. For document chunks this is the end page of the document.",
63
+ )
38
64
 
39
65
  object: str | None = None
40
66
  metadata: ContentMetadata | None = None
@@ -45,9 +71,19 @@ class ContentChunk(BaseModel):
45
71
 
46
72
  class Content(BaseModel):
47
73
  model_config = model_config
48
- id: str
49
- key: str
50
- title: str | None = None
74
+ id: str = Field(
75
+ default="",
76
+ description="The id of the content. The id starts with 'cont_' followed by an alphanumeric string of length 24.",
77
+ examples=["cont_abcdefgehijklmnopqrstuvwx"],
78
+ )
79
+ key: str = Field(
80
+ default="",
81
+ description="The key of the content. For documents this is the the filename",
82
+ )
83
+ title: str | None = Field(
84
+ default=None,
85
+ description="The title of the content. For documents this is the title of the document.",
86
+ )
51
87
  url: str | None = None
52
88
  chunks: list[ContentChunk] = []
53
89
  write_url: str | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unique_toolkit
3
- Version: 1.2.1
3
+ Version: 1.3.1
4
4
  Summary:
5
5
  License: Proprietary
6
6
  Author: Cedric Klinkert
@@ -119,6 +119,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
119
119
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
120
120
 
121
121
 
122
+ ## [1.3.1] - 2025-09-29
123
+ - More documentation on referencing
124
+
125
+ ## [1.3.0] - 2025-09-28
126
+ - Add utilitiy to enhance pydantic model with metadata
127
+ - Add capability to collect this metadata with same hierarchy as nested pydantic models
128
+
122
129
  ## [1.2.1] - 2025-09-28
123
130
  - Fix bug where camel case arguments were not properly validated.
124
131
 
@@ -13,6 +13,7 @@ unique_toolkit/_common/endpoint_builder.py,sha256=WzJrJ7azUQhvQRd-vsFFoyj6omJpHi
13
13
  unique_toolkit/_common/endpoint_requestor.py,sha256=JbbfJGLxgxLz8a3Yx1FdJvdHGbCYO8MSBd7cLg_Mtp0,5927
14
14
  unique_toolkit/_common/exception.py,sha256=caQIE1btsQnpKCHqL2cgWUSbHup06enQu_Pt7uGUTTE,727
15
15
  unique_toolkit/_common/feature_flags/schema.py,sha256=F1NdVJFNU8PKlS7bYzrIPeDu2LxRqHSM9pyw622a1Kk,547
16
+ unique_toolkit/_common/pydantic/rjsf_tags.py,sha256=T3AZIF8wny3fFov66s258nEl1GqfKevFouTtG6k9PqU,31219
16
17
  unique_toolkit/_common/pydantic_helpers.py,sha256=1zzg6PlzSkHqPTdX-KoBaDHmBeeG7S5PprBsyMSCEuU,4806
17
18
  unique_toolkit/_common/string_utilities.py,sha256=pbsjpnz1mwGeugebHzubzmmDtlm18B8e7xJdSvLnor0,2496
18
19
  unique_toolkit/_common/token/image_token_counting.py,sha256=VpFfZyY0GIH27q_Wy4YNjk2algqvbCtJyzuuROoFQPw,2189
@@ -98,7 +99,7 @@ unique_toolkit/chat/utils.py,sha256=ihm-wQykBWhB4liR3LnwPVPt_qGW6ETq21Mw4HY0THE,
98
99
  unique_toolkit/content/__init__.py,sha256=EdJg_A_7loEtCQf4cah3QARQreJx6pdz89Rm96YbMVg,940
99
100
  unique_toolkit/content/constants.py,sha256=1iy4Y67xobl5VTnJB6SxSyuoBWbdLl9244xfVMUZi5o,60
100
101
  unique_toolkit/content/functions.py,sha256=1zhxaJEYTvvd4qzkrbEFcrjdJxhHkfUY3dEpNfNC_hk,19052
101
- unique_toolkit/content/schemas.py,sha256=KJ604BOx0vBh2AwlTCZkOo55aHsI6yj8vxDAARKKqEo,2995
102
+ unique_toolkit/content/schemas.py,sha256=WB3InkKIvfWbyg9CsKFLn8Zf4zrE-YqGpCc3a0zOk7k,4774
102
103
  unique_toolkit/content/service.py,sha256=ZUXJwfNdHsAw_F7cfRMDVgHpSKxiwG6Cn8p7c4hV8TM,24053
103
104
  unique_toolkit/content/utils.py,sha256=qNVmHTuETaPNGqheg7TbgPr1_1jbNHDc09N5RrmUIyo,7901
104
105
  unique_toolkit/embedding/__init__.py,sha256=uUyzjonPvuDCYsvXCIt7ErQXopLggpzX-MEQd3_e2kE,250
@@ -132,7 +133,7 @@ unique_toolkit/short_term_memory/schemas.py,sha256=OhfcXyF6ACdwIXW45sKzjtZX_gkcJ
132
133
  unique_toolkit/short_term_memory/service.py,sha256=5PeVBu1ZCAfyDb2HLVvlmqSbyzBBuE9sI2o9Aajqjxg,8884
133
134
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
134
135
  unique_toolkit/smart_rules/compile.py,sha256=cxWjb2dxEI2HGsakKdVCkSNi7VK9mr08w5sDcFCQyWI,9553
135
- unique_toolkit-1.2.1.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
136
- unique_toolkit-1.2.1.dist-info/METADATA,sha256=x5c1eRkDvdDVmjh1lzkYEVu91II0o79rZ0owyyfX8gM,33381
137
- unique_toolkit-1.2.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
138
- unique_toolkit-1.2.1.dist-info/RECORD,,
136
+ unique_toolkit-1.3.1.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
137
+ unique_toolkit-1.3.1.dist-info/METADATA,sha256=rLo_XrmExigQ1oQ6KFaUy6EWiMmwO7uj-u7SH55nsq4,33610
138
+ unique_toolkit-1.3.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
139
+ unique_toolkit-1.3.1.dist-info/RECORD,,