fh-pydantic-form 0.2.5__py3-none-any.whl → 0.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.

Potentially problematic release.


This version of fh-pydantic-form might be problematic. Click here for more details.

@@ -0,0 +1,599 @@
1
+ """
2
+ ComparisonForm - Side-by-side form comparison with metrics visualization
3
+
4
+ This module provides a meta-renderer that displays two PydanticForm instances
5
+ side-by-side with visual comparison feedback and synchronized accordion states.
6
+ """
7
+
8
+ import logging
9
+ from typing import (
10
+ Any,
11
+ Dict,
12
+ Generic,
13
+ List,
14
+ Optional,
15
+ Type,
16
+ TypeVar,
17
+ )
18
+
19
+ import fasthtml.common as fh
20
+ import monsterui.all as mui
21
+ from fastcore.xml import FT
22
+ from pydantic import BaseModel
23
+
24
+ from fh_pydantic_form.form_renderer import PydanticForm
25
+ from fh_pydantic_form.registry import FieldRendererRegistry
26
+ from fh_pydantic_form.type_helpers import MetricEntry, MetricsDict
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # TypeVar for generic model typing
31
+ ModelType = TypeVar("ModelType", bound=BaseModel)
32
+
33
+
34
+ def comparison_form_js():
35
+ """JavaScript for comparison: sync top-level and list accordions."""
36
+ return fh.Script("""
37
+ window.fhpfInitComparisonSync = function initComparisonSync(){
38
+ // 1) Wait until UIkit and its util are available
39
+ if (!window.UIkit || !UIkit.util) {
40
+ return setTimeout(initComparisonSync, 50);
41
+ }
42
+
43
+ // 2) Sync top-level accordions (BaseModelFieldRenderer)
44
+ UIkit.util.on(
45
+ document,
46
+ 'show hide', // UIkit fires plain 'show'/'hide'
47
+ 'ul[uk-accordion] > li', // only the top-level items
48
+ mirrorTopLevel
49
+ );
50
+
51
+ function mirrorTopLevel(ev) {
52
+ const sourceLi = ev.target.closest('li');
53
+ if (!sourceLi) return;
54
+
55
+ // Find our grid-cell wrapper (both left & right share the same data-path)
56
+ const cell = sourceLi.closest('[data-path]');
57
+ if (!cell) return;
58
+ const path = cell.dataset.path;
59
+
60
+ // Determine index of this <li> inside its <ul>
61
+ const idx = Array.prototype.indexOf.call(
62
+ sourceLi.parentElement.children,
63
+ sourceLi
64
+ );
65
+ const opening = ev.type === 'show';
66
+
67
+ // Mirror on the other side
68
+ document
69
+ .querySelectorAll(`[data-path="${path}"]`)
70
+ .forEach(peerCell => {
71
+ if (peerCell === cell) return;
72
+
73
+ const peerAcc = peerCell.querySelector('ul[uk-accordion]');
74
+ if (!peerAcc || idx >= peerAcc.children.length) return;
75
+
76
+ const peerLi = peerAcc.children[idx];
77
+ const peerContent = peerLi.querySelector('.uk-accordion-content');
78
+
79
+ if (opening) {
80
+ peerLi.classList.add('uk-open');
81
+ if (peerContent) {
82
+ peerContent.hidden = false;
83
+ peerContent.style.height = 'auto';
84
+ }
85
+ } else {
86
+ peerLi.classList.remove('uk-open');
87
+ if (peerContent) {
88
+ peerContent.hidden = true;
89
+ }
90
+ }
91
+ });
92
+ }
93
+
94
+ // 3) Wrap the list-toggle so ListFieldRenderer accordions sync too
95
+ if (typeof window.toggleListItems === 'function' && !window.__listSyncWrapped) {
96
+ // guard to only wrap once
97
+ window.__listSyncWrapped = true;
98
+ const originalToggle = window.toggleListItems;
99
+
100
+ window.toggleListItems = function(containerId) {
101
+ // a) Toggle this column first
102
+ originalToggle(containerId);
103
+
104
+ // b) Find the enclosing data-path
105
+ const container = document.getElementById(containerId);
106
+ if (!container) return;
107
+ const cell = container.closest('[data-path]');
108
+ if (!cell) return;
109
+ const path = cell.dataset.path;
110
+
111
+ // c) Find the peer's list-container by suffix match
112
+ document
113
+ .querySelectorAll(`[data-path="${path}"]`)
114
+ .forEach(peerCell => {
115
+ if (peerCell === cell) return;
116
+
117
+ // look up any [id$="_items_container"]
118
+ const peerContainer = peerCell.querySelector('[id$="_items_container"]');
119
+ if (peerContainer) {
120
+ originalToggle(peerContainer.id);
121
+ }
122
+ });
123
+ };
124
+ }
125
+ };
126
+
127
+ // Initial run
128
+ window.fhpfInitComparisonSync();
129
+
130
+ // Re-run after HTMX swaps to maintain sync
131
+ document.addEventListener('htmx:afterSwap', function(event) {
132
+ // Re-initialize the comparison sync
133
+ window.fhpfInitComparisonSync();
134
+ });
135
+ """)
136
+
137
+
138
+ class ComparisonForm(Generic[ModelType]):
139
+ """
140
+ Meta-renderer for side-by-side form comparison with metrics visualization
141
+
142
+ This class creates a two-column layout with synchronized accordions and
143
+ visual comparison feedback (colors, tooltips, metric badges).
144
+
145
+ The ComparisonForm is a view-only composition helper; state management
146
+ lives in the underlying PydanticForm instances.
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ name: str,
152
+ left_form: PydanticForm[ModelType],
153
+ right_form: PydanticForm[ModelType],
154
+ *,
155
+ left_label: str = "Reference",
156
+ right_label: str = "Generated",
157
+ ):
158
+ """
159
+ Initialize the comparison form
160
+
161
+ Args:
162
+ name: Unique name for this comparison form
163
+ left_form: Pre-constructed PydanticForm for left column
164
+ right_form: Pre-constructed PydanticForm for right column
165
+ left_label: Label for left column
166
+ right_label: Label for right column
167
+
168
+ Raises:
169
+ ValueError: If the two forms are not based on the same model class
170
+ """
171
+ # Validate that both forms use the same model
172
+ if left_form.model_class is not right_form.model_class:
173
+ raise ValueError(
174
+ f"Both forms must be based on the same model class. "
175
+ f"Got {left_form.model_class.__name__} and {right_form.model_class.__name__}"
176
+ )
177
+
178
+ self.name = name
179
+ self.left_form = left_form
180
+ self.right_form = right_form
181
+ self.model_class = left_form.model_class # Convenience reference
182
+ self.left_label = left_label
183
+ self.right_label = right_label
184
+
185
+ # Use spacing from left form (or could add override parameter if needed)
186
+ self.spacing = left_form.spacing
187
+
188
+ def _get_field_path_string(self, field_path: List[str]) -> str:
189
+ """Convert field path list to dot-notation string for comparison lookup"""
190
+ return ".".join(field_path)
191
+
192
+ def _render_column(
193
+ self,
194
+ *,
195
+ form: PydanticForm[ModelType],
196
+ header_label: str,
197
+ start_order: int,
198
+ wrapper_id: str,
199
+ ) -> FT:
200
+ """
201
+ Render a single column with CSS order values for grid alignment
202
+
203
+ Args:
204
+ form: The PydanticForm instance for this column
205
+ header_label: Label for the column header
206
+ start_order: Starting order value (0 for left, 1 for right)
207
+ wrapper_id: ID for the wrapper div
208
+
209
+ Returns:
210
+ A div with class="contents" containing ordered grid items
211
+ """
212
+ # Header with order
213
+ cells = [
214
+ fh.Div(
215
+ fh.H3(header_label, cls="text-lg font-semibold text-gray-700"),
216
+ cls="pb-2 border-b",
217
+ style=f"order:{start_order}",
218
+ )
219
+ ]
220
+
221
+ # Start at order + 2, increment by 2 for each field
222
+ order_idx = start_order + 2
223
+
224
+ # Create renderers for each field
225
+ registry = FieldRendererRegistry()
226
+
227
+ for field_name, field_info in self.model_class.model_fields.items():
228
+ # Skip excluded fields
229
+ if field_name in (form.exclude_fields or []):
230
+ continue
231
+
232
+ # Get value from form
233
+ value = form.values_dict.get(field_name)
234
+
235
+ # Get path string for data-path attribute
236
+ path_str = field_name
237
+
238
+ # Get renderer class
239
+ renderer_cls = registry.get_renderer(field_name, field_info)
240
+ if not renderer_cls:
241
+ from fh_pydantic_form.field_renderers import StringFieldRenderer
242
+
243
+ renderer_cls = StringFieldRenderer
244
+
245
+ # Determine comparison-specific refresh endpoint
246
+ comparison_refresh = f"/compare/{self.name}/{'left' if form is self.left_form else 'right'}/refresh"
247
+
248
+ # Create renderer
249
+ renderer = renderer_cls(
250
+ field_name=field_name,
251
+ field_info=field_info,
252
+ value=value,
253
+ prefix=form.base_prefix,
254
+ disabled=form.disabled,
255
+ spacing=form.spacing,
256
+ field_path=[field_name],
257
+ form_name=form.name,
258
+ metrics_dict=form.metrics_dict, # Use form's own metrics
259
+ refresh_endpoint_override=comparison_refresh, # Pass comparison-specific refresh endpoint
260
+ )
261
+
262
+ # Render with data-path and order
263
+ cells.append(
264
+ fh.Div(
265
+ renderer.render(),
266
+ cls="",
267
+ **{"data-path": path_str, "style": f"order:{order_idx}"},
268
+ )
269
+ )
270
+
271
+ order_idx += 2
272
+
273
+ # Return wrapper with display: contents
274
+ return fh.Div(*cells, id=wrapper_id, cls="contents")
275
+
276
+ # def _create_field_pairs(
277
+ # self,
278
+ # ) -> List[Tuple[str, BaseFieldRenderer, BaseFieldRenderer]]:
279
+ # """
280
+ # Create pairs of renderers (left, right) for each field path
281
+
282
+ # Returns:
283
+ # List of (path_string, left_renderer, right_renderer) tuples
284
+ # """
285
+ # pairs = []
286
+ # registry = FieldRendererRegistry()
287
+
288
+ # # Walk through model fields to create renderer pairs
289
+ # for field_name, field_info in self.model_class.model_fields.items():
290
+ # # Skip fields that are excluded in either form
291
+ # if field_name in (self.left_form.exclude_fields or []) or field_name in (
292
+ # self.right_form.exclude_fields or []
293
+ # ):
294
+ # logger.debug(
295
+ # f"Skipping field '{field_name}' - excluded in one or both forms"
296
+ # )
297
+ # continue
298
+
299
+ # # Get values from each form
300
+ # left_value = self.left_form.values_dict.get(field_name)
301
+ # right_value = self.right_form.values_dict.get(field_name)
302
+
303
+ # # Get the path string for comparison lookup
304
+ # path_str = field_name
305
+ # left_comparison_metric = self.left_metrics.get(path_str)
306
+ # right_comparison_metric = self.right_metrics.get(path_str)
307
+
308
+ # # Get renderer class
309
+ # renderer_cls = registry.get_renderer(field_name, field_info)
310
+ # if not renderer_cls:
311
+ # from fh_pydantic_form.field_renderers import StringFieldRenderer
312
+
313
+ # renderer_cls = StringFieldRenderer
314
+
315
+ # # Create left renderer
316
+ # left_renderer = renderer_cls(
317
+ # field_name=field_name,
318
+ # field_info=field_info,
319
+ # value=left_value,
320
+ # prefix=self.left_form.base_prefix,
321
+ # disabled=self.left_form.disabled,
322
+ # spacing=self.left_form.spacing,
323
+ # field_path=[field_name],
324
+ # form_name=self.left_form.name,
325
+ # comparison=left_comparison_metric,
326
+ # comparison_map=self.left_metrics, # Pass the full comparison map
327
+ # )
328
+
329
+ # # Create right renderer
330
+ # right_renderer = renderer_cls(
331
+ # field_name=field_name,
332
+ # field_info=field_info,
333
+ # value=right_value,
334
+ # prefix=self.right_form.base_prefix,
335
+ # disabled=self.right_form.disabled,
336
+ # spacing=self.right_form.spacing,
337
+ # field_path=[field_name],
338
+ # form_name=self.right_form.name,
339
+ # comparison=right_comparison_metric,
340
+ # comparison_map=self.right_metrics, # Pass the full comparison map
341
+ # )
342
+
343
+ # pairs.append((path_str, left_renderer, right_renderer))
344
+
345
+ # return pairs
346
+
347
+ def render_inputs(self) -> FT:
348
+ """
349
+ Render the comparison form with side-by-side layout
350
+
351
+ Returns:
352
+ A FastHTML component with CSS Grid layout
353
+ """
354
+ # Render left column with wrapper
355
+ left_wrapper = self._render_column(
356
+ form=self.left_form,
357
+ header_label=self.left_label,
358
+ start_order=0,
359
+ wrapper_id=f"{self.left_form.name}-inputs-wrapper",
360
+ )
361
+
362
+ # Render right column with wrapper
363
+ right_wrapper = self._render_column(
364
+ form=self.right_form,
365
+ header_label=self.right_label,
366
+ start_order=1,
367
+ wrapper_id=f"{self.right_form.name}-inputs-wrapper",
368
+ )
369
+
370
+ # Create the grid container with both wrappers
371
+ grid_container = fh.Div(
372
+ left_wrapper,
373
+ right_wrapper,
374
+ cls="fhpf-compare grid grid-cols-2 gap-x-6 gap-y-2 items-start",
375
+ id=f"{self.name}-comparison-grid",
376
+ )
377
+
378
+ return fh.Div(grid_container, cls="w-full")
379
+
380
+ def register_routes(self, app):
381
+ """
382
+ Register HTMX routes for the comparison form
383
+
384
+ Args:
385
+ app: FastHTML app instance
386
+ """
387
+ # Register individual form routes (for list manipulation)
388
+ self.left_form.register_routes(app)
389
+ self.right_form.register_routes(app)
390
+
391
+ # Register comparison-specific reset/refresh routes
392
+ def create_reset_handler(
393
+ form: PydanticForm[ModelType],
394
+ side: str,
395
+ label: str,
396
+ ):
397
+ """Factory function to create reset handler with proper closure"""
398
+
399
+ async def handler(req):
400
+ """Reset one side of the comparison form"""
401
+ # Reset the form state
402
+ await form.handle_reset_request()
403
+
404
+ # Render the entire column with proper ordering
405
+ start_order = 0 if side == "left" else 1
406
+ wrapper = self._render_column(
407
+ form=form,
408
+ header_label=label,
409
+ start_order=start_order,
410
+ wrapper_id=f"{form.name}-inputs-wrapper",
411
+ )
412
+ return wrapper
413
+
414
+ return handler
415
+
416
+ def create_refresh_handler(
417
+ form: PydanticForm[ModelType],
418
+ side: str,
419
+ label: str,
420
+ ):
421
+ """Factory function to create refresh handler with proper closure"""
422
+
423
+ async def handler(req):
424
+ """Refresh one side of the comparison form"""
425
+ # Refresh the form state and capture any warnings
426
+ refresh_result = await form.handle_refresh_request(req)
427
+
428
+ # Render the entire column with proper ordering
429
+ start_order = 0 if side == "left" else 1
430
+ wrapper = self._render_column(
431
+ form=form,
432
+ header_label=label,
433
+ start_order=start_order,
434
+ wrapper_id=f"{form.name}-inputs-wrapper",
435
+ )
436
+
437
+ # If refresh returned a warning, include it in the response
438
+ if isinstance(refresh_result, tuple) and len(refresh_result) == 2:
439
+ alert, _ = refresh_result
440
+ # Return both the alert and the wrapper
441
+ return fh.Div(alert, wrapper)
442
+ else:
443
+ # No warning, just return the wrapper
444
+ return wrapper
445
+
446
+ return handler
447
+
448
+ for side, form, label in [
449
+ ("left", self.left_form, self.left_label),
450
+ ("right", self.right_form, self.right_label),
451
+ ]:
452
+ assert form is not None
453
+
454
+ # Reset route
455
+ reset_path = f"/compare/{self.name}/{side}/reset"
456
+ reset_handler = create_reset_handler(form, side, label)
457
+ app.route(reset_path, methods=["POST"])(reset_handler)
458
+ logger.debug(f"Registered comparison reset route: {reset_path}")
459
+
460
+ # Refresh route
461
+ refresh_path = f"/compare/{self.name}/{side}/refresh"
462
+ refresh_handler = create_refresh_handler(form, side, label)
463
+ app.route(refresh_path, methods=["POST"])(refresh_handler)
464
+ logger.debug(f"Registered comparison refresh route: {refresh_path}")
465
+
466
+ def form_wrapper(self, content: FT, form_id: Optional[str] = None) -> FT:
467
+ """
468
+ Wrap the comparison content in a form element with proper ID
469
+
470
+ Args:
471
+ content: The form content to wrap
472
+ form_id: Optional form ID (defaults to {name}-comparison-form)
473
+
474
+ Returns:
475
+ A form element containing the content
476
+ """
477
+ form_id = form_id or f"{self.name}-comparison-form"
478
+ wrapper_id = f"{self.name}-comparison-wrapper"
479
+
480
+ # Note: Removed hx_include="closest form" since the wrapper only contains foreign forms
481
+ return mui.Form(
482
+ fh.Div(content, id=wrapper_id),
483
+ id=form_id,
484
+ )
485
+
486
+ def _button_helper(self, *, side: str, action: str, text: str, **kwargs) -> FT:
487
+ """
488
+ Helper method to create buttons that target comparison-specific routes
489
+
490
+ Args:
491
+ side: "left" or "right"
492
+ action: "reset" or "refresh"
493
+ text: Button text
494
+ **kwargs: Additional button attributes
495
+
496
+ Returns:
497
+ A button component
498
+ """
499
+ form = self.left_form if side == "left" else self.right_form
500
+
501
+ # Create prefix-based selector
502
+ prefix_selector = f"form [name^='{form.base_prefix}']"
503
+
504
+ # Set default attributes
505
+ kwargs.setdefault("hx_post", f"/compare/{self.name}/{side}/{action}")
506
+ kwargs.setdefault("hx_target", f"#{form.name}-inputs-wrapper")
507
+ kwargs.setdefault("hx_swap", "innerHTML")
508
+ kwargs.setdefault("hx_include", prefix_selector)
509
+
510
+ # Delegate to the underlying form's button method
511
+ button_method = getattr(form, f"{action}_button")
512
+ return button_method(text, **kwargs)
513
+
514
+ def left_reset_button(self, text: Optional[str] = None, **kwargs) -> FT:
515
+ """Create a reset button for the left form"""
516
+ return self._button_helper(
517
+ side="left", action="reset", text=text or "↩️ Reset Left", **kwargs
518
+ )
519
+
520
+ def left_refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
521
+ """Create a refresh button for the left form"""
522
+ return self._button_helper(
523
+ side="left", action="refresh", text=text or "🔄 Refresh Left", **kwargs
524
+ )
525
+
526
+ def right_reset_button(self, text: Optional[str] = None, **kwargs) -> FT:
527
+ """Create a reset button for the right form"""
528
+ return self._button_helper(
529
+ side="right", action="reset", text=text or "↩️ Reset Right", **kwargs
530
+ )
531
+
532
+ def right_refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
533
+ """Create a refresh button for the right form"""
534
+ return self._button_helper(
535
+ side="right", action="refresh", text=text or "🔄 Refresh Right", **kwargs
536
+ )
537
+
538
+
539
+ def simple_diff_metrics(
540
+ left_data: BaseModel | Dict[str, Any],
541
+ right_data: BaseModel | Dict[str, Any],
542
+ model_class: Type[BaseModel],
543
+ ) -> MetricsDict:
544
+ """
545
+ Simple helper to generate metrics based on equality
546
+
547
+ Args:
548
+ left_data: Reference data
549
+ right_data: Data to compare
550
+ model_class: Model class for structure
551
+
552
+ Returns:
553
+ MetricsDict with simple equality-based metrics
554
+ """
555
+ metrics_dict = {}
556
+
557
+ # Convert to dicts if needed
558
+ if hasattr(left_data, "model_dump"):
559
+ left_dict = left_data.model_dump()
560
+ else:
561
+ left_dict = left_data or {}
562
+
563
+ if hasattr(right_data, "model_dump"):
564
+ right_dict = right_data.model_dump()
565
+ else:
566
+ right_dict = right_data or {}
567
+
568
+ # Compare each field
569
+ for field_name in model_class.model_fields:
570
+ left_val = left_dict.get(field_name)
571
+ right_val = right_dict.get(field_name)
572
+
573
+ if left_val == right_val:
574
+ metrics_dict[field_name] = MetricEntry(
575
+ metric=1.0, color="green", comment="Values match exactly"
576
+ )
577
+ elif left_val is None or right_val is None:
578
+ metrics_dict[field_name] = MetricEntry(
579
+ metric=0.0, color="orange", comment="One value is missing"
580
+ )
581
+ else:
582
+ # Try to compute similarity for strings
583
+ if isinstance(left_val, str) and isinstance(right_val, str):
584
+ # Simple character overlap ratio
585
+ common = sum(1 for a, b in zip(left_val, right_val) if a == b)
586
+ max_len = max(len(left_val), len(right_val))
587
+ similarity = common / max_len if max_len > 0 else 0
588
+
589
+ metrics_dict[field_name] = MetricEntry(
590
+ metric=round(similarity, 2),
591
+ comment=f"String similarity: {similarity:.0%}",
592
+ )
593
+ else:
594
+ metrics_dict[field_name] = MetricEntry(
595
+ metric=0.0,
596
+ comment=f"Different values: {left_val} vs {right_val}",
597
+ )
598
+
599
+ return metrics_dict