exdrf 0.0.1.dev0__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.
Files changed (57) hide show
  1. exdrf/__init__.py +0 -0
  2. exdrf/__version__.py +24 -0
  3. exdrf/api.py +51 -0
  4. exdrf/constants.py +30 -0
  5. exdrf/dataset.py +197 -0
  6. exdrf/field.py +554 -0
  7. exdrf/field_types/__init__.py +0 -0
  8. exdrf/field_types/api.py +78 -0
  9. exdrf/field_types/blob_field.py +44 -0
  10. exdrf/field_types/bool_field.py +47 -0
  11. exdrf/field_types/date_field.py +49 -0
  12. exdrf/field_types/date_time.py +52 -0
  13. exdrf/field_types/dur_field.py +44 -0
  14. exdrf/field_types/enum_field.py +41 -0
  15. exdrf/field_types/filter_field.py +11 -0
  16. exdrf/field_types/float_field.py +85 -0
  17. exdrf/field_types/float_list.py +18 -0
  18. exdrf/field_types/formatted.py +39 -0
  19. exdrf/field_types/int_field.py +70 -0
  20. exdrf/field_types/int_list.py +18 -0
  21. exdrf/field_types/ref_base.py +105 -0
  22. exdrf/field_types/ref_m2m.py +39 -0
  23. exdrf/field_types/ref_m2o.py +23 -0
  24. exdrf/field_types/ref_o2m.py +36 -0
  25. exdrf/field_types/ref_o2o.py +32 -0
  26. exdrf/field_types/sort_field.py +18 -0
  27. exdrf/field_types/str_field.py +77 -0
  28. exdrf/field_types/str_list.py +18 -0
  29. exdrf/field_types/time_field.py +49 -0
  30. exdrf/filter.py +653 -0
  31. exdrf/filter_dsl.py +950 -0
  32. exdrf/filter_op_catalog.py +222 -0
  33. exdrf/label_dsl.py +691 -0
  34. exdrf/moment.py +496 -0
  35. exdrf/py.typed +0 -0
  36. exdrf/py_support.py +21 -0
  37. exdrf/resource.py +901 -0
  38. exdrf/sa_fi_item.py +69 -0
  39. exdrf/sa_filter_op.py +324 -0
  40. exdrf/utils.py +17 -0
  41. exdrf/validator.py +45 -0
  42. exdrf/var_bag.py +328 -0
  43. exdrf/visitor.py +58 -0
  44. exdrf-0.0.1.dev0.dist-info/METADATA +42 -0
  45. exdrf-0.0.1.dev0.dist-info/RECORD +57 -0
  46. exdrf-0.0.1.dev0.dist-info/WHEEL +5 -0
  47. exdrf-0.0.1.dev0.dist-info/top_level.txt +3 -0
  48. exdrf_tests/__init__.py +0 -0
  49. exdrf_tests/test_dataset.py +422 -0
  50. exdrf_tests/test_field.py +109 -0
  51. exdrf_tests/test_filter.py +425 -0
  52. exdrf_tests/test_filter_dsl.py +556 -0
  53. exdrf_tests/test_label_dsl.py +234 -0
  54. exdrf_tests/test_resource.py +107 -0
  55. exdrf_tests/test_utils.py +43 -0
  56. exdrf_tests/test_visitor.py +31 -0
  57. exdrf_tests/var_bag_test.py +502 -0
exdrf/resource.py ADDED
@@ -0,0 +1,901 @@
1
+ import os
2
+ import re
3
+ from collections import OrderedDict as OrDict
4
+ from functools import cached_property
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Dict,
9
+ List,
10
+ Optional,
11
+ OrderedDict,
12
+ Set,
13
+ Tuple,
14
+ Union,
15
+ cast,
16
+ )
17
+
18
+ from attrs import define, field
19
+ from pydantic import BaseModel, Field, field_validator
20
+
21
+ from exdrf.constants import FIELD_TYPE_INTEGER, FIELD_TYPE_REF_ONE_TO_MANY
22
+ from exdrf.label_dsl import (
23
+ generate_python_code,
24
+ generate_typescript_code,
25
+ get_used_fields,
26
+ parse_expr,
27
+ )
28
+ from exdrf.utils import doc_lines, inflect_e
29
+
30
+ if TYPE_CHECKING:
31
+ from exdrf.dataset import ExDataset
32
+ from exdrf.field import ExField
33
+ from exdrf.field_types.ref_base import RefBaseField
34
+ from exdrf.field_types.ref_m2m import RefManyToManyField
35
+ from exdrf.field_types.ref_o2m import RefOneToManyField
36
+ from exdrf.field_types.str_field import StrField
37
+ from exdrf.label_dsl import ASTNode
38
+ from exdrf.visitor import ExVisitor
39
+
40
+ CATEGORY_SEGREGATION_LIMIT = 6
41
+
42
+
43
+ @define
44
+ class ExResource:
45
+ """The resource consists of a list of fields and is part of a dataset.
46
+
47
+ You can retrieve a field using the `resource[key]` syntax, where key is
48
+ either the name of the field or its index.
49
+
50
+ Attributes:
51
+ name: The name of the resource.
52
+ dataset: The dataset that the resource is part of.
53
+ fields: The fields that are part of this resource.
54
+ categories: The categories of the resource.
55
+ description: The description of the resource.
56
+ src: The source of the resource. For sqlalchemy models this is the
57
+ SQLAlchemy model class. For pydantic models this is the pydantic
58
+ model class.
59
+ label_ast: describes how to construct the label of a record.
60
+ provides: The concepts that the resource provides.
61
+ depends_on: The concepts that the resource depends on.
62
+ """
63
+
64
+ name: str
65
+ dataset: "ExDataset" = field(default=None, repr=False)
66
+ fields: List["ExField"] = field(factory=list)
67
+ categories: List[str] = field(factory=list)
68
+ description: str = ""
69
+ src: Any = field(default=None)
70
+ label_ast: "ASTNode" = field(default=None)
71
+ provides: List[str] = field(factory=list)
72
+ depends_on: List[Tuple[str, str]] = field(factory=list)
73
+
74
+ def __attrs_post_init__(self):
75
+ out = self.fields
76
+ self.fields = []
77
+ for fld in out:
78
+ self.add_field(fld)
79
+ field_map = {f.name: f for f in out}
80
+ for fld in out:
81
+ self.post_process_field(fld, field_map)
82
+
83
+ def __str__(self) -> str:
84
+ return self.__repr__()
85
+
86
+ def __repr__(self) -> str:
87
+ return f"<Resource {self.name} ({len(self.fields)} fields)>"
88
+
89
+ def __hash__(self):
90
+ return hash(f"{self.name}.{'.'.join(self.categories)}")
91
+
92
+ def __contains__(self, key: Union[int, str]) -> bool:
93
+ if isinstance(key, int):
94
+ return 0 <= key < len(self.fields)
95
+ return any(f.name == key for f in self.fields)
96
+
97
+ def __iter__(self):
98
+ """Make the resource iterable over its fields."""
99
+ return iter(self.fields)
100
+
101
+ def __len__(self) -> int:
102
+ """Return the number of fields in the resource."""
103
+ return len(self.fields)
104
+
105
+ def __in__(self, key: Union[int, str]) -> bool:
106
+ """Check if a field exists in the resource.
107
+
108
+ Args:
109
+ key: The key to check for. Can be either an index or field name.
110
+
111
+ Returns:
112
+ True if the field exists, False otherwise.
113
+ """
114
+ if isinstance(key, int):
115
+ return key < len(self.fields)
116
+ return any(f.name == key for f in self.fields)
117
+
118
+ def __getitem__(self, key: Union[int, str]) -> "ExField":
119
+ # If it is an index, return the field at that index.
120
+ if isinstance(key, int):
121
+ return self.fields[key]
122
+
123
+ # Locate the field by name.
124
+ for m in self.fields:
125
+ if m.name == key:
126
+ return m
127
+
128
+ # If the field is not found, raise an error.
129
+ raise KeyError(f"No field found for key `{key}` in model `{self.name}`")
130
+
131
+ @cached_property
132
+ def ref_fields(self) -> List["RefBaseField"]:
133
+ """Get the fields that are references to other resources.
134
+
135
+ Note that `get_dependencies` will return all related resources, even
136
+ if they are not referenced in the fields of the resource.
137
+
138
+ Returns:
139
+ The fields that are references to other resources.
140
+ """
141
+ return cast(
142
+ List["RefBaseField"],
143
+ [fld for fld in self.fields if fld.is_ref_type],
144
+ )
145
+
146
+ @cached_property
147
+ def derived_fields(self) -> List["ExField"]:
148
+ """Get the fields that are derived from other fields."""
149
+ return [fld for fld in self.fields if fld.is_derived]
150
+
151
+ @cached_property
152
+ def pascal_case_name(self) -> str:
153
+ """Return the name of the resource in PascalCase."""
154
+ return self.name
155
+
156
+ @cached_property
157
+ def snake_case_name(self) -> str:
158
+ """Return the name of the resource in snake_case.
159
+
160
+ Examples:
161
+ If self.name == "ContractProposal", then:
162
+ - snake_case_name -> "contract_proposal"
163
+ If self.name == "IssItem", then:
164
+ - snake_case_name -> "iss_item"
165
+ """
166
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", self.name).lower()
167
+
168
+ @cached_property
169
+ def snake_case_name_plural(self) -> str:
170
+ """Return the name of the resource in snake_case.
171
+
172
+ Examples:
173
+ If self.name == "ContractProposal", then:
174
+ - snake_case_name_plural -> "contract_proposals"
175
+ If self.name == "IssItem", then:
176
+ - snake_case_name_plural -> "iss_items"
177
+ """
178
+ return inflect_e.plural(
179
+ re.sub(r"(?<!^)(?=[A-Z])", "_", self.name).lower() # type: ignore
180
+ )
181
+
182
+ @cached_property
183
+ def camel_case_name(self) -> str:
184
+ """Return the name of the resource in camelCase."""
185
+ return self.name[0].lower() + self.name[1:]
186
+
187
+ @cached_property
188
+ def text_name(self) -> str:
189
+ """Return the name of the resource in `Text case`."""
190
+ tmp = re.sub(r"(?<!^)(?=[A-Z])", " ", self.name).lower()
191
+ return tmp[0].upper() + tmp[1:]
192
+
193
+ @cached_property
194
+ def doc_lines(self) -> List[str]:
195
+ """Get the docstring of the field as a set of lines.
196
+
197
+ Returns:
198
+ The docstring of the field as a set of lines.
199
+ """
200
+ return doc_lines(self.description)
201
+
202
+ def resource_properties(self, explicit: bool = False) -> Dict[str, Any]:
203
+ """Build a JSON-friendly metadata dict for this resource.
204
+
205
+ Mirrors :meth:`exdrf.field.ExField.field_properties` for use in
206
+ emitters (e.g. Pydantic ``json_schema_extra``).
207
+
208
+ Args:
209
+ explicit: If True, include all keys, including empty strings and
210
+ empty collections where applicable.
211
+
212
+ Returns:
213
+ Serializable resource-level properties.
214
+ """
215
+ if explicit:
216
+ return {
217
+ "name": self.name,
218
+ "categories": list(self.categories),
219
+ "description": self.description,
220
+ "text_name": self.text_name,
221
+ "provides": list(self.provides),
222
+ "depends_on": [list(pair) for pair in self.depends_on],
223
+ }
224
+ result: Dict[str, Any] = {"name": self.name}
225
+ if self.categories:
226
+ result["categories"] = list(self.categories)
227
+ if self.description:
228
+ result["description"] = self.description
229
+ if self.provides:
230
+ result["provides"] = list(self.provides)
231
+ if self.depends_on:
232
+ result["depends_on"] = [list(pair) for pair in self.depends_on]
233
+ return result
234
+
235
+ def add_field(self, fld: "ExField") -> None:
236
+ """Add a field to the resource.
237
+
238
+ Args:
239
+ field: The field to add.
240
+ """
241
+
242
+ assert fld.name, "Field name must be set"
243
+ assert fld.type_name, f"Field type must be set in {fld.name}"
244
+
245
+ self.fields.append(fld)
246
+ fld.resource = self # type: ignore
247
+
248
+ if not fld.category:
249
+ fld.category = self.get_default_field_category(fld)
250
+
251
+ def post_process_field(
252
+ self, fld: "ExField", field_map: Dict[str, "ExField"]
253
+ ) -> None:
254
+ """Tie fields together.
255
+
256
+ Args:
257
+ fld: The field to post-process.
258
+ field_map: A dictionary that maps field names to fields.
259
+ """
260
+ from exdrf.constants import FIELD_TYPE_STRING
261
+ from exdrf.field import NO_DIACRITICS
262
+
263
+ if fld.derived:
264
+ other_name, kind = fld.derived
265
+ if kind == NO_DIACRITICS:
266
+ if fld.type_name != FIELD_TYPE_STRING:
267
+ raise ValueError("Only string types supports NO_DIACRITICS")
268
+ else:
269
+ return
270
+
271
+ other = field_map.get(other_name, None)
272
+ if other is None:
273
+ raise ValueError(
274
+ f"The field {fld.name} depends on the field "
275
+ f"{other_name}, which was not found in the current "
276
+ "resource."
277
+ )
278
+
279
+ if other.type_name != FIELD_TYPE_STRING:
280
+ raise ValueError(
281
+ "Only string types supports NO_DIACRITICS. "
282
+ f"The target field {other_name} is a {other.type_name}"
283
+ )
284
+
285
+ other_str = cast("StrField", other)
286
+ other_str.no_dia_field = fld
287
+
288
+ def get_default_field_category(self, fld: "ExField") -> str:
289
+ """Get the default category for a field.
290
+
291
+ When adding a new field with an empty category the `add_field()`
292
+ method will call this method to get the default category. Reimplement
293
+ it if you want to assign categories to fields automatically.
294
+ """
295
+ return "keys" if fld.primary else "general"
296
+
297
+ def get_fields_for_ref_filtering(self) -> List["ExField"]:
298
+ """Get the fields that are going to be used with other models that
299
+ reference this model when the user searches for text.
300
+ """
301
+ lst = self.minium_field_set_wo_primaries() or self.minimum_field_set
302
+ return [self[n] for n in lst if not self[n].is_ref_type]
303
+
304
+ def visit(
305
+ self,
306
+ visitor: "ExVisitor",
307
+ omit_fields: Optional[bool] = False,
308
+ ) -> bool:
309
+ """Visit the resource and its fields.
310
+
311
+ Args:
312
+ visitor: The visitor to use.
313
+ omit_fields: If True, resource fields will not be visited.
314
+
315
+ Returns:
316
+ bool: True if the visit should continue, False otherwise.
317
+ """
318
+ if not visitor.visit_resource(self): # type: ignore
319
+ return False
320
+
321
+ if not omit_fields:
322
+ for fld in self.fields:
323
+ if not fld.visit(visitor):
324
+ return False
325
+
326
+ return True
327
+
328
+ def get_dependencies(self, fk_only: bool = False) -> Set["ExResource"]:
329
+ """Get the set of resources that this resource depends on.
330
+
331
+ The method interrogates the fields of the resource and checks if any of
332
+ them are references to other resources. If so, it adds them to the set
333
+ of dependencies. This is useful for generating import statements or for
334
+ determining the order in which resources should be processed.
335
+
336
+ Note that only the first level of dependencies is considered so, if
337
+ resource A depends on resource B, and resource B depends on resource C,
338
+ resource C will not be reported by this method.
339
+
340
+ Args:
341
+ fk_only: If True, only dependencies that have their foreign key
342
+ in the current resources are returned (ManyToOne and OneToOne).
343
+
344
+ Returns:
345
+ The set of resources that this resource depends on.
346
+ """
347
+ deps = set()
348
+ for fld in self.fields:
349
+ if fk_only:
350
+ if fld.is_many_to_one_type or fld.is_one_to_one_type:
351
+ fld = cast("RefBaseField", fld)
352
+ if fld.ref is not self:
353
+ deps.add(fld.ref)
354
+
355
+ # Extra dependencies are not included when fk_only is True.
356
+ continue
357
+
358
+ fld = cast("RefBaseField", fld)
359
+ if fld.is_ref_type and fld.ref is not self:
360
+ deps.add(fld.ref)
361
+ for extra in fld.extra_ref(self.dataset):
362
+ if extra is not self:
363
+ deps.add(extra)
364
+ return deps
365
+
366
+ def get_dep_fields(self, dep: "ExResource") -> List["ExField"]:
367
+ """Get the fields that references a particular dependency.
368
+
369
+ Args:
370
+ dep: The dependency to look for.
371
+
372
+ Returns:
373
+ The fields that references the dependency.
374
+ """
375
+ return [fld for fld in self.ref_fields if fld.ref is dep]
376
+
377
+ @cached_property
378
+ def minimum_field_set(self) -> List[str]:
379
+ """Get the minimum set of fields that are used to represent the
380
+ resource.
381
+
382
+ This set includes all the fields that are used in the label definition
383
+ and all the primary-key fields (fields that contribute to computing
384
+ the identity of the resource).
385
+ """
386
+ names: Set[str] = set(get_used_fields(self.label_ast))
387
+ for f in self.fields:
388
+ if f.primary:
389
+ names.add(f.name)
390
+ return sorted(names)
391
+
392
+ def minium_field_set_wo_primaries(self) -> List[str]:
393
+ """Get the minimum set of fields that are used to represent the
394
+ resource except those fields that are also primary keys.
395
+ """
396
+ names: Set[str] = set(
397
+ [n.split(".")[0] for n in get_used_fields(self.label_ast)]
398
+ )
399
+ return sorted(n for n in names if self.__in__(n) and not self[n].primary)
400
+
401
+ def primary_fields(self) -> List[str]:
402
+ """Get the fields that are primary keys of the resource."""
403
+ names: Set[str] = set()
404
+ for f in self.fields:
405
+ if f.primary:
406
+ names.add(f.name)
407
+ return sorted(names)
408
+
409
+ def primary_inst_fields(self) -> List["ExField"]:
410
+ """Get the fields that are primary keys of the resource."""
411
+ names: Set[str] = set()
412
+ for f in self.fields:
413
+ if f.primary:
414
+ names.add(f.name)
415
+ return [self[n] for n in sorted(names)]
416
+
417
+ @cached_property
418
+ def is_primary_simple(self) -> bool:
419
+ """Check if the resource has a simple primary key.
420
+
421
+ A simple primary key is a single field that is used to identify the
422
+ resource. If the resource has no primary key or more than one primary
423
+ key, it is not a simple primary key.
424
+ """
425
+ return len(self.primary_fields()) == 1
426
+
427
+ @cached_property
428
+ def is_primary_simple_id(self) -> bool:
429
+ """Check if the resource has a single primary key called `id`."""
430
+ pf = self.primary_fields()
431
+ return len(pf) == 1 and pf[0] == "id"
432
+
433
+ @cached_property
434
+ def is_connection_resource(self) -> bool:
435
+ """Check if the resource is a connection resource.
436
+
437
+ A connection resource is a resource that is used to connect two other
438
+ resources. It is not a real resource and should not be included in the
439
+ UI.
440
+ """
441
+ return all(f.primary for f in self.fields)
442
+
443
+ @cached_property
444
+ def is_join_table(self) -> bool:
445
+ """A table that only contains two foreign key fields."""
446
+ candidates = []
447
+ for f in self.fields:
448
+ if f.is_ref_type:
449
+ continue
450
+ if not f.primary:
451
+ return False
452
+ if f.type_name != FIELD_TYPE_INTEGER:
453
+ return False
454
+ if not f.name.endswith("_id"):
455
+ return False
456
+ candidates.append(f)
457
+ if len(candidates) != 2:
458
+ return False
459
+ return True
460
+
461
+ def rel_import(
462
+ self,
463
+ other: Union["ExResource", List[str]],
464
+ path_up: str = "..",
465
+ path_sep: str = "/",
466
+ ) -> str:
467
+ """Compute the import path for a resource relative to another resource.
468
+
469
+ Resources are assumed to live at the end of the path indicated by the
470
+ `categories` list. The import path is computed by finding the common
471
+ prefix between the two resources and then computing the relative path
472
+ from the other resource to this one.
473
+
474
+ Args:
475
+ other: The resource to import from or a path as a list of strings.
476
+ path_up: The string to use to go up in the path. Defaults to '..'.
477
+ path_sep: The string to use to separate the elements of the path.
478
+ Defaults to '/'.
479
+
480
+ Returns:
481
+ The relative import path, with elements separated by slashes.
482
+ """
483
+ if isinstance(other, ExResource):
484
+ other_categories = other.categories
485
+ else:
486
+ other_categories = other
487
+
488
+ # Find the common prefix.
489
+ i = 0
490
+ while (
491
+ i < len(self.categories)
492
+ and i < len(other_categories)
493
+ and self.categories[i] == other_categories[i]
494
+ ):
495
+ i += 1
496
+
497
+ # Compute the relative path.
498
+ path = [path_up] * (len(other_categories) - i)
499
+ path.extend(other_categories[i:])
500
+
501
+ return path_sep.join(path)
502
+
503
+ def ensure_path(self, path: str, extension: str, name: Optional[str] = None):
504
+ """Ensure that a path exists and computes file path.
505
+
506
+ The final path is computed by joining the base `path` with the
507
+ categories of the resource and the name of the resource, and appending
508
+ the `extension`.
509
+
510
+ Args:
511
+ path: The base path to write the file to.
512
+ extension: The extension of the file without a dot.
513
+ name: override the name of the resource; the resource name is
514
+ stored as a Pascal-case string and you may want to use a
515
+ different case convention for the file; also, there's nothing
516
+ preventing you from including a prefix path with the name
517
+ that will be applied at the end of the categories path.
518
+
519
+ Returns:
520
+ The full path to the file.
521
+ """
522
+ # Create the output file path.
523
+ file_path = os.path.join(
524
+ path, *self.categories, f"{name or self.name}.{extension}"
525
+ )
526
+
527
+ # Create the output directory if it doesn't exist.
528
+ dir_path = os.path.dirname(file_path)
529
+ os.makedirs(dir_path, exist_ok=True)
530
+
531
+ return file_path
532
+
533
+ def parse_pos_hint(
534
+ self, pos_hint: Optional[str]
535
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
536
+ """Parse a pos_hint into sort value and relationships.
537
+
538
+ Args:
539
+ pos_hint: The positional hint string to parse.
540
+
541
+ Returns:
542
+ A tuple of (sort_value, after_name, before_name).
543
+ """
544
+ if not pos_hint:
545
+ return None, None, None
546
+
547
+ # Extract relative positioning rules from the hint.
548
+ after_match = re.search(r"\[after:([^\]]+)\]", pos_hint)
549
+ before_match = re.search(r"\[before:([^\]]+)\]", pos_hint)
550
+ after_name = after_match.group(1).strip() if after_match else None
551
+ before_name = before_match.group(1).strip() if before_match else None
552
+
553
+ # Remove relationship tokens to keep the sort value intact.
554
+ sort_value = re.sub(r"\[(?:after|before):[^\]]+\]", "", pos_hint).strip()
555
+
556
+ return sort_value, after_name, before_name
557
+
558
+ def apply_pos_hint_relations(self, fields: List["ExField"]) -> List["ExField"]:
559
+ """Apply [after]/[before] relations to a sorted field list.
560
+
561
+ Args:
562
+ fields: The list of fields sorted by the base sort key.
563
+
564
+ Returns:
565
+ A reordered list that respects [after]/[before] hints.
566
+ """
567
+ ordered = list(fields)
568
+
569
+ # Reposition fields according to relative hints.
570
+ for fld in fields:
571
+ if not fld.pos_hint:
572
+ continue
573
+
574
+ _, after_name, before_name = self.parse_pos_hint(fld.pos_hint)
575
+ if not after_name and not before_name:
576
+ continue
577
+
578
+ name_to_index = {f.name: idx for idx, f in enumerate(ordered)}
579
+ current_index = name_to_index.get(fld.name)
580
+ if current_index is None:
581
+ continue
582
+
583
+ # Resolve the target index while keeping categories aligned.
584
+ after_index = None
585
+ before_index = None
586
+ if after_name in name_to_index:
587
+ after_fld = ordered[name_to_index[after_name]]
588
+ if after_fld.category == fld.category:
589
+ after_index = name_to_index[after_name]
590
+ if before_name in name_to_index:
591
+ before_fld = ordered[name_to_index[before_name]]
592
+ if before_fld.category == fld.category:
593
+ before_index = name_to_index[before_name]
594
+
595
+ if after_index is not None and before_index is not None:
596
+ if after_index < before_index:
597
+ target_index = after_index + 1
598
+ if target_index > before_index:
599
+ target_index = before_index
600
+ else:
601
+ target_index = before_index
602
+ elif after_index is not None:
603
+ target_index = after_index + 1
604
+ elif before_index is not None:
605
+ target_index = before_index
606
+ else:
607
+ continue
608
+
609
+ if target_index == current_index:
610
+ continue
611
+
612
+ ordered.pop(current_index)
613
+ if target_index > current_index:
614
+ target_index -= 1
615
+ ordered.insert(target_index, fld)
616
+
617
+ return ordered
618
+
619
+ def field_sort_key(self, fld: "ExField") -> str:
620
+ """Get the sort key for a field.
621
+
622
+ The sort key is used to sort the fields in the resource. By default it
623
+ is computed by joining the categories of the resource with the name of
624
+ the field.
625
+
626
+ You may want to reimplement this method in a subclass if you want to
627
+ the fields ranked before the alphabetical sort.
628
+
629
+ Args:
630
+ fld: The field to get the sort key for.
631
+
632
+ Returns:
633
+ The sort key for the field.
634
+ """
635
+ label_fields = set(self.minimum_field_set)
636
+
637
+ category = fld.category or ""
638
+ sort_value = fld.pos_hint
639
+ if sort_value is None:
640
+ if fld.is_one_to_one_type or fld.is_many_to_one_type:
641
+ particle = "T"
642
+ elif fld.is_many_to_many_type or fld.is_one_to_many_type:
643
+ particle = "U"
644
+ elif fld.fk_to or fld.fk_from:
645
+ particle = "V"
646
+ elif fld.primary:
647
+ particle = "X"
648
+ elif fld.name == "deleted":
649
+ particle = "Y"
650
+ elif fld.name in ("created_on", "updated_on"):
651
+ particle = "Z"
652
+ elif fld.name in label_fields:
653
+ particle = "A"
654
+ else:
655
+ particle = "B"
656
+ sort_value = f"{particle}.{fld.name}"
657
+ return f"{category}.{sort_value}".lower()
658
+
659
+ @cached_property
660
+ def sorted_fields(self) -> List["ExField"]:
661
+ """Get a sorted list of fields.
662
+
663
+ You can customize the order of the fields by reimplementing the
664
+ `field_sort_key` method.
665
+ """
666
+ sorted_fields = sorted(
667
+ self.fields,
668
+ key=self.field_sort_key,
669
+ )
670
+
671
+ # Apply higher-precedence relative ordering hints.
672
+ return self.apply_pos_hint_relations(sorted_fields)
673
+
674
+ def fields_by_category(
675
+ self,
676
+ exclude_names: Optional[Set[str]] = None,
677
+ exclude_derived: Optional[bool] = False,
678
+ exclude_ref_fields: Optional[bool] = False,
679
+ exclude_fk_to: Optional[bool] = False,
680
+ exclude_fk_from: Optional[bool] = False,
681
+ exclude_many_to_many: Optional[bool] = False,
682
+ exclude_many_to_one: Optional[bool] = False,
683
+ exclude_one_to_many: Optional[bool] = False,
684
+ exclude_one_to_one: Optional[bool] = False,
685
+ exclude_bridge: Optional[bool] = False,
686
+ exclude_one_to_many_use_rel: Optional[bool] = False,
687
+ ) -> Dict[str, List["ExField"]]:
688
+ """Get a dictionary that maps categories to fields.
689
+
690
+ The keys of the dictionary are the categories and the values are lists
691
+ of sorted fields in that category. The fields are sorted using the
692
+ `field_sort_key()` key.
693
+
694
+ Args:
695
+ exclude_names: The names of the fields to exclude.
696
+ exclude_derived: If True, derived fields will not be included.
697
+ exclude_ref_fields: If True, reference fields will not be included.
698
+ exclude_one_to_many_use_rel: If True, omit non-bridge OneToMany
699
+ fields that have ``use_rel`` set (handled on editor rel tabs).
700
+ """
701
+ categories: Dict[str, List["ExField"]] = {}
702
+
703
+ def is_included(f: "ExField") -> bool:
704
+ if exclude_names and f.name in exclude_names:
705
+ return False
706
+ if exclude_derived and f.is_derived:
707
+ return False
708
+ if exclude_ref_fields and f.is_ref_type:
709
+ return False
710
+ if exclude_fk_to and f.fk_to is not None:
711
+ return False
712
+ if exclude_fk_from and f.fk_from is not None:
713
+ return False
714
+ if exclude_many_to_many and f.is_many_to_many_type:
715
+ return False
716
+ if exclude_many_to_one and f.is_many_to_one_type:
717
+ return False
718
+ if exclude_one_to_many and f.is_one_to_many_type:
719
+ return False
720
+ if exclude_one_to_one and f.is_one_to_one_type:
721
+ return False
722
+ if exclude_bridge and hasattr(f, "bridge") and bool(getattr(f, "bridge")):
723
+ return False
724
+ if exclude_one_to_many_use_rel:
725
+ if (
726
+ f.is_one_to_many_type
727
+ and getattr(f, "use_rel", False)
728
+ and not (hasattr(f, "bridge") and bool(f.bridge))
729
+ ):
730
+ return False
731
+ return True
732
+
733
+ included_fields = [f for f in self.sorted_fields if is_included(f)]
734
+ if len(included_fields) < CATEGORY_SEGREGATION_LIMIT:
735
+ return {
736
+ "general": included_fields,
737
+ }
738
+
739
+ for f in included_fields:
740
+ category = f.category
741
+ lst = categories.get(category, None)
742
+ if lst is None:
743
+ categories[category] = lst = []
744
+ lst.append(f)
745
+ return categories
746
+
747
+ def category_sort_key(self, cat: str) -> str:
748
+ """Get the sort key for a category.
749
+
750
+ The sort key is used to sort the categories in the resource. By
751
+ default it is the category itself.
752
+
753
+ You may want to reimplement this method in a subclass if you want to
754
+ the categories ranked before the alphabetical sort.
755
+
756
+ Args:
757
+ cat: The category to get the sort key for.
758
+
759
+ Returns:
760
+ The sort key for the category.
761
+ """
762
+ if cat == "general":
763
+ return "a-" + cat
764
+ if cat == "management":
765
+ return "z-" + cat
766
+ if cat == "comments":
767
+ return "y-" + cat
768
+ return "p-" + cat
769
+
770
+ def sorted_fields_and_categories(
771
+ self,
772
+ exclude_names: Optional[Set[str]] = None,
773
+ exclude_derived: Optional[bool] = False,
774
+ exclude_ref_fields: Optional[bool] = False,
775
+ exclude_fk_to: Optional[bool] = False,
776
+ exclude_fk_from: Optional[bool] = False,
777
+ exclude_many_to_many: Optional[bool] = False,
778
+ exclude_many_to_one: Optional[bool] = False,
779
+ exclude_one_to_many: Optional[bool] = False,
780
+ exclude_one_to_one: Optional[bool] = False,
781
+ exclude_bridge: Optional[bool] = False,
782
+ exclude_one_to_many_use_rel: Optional[bool] = False,
783
+ ) -> OrderedDict[str, List["ExField"]]:
784
+ """Get a dictionary that maps categories to fields.
785
+
786
+ Both the fields and the categories are sorted:
787
+ - the fields are sorted using the `field_sort_key()` key.
788
+ - the categories are sorted using the `category_sort_key()` function.
789
+
790
+ Args:
791
+ exclude_names: The names of the fields to exclude.
792
+ exclude_derived: If True, derived fields will not be included.
793
+ exclude_ref_fields: If True, reference fields will not be included.
794
+ exclude_one_to_many_use_rel: Passed to ``fields_by_category``.
795
+ """
796
+ categories = self.fields_by_category(
797
+ exclude_names=exclude_names,
798
+ exclude_derived=exclude_derived,
799
+ exclude_ref_fields=exclude_ref_fields,
800
+ exclude_fk_to=exclude_fk_to,
801
+ exclude_fk_from=exclude_fk_from,
802
+ exclude_many_to_many=exclude_many_to_many,
803
+ exclude_many_to_one=exclude_many_to_one,
804
+ exclude_one_to_many=exclude_one_to_many,
805
+ exclude_one_to_one=exclude_one_to_one,
806
+ exclude_bridge=exclude_bridge,
807
+ exclude_one_to_many_use_rel=exclude_one_to_many_use_rel,
808
+ )
809
+ result = OrDict()
810
+ for k in sorted(categories.keys(), key=self.category_sort_key):
811
+ result[k] = categories[k]
812
+ return result
813
+
814
+ def label_to_python(self) -> str:
815
+ """Convert a label to python code."""
816
+ return generate_python_code(self.label_ast)
817
+
818
+ def label_to_typescript(self) -> str:
819
+ """Convert a label to typescript code."""
820
+ return generate_typescript_code(self.label_ast)
821
+
822
+ def get_no_dia_map(self) -> Dict[str, str]:
823
+ """Get a dictionary that maps field names to fields that are used to
824
+ compute the value of the field without diacritics.
825
+ """
826
+ result = {}
827
+ for fld in self.fields:
828
+ if (
829
+ hasattr(fld, "no_dia_field") and fld.no_dia_field is not None # type: ignore
830
+ ):
831
+ result[fld.name] = fld.no_dia_field.name # type: ignore
832
+ return result
833
+
834
+ def iter_many(self, include_bridge: bool = False):
835
+ from exdrf.constants import FIELD_TYPE_REF_MANY_TO_MANY
836
+
837
+ for other_r in self.dataset.resources:
838
+ if other_r is self:
839
+ continue
840
+ for fld in other_r.fields:
841
+ if fld.type_name == FIELD_TYPE_REF_MANY_TO_MANY:
842
+ fld_m = cast("RefManyToManyField", fld)
843
+ if fld_m.ref is self:
844
+ yield other_r, fld_m, fld_m.ref_intermediate
845
+ continue
846
+
847
+ if include_bridge and fld.type_name == FIELD_TYPE_REF_ONE_TO_MANY:
848
+ fld_o = cast("RefOneToManyField", fld)
849
+ if fld_o.bridge is self:
850
+ yield other_r, fld_o, fld_o.ref
851
+ continue
852
+
853
+
854
+ class ResExtraInfo(BaseModel):
855
+ """The layout of the dictionary associated with resources in the model.
856
+
857
+ Attributes:
858
+ label: The string definition of the layer composition function using
859
+ layer_dsl syntax.
860
+ provides: The concepts that the resource provides. This can be used
861
+ to indicate that the control's value has a certain meaning.
862
+ This can be a hint that other resources that depend on this one
863
+ should be updated when the value representing this resource changes.
864
+ depends_on: The concepts that the resource depends on. A change
865
+ in a resource listed here would change the meaning of this
866
+ resource's value.
867
+ """
868
+
869
+ label: Optional[str] = None
870
+ provides: List[str] = Field(default_factory=list)
871
+ depends_on: List[Tuple[str, str]] = Field(default_factory=list)
872
+
873
+ def get_layer_ast(self) -> "ASTNode":
874
+ """Return the layer composition function using layer_dsl syntax."""
875
+ if not self.label:
876
+ return []
877
+ return parse_expr(self.label)
878
+
879
+ @field_validator("provides", mode="before")
880
+ @classmethod
881
+ def parse_provides(cls, v):
882
+ if v is None:
883
+ return []
884
+ if isinstance(v, str):
885
+ return [item.strip() for item in v.split(",") if item.strip()]
886
+ return v
887
+
888
+ @field_validator("depends_on", mode="before")
889
+ @classmethod
890
+ def parse_depends_on(cls, v):
891
+ if v is None:
892
+ return []
893
+ if isinstance(v, str):
894
+ result = []
895
+ for part in v.split(","):
896
+ if not part.strip():
897
+ continue
898
+ concept, target = part.strip().split(":", maxsplit=1)
899
+ result.append((concept.strip(), target.strip()))
900
+ return result
901
+ return v