unique_toolkit 1.2.1__py3-none-any.whl → 1.3.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,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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unique_toolkit
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary:
5
5
  License: Proprietary
6
6
  Author: Cedric Klinkert
@@ -118,6 +118,9 @@ All notable changes to this project will be documented in this file.
118
118
  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
+ ## [1.3.0] - 2025-09-28
122
+ - Add utilitiy to enhance pydantic model with metadata
123
+ - Add capability to collect this metadata with same hierarchy as nested pydantic models
121
124
 
122
125
  ## [1.2.1] - 2025-09-28
123
126
  - Fix bug where camel case arguments were not properly validated.
@@ -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
@@ -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.0.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
137
+ unique_toolkit-1.3.0.dist-info/METADATA,sha256=63zIDL6zXri4f4WWuUFB4jb0m5XGi6oL4jdSUlCfTf4,33548
138
+ unique_toolkit-1.3.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
139
+ unique_toolkit-1.3.0.dist-info/RECORD,,