dataface 0.3.2.dev111__py3-none-any.whl → 0.3.2.dev150__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 (73) hide show
  1. dataface/DATAFACE_SYNTAX.md +15 -4
  2. dataface/agent_api/docs/yaml-reference.md +33 -133
  3. dataface/agent_api/serve.py +0 -2
  4. dataface/agent_api/validate.py +8 -0
  5. dataface/cli/commands/render.py +6 -0
  6. dataface/cli/commands/serve.py +0 -6
  7. dataface/cli/main.py +20 -14
  8. dataface/core/aliases.py +27 -0
  9. dataface/core/compile/__init__.py +0 -8
  10. dataface/core/compile/channel.py +0 -13
  11. dataface/core/compile/compiler.py +73 -71
  12. dataface/core/compile/introspection.py +2 -13
  13. dataface/core/compile/models/chart/authored/_base.py +1 -1
  14. dataface/core/compile/models/chart/normalized/__init__.py +2 -0
  15. dataface/core/compile/models/chart/normalized/_base.py +26 -0
  16. dataface/core/compile/models/config.py +5 -0
  17. dataface/core/compile/models/face/authored.py +24 -89
  18. dataface/core/compile/models/query/authored.py +1 -119
  19. dataface/core/compile/models/refs.py +0 -2
  20. dataface/core/compile/models/source.py +73 -159
  21. dataface/core/compile/models/style/theme/variables.py +0 -3
  22. dataface/core/compile/normalize_charts.py +4 -152
  23. dataface/core/compile/normalize_layout.py +20 -284
  24. dataface/core/compile/normalize_queries.py +3 -506
  25. dataface/core/compile/normalizer.py +4 -10
  26. dataface/core/compile/palette.py +19 -7
  27. dataface/core/compile/validator.py +121 -44
  28. dataface/core/compile/yaml_error_formatter.py +107 -2
  29. dataface/core/dashboard.py +3 -1
  30. dataface/core/defaults/default_config.yml +4 -0
  31. dataface/core/defaults/themes/_base.yaml +0 -1
  32. dataface/core/defaults/themes/editorial.yaml +3 -0
  33. dataface/core/defaults/themes/stark.yaml +8 -0
  34. dataface/core/diagnostics/__init__.py +10 -0
  35. dataface/core/diagnostics/codes_compile.py +4 -2
  36. dataface/core/diagnostics/codes_execute.py +70 -0
  37. dataface/core/diagnostics/codes_render.py +58 -4
  38. dataface/core/execute/adapters/__init__.py +1 -1
  39. dataface/core/execute/cache_keys.py +4 -9
  40. dataface/core/execute/executor.py +25 -3
  41. dataface/core/execute/file_source_materializer.py +185 -32
  42. dataface/core/execute/trivial_local_cache.py +5 -2
  43. dataface/core/inspect/__init__.py +0 -1
  44. dataface/core/project.py +6 -2
  45. dataface/core/render/chart/mark_extents.py +339 -0
  46. dataface/core/render/chart/rendering.py +97 -34
  47. dataface/core/render/conditions.py +122 -0
  48. dataface/core/render/controls.py +60 -187
  49. dataface/core/render/data_format.py +1 -1
  50. dataface/core/render/face_resolve.py +7 -0
  51. dataface/core/render/faces.py +94 -30
  52. dataface/core/render/layout_sizing.py +46 -0
  53. dataface/core/render/renderer.py +14 -1
  54. dataface/core/render/sizing.py +42 -105
  55. dataface/core/render/svg_utils.py +60 -14
  56. dataface/core/render/terminal.py +1 -1
  57. dataface/core/render/variables_layout.py +211 -0
  58. dataface/core/render/variables_resolve.py +376 -0
  59. dataface/core/render/variables_strip.py +291 -368
  60. dataface/core/render/warnings/base.py +18 -0
  61. dataface/core/render/warnings/layout_min_exceeds_height.py +68 -0
  62. dataface/core/render/warnings/registry.py +2 -0
  63. dataface/core/serve/alias_index.py +16 -22
  64. dataface/data/schemas/board-resolved/board-resolved.schema.json +1 -499
  65. {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/METADATA +1 -3
  66. {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/RECORD +69 -67
  67. dataface/core/inspect/partials/categorical.yml +0 -38
  68. dataface/core/inspect/partials/date.yml +0 -38
  69. dataface/core/inspect/partials/numeric.yml +0 -53
  70. dataface/core/inspect/templates/charts.yml +0 -94
  71. {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/WHEEL +0 -0
  72. {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/entry_points.txt +0 -0
  73. {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/licenses/LICENSE +0 -0
@@ -3,9 +3,10 @@
3
3
  Error codes: KPI multirow, bar duplicate rows, map key mismatch, format
4
4
  unsupported, no layout, input invalid, Vega-Lite unsupported type, histogram
5
5
  non-numeric, histogram pre-aggregated, label/ticks validation, percent range,
6
- emitter not found, labels field not found, scale domain, concat overshoot.
6
+ emitter not found, labels field not found, scale domain, concat overshoot,
7
+ chart painted no marks.
7
8
 
8
- Warning codes: the 14 render-time detector codes, declared here (not in the
9
+ Warning codes: every render-time detector code, declared here (not in the
9
10
  detector modules, which sit above this leaf) so the registry is complete on
10
11
  import of core.diagnostics. Detectors import their constants back from this
11
12
  package.
@@ -162,7 +163,7 @@ ERR_DUPLICATE_CHART_ID = REGISTRY.register(
162
163
  ),
163
164
  doc=(
164
165
  "Chart ids are unique within a face but not across a face tree: two "
165
- "imported partials, or several foreach iterations of one partial, can "
166
+ "imported partials, including the same partial imported twice, can "
166
167
  "declare the same id. The flat output formats key charts by id, so a "
167
168
  "collision would silently drop every chart but the last. Rename the "
168
169
  "colliding chart, or render with a format that preserves the layout."
@@ -530,9 +531,31 @@ ERR_BOARD_RECORDING_MISMATCH = REGISTRY.register(
530
531
  )
531
532
  )
532
533
 
534
+ ERR_CHART_PAINTED_NO_MARKS = REGISTRY.register(
535
+ ErrorCode(
536
+ code="ERR-CHART-PAINTED-NO-MARKS",
537
+ domain="render",
538
+ title="Chart received rows but painted no marks",
539
+ message_template=(
540
+ "Chart {chart_id!r} received {row_count} row(s) but every mark it drew "
541
+ "has zero width or height — check whether the x/y fields and scale "
542
+ "types match the data's actual shape."
543
+ ),
544
+ doc=(
545
+ "Fired when a plotting-family chart's query returns at least one row "
546
+ "but the rendered SVG contains no mark with visible extent — every "
547
+ "bar, line, area, point, wedge, or shape it drew is degenerate. "
548
+ "`WARN-QUERY-RETURNED-ZERO-ROWS` covers the honest empty case (no "
549
+ "rows); this covers the dishonest one — rows arrived, the renderer "
550
+ "just didn't paint anything visible with them."
551
+ ),
552
+ docs_topic="charts",
553
+ )
554
+ )
555
+
533
556
 
534
557
  # ──────────────────────────────── Warning codes ───────────────────────────────
535
- # All 14 render-time warning codes declared here in the leaf; detectors
558
+ # Every render-time warning code is declared here in the leaf; detectors
536
559
  # import their constants back rather than declaring codes in-module.
537
560
 
538
561
  WARN_BAR_BAND_WIDTH_TOO_NARROW = REGISTRY.register(
@@ -586,6 +609,37 @@ WARN_LAYERED_CHART_SHARED_Y_AXIS_SCALE_MISMATCH = REGISTRY.register(
586
609
  )
587
610
  )
588
611
 
612
+ WARN_LAYOUT_MIN_EXCEEDS_HEIGHT = REGISTRY.register(
613
+ WarningCode(
614
+ code="WARN-LAYOUT-MIN-EXCEEDS-HEIGHT",
615
+ domain="render",
616
+ title="Chart's category count needs more height than its row allows",
617
+ message_template=(
618
+ "Chart {chart_id!r} has {n_categories} category bands, which need "
619
+ "at least {min_height:.0f}px to stay readable, but its row/tile "
620
+ "height caps it at {authored_height:.0f}px. The chart rendered at "
621
+ "{min_height:.0f}px anyway — the row's authored height was not honored, "
622
+ "and any sibling charts sharing the row grew to match."
623
+ ),
624
+ fix_template=(
625
+ "Raise the row's height to fit the category count, or reduce the "
626
+ "categories (filter, paginate, or roll up to a coarser grain) so "
627
+ "the readable minimum fits inside the authored height."
628
+ ),
629
+ doc=(
630
+ "Fires when a horizontal bar chart's category count forces a "
631
+ "minimum height — one readable band per category — that exceeds "
632
+ "the row/tile height its author assigned. The engine expands the "
633
+ "chart to the computed minimum regardless (squashing labels past "
634
+ "legibility to honor an impossible authored height would be worse), "
635
+ "so the row's authored height silently loses; any sibling chart in "
636
+ "the same row inherits the expansion. This warns so the author "
637
+ "learns why the row grew, instead of measuring it by hand."
638
+ ),
639
+ docs_topic="charts",
640
+ )
641
+ )
642
+
589
643
  WARN_LIKELY_CURRENCY_OR_PERCENT_MISSING_FORMATTER = REGISTRY.register(
590
644
  WarningCode(
591
645
  code="WARN-LIKELY-CURRENCY-OR-PERCENT-MISSING-FORMATTER",
@@ -10,7 +10,7 @@ Available Adapters:
10
10
 
11
11
  MetricFlow queries lower to SqlQuery at compile time (normalize_queries.py) by
12
12
  calling MetricFlow's own compiler against the dbt semantic manifest — there is
13
- no execute-time MetricFlow adapter (mirroring the LookML compile-time lowering seam).
13
+ no execute-time MetricFlow adapter.
14
14
  """
15
15
 
16
16
  from dataface.core.execute.adapters.adapter_registry import (
@@ -43,20 +43,15 @@ SECRET_FIELDS: frozenset[str] = frozenset(
43
43
  }
44
44
  )
45
45
 
46
- # Fields that describe caching policy, LookML explore scoping, or cost
47
- # attribution — not connection identity. Dropped so editing them doesn't
46
+ # Fields that describe caching policy or cost attribution — not connection
47
+ # identity. Dropped so editing them doesn't
48
48
  # cold-restart the whole source's cache (every cache entry keyed off that
49
49
  # source_hash would otherwise miss).
50
50
  # `cache`: the source's own authored cache: block (BaseSourceConfig.cache).
51
- # `models`/`lookml_path`: LookML model/explore grouping metadata
52
- # (LookMLModelsMixin) — it selects which explores a query can name, but does
53
- # not change the warehouse connection or the SQL a query executes.
54
51
  # `attribution`: cost-attribution metadata (BaseSourceConfig.attribution) — it
55
52
  # rides along to the warehouse as job labels but changes neither the connection
56
53
  # nor the rows a query returns.
57
- INCIDENTAL_FIELDS: frozenset[str] = frozenset(
58
- {"cache", "models", "lookml_path", "attribution"}
59
- )
54
+ INCIDENTAL_FIELDS: frozenset[str] = frozenset({"cache", "attribution"})
60
55
 
61
56
 
62
57
  def source_identity(
@@ -71,7 +66,7 @@ def source_identity(
71
66
  Identity-bearing fields (user, host, project, dataset, keyfile, etc.)
72
67
  are preserved so two users with the same source name but different
73
68
  credentials get distinct cache entries. Incidental fields (the source's
74
- own ``cache:`` block, LookML ``models:``/``lookml_path``) are also
69
+ own ``cache:`` block, cost attribution) are also
75
70
  dropped — see ``INCIDENTAL_FIELDS``.
76
71
 
77
72
  Args:
@@ -61,12 +61,14 @@ from dataface.core.compile.variables import (
61
61
  coerce_variable_values,
62
62
  parse_variable_json_strings,
63
63
  )
64
+ from dataface.core.diagnostics.base import DatafaceError
64
65
  from dataface.core.diagnostics.execution import ExecutionError, QueryError
65
66
  from dataface.core.execute.cache_backend import (
66
67
  CachedQueryFailure,
67
68
  CacheRows,
68
69
  )
69
70
  from dataface.core.execute.duckdb_cache import compute_cache_key
71
+ from dataface.core.project import is_glob
70
72
 
71
73
  if TYPE_CHECKING:
72
74
  from dataface.core.compile.models.source import (
@@ -439,9 +441,15 @@ class Executor:
439
441
  # Type narrower: _has_file_support (checked above) guarantees a
440
442
  # non-None result, but the checker can't connect the two.
441
443
  assert materializer is not None
444
+ # _resolve_file_source (above) only returns non-None when
445
+ # query.source is set — the assertion surfaces any future drift.
446
+ assert query.source is not None
442
447
  try:
443
448
  result_data = materializer.materialize_and_run(
444
- file_source, query.sql, variables or {}
449
+ file_source,
450
+ query.sql,
451
+ variables or {},
452
+ source_name=query.source,
445
453
  )
446
454
  except Exception as e:
447
455
  if should_use_cache:
@@ -454,7 +462,11 @@ class Executor:
454
462
  False,
455
463
  )
456
464
  )
457
- raise QueryError(str(e), query_name) from e
465
+ raise QueryError(
466
+ str(e),
467
+ query_name,
468
+ code=e.code if isinstance(e, DatafaceError) else None,
469
+ ) from e
458
470
  data_as_of = datetime.now(timezone.utc)
459
471
  # Write through like any other query: the cache key now folds in the
460
472
  # file's version token (Project.file_version), so an edited data file
@@ -888,9 +900,19 @@ class Executor:
888
900
  # Pre-hash each file's token to a fixed hex digest before joining, so the
889
901
  # combination is independent of any delimiter appearing inside a token
890
902
  # (the Project.file_version contract does not restrict the token alphabet).
903
+ # Glob patterns must be expanded first — file_version on a literal glob
904
+ # path would fail with FileNotFoundError on a local project.
905
+ relpaths: list[str] = []
906
+ for path_or_glob in file_source.files.values():
907
+ if is_glob(path_or_glob):
908
+ relpaths.extend(
909
+ sorted(p.relpath for p in project.files.glob(path_or_glob))
910
+ )
911
+ else:
912
+ relpaths.append(path_or_glob)
891
913
  parts = sorted(
892
914
  hashlib.sha256(project.file_version(relpath).encode()).hexdigest()
893
- for relpath in file_source.files.values()
915
+ for relpath in relpaths
894
916
  )
895
917
  version = hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]
896
918
  self._source_versions[source_name] = version
@@ -13,6 +13,11 @@ Cache key per file table:
13
13
  cheap, so a warm table skips the read entirely.
14
14
  variables_hash = FILE_SOURCE_VARS_HASH (file sources have no variables)
15
15
 
16
+ For glob patterns (paths containing ``*`` or ``?``), the files: value is expanded
17
+ via ``project.files.glob()``. The combined version key encodes the full set of
18
+ matched relpaths and their individual version tokens, so the cache invalidates
19
+ when any matched file changes OR when files are added/removed from the glob set.
20
+
16
21
  On a cache hit the table/view already exists and the file is never read; on a miss
17
22
  we read + parse + put + register. When the cache is a DuckDB-backed cache,
18
23
  disable_external_access() is called once at construction so author SQL cannot call
@@ -26,21 +31,32 @@ import io
26
31
  import logging
27
32
  import threading
28
33
  from collections.abc import Callable
29
- from typing import TYPE_CHECKING, Any
34
+ from typing import TYPE_CHECKING, Any, TypeAlias
30
35
 
36
+ from dataface.core.compile.config import get_execution_config
31
37
  from dataface.core.compile.models.source import (
32
38
  CsvSourceConfig,
33
39
  JsonSourceConfig,
34
40
  ParquetSourceConfig,
35
41
  )
36
42
  from dataface.core.compile.parameterized import render_parameterized
43
+ from dataface.core.diagnostics.base import DatafaceError
44
+ from dataface.core.diagnostics.codes_execute import (
45
+ ERR_GLOB_EMPTY,
46
+ ERR_GLOB_SCHEMA_MISMATCH,
47
+ ERR_GLOB_TOO_MANY,
48
+ )
37
49
  from dataface.core.execute.cache_backend import FILE_SOURCE_VARS_HASH, CacheHit
38
50
  from dataface.core.execute.duckdb_cache import compute_source_hash
51
+ from dataface.core.project import is_glob
39
52
 
40
53
  if TYPE_CHECKING:
41
54
  from dataface.core.execute.cache_backend import QueryResultCache
42
55
  from dataface.core.project import Project
43
56
 
57
+ # JSON files carry runtime-dynamic column schemas; Any is the correct boundary type.
58
+ _JsonRow: TypeAlias = dict[str, Any]
59
+
44
60
  logger = logging.getLogger(__name__)
45
61
 
46
62
 
@@ -73,6 +89,18 @@ def _version_key(token: str) -> str:
73
89
  return hashlib.sha256(token.encode()).hexdigest()[:16]
74
90
 
75
91
 
92
+ def _glob_vkey(sorted_pairs: list[tuple[str, str]]) -> str:
93
+ """Composite version key for a glob table.
94
+
95
+ Encodes both the set of matched relpaths and their per-file version tokens
96
+ so the cache invalidates when any file changes content, is added, or is
97
+ removed from the glob match set. ``sorted_pairs`` must be pre-sorted by
98
+ relpath (``project.files.glob`` yields sorted order).
99
+ """
100
+ combined = "\n".join(f"{relpath}:{vk}" for relpath, vk in sorted_pairs)
101
+ return hashlib.sha256(combined.encode()).hexdigest()[:16]
102
+
103
+
76
104
  class FileSourceMaterializer:
77
105
  """Materializes file-based sources into any QueryResultCache backend.
78
106
 
@@ -106,45 +134,124 @@ class FileSourceMaterializer:
106
134
  source: CsvSourceConfig | JsonSourceConfig | ParquetSourceConfig,
107
135
  sql: str,
108
136
  variables: dict[str, Any],
109
- ) -> list[dict[str, Any]]:
137
+ source_name: str,
138
+ ) -> list[_JsonRow]:
110
139
  """Ensure all tables in *source* are in the cache, then execute *sql*.
111
140
 
141
+ Glob patterns (``*`` / ``?``) in ``source.files`` values are expanded via
142
+ ``project.files.glob()``. Each glob must match at least one file and may
143
+ not exceed ``execution.max_glob_file_count``. All matched files are loaded
144
+ and their rows concatenated into one table.
145
+
112
146
  Args:
113
147
  source: A CsvSourceConfig, JsonSourceConfig, or ParquetSourceConfig.
114
148
  sql: SQL template (may contain {{ variable }} Jinja expressions).
115
149
  variables: Variable values substituted into *sql* via render_parameterized.
150
+ source_name: Authored source name (for error messages).
116
151
 
117
152
  Returns:
118
153
  Rows returned by *sql*.
119
154
 
120
155
  Raises:
156
+ DatafaceError: Empty glob match or fan-out cap exceeded.
121
157
  RuntimeError: If SQL execution fails (propagated from execute_file_source_sql,
122
158
  includes external-access violations in author SQL).
123
159
  """
124
160
  source_hash = compute_source_hash(source)
125
-
126
- # Cheap per-table version token (a stat/OID lookup, NO file read). This
127
- # is the file-identity for the cache key; the file itself is read only on
128
- # a miss, inside the lock, so it is loaded at most once per render.
129
- versions: list[tuple[str, str, str]] = [
130
- (table_name, relpath, _version_key(self._project.file_version(relpath)))
131
- for table_name, relpath in source.files.items()
132
- ]
161
+ cap = get_execution_config().max_glob_file_count
162
+
163
+ # Each entry: (table_name, vkey, sorted_relpaths).
164
+ # For literal paths: one relpath, vkey from that file alone.
165
+ # For globs: sorted relpaths from glob expansion, composite vkey.
166
+ # Glob expansion and cap/empty checks happen here — before the lock,
167
+ # before any file reads, so a bad glob fails fast.
168
+ table_specs: list[tuple[str, str, list[str]]] = []
169
+ for table_name, path_or_glob in source.files.items():
170
+ if is_glob(path_or_glob):
171
+ matched = sorted(
172
+ p.relpath for p in self._project.files.glob(path_or_glob)
173
+ )
174
+ if not matched:
175
+ raise DatafaceError.from_code(
176
+ ERR_GLOB_EMPTY,
177
+ source_name=source_name,
178
+ table_name=table_name,
179
+ pattern=path_or_glob,
180
+ )
181
+ if len(matched) > cap:
182
+ raise DatafaceError.from_code(
183
+ ERR_GLOB_TOO_MANY,
184
+ source_name=source_name,
185
+ table_name=table_name,
186
+ pattern=path_or_glob,
187
+ count=len(matched),
188
+ cap=cap,
189
+ )
190
+ pairs = [
191
+ (r, _version_key(self._project.file_version(r))) for r in matched
192
+ ]
193
+ vkey = _glob_vkey(pairs)
194
+ table_specs.append((table_name, vkey, matched))
195
+ else:
196
+ vkey = _version_key(self._project.file_version(path_or_glob))
197
+ table_specs.append((table_name, vkey, [path_or_glob]))
133
198
 
134
199
  # Render {{ variable }} templates to parameterized SQL before executing.
135
200
  # profile_type="duckdb" → $1/$2/… placeholders (DuckDB positional style).
136
201
  parameterized = render_parameterized(sql, variables, profile_type="duckdb")
137
202
 
203
+ union_by_name = isinstance(source, JsonSourceConfig) and source.union_by_name
204
+
138
205
  with self._lock:
139
- for table_name, relpath, vkey in versions:
140
- # Cache miss → read + parse + store. A hit never opens the file.
206
+ for table_name, vkey, relpaths in table_specs:
207
+ # Cache miss → read + parse + store. A hit never opens any file.
141
208
  if not isinstance(
142
209
  self._cache.get(source_hash, vkey, FILE_SOURCE_VARS_HASH), CacheHit
143
210
  ):
144
- rows = _parse(source, self._project.read_bytes(relpath))
211
+ rows: list[_JsonRow] = []
212
+ first_relpath = ""
213
+ first_keys: frozenset[str] = frozenset()
214
+ for relpath in relpaths:
215
+ file_rows = _parse(
216
+ source, self._project.read_bytes(relpath), relpath
217
+ )
218
+ if file_rows:
219
+ # Schema check uses only the first row's keys. This catches
220
+ # header-level mismatches (the common authoring mistake) but
221
+ # will not detect a mid-file column shift in irregular formats.
222
+ file_keys = frozenset(file_rows[0])
223
+ if not first_relpath:
224
+ first_keys = file_keys
225
+ first_relpath = relpath
226
+ elif not union_by_name and file_keys != first_keys:
227
+ extra = sorted(file_keys - first_keys)
228
+ missing = sorted(first_keys - file_keys)
229
+ detail = (f"Extra: {extra}. " if extra else "") + (
230
+ f"Missing: {missing}. " if missing else ""
231
+ )
232
+ raise DatafaceError.from_code(
233
+ ERR_GLOB_SCHEMA_MISMATCH,
234
+ source_name=source_name,
235
+ table_name=table_name,
236
+ path=relpath,
237
+ first_path=first_relpath,
238
+ detail=detail,
239
+ )
240
+ rows.extend(file_rows)
241
+ if union_by_name and rows:
242
+ # Union of column names in first-seen insertion order so
243
+ # the created table's column order is deterministic across
244
+ # processes (a set would vary under hash randomisation).
245
+ all_keys = dict.fromkeys(k for row in rows for k in row)
246
+ for row in rows:
247
+ for k in all_keys:
248
+ row.setdefault(k, None)
249
+ # Individual glob-matched files with zero rows are silently
250
+ # skipped (common for empty partitions); the error fires only
251
+ # when *all* matched files together contribute no rows.
145
252
  if not rows:
146
253
  raise ValueError(
147
- f"File table '{table_name}': parsed file contains no rows. "
254
+ f"File table {table_name!r}: parsed file(s) contain no rows. "
148
255
  "Empty file sources are not supported — "
149
256
  "provide a file with at least one data row."
150
257
  )
@@ -171,19 +278,19 @@ class FileSourceMaterializer:
171
278
 
172
279
 
173
280
  def _parse(
174
- source: CsvSourceConfig | JsonSourceConfig | ParquetSourceConfig, file_bytes: bytes
175
- ) -> list[dict[str, Any]]:
281
+ source: CsvSourceConfig | JsonSourceConfig | ParquetSourceConfig,
282
+ file_bytes: bytes,
283
+ relpath: str,
284
+ ) -> list[_JsonRow]:
176
285
  """Dispatch to the correct parser for the given source type."""
177
286
  if isinstance(source, CsvSourceConfig):
178
287
  return _parse_csv(file_bytes, source.delimiter, source.encoding)
179
288
  if isinstance(source, JsonSourceConfig):
180
- return _parse_json(file_bytes)
289
+ return _parse_json(file_bytes, relpath)
181
290
  return _parse_parquet(file_bytes)
182
291
 
183
292
 
184
- def _parse_csv(
185
- file_bytes: bytes, delimiter: str, encoding: str
186
- ) -> list[dict[str, Any]]:
293
+ def _parse_csv(file_bytes: bytes, delimiter: str, encoding: str) -> list[_JsonRow]:
187
294
  """Parse CSV bytes to list[dict] using PyArrow's CSV reader.
188
295
 
189
296
  PyArrow infers column types (int, float, string, bool) — no-magic violation
@@ -199,30 +306,76 @@ def _parse_csv(
199
306
  return table.to_pylist()
200
307
 
201
308
 
202
- def _parse_json(file_bytes: bytes) -> list[dict[str, Any]]:
203
- """Parse JSON bytes to list[dict].
309
+ def _parse_json(
310
+ file_bytes: bytes,
311
+ relpath: str,
312
+ ) -> list[_JsonRow]:
313
+ """Parse JSON or NDJSON bytes to list[dict].
204
314
 
205
- Accepts a top-level JSON array of objects (the common authored case).
206
- Raises ValueError for non-array shapes (dicts, scalars) — use a warehouse
207
- connection for complex JSON shapes.
315
+ The parser is chosen by extension: ``.jsonl`` → NDJSON, ``.json`` → standard
316
+ JSON. The extension is validated at compile time, so only these two values
317
+ are possible at runtime.
318
+
319
+ Raises ValueError when the file cannot be parsed in the selected format.
208
320
  """
209
321
  import json
210
322
 
323
+ if relpath.lower().endswith(".jsonl"):
324
+ return _parse_ndjson(file_bytes)
325
+
326
+ try:
327
+ text = file_bytes.decode("utf-8")
328
+ except UnicodeDecodeError as e:
329
+ raise ValueError(f"JSON file is not valid UTF-8: {e}") from e
330
+
211
331
  try:
212
- parsed = json.loads(file_bytes.decode("utf-8"))
213
- except (json.JSONDecodeError, UnicodeDecodeError) as e:
214
- raise ValueError(f"JSON file is not valid UTF-8 JSON: {e}") from e
332
+ parsed = json.loads(text)
333
+ except json.JSONDecodeError as e:
334
+ raise ValueError(f"JSON file is not valid JSON: {e}") from e
215
335
 
336
+ if isinstance(parsed, dict):
337
+ return [parsed]
216
338
  if not isinstance(parsed, list):
217
339
  raise ValueError(
218
- f"JSON file must contain a top-level array of objects, "
219
- f"got: {type(parsed).__name__!r}. "
220
- "Use a warehouse connection for complex JSON shapes."
340
+ f"JSON file must contain a top-level object or array of objects, "
341
+ f"got: {type(parsed).__name__!r}."
221
342
  )
222
343
  return parsed
223
344
 
224
345
 
225
- def _parse_parquet(file_bytes: bytes) -> list[dict[str, Any]]:
346
+ def _parse_ndjson(file_bytes: bytes) -> list[_JsonRow]:
347
+ """Parse newline-delimited JSON (NDJSON) bytes to list[dict].
348
+
349
+ Each non-empty line must be a JSON object ``{...}``.
350
+
351
+ Raises ValueError on any line that is not a valid JSON object.
352
+ """
353
+ import json
354
+
355
+ try:
356
+ text = file_bytes.decode("utf-8")
357
+ except UnicodeDecodeError as e:
358
+ raise ValueError(f"NDJSON file is not valid UTF-8: {e}") from e
359
+
360
+ rows: list[_JsonRow] = []
361
+ for lineno, line in enumerate(text.splitlines(), start=1):
362
+ line = line.strip()
363
+ if not line:
364
+ continue
365
+ try:
366
+ obj = json.loads(line)
367
+ except json.JSONDecodeError as e:
368
+ raise ValueError(f"NDJSON line {lineno} is not valid JSON: {e}") from e
369
+ if not isinstance(obj, dict):
370
+ raise ValueError(
371
+ f"NDJSON line {lineno} must be a JSON object, "
372
+ f"got: {type(obj).__name__!r}."
373
+ )
374
+ rows.append(obj)
375
+ return rows
376
+
377
+
378
+ def _parse_parquet(file_bytes: bytes) -> list[_JsonRow]:
226
379
  """Parse Parquet bytes to list[dict] using PyArrow."""
227
380
  import pyarrow.parquet as pq
228
381
 
@@ -208,9 +208,12 @@ class TrivialDuckDBCache(_DuckDBResultCacheBase):
208
208
  def _ensure_result_table(self, table_name: str, data: list[dict[str, Any]]) -> None:
209
209
  if self._table_exists(table_name):
210
210
  return
211
- sample = data[0]
211
+ # Scan all rows for the first non-None value per column so union_by_name
212
+ # padding (which fills row 0 with None for absent columns) doesn't collapse
213
+ # numeric columns to VARCHAR.
212
214
  cols = [
213
- f'"{col_name}" {_infer_type(value)}' for col_name, value in sample.items()
215
+ f'"{col_name}" {_infer_type(next((r[col_name] for r in data if r.get(col_name) is not None), None))}'
216
+ for col_name in data[0]
214
217
  ]
215
218
  self.conn.execute(f"CREATE TABLE {_q(table_name)} ({', '.join(cols)})")
216
219
 
@@ -23,7 +23,6 @@ if TYPE_CHECKING:
23
23
  # Template files live in dataface/core/inspect/templates/{name}.yml
24
24
  INSPECT_TEMPLATES: list[str] = [
25
25
  "categorical_column",
26
- "charts",
27
26
  "date_column",
28
27
  "model",
29
28
  "numeric_column",
dataface/core/project.py CHANGED
@@ -126,7 +126,6 @@ IMAGE_ASSET_SUFFIXES: tuple[str, ...] = (
126
126
  # faces (.md/.markdown); composed in so this set can never
127
127
  # drift behind a new face suffix.
128
128
  # IMAGE_ASSET_SUFFIXES — images embeddable from markdown/faces (see above).
129
- # .lkml — LookML models (LookMLModelsMixin.lookml_path)
130
129
  # .csv/.json/.parquet — file sources (CsvSourceConfig etc.), whose `files:`
131
130
  # paths are required to carry these exact extensions
132
131
  # (models/source.py `_validate_files_mapping`)
@@ -136,7 +135,7 @@ IMAGE_ASSET_SUFFIXES: tuple[str, ...] = (
136
135
  SERVED_FILE_SUFFIXES: frozenset[str] = (
137
136
  frozenset(FACE_CANDIDATE_SUFFIXES)
138
137
  | frozenset(IMAGE_ASSET_SUFFIXES)
139
- | {".lkml", ".csv", ".json", ".parquet"}
138
+ | {".csv", ".json", ".jsonl", ".parquet"}
140
139
  )
141
140
 
142
141
 
@@ -191,6 +190,11 @@ def glob_to_regex(pattern: str) -> re.Pattern[str]:
191
190
  return re.compile("".join(out) + r"\Z")
192
191
 
193
192
 
193
+ def is_glob(path: str) -> bool:
194
+ """True when *path* contains a shell glob character (``*`` or ``?``)."""
195
+ return "*" in path or "?" in path
196
+
197
+
194
198
  def glob_literal_prefix(pattern: str) -> str:
195
199
  """The leading wildcard-free directory segments of a glob pattern.
196
200