textual-wtf 0.8.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.
textual_wtf/fields.py ADDED
@@ -0,0 +1,264 @@
1
+ """Field implementations for forms"""
2
+ from abc import ABC, abstractmethod
3
+ from typing import Any, Optional, Type, List, TYPE_CHECKING
4
+ from textual.widget import Widget
5
+ from textual.validation import Validator
6
+
7
+ from .exceptions import ValidationError, FieldError
8
+
9
+ if TYPE_CHECKING:
10
+ from .forms import Form
11
+
12
+
13
+ class Field(ABC):
14
+ """
15
+ Base field class - defines interface for all field types.
16
+
17
+ This class holds ONLY configuration (immutable, class-level).
18
+ Runtime state (widget instance, errors, current value) is held
19
+ in BoundField instances created when a Form is instantiated.
20
+
21
+ This separation allows Field instances to be safely shared across
22
+ multiple Form instances (thread-safe, no deep copying needed).
23
+ """
24
+
25
+ default_widget: Optional[Type[Widget]] = None
26
+
27
+ def __init__(self, *, widget: Optional[Type[Widget]] = None,
28
+ validators: Optional[List[Validator]] = None,
29
+ required: bool = False, initial: Any = None,
30
+ label: Optional[str] = None, help_text: Optional[str] = None,
31
+ disabled: bool = False, **widget_kwargs):
32
+ """
33
+ Initialize field configuration
34
+
35
+ Args:
36
+ widget: Widget class to use (overrides default_widget)
37
+ validators: List of validators
38
+ required: Whether field is required
39
+ initial: Initial/default value
40
+ label: Field label for display
41
+ help_text: Help text for field
42
+ disabled: Whether field is disabled
43
+ **widget_kwargs: Additional kwargs passed to widget
44
+ """
45
+ # Configuration only (immutable)
46
+ self.widget_class = widget or self.default_widget
47
+ self.validators = validators or []
48
+ self.required = required
49
+ self.initial = initial
50
+ self.label = label
51
+ self.help_text = help_text
52
+ self.disabled = disabled
53
+ self.widget_kwargs = widget_kwargs
54
+
55
+ # NOTE: Runtime state (_widget_instance, _errors, name, form)
56
+ # is now held in BoundField instances, not here!
57
+
58
+ @abstractmethod
59
+ def to_python(self, value: Any) -> Any:
60
+ """Convert widget value to Python value"""
61
+ pass
62
+
63
+ @abstractmethod
64
+ def to_widget(self, value: Any) -> Any:
65
+ """Convert Python value to widget value"""
66
+ pass
67
+
68
+ def bind(self, form: 'Form', name: str, initial: Any = None) -> 'BoundField':
69
+ """
70
+ Create a BoundField from this Field configuration
71
+
72
+ This is called by Form.__init__() to create runtime state
73
+ for this field in a specific form instance.
74
+
75
+ Args:
76
+ form: Parent form instance
77
+ name: Field name in the form
78
+ initial: Initial value (overrides self.initial if provided)
79
+
80
+ Returns:
81
+ BoundField instance holding runtime state
82
+ """
83
+ from .bound_fields import BoundField
84
+ return BoundField(self, form, name, initial)
85
+
86
+ def create_widget(self) -> Widget:
87
+ """
88
+ Factory method to create configured widget
89
+
90
+ Note: This creates the widget but doesn't store it.
91
+ The BoundField is responsible for storing widget instances.
92
+
93
+ Returns:
94
+ Configured widget instance
95
+ """
96
+ if not self.widget_class:
97
+ raise FieldError(
98
+ f"{self.__class__.__name__} must define default_widget "
99
+ f"or pass widget parameter"
100
+ )
101
+
102
+ kwargs = self.widget_kwargs.copy()
103
+
104
+ # Pass validators to widget if it supports them
105
+ if hasattr(self.widget_class, '__init__'):
106
+ init_params = self.widget_class.__init__.__code__.co_varnames
107
+ if 'validators' in init_params:
108
+ kwargs.setdefault('validators', self.validators)
109
+
110
+ widget = self.widget_class(**kwargs)
111
+ return widget
112
+
113
+ def validate(self, value: Any) -> None:
114
+ """
115
+ Validate Python value (stateless)
116
+
117
+ This method is now stateless - it doesn't modify _errors.
118
+ Instead, it raises ValidationError which BoundField catches
119
+ and stores in its own _errors list.
120
+
121
+ Args:
122
+ value: Python value to validate
123
+
124
+ Raises:
125
+ ValidationError: If validation fails
126
+ """
127
+ if self.required and value is None:
128
+ raise ValidationError(f"{self.label or 'Field'} is required")
129
+
130
+ def clean(self, value: Any) -> Any:
131
+ """
132
+ Convert and validate value (stateless)
133
+
134
+ Args:
135
+ value: Raw value to clean
136
+
137
+ Returns:
138
+ Cleaned Python value
139
+
140
+ Raises:
141
+ ValidationError: If validation fails
142
+ """
143
+ python_value = self.to_python(value)
144
+ self.validate(python_value)
145
+ return python_value
146
+
147
+
148
+ class StringField(Field):
149
+ """Text field (single or multi-line)"""
150
+
151
+ def __init__(self, *, max_length: Optional[int] = None,
152
+ min_length: Optional[int] = None, multiline: bool = False, **kwargs):
153
+ # Choose widget based on multiline flag
154
+ if multiline and 'widget' not in kwargs:
155
+ from .widgets import FormTextArea
156
+ kwargs['widget'] = FormTextArea
157
+ else:
158
+ from .widgets import FormInput
159
+ if 'widget' not in kwargs:
160
+ kwargs['widget'] = FormInput
161
+
162
+ super().__init__(**kwargs)
163
+ self.max_length = max_length
164
+ self.min_length = min_length
165
+
166
+ def to_python(self, value: Any) -> Optional[str]:
167
+ """Convert to Python string"""
168
+ if value in (None, ''):
169
+ return None
170
+ return str(value).strip()
171
+
172
+ def to_widget(self, value: Any) -> str:
173
+ """Convert to widget string"""
174
+ return value if value is not None else ''
175
+
176
+
177
+ class IntegerField(Field):
178
+ """Integer field"""
179
+
180
+ def __init__(self, *, min_value: Optional[int] = None,
181
+ max_value: Optional[int] = None, **kwargs):
182
+ from .widgets import FormIntegerInput
183
+ if 'widget' not in kwargs:
184
+ kwargs['widget'] = FormIntegerInput
185
+
186
+ super().__init__(**kwargs)
187
+ self.min_value = min_value
188
+ self.max_value = max_value
189
+
190
+ def to_python(self, value: Any) -> Optional[int]:
191
+ """Convert to Python int"""
192
+ if value in (None, ''):
193
+ return None
194
+ try:
195
+ return int(value)
196
+ except (ValueError, TypeError) as e:
197
+ raise ValidationError(f"Invalid integer: {value}") from e
198
+
199
+ def to_widget(self, value: Any) -> str:
200
+ """Convert to widget string"""
201
+ return str(value) if value is not None else ''
202
+
203
+ def validate(self, value: Any) -> None:
204
+ """Validate integer constraints"""
205
+ super().validate(value)
206
+ if value is not None:
207
+ if self.min_value is not None and value < self.min_value:
208
+ raise ValidationError(f"Must be at least {self.min_value}")
209
+ if self.max_value is not None and value > self.max_value:
210
+ raise ValidationError(f"Must be at most {self.max_value}")
211
+
212
+
213
+ class BooleanField(Field):
214
+ """Boolean/checkbox field"""
215
+
216
+ def __init__(self, **kwargs):
217
+ from .widgets import FormCheckbox
218
+ if 'widget' not in kwargs:
219
+ kwargs['widget'] = FormCheckbox
220
+ super().__init__(**kwargs)
221
+
222
+ def to_python(self, value: Any) -> bool:
223
+ """Convert to Python bool"""
224
+ if isinstance(value, bool):
225
+ return value
226
+ if isinstance(value, str):
227
+ return value.lower() in ('true', 'yes', '1', 'on')
228
+ return bool(value)
229
+
230
+ def to_widget(self, value: Any) -> bool:
231
+ """Convert to widget bool"""
232
+ return self.to_python(value)
233
+
234
+
235
+ class ChoiceField(Field):
236
+ """Select/dropdown field"""
237
+
238
+ def __init__(self, *, choices: List[tuple[str, str]], **kwargs):
239
+ from .widgets import FormSelect
240
+ if 'widget' not in kwargs:
241
+ kwargs['widget'] = FormSelect
242
+
243
+ super().__init__(**kwargs)
244
+ self.choices = choices
245
+ self.widget_kwargs['choices'] = choices
246
+ self.widget_kwargs['allow_blank'] = not kwargs.get('required', False)
247
+
248
+ def to_python(self, value: Any) -> Optional[str]:
249
+ """Convert to Python string"""
250
+ if value in (None, ''):
251
+ return None
252
+ return str(value)
253
+
254
+ def to_widget(self, value: Any) -> str:
255
+ """Convert to widget string"""
256
+ return value if value is not None else ''
257
+
258
+
259
+ class TextField(StringField):
260
+ """Multi-line text field (alias for StringField with multiline=True)"""
261
+
262
+ def __init__(self, **kwargs):
263
+ kwargs['multiline'] = True
264
+ super().__init__(**kwargs)
textual_wtf/forms.py ADDED
@@ -0,0 +1,433 @@
1
+ """Form metaclass and base classes"""
2
+ import copy
3
+ from typing import Dict, Any, Optional, List
4
+ from textual import on
5
+ from textual.containers import Vertical, Center, Horizontal, VerticalScroll
6
+ from textual.widgets import Button, Static, Label
7
+ from textual.message import Message
8
+
9
+ from .fields import Field
10
+ from .exceptions import FieldError, AmbiguousFieldError, ValidationError
11
+
12
+
13
+ class ComposedForm:
14
+ """Marker for composed form inclusion"""
15
+ def __init__(self, form_class: type, prefix: str = '', title: Optional[str] = None):
16
+ self.form_class = form_class
17
+ self.prefix = prefix
18
+ # If no title provided but prefix exists, capitalize prefix as title
19
+ if title is None and prefix:
20
+ self.title = prefix.capitalize()
21
+ else:
22
+ self.title = title
23
+
24
+
25
+ class FormMetaclass(type):
26
+ """Metaclass that collects Field declarations and expands composed forms"""
27
+
28
+ def __new__(mcs, name, bases, attrs):
29
+ # Collect fields and composed forms in declaration order
30
+ items_in_order = []
31
+
32
+ for key, value in list(attrs.items()):
33
+ if isinstance(value, Field):
34
+ items_in_order.append(('field', key, value))
35
+ attrs.pop(key) # Remove from class attrs
36
+ elif isinstance(value, ComposedForm):
37
+ items_in_order.append(('composed', key, value))
38
+ attrs.pop(key) # Remove from class attrs
39
+
40
+ # Process items in order, expanding composed forms
41
+ all_fields = []
42
+ declared_fields = []
43
+ composition_metadata = {}
44
+
45
+ for item_type, attr_name, item in items_in_order:
46
+ if item_type == 'field':
47
+ # Regular field - add directly
48
+ all_fields.append((attr_name, item))
49
+ declared_fields.append((attr_name, item))
50
+
51
+ elif item_type == 'composed':
52
+ # Composed form - expand it
53
+ composed = item
54
+
55
+ if not hasattr(composed.form_class, '_base_fields'):
56
+ raise FieldError(
57
+ f"Cannot compose {composed.form_class.__name__}: "
58
+ f"it doesn't appear to be a Form class"
59
+ )
60
+
61
+ composed_fields = composed.form_class._base_fields
62
+ prefix = composed.prefix
63
+
64
+ # Expand fields with prefix
65
+ for field_name, field in composed_fields.items():
66
+ # Generate new field name
67
+ if prefix:
68
+ new_name = f"{prefix}_{field_name}"
69
+ else:
70
+ new_name = field_name
71
+
72
+ # Deep copy the field
73
+ new_field = copy.deepcopy(field)
74
+ all_fields.append((new_name, new_field))
75
+
76
+ # Track composition metadata
77
+ if prefix or composed.title:
78
+ composition_metadata[new_name] = {
79
+ 'composed_from': attr_name,
80
+ 'prefix': prefix,
81
+ 'original_name': field_name,
82
+ 'title': composed.title
83
+ }
84
+
85
+ # Check for name collisions
86
+ field_names = {}
87
+ for field_name, field in all_fields:
88
+ if field_name in field_names:
89
+ raise FieldError(
90
+ f"Field name collision: '{field_name}' is defined multiple times. "
91
+ f"Use different prefixes to avoid collisions."
92
+ )
93
+ field_names[field_name] = field
94
+
95
+ # Store as _base_fields
96
+ new_class = super().__new__(mcs, name, bases, attrs)
97
+ new_class._base_fields = dict(all_fields)
98
+ new_class._declared_fields = dict(declared_fields)
99
+ new_class._composition_metadata = composition_metadata
100
+ return new_class
101
+
102
+
103
+ class RenderedForm(VerticalScroll):
104
+ """Rendered form widget that displays fields and buttons"""
105
+
106
+ DEFAULT_CSS = """
107
+ RenderedForm {
108
+ keyline: thin green;
109
+ }
110
+
111
+ Vertical {
112
+ margin: 1;
113
+ }
114
+
115
+ #form-title {
116
+ background: blue;
117
+ height: auto;
118
+ margin: 1;
119
+ }
120
+
121
+ .subform-title {
122
+ background: white;
123
+ color: black;
124
+ height: auto;
125
+ padding: 0 1;
126
+ margin: 1 0 0 0;
127
+ }
128
+
129
+ .form-field {
130
+ height: auto;
131
+ }
132
+
133
+ .form-error {
134
+ color: red;
135
+ width: 1fr;
136
+ }
137
+
138
+ #buttons {
139
+ height: auto;
140
+ align: center middle;
141
+ margin: 0;
142
+ }
143
+
144
+ #outer-buttons {
145
+ height: auto;
146
+ }
147
+
148
+ Input {
149
+ height: auto;
150
+ }
151
+
152
+ TextArea {
153
+ height: 6;
154
+ }
155
+ """
156
+
157
+ def __init__(self, form, data: Optional[Dict[str, Any]] = None,
158
+ field_order: Optional[List[str]] = None, id=None):
159
+ """
160
+ Initialize rendered form
161
+ """
162
+ super().__init__(id=id, **form.kwargs)
163
+ self.form = form
164
+ self.fields = form.fields
165
+ self.data = data
166
+ self.field_order = field_order
167
+
168
+ if data is not None:
169
+ self.set_data(data)
170
+
171
+ def compose(self):
172
+ """Compose the form UI"""
173
+ # Optional title
174
+ if self.form.title is not None:
175
+ yield Vertical(
176
+ Center(Static(f"---- {self.form.title} ----")),
177
+ id="form-title"
178
+ )
179
+
180
+ # Track which subforms we've already rendered headers for
181
+ rendered_subforms = set()
182
+
183
+ # Render each field
184
+ for name, field in self.form.fields.items():
185
+ # Check if this field is part of a composed subform with a title
186
+ metadata = self.form._composition_metadata.get(name)
187
+ if metadata and metadata.get('title'):
188
+ subform_id = metadata['composed_from']
189
+
190
+ # Render subform title once per subform
191
+ if subform_id not in rendered_subforms:
192
+ yield Static(metadata['title'], classes="subform-title")
193
+ rendered_subforms.add(subform_id)
194
+
195
+ with Vertical(classes="form-field"):
196
+ if field.label:
197
+ yield Label(field.label)
198
+ yield field.widget
199
+
200
+ # Set initial data if provided
201
+ if self.data and name in self.data:
202
+ field.value = self.data[name]
203
+
204
+ # Submit/Cancel buttons
205
+ yield Vertical(
206
+ Horizontal(
207
+ Button("Cancel", id="cancel"),
208
+ Button("Submit", id="submit", variant="primary"),
209
+ id="buttons"
210
+ ),
211
+ id="outer-buttons"
212
+ )
213
+
214
+ def get_data(self) -> Dict[str, Any]:
215
+ """Get current form data"""
216
+ return self.form.get_data()
217
+
218
+ def set_data(self, data: Dict[str, Any]):
219
+ """Set form data"""
220
+ return self.form.set_data(data)
221
+
222
+ async def validate(self):
223
+ """Validate all form fields"""
224
+ return await self.form.validate()
225
+
226
+ @on(Button.Pressed, "#submit")
227
+ async def submit_pressed(self, event: Button.Pressed) -> None:
228
+ """Handle submit button press"""
229
+ if await self.validate():
230
+ self.post_message(Form.Submitted(self))
231
+ else:
232
+ self.app.notify("Please fix the errors before submitting", severity="error")
233
+
234
+ @on(Button.Pressed, "#cancel")
235
+ async def cancel_pressed(self, event: Button.Pressed) -> None:
236
+ """Handle cancel button press"""
237
+ self.post_message(Form.Cancelled(self))
238
+
239
+
240
+ class BaseForm:
241
+ """Base form class without metaclass"""
242
+
243
+ def __init__(self, *children, data: Optional[Dict[str, Any]] = None,
244
+ field_order: Optional[List[str]] = None,
245
+ title: Optional[str] = None, render_type=RenderedForm, **kwargs):
246
+ """
247
+ Initialize form
248
+
249
+ Creates BoundField instances from class-level Field definitions.
250
+ This is much faster than deep copying and enables thread-safe
251
+ Form class reuse.
252
+
253
+ Args:
254
+ data: Initial data dict
255
+ field_order: Custom field ordering
256
+ title: Form title
257
+ render_type: Custom renderer class
258
+ **kwargs: Additional kwargs for renderer
259
+ """
260
+ self.data = data
261
+ self.children = children
262
+ self.field_order = field_order
263
+ self.title = title
264
+ self.kwargs = kwargs
265
+ self.render_type = render_type
266
+
267
+ # Create BoundFields from class-level Field definitions
268
+ # NO MORE DEEP COPY - just create lightweight BoundField wrappers
269
+ self.bound_fields: Dict[str, 'BoundField'] = {}
270
+
271
+ for name, field in self._base_fields.items():
272
+ # Get initial value from data if provided
273
+ initial = data.get(name) if data else None
274
+ # Create BoundField (holds runtime state)
275
+ bound_field = field.bind(self, name, initial)
276
+ self.bound_fields[name] = bound_field
277
+
278
+ # Set as attribute for direct access (form.fieldname)
279
+ setattr(self, name, bound_field)
280
+
281
+ # Apply custom field ordering if provided
282
+ self.order_fields(self.field_order)
283
+
284
+ @property
285
+ def fields(self) -> Dict[str, 'BoundField']:
286
+ """
287
+ Backward compatibility: alias for bound_fields
288
+
289
+ Returns bound_fields so existing code using form.fields continues to work.
290
+ """
291
+ return self.bound_fields
292
+
293
+ def __getattr__(self, name: str) -> 'BoundField':
294
+ """
295
+ Allow dot-access to fields using the "SQL-style" resolution logic.
296
+ This is called only if the attribute was not found by normal lookup.
297
+ """
298
+ # Try to resolve using get_field logic
299
+ field = self.get_field(name)
300
+ if field:
301
+ return field
302
+
303
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
304
+
305
+ @classmethod
306
+ def compose(cls, prefix: str = '', title: Optional[str] = None) -> ComposedForm:
307
+ """Create a composition marker for including this form in another"""
308
+ return ComposedForm(cls, prefix=prefix, title=title)
309
+
310
+ def get_data(self) -> Dict[str, Any]:
311
+ """Get current values from all fields"""
312
+ data: Dict[str, Any] = {}
313
+ for name, field in self.fields.items():
314
+ data[name] = field.value
315
+ return data
316
+
317
+ def set_data(self, data: Dict[str, Any]):
318
+ """Set values for fields from dict"""
319
+ for name, value in data.items():
320
+ if name in self.fields:
321
+ self.fields[name].value = value
322
+
323
+ def order_fields(self, field_order):
324
+ """Reorder fields according to field_order list"""
325
+ if field_order is None:
326
+ return
327
+
328
+ # Work directly with bound_fields since fields is now a property
329
+ ordered = {}
330
+ # First add fields in specified order
331
+ for key in field_order:
332
+ if key in self.bound_fields:
333
+ ordered[key] = self.bound_fields.pop(key)
334
+
335
+ # Then add any remaining fields
336
+ for k in list(self.bound_fields):
337
+ ordered[k] = self.bound_fields.pop(k)
338
+
339
+ self.bound_fields = ordered
340
+
341
+ def get_fields_dict(self) -> Dict[str, 'BoundField']:
342
+ """Get fields dictionary without rendering"""
343
+ return self.bound_fields
344
+
345
+ def get_field_names(self) -> List[str]:
346
+ """Get list of field names in order"""
347
+ return list(self.bound_fields.keys())
348
+
349
+ def get_field(self, name: str) -> Optional['BoundField']:
350
+ """
351
+ Get a specific field by name with SQL-style resolution
352
+ """
353
+ # Try exact match first
354
+ if name in self.fields:
355
+ return self.fields[name]
356
+
357
+ # Try unqualified match (fields ending with _<name>)
358
+ candidates = [
359
+ field_name for field_name in self.fields
360
+ if field_name == name or field_name.endswith('_' + name)
361
+ ]
362
+
363
+ if len(candidates) == 0:
364
+ return None # Not found
365
+ elif len(candidates) == 1:
366
+ return self.fields[candidates[0]] # Unambiguous
367
+ else:
368
+ from .exceptions import AmbiguousFieldError
369
+ raise AmbiguousFieldError(
370
+ f"Field '{name}' is ambiguous. Could be: {', '.join(sorted(candidates))}. "
371
+ f"Use the full qualified name to disambiguate."
372
+ )
373
+
374
+ def render(self, id=None) -> RenderedForm:
375
+ """Render the form as a Textual widget"""
376
+ # Create widgets for all fields
377
+ for name, field in self.fields.items():
378
+ field.widget = field.create_widget()
379
+
380
+ # Create and return rendered form
381
+ self.rform = self.render_type(
382
+ self,
383
+ id=id,
384
+ data=self.data,
385
+ field_order=self.field_order
386
+ )
387
+ return self.rform
388
+
389
+ async def validate(self):
390
+ """Validate all form fields"""
391
+ result = True
392
+
393
+ for name, field in self.fields.items():
394
+ widget = field.widget
395
+ container = widget.parent
396
+
397
+ # Clear previous errors
398
+ await container.remove_children(".form-error")
399
+
400
+ # Widget-level validation (validators passed to widget)
401
+ vr = widget.validate(widget.value)
402
+ if vr is not None and not vr.is_valid:
403
+ result = False
404
+ # Display errors
405
+ for msg in vr.failure_descriptions:
406
+ container.mount(Center(Static(msg, classes="form-error")))
407
+
408
+ # Field-level validation (required, custom Field validators)
409
+ try:
410
+ field.clean(field.value)
411
+ except ValidationError as e:
412
+ result = False
413
+ container.mount(Center(Static(str(e), classes="form-error")))
414
+
415
+ return result
416
+
417
+
418
+ class Form(BaseForm, metaclass=FormMetaclass):
419
+ """
420
+ Form with declarative field syntax
421
+ """
422
+
423
+ class Submitted(Message):
424
+ """Posted when form is submitted successfully"""
425
+ def __init__(self, r_form: RenderedForm):
426
+ super().__init__()
427
+ self.form = r_form
428
+
429
+ class Cancelled(Message):
430
+ """Posted when form is cancelled"""
431
+ def __init__(self, r_form: RenderedForm):
432
+ super().__init__()
433
+ self.form = r_form
textual_wtf/py.typed ADDED
File without changes