nadata 0.1.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.
Files changed (86) hide show
  1. nadata/__init__.py +15 -0
  2. nadata/api.py +894 -0
  3. nadata/cli.py +445 -0
  4. nadata/console.py +486 -0
  5. nadata/core/__init__.py +0 -0
  6. nadata/core/database/__init__.py +38 -0
  7. nadata/core/database/builtins.py +87 -0
  8. nadata/core/database/database.py +958 -0
  9. nadata/core/database/dumper.py +285 -0
  10. nadata/core/database/entity_id.py +142 -0
  11. nadata/core/database/instance.py +706 -0
  12. nadata/core/database/network_index.py +1142 -0
  13. nadata/core/database/page_manager.py +175 -0
  14. nadata/core/database/persistence/__init__.py +28 -0
  15. nadata/core/database/persistence/binary_cells.py +1141 -0
  16. nadata/core/database/persistence/compaction.py +97 -0
  17. nadata/core/database/persistence/dumper.py +798 -0
  18. nadata/core/database/persistence/lazy_instances.py +1537 -0
  19. nadata/core/database/persistence/loader.py +424 -0
  20. nadata/core/database/persistence/record_store.py +228 -0
  21. nadata/core/database/persistence/serialization.py +605 -0
  22. nadata/core/database/query_engine.py +222 -0
  23. nadata/core/database/queryset.py +508 -0
  24. nadata/core/database/reconciliation.py +287 -0
  25. nadata/core/database/slotted_page.py +190 -0
  26. nadata/core/database/spatial_index.py +1080 -0
  27. nadata/core/expressions/__init__.py +44 -0
  28. nadata/core/expressions/autograd.py +2909 -0
  29. nadata/core/expressions/builtins.py +4673 -0
  30. nadata/core/expressions/expression.py +2658 -0
  31. nadata/core/expressions/io_builtins.py +972 -0
  32. nadata/core/expressions/scope.py +336 -0
  33. nadata/core/expressions/statement.py +953 -0
  34. nadata/core/struct/__init__.py +14 -0
  35. nadata/core/struct/struct.py +218 -0
  36. nadata/core/struct/struct_attribute.py +100 -0
  37. nadata/core/struct/struct_method.py +324 -0
  38. nadata/core/types/__init__.py +33 -0
  39. nadata/core/types/bits/__init__.py +0 -0
  40. nadata/core/types/bits/bits.py +686 -0
  41. nadata/core/types/home_types/__init__.py +19 -0
  42. nadata/core/types/home_types/color.py +102 -0
  43. nadata/core/types/home_types/coordinate.py +1008 -0
  44. nadata/core/types/home_types/datetime.py +362 -0
  45. nadata/core/types/home_types/geogrid.py +92 -0
  46. nadata/core/types/home_types/geometry.py +1223 -0
  47. nadata/core/types/home_types/graph.py +0 -0
  48. nadata/core/types/home_types/planet.py +359 -0
  49. nadata/core/types/home_types/sea_level.py +98 -0
  50. nadata/core/types/home_types/vec.py +0 -0
  51. nadata/core/types/types.py +1345 -0
  52. nadata/core/values/__init__.py +14 -0
  53. nadata/core/values/values.py +1568 -0
  54. nadata/parser/__init__.py +0 -0
  55. nadata/parser/expressions/__init__.py +6 -0
  56. nadata/parser/expressions/dependencies.py +318 -0
  57. nadata/parser/expressions/expression_parser.py +993 -0
  58. nadata/parser/expressions/splitter.py +98 -0
  59. nadata/parser/expressions/statement_parser.py +1425 -0
  60. nadata/parser/expressions/tokenizer.py +402 -0
  61. nadata/parser/expressions/type_parser.py +187 -0
  62. nadata/parser/nadata_loader/__init__.py +8 -0
  63. nadata/parser/nadata_loader/loader.py +951 -0
  64. nadata/parser/nastruct_parser/__init__.py +0 -0
  65. nadata/parser/nastruct_parser/declaration.py +105 -0
  66. nadata/parser/nastruct_parser/imports.py +164 -0
  67. nadata/parser/nastruct_parser/nastruct_parser.py +209 -0
  68. nadata/parser/nastruct_parser/parse_attributes.py +149 -0
  69. nadata/parser/nastruct_parser/parse_callables.py +436 -0
  70. nadata/parser/nastruct_parser/parse_expression.py +59 -0
  71. nadata/parser/nastruct_parser/parse_structs.py +206 -0
  72. nadata/parser/nastruct_parser/parser_state.py +84 -0
  73. nadata/parser/nastruct_parser/resolver.py +1412 -0
  74. nadata/parser/nastruct_parser/struct_body_state.py +50 -0
  75. nadata/parser/nastruct_parser/tmp_structs.py +156 -0
  76. nadata/parser/utils/__init__.py +20 -0
  77. nadata/parser/utils/brackets.py +252 -0
  78. nadata/parser/utils/comments_remove.py +149 -0
  79. nadata/parser/utils/identifiers.py +121 -0
  80. nadata/session.py +868 -0
  81. nadata-0.1.0.dist-info/METADATA +191 -0
  82. nadata-0.1.0.dist-info/RECORD +86 -0
  83. nadata-0.1.0.dist-info/WHEEL +5 -0
  84. nadata-0.1.0.dist-info/entry_points.txt +2 -0
  85. nadata-0.1.0.dist-info/licenses/LICENSE +21 -0
  86. nadata-0.1.0.dist-info/top_level.txt +1 -0
nadata/api.py ADDED
@@ -0,0 +1,894 @@
1
+ """
2
+ High level API for using Nadata from other programs.
3
+
4
+ Exposes the Nadata facade: parse projects (schemas plus data files), open
5
+ persisted databases, and interact with instances, functions, and queries
6
+ using plain Python values.
7
+ """
8
+
9
+ # Import Modules
10
+ from __future__ import annotations
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any, Optional, cast
15
+
16
+ from nadata.core.database.database import NaDataDatabase
17
+ from nadata.core.database.instance import StructInstance
18
+ from nadata.core.database.persistence import (
19
+ append_database_changes,
20
+ compact_database,
21
+ dump_database,
22
+ load_database,
23
+ )
24
+ from nadata.core.database.persistence.dumper import flush_dirty_instances
25
+ from nadata.session import (
26
+ MANUAL,
27
+ ScriptFile,
28
+ build_prelude,
29
+ folder_is_session,
30
+ load_folder_session,
31
+ parse_script_file,
32
+ )
33
+ from nadata.core.database.queryset import QuerySet
34
+ from nadata.core.expressions.expression import LiteralExpr
35
+ from nadata.core.expressions.scope import ExecutionScope
36
+ from nadata.core.struct.struct import Struct
37
+ from nadata.core.types.home_types.color import Color
38
+ from nadata.core.types.home_types.datetime import DateTime
39
+ from nadata.core.types.types import (
40
+ Float64,
41
+ Int64,
42
+ Type,
43
+ TypeArray,
44
+ TypeBool,
45
+ TypeDict,
46
+ TypeStr,
47
+ )
48
+ from nadata.core.values.values import (
49
+ TypeExternal,
50
+ Value,
51
+ ValueArray,
52
+ ValueDict,
53
+ ValueExternal,
54
+ )
55
+ from nadata.parser.nadata_loader.loader import (
56
+ NaDataLoader,
57
+ collect_polymorphic_instances,
58
+ )
59
+ from nadata.parser.nastruct_parser.imports import resolve_schema_entry
60
+ from nadata.parser.nastruct_parser.nastruct_parser import NaStructParser
61
+
62
+
63
+ def _wrap_argument(value: Any) -> Value:
64
+ """
65
+ Wraps a plain Python value into a typed runtime Value.
66
+
67
+ Supports existing Values, struct instances, native scalars, and lists /
68
+ dicts of those (element and key types inferred from the first item).
69
+
70
+ Args:
71
+ value (Any): Plain Python value to wrap.
72
+
73
+ Returns:
74
+ Value: Typed runtime Value.
75
+
76
+ Raises:
77
+ TypeError: If the value type is not supported.
78
+ """
79
+
80
+ if isinstance(value, Value):
81
+ return value
82
+
83
+ if isinstance(value, StructInstance):
84
+ return ValueExternal(payload=value)
85
+
86
+ if isinstance(value, (bool, int, float, str, DateTime, Color)):
87
+ return LiteralExpr(value=value).value
88
+
89
+ if isinstance(value, list):
90
+ wrapped_elements: list[Value] = [_wrap_argument(value=element) for element in value]
91
+
92
+ element_type: Any = (
93
+ wrapped_elements[0].value_type if wrapped_elements else TypeExternal()
94
+ )
95
+
96
+ array_type: TypeArray = TypeArray(value_type=cast(Type, element_type))
97
+
98
+ return ValueArray(
99
+ value_type=array_type,
100
+ elements=[wrapped.get() for wrapped in wrapped_elements],
101
+ )
102
+
103
+ if isinstance(value, dict):
104
+ wrapped_entries: dict[Any, Value] = {
105
+ key: _wrap_argument(value=entry) for key, entry in value.items()
106
+ }
107
+
108
+ first_entry: Optional[Value] = (
109
+ next(iter(wrapped_entries.values())) if wrapped_entries else None
110
+ )
111
+
112
+ first_key: Any = next(iter(wrapped_entries)) if wrapped_entries else None
113
+
114
+ entry_type: Any = first_entry.value_type if first_entry is not None else TypeExternal()
115
+
116
+ dict_type: TypeDict = TypeDict(
117
+ value_type=cast(Type, entry_type),
118
+ key_type=cast(Type, _infer_key_type(first_key=first_key)),
119
+ )
120
+
121
+ return ValueDict(
122
+ value_type=dict_type,
123
+ entries={key: wrapped.get() for key, wrapped in wrapped_entries.items()},
124
+ )
125
+
126
+ raise TypeError(f"Cannot wrap argument of type '{type(value).__name__}'")
127
+
128
+
129
+ def _infer_key_type(first_key: Any) -> Any:
130
+ """
131
+ Infers a schema key type from the first dict entry key.
132
+
133
+ Args:
134
+ first_key (Any): First key of the dict argument.
135
+
136
+ Returns:
137
+ Any: Inferred key schema type.
138
+ """
139
+
140
+ if isinstance(first_key, bool):
141
+ return TypeBool()
142
+
143
+ if isinstance(first_key, int):
144
+ return Int64
145
+
146
+ if isinstance(first_key, float):
147
+ return Float64
148
+
149
+ if isinstance(first_key, str):
150
+ return TypeStr()
151
+
152
+ return TypeExternal()
153
+
154
+
155
+ def _unwrap_value(val: Any) -> Any:
156
+ """
157
+ Unwraps a runtime Value into its raw Python payload.
158
+
159
+ Args:
160
+ val (Any): Value or raw payload to unwrap.
161
+
162
+ Returns:
163
+ Any: Raw underlying payload.
164
+ """
165
+
166
+ return val.get() if isinstance(val, Value) else val
167
+
168
+
169
+ class Nadata:
170
+ """
171
+ High level facade over a Nadata database.
172
+
173
+ Loads projects (schema folders, single schema files, or persisted
174
+ database files) and exposes instance access, function and method calls,
175
+ polymorphic queries, and persistence using plain Python values.
176
+
177
+ Attributes:
178
+ database (NaDataDatabase): Underlying database engine.
179
+ _globals (Optional[dict[str, Any]]): Lazy runtime globals.
180
+ _scope (Optional[ExecutionScope]): Lazy execution scope.
181
+ """
182
+
183
+ def __init__(self, database: NaDataDatabase) -> None:
184
+ """
185
+ Initializes the Nadata facade over an existing database.
186
+
187
+ Args:
188
+ database (NaDataDatabase): Database to expose.
189
+ """
190
+
191
+ self.database: NaDataDatabase = database
192
+ self._globals: Optional[dict[str, Any]] = None
193
+ self._scope: Optional[ExecutionScope] = None
194
+ self.session_folder: Optional[str] = None
195
+ self.session_db: Optional[str] = None
196
+
197
+ # --- Construction ---
198
+
199
+ @classmethod
200
+ def load(cls, path: str) -> "Nadata":
201
+ """
202
+ Loads a Nadata source: a project folder, a schema file, or a
203
+ persisted database file.
204
+
205
+ Args:
206
+ path (str): Path to load:
207
+ - directory: parses every .nastruct (schemas) and .nadata
208
+ (data) file found recursively;
209
+ - .nastruct file: parses a single schema file;
210
+ - .ndb file: opens a persisted database (same as open);
211
+ - .nadata file: not supported (data requires its schema).
212
+
213
+ Returns:
214
+ Nadata: The loaded facade.
215
+
216
+ Raises:
217
+ ValueError: If the path type is unsupported.
218
+ """
219
+
220
+ if os.path.isdir(path):
221
+ return cls._load_project(folder_path=path)
222
+
223
+ if path.endswith(".ndb"):
224
+ return cls.open(storage_path=path)
225
+
226
+ if path.endswith(".nastruct"):
227
+ return cls._load_schema_file(file_path=path)
228
+
229
+ if path.endswith(".nadata"):
230
+ raise ValueError(
231
+ f"Cannot load data file '{path}' alone: load the project "
232
+ f"folder containing its schema instead"
233
+ )
234
+
235
+ raise ValueError(f"Unsupported Nadata path '{path}'")
236
+
237
+ @classmethod
238
+ def open(cls, storage_path: str) -> "Nadata":
239
+ """
240
+ Opens a persisted database file previously written by dump.
241
+
242
+ Args:
243
+ storage_path (str): Path to the .ndb database file.
244
+
245
+ Returns:
246
+ Nadata: The reopened facade.
247
+ """
248
+
249
+ return cls(database=load_database(storage_path=storage_path))
250
+
251
+ @classmethod
252
+ def _load_project(cls, folder_path: str) -> "Nadata":
253
+ """
254
+ Parses a project folder: schemas first, then data files.
255
+
256
+ Folders with directive-marked scripts (#init/#auto/#manual) or an
257
+ existing nadata.ndb world use session semantics instead: the
258
+ world opens incrementally, #init runs once, #auto runs, and
259
+ #manual scripts are left for explicit invocation.
260
+
261
+ Args:
262
+ folder_path (str): Folder containing .nastruct and .nadata files.
263
+
264
+ Returns:
265
+ Nadata: The loaded facade.
266
+ """
267
+
268
+ if folder_is_session(folder=Path(folder_path)):
269
+ return cls.open_folder(folder_path=folder_path)
270
+
271
+ parser: NaStructParser = NaStructParser()
272
+ database: NaDataDatabase = parser.parse_folder(folder_path=folder_path)
273
+
274
+ if any(Path(folder_path).rglob("*.nadata")):
275
+ loader: NaDataLoader = NaDataLoader(database=database)
276
+ loader.load_folder(folder_path=folder_path)
277
+
278
+ return cls(database=database)
279
+
280
+ @classmethod
281
+ def open_folder(
282
+ cls,
283
+ folder_path: str,
284
+ db_path: Optional[str] = None,
285
+ run_auto: bool = True,
286
+ force_init: bool = False,
287
+ ) -> "Nadata":
288
+ """
289
+ Runs the folder session pipeline and binds the session context.
290
+
291
+ Args:
292
+ folder_path (str): Project folder.
293
+ db_path (Optional[str], optional): World database file
294
+ (defaults to '<folder>/nadata.ndb'). Defaults to None.
295
+ run_auto (bool, optional): Whether to run #auto scripts.
296
+ Defaults to True.
297
+ force_init (bool, optional): Re-run #init scripts. Defaults
298
+ to False.
299
+
300
+ Returns:
301
+ Nadata: The facade over the world database, with the session
302
+ folder and db recorded for console commands.
303
+ """
304
+
305
+ database: NaDataDatabase
306
+ result: Any
307
+
308
+ database, result = load_folder_session(
309
+ folder_path=folder_path,
310
+ db_path=db_path,
311
+ run_auto=run_auto,
312
+ force_init=force_init,
313
+ )
314
+
315
+ app: Nadata = cls(database=database)
316
+
317
+ app.session_folder = str(Path(folder_path))
318
+ app.session_db = result.db_path
319
+
320
+ return app
321
+
322
+ @classmethod
323
+ def _load_schema_file(cls, file_path: str) -> "Nadata":
324
+ """
325
+ Parses a single .nastruct schema file.
326
+
327
+ Args:
328
+ file_path (str): Path to the schema file.
329
+
330
+ Returns:
331
+ Nadata: The loaded facade (no instances).
332
+ """
333
+
334
+ parser: NaStructParser = NaStructParser()
335
+
336
+ # Imports resolve before parsing: dependency order, deduped
337
+ for _path, content in resolve_schema_entry(entry=Path(file_path)):
338
+ parser.parse_file(content=content)
339
+
340
+ return cls(database=parser.resolve())
341
+
342
+ # --- Introspection ---
343
+
344
+ @property
345
+ def struct_names(self) -> list[str]:
346
+ """
347
+ Returns the registered struct names.
348
+
349
+ Returns:
350
+ list[str]: Struct names in registration order.
351
+ """
352
+
353
+ return list(self.database.tables_by_struct_name)
354
+
355
+ @property
356
+ def function_names(self) -> list[str]:
357
+ """
358
+ Returns the registered standalone function names.
359
+
360
+ Returns:
361
+ list[str]: Function names.
362
+ """
363
+
364
+ return list(self.database.functions)
365
+
366
+ @property
367
+ def symbol_names(self) -> list[str]:
368
+ """
369
+ Returns the bound instance symbol names.
370
+
371
+ Returns:
372
+ list[str]: Symbol names.
373
+ """
374
+
375
+ return list(self.database.symbols)
376
+
377
+ def summary(self) -> str:
378
+ """
379
+ Renders a human readable summary of the database contents.
380
+
381
+ Returns:
382
+ str: Multi line summary.
383
+ """
384
+
385
+ lines: list[str] = [f"Structs ({len(self.struct_names)}):"]
386
+
387
+ for struct_name in self.struct_names:
388
+ table = self.database.tables_by_struct_name[struct_name]
389
+ struct: Struct = table.struct
390
+
391
+ parent_note: str = (
392
+ f", extends {struct.parent.name}" if struct.parent is not None else ""
393
+ )
394
+
395
+ lines.append(
396
+ f" {struct_name}{parent_note}: "
397
+ f"{len(struct.get_all_attributes())} attributes, "
398
+ f"{len(struct.methods)} methods, "
399
+ f"{len(table.instances)} instances"
400
+ )
401
+
402
+ functions_note: str = (
403
+ ", ".join(sorted(self.function_names)) if self.function_names else "(none)"
404
+ )
405
+ lines.append(f"Functions ({len(self.function_names)}): {functions_note}")
406
+
407
+ symbols_note: str = (
408
+ ", ".join(sorted(self.symbol_names)) if self.symbol_names else "(none)"
409
+ )
410
+ lines.append(f"Symbols ({len(self.symbol_names)}): {symbols_note}")
411
+
412
+ return "\n".join(lines)
413
+
414
+ def total_instance_count(self) -> int:
415
+ """
416
+ Counts every stored instance across all tables.
417
+
418
+ Returns:
419
+ int: Total instance count.
420
+ """
421
+
422
+ return sum(
423
+ len(table.instances)
424
+ for table in self.database.tables_by_struct_name.values()
425
+ )
426
+
427
+ # --- Instance access ---
428
+
429
+ def get(self, symbol: str) -> StructInstance:
430
+ """
431
+ Returns the instance bound to a symbol name.
432
+
433
+ Args:
434
+ symbol (str): Symbol name (e.g. "tom").
435
+
436
+ Returns:
437
+ StructInstance: The bound instance.
438
+
439
+ Raises:
440
+ KeyError: If the symbol is unknown.
441
+ """
442
+
443
+ instance: Optional[StructInstance] = self.database.get_by_symbol(name=symbol)
444
+
445
+ if instance is None:
446
+ raise KeyError(f"Unknown symbol '{symbol}'")
447
+
448
+ return instance
449
+
450
+ def get_attribute(self, symbol: str, name: str) -> Any:
451
+ """
452
+ Returns an instance attribute as a plain Python value.
453
+
454
+ Args:
455
+ symbol (str): Symbol name of the instance.
456
+ name (str): Attribute name.
457
+
458
+ Returns:
459
+ Any: Raw attribute payload.
460
+ """
461
+
462
+ return _unwrap_value(self.get(symbol=symbol).get_attribute(name=name))
463
+
464
+ def set_attribute(self, symbol: str, name: str, value: Any) -> None:
465
+ """
466
+ Sets an instance attribute from a plain Python value.
467
+
468
+ Args:
469
+ symbol (str): Symbol name of the instance.
470
+ name (str): Attribute name.
471
+ value (Any): Plain Python value (validated against the schema).
472
+ """
473
+
474
+ self.get(symbol=symbol).set_attribute(name=name, value=_wrap_argument(value=value))
475
+
476
+ def create_instance(
477
+ self,
478
+ struct_name: str,
479
+ attributes: Optional[dict[str, Any]] = None,
480
+ symbol: Optional[str] = None
481
+ ) -> StructInstance:
482
+ """
483
+ Creates a new instance of a struct with validated plain Python values.
484
+
485
+ Args:
486
+ struct_name (str): Name of the struct to instantiate.
487
+ attributes (Optional[dict[str, Any]], optional): Attribute values.
488
+ Defaults to None.
489
+ symbol (Optional[str], optional): Symbol to bind. Defaults to None.
490
+
491
+ Returns:
492
+ StructInstance: The created instance.
493
+
494
+ Raises:
495
+ KeyError: If the struct name is unknown.
496
+ """
497
+
498
+ struct: Struct = self._struct_or_raise(struct_name=struct_name)
499
+
500
+ instance: StructInstance = self.database.create_instance(
501
+ struct=struct,
502
+ attributes=None,
503
+ symbol=symbol
504
+ )
505
+
506
+ if attributes:
507
+ wrapped: dict[str, Value] = {
508
+ name: _wrap_argument(value=value) for name, value in attributes.items()
509
+ }
510
+
511
+ instance.set_creation_attributes(values=wrapped)
512
+
513
+ return instance
514
+
515
+ # --- Calls and queries ---
516
+
517
+ def call_function(self, name: str, *args: Any, **kwargs: Any) -> Any:
518
+ """
519
+ Calls a standalone schema function with plain Python arguments.
520
+
521
+ Args:
522
+ name (str): Function name.
523
+ *args (Any): Positional arguments.
524
+ **kwargs (Any): Keyword arguments.
525
+
526
+ Returns:
527
+ Any: Raw function result.
528
+
529
+ Raises:
530
+ KeyError: If the function name is unknown.
531
+ """
532
+
533
+ self._ensure_globals()
534
+
535
+ if name not in self.database.functions:
536
+ raise KeyError(f"Unknown function '{name}'")
537
+
538
+ function = self.database.functions[name]
539
+
540
+ result: Any = function.execute(
541
+ self_instance=None,
542
+ args=[_wrap_argument(value=arg) for arg in args],
543
+ kwargs={key: _wrap_argument(value=value) for key, value in kwargs.items()},
544
+ globals_dict=self._globals,
545
+ )
546
+
547
+ return _unwrap_value(result)
548
+
549
+ def call_method(self, symbol: str, name: str, *args: Any, **kwargs: Any) -> Any:
550
+ """
551
+ Calls a method on a symbol bound instance with plain Python arguments.
552
+
553
+ Args:
554
+ symbol (str): Symbol name of the receiver instance.
555
+ name (str): Method name.
556
+ *args (Any): Positional arguments.
557
+ **kwargs (Any): Keyword arguments.
558
+
559
+ Returns:
560
+ Any: Raw method result.
561
+
562
+ Raises:
563
+ KeyError: If the symbol or method name is unknown.
564
+ """
565
+
566
+ self._ensure_globals()
567
+
568
+ instance: StructInstance = self.get(symbol=symbol)
569
+ method = instance.struct.get_method(name=name)
570
+
571
+ if method is None:
572
+ raise KeyError(f"Struct '{instance.struct.name}' has no method '{name}'")
573
+
574
+ result: Any = method.execute(
575
+ self_instance=instance,
576
+ args=[_wrap_argument(value=arg) for arg in args],
577
+ kwargs={key: _wrap_argument(value=value) for key, value in kwargs.items()},
578
+ globals_dict=self._globals,
579
+ )
580
+
581
+ return _unwrap_value(result)
582
+
583
+ def query(self, struct_name: str) -> QuerySet:
584
+ """
585
+ Returns a query set over every instance of a struct, including all
586
+ subclass instances (polymorphic).
587
+
588
+ Args:
589
+ struct_name (str): Name of the struct to query.
590
+
591
+ Returns:
592
+ QuerySet: Query set supporting chained where() / count() / ...
593
+
594
+ Raises:
595
+ KeyError: If the struct name is unknown.
596
+ """
597
+
598
+ self._ensure_globals()
599
+
600
+ struct: Struct = self._struct_or_raise(struct_name=struct_name)
601
+
602
+ return QuerySet(
603
+ struct=struct,
604
+ instances=collect_polymorphic_instances(
605
+ struct=struct,
606
+ database=self.database
607
+ ),
608
+ database=self.database,
609
+ query_executor=self.database.query_executor
610
+ )
611
+
612
+ def count(self, struct_name: str) -> int:
613
+ """
614
+ Counts every instance of a struct, including subclass instances.
615
+
616
+ Args:
617
+ struct_name (str): Name of the struct to count.
618
+
619
+ Returns:
620
+ int: Polymorphic instance count.
621
+ """
622
+
623
+ return len(self.query(struct_name=struct_name).to_list())
624
+
625
+ # --- Persistence ---
626
+
627
+ def dump(self, storage_path: str) -> None:
628
+ """
629
+ Persists the database to a slotted page file.
630
+
631
+ Args:
632
+ storage_path (str): Target .ndb file path (must not exist).
633
+ """
634
+
635
+ dump_database(database=self.database, storage_path=storage_path)
636
+
637
+ def save_changes(self, storage_path: str) -> int:
638
+ """
639
+ Appends only new or changed data to an existing database file.
640
+
641
+ The current state is diffed against the file contents: new structs,
642
+ new or changed instances, and new symbol bindings are appended as a
643
+ delta that the loader replays over the earlier snapshot. Calling
644
+ this repeatedly on the same file is safe; unchanged data produces
645
+ no records.
646
+
647
+ Args:
648
+ storage_path (str): Path to the existing .ndb file to extend.
649
+
650
+ Returns:
651
+ int: Number of delta documents appended.
652
+
653
+ Raises:
654
+ ValueError: If the file does not exist or is not a Nadata dump.
655
+ """
656
+
657
+ written: int = append_database_changes(
658
+ database=self.database,
659
+ storage_path=storage_path,
660
+ )
661
+
662
+ # The appended delta may have introduced new symbols or structs
663
+ self._globals = None
664
+
665
+ return written
666
+
667
+ def set_loop_limit(self, limit: int) -> None:
668
+ """
669
+ Sets the maximum number of iterations allowed per loop.
670
+
671
+ The limit is recorded in the database settings, so the next dump or
672
+ incremental save persists it, and future executions read it from
673
+ there. The default fuel limit is 10,000 iterations per loop; loops
674
+ exceeding it raise LoopLimitError. The new limit applies to
675
+ subsequent executions (call sites rebuild their scope lazily).
676
+
677
+ Args:
678
+ limit (int): New per-loop iteration limit (at least 1).
679
+
680
+ Raises:
681
+ TypeError: If the limit is not an integer.
682
+ ValueError: If the limit is below 1.
683
+ """
684
+
685
+ if isinstance(limit, bool) or not isinstance(limit, int):
686
+ raise TypeError(f"Loop limit must be an integer, got '{type(limit).__name__}'")
687
+
688
+ if limit < 1:
689
+ raise ValueError(f"Loop limit must be at least 1, got {limit}")
690
+
691
+ self.database.settings["loop_limit"] = limit
692
+
693
+ # Rebuild globals on next use: the fuel object is seeded from settings
694
+ self._globals = None
695
+
696
+ def compact(self, output_path: Optional[str] = None) -> tuple[int, int]:
697
+ """
698
+ Rewrites a database file into a minimal snapshot of the current state.
699
+
700
+ Incremental saves append deltas that accumulate as replay history;
701
+ compaction rewrites the file so it holds only live documents. The
702
+ output is written to a temporary file and atomically renamed, so a
703
+ crash mid-compaction never damages the source.
704
+
705
+ When compacting in place over the file this facade was opened from,
706
+ the database is reloaded afterwards so subsequent operations see the
707
+ fresh file. When compacting to a different path, the facade keeps
708
+ working on its current state.
709
+
710
+ Args:
711
+ output_path (Optional[str], optional): Where to write the
712
+ compacted file. Defaults to None (compact in place using
713
+ the file this facade was opened from).
714
+
715
+ Returns:
716
+ tuple[int, int]: Document counts (before, after).
717
+
718
+ Raises:
719
+ ValueError: If no target file is known (compact a project by
720
+ dumping it first), or the source is missing or invalid.
721
+ """
722
+
723
+ default_target: Optional[str] = self.database.page_manager.storage_path
724
+ target: str = output_path if output_path is not None else (default_target or "")
725
+
726
+ if not target:
727
+ raise ValueError(
728
+ "No database file to compact: this facade was loaded from a "
729
+ "project (pass output_path, or dump it first)"
730
+ )
731
+
732
+ in_place: bool = output_path is None or output_path == default_target
733
+
734
+ before: int
735
+ after: int
736
+ before, after = compact_database(
737
+ storage_path=target,
738
+ output_path=output_path,
739
+ )
740
+
741
+ if in_place and default_target is not None:
742
+ # Reload so cached pages of the pre-compaction file are dropped
743
+ self.database = load_database(storage_path=default_target)
744
+ self._globals = None
745
+
746
+ return before, after
747
+
748
+ # --- Code execution ---
749
+
750
+ def execute_schema(self, schema_text: str) -> None:
751
+ """
752
+ Parses and resolves .nastruct schema source into the database.
753
+
754
+ New structs and functions become available immediately; re-declaring
755
+ an attribute that already exists raises a duplicate error.
756
+
757
+ Args:
758
+ schema_text (str): Raw .nastruct source text.
759
+ """
760
+
761
+ parser: NaStructParser = NaStructParser(database=self.database)
762
+
763
+ parser.parse_file(content=schema_text)
764
+ parser.resolve()
765
+
766
+ # New structs and functions must appear in the runtime globals
767
+ self._globals = None
768
+
769
+ def execute_data(self, data_text: str) -> None:
770
+ """
771
+ Parses and executes .nadata source (instances and scripts).
772
+
773
+ Instance declarations are created and bound, and script statements
774
+ (function calls, queries, prints) execute immediately.
775
+
776
+ Args:
777
+ data_text (str): Raw .nadata source text.
778
+ """
779
+
780
+ loader: NaDataLoader = NaDataLoader(database=self.database)
781
+
782
+ loader.load_file(content=data_text)
783
+
784
+ # New instances and symbols must appear in the runtime globals
785
+ self._globals = None
786
+
787
+ def execute_script_file(
788
+ self,
789
+ file_path: str,
790
+ params: Optional[dict[str, Any]] = None,
791
+ ) -> None:
792
+ """
793
+ Runs one #manual script file with its parameter prelude.
794
+
795
+ Args:
796
+ file_path (str): Script path (resolved against the session
797
+ folder when the app was opened as a folder session).
798
+ params (Optional[dict[str, Any]], optional): Parameter values
799
+ (strings or natives). Defaults to None.
800
+
801
+ Raises:
802
+ ValueError: If the file is missing, not #manual, or the
803
+ parameters mismatch the declared signature.
804
+ """
805
+
806
+ resolved: Path = Path(file_path)
807
+
808
+ if not resolved.is_file() and self.session_folder is not None:
809
+ resolved = Path(self.session_folder) / file_path
810
+
811
+ if not resolved.is_file():
812
+ raise ValueError(f"Script file not found: '{file_path}'")
813
+
814
+ folder: Path = (
815
+ Path(self.session_folder)
816
+ if self.session_folder is not None
817
+ else resolved.parent
818
+ )
819
+
820
+ script: ScriptFile = parse_script_file(path=resolved, folder=folder)
821
+
822
+ if script.kind != MANUAL:
823
+ raise ValueError(
824
+ f"'{script.rel_path}' is #{script.kind}, not #manual: "
825
+ f"only #manual scripts run explicitly"
826
+ )
827
+
828
+ loader: NaDataLoader = NaDataLoader(database=self.database)
829
+
830
+ loader.prelude = build_prelude(
831
+ params=script.params,
832
+ provided=params or {},
833
+ )
834
+
835
+ try:
836
+ loader.load_file(content=script.text)
837
+
838
+ finally:
839
+ loader.prelude = {}
840
+
841
+ # New instances and symbols must appear in the runtime globals
842
+ self._globals = None
843
+
844
+ def execute(self, text: str) -> None:
845
+ """
846
+ Executes Nadata source, auto detecting schema vs data content.
847
+
848
+ Text containing top level 'class' or 'fn' declarations is treated as
849
+ .nastruct schema source, everything else as .nadata data source.
850
+
851
+ Args:
852
+ text (str): Raw Nadata source text.
853
+ """
854
+
855
+ if re.search(r"(?:^|\n)\s*(?:class|fn)\s", text):
856
+ self.execute_schema(schema_text=text)
857
+
858
+ else:
859
+ self.execute_data(data_text=text)
860
+
861
+ # --- Internals ---
862
+
863
+ def _struct_or_raise(self, struct_name: str) -> Struct:
864
+ """
865
+ Looks up a struct by name.
866
+
867
+ Args:
868
+ struct_name (str): Struct name to look up.
869
+
870
+ Returns:
871
+ Struct: The registered struct.
872
+
873
+ Raises:
874
+ KeyError: If no struct is registered under the name.
875
+ """
876
+
877
+ table = self.database.tables_by_struct_name.get(struct_name)
878
+
879
+ if table is None:
880
+ raise KeyError(f"Unknown struct '{struct_name}'")
881
+
882
+ return table.struct
883
+
884
+ def _ensure_globals(self) -> None:
885
+ """
886
+ Builds the runtime globals and scope on first use.
887
+
888
+ Built lazily so that instances created after construction (data
889
+ loading, create_instance) are visible to calls and queries.
890
+ """
891
+
892
+ if self._globals is None:
893
+ self._globals = NaDataLoader.build_runtime_globals(database=self.database)
894
+ self._scope = ExecutionScope(globals_dict=self._globals)