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.
- dataface/DATAFACE_SYNTAX.md +15 -4
- dataface/agent_api/docs/yaml-reference.md +33 -133
- dataface/agent_api/serve.py +0 -2
- dataface/agent_api/validate.py +8 -0
- dataface/cli/commands/render.py +6 -0
- dataface/cli/commands/serve.py +0 -6
- dataface/cli/main.py +20 -14
- dataface/core/aliases.py +27 -0
- dataface/core/compile/__init__.py +0 -8
- dataface/core/compile/channel.py +0 -13
- dataface/core/compile/compiler.py +73 -71
- dataface/core/compile/introspection.py +2 -13
- dataface/core/compile/models/chart/authored/_base.py +1 -1
- dataface/core/compile/models/chart/normalized/__init__.py +2 -0
- dataface/core/compile/models/chart/normalized/_base.py +26 -0
- dataface/core/compile/models/config.py +5 -0
- dataface/core/compile/models/face/authored.py +24 -89
- dataface/core/compile/models/query/authored.py +1 -119
- dataface/core/compile/models/refs.py +0 -2
- dataface/core/compile/models/source.py +73 -159
- dataface/core/compile/models/style/theme/variables.py +0 -3
- dataface/core/compile/normalize_charts.py +4 -152
- dataface/core/compile/normalize_layout.py +20 -284
- dataface/core/compile/normalize_queries.py +3 -506
- dataface/core/compile/normalizer.py +4 -10
- dataface/core/compile/palette.py +19 -7
- dataface/core/compile/validator.py +121 -44
- dataface/core/compile/yaml_error_formatter.py +107 -2
- dataface/core/dashboard.py +3 -1
- dataface/core/defaults/default_config.yml +4 -0
- dataface/core/defaults/themes/_base.yaml +0 -1
- dataface/core/defaults/themes/editorial.yaml +3 -0
- dataface/core/defaults/themes/stark.yaml +8 -0
- dataface/core/diagnostics/__init__.py +10 -0
- dataface/core/diagnostics/codes_compile.py +4 -2
- dataface/core/diagnostics/codes_execute.py +70 -0
- dataface/core/diagnostics/codes_render.py +58 -4
- dataface/core/execute/adapters/__init__.py +1 -1
- dataface/core/execute/cache_keys.py +4 -9
- dataface/core/execute/executor.py +25 -3
- dataface/core/execute/file_source_materializer.py +185 -32
- dataface/core/execute/trivial_local_cache.py +5 -2
- dataface/core/inspect/__init__.py +0 -1
- dataface/core/project.py +6 -2
- dataface/core/render/chart/mark_extents.py +339 -0
- dataface/core/render/chart/rendering.py +97 -34
- dataface/core/render/conditions.py +122 -0
- dataface/core/render/controls.py +60 -187
- dataface/core/render/data_format.py +1 -1
- dataface/core/render/face_resolve.py +7 -0
- dataface/core/render/faces.py +94 -30
- dataface/core/render/layout_sizing.py +46 -0
- dataface/core/render/renderer.py +14 -1
- dataface/core/render/sizing.py +42 -105
- dataface/core/render/svg_utils.py +60 -14
- dataface/core/render/terminal.py +1 -1
- dataface/core/render/variables_layout.py +211 -0
- dataface/core/render/variables_resolve.py +376 -0
- dataface/core/render/variables_strip.py +291 -368
- dataface/core/render/warnings/base.py +18 -0
- dataface/core/render/warnings/layout_min_exceeds_height.py +68 -0
- dataface/core/render/warnings/registry.py +2 -0
- dataface/core/serve/alias_index.py +16 -22
- dataface/data/schemas/board-resolved/board-resolved.schema.json +1 -499
- {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/METADATA +1 -3
- {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/RECORD +69 -67
- dataface/core/inspect/partials/categorical.yml +0 -38
- dataface/core/inspect/partials/date.yml +0 -38
- dataface/core/inspect/partials/numeric.yml +0 -53
- dataface/core/inspect/templates/charts.yml +0 -94
- {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/WHEEL +0 -0
- {dataface-0.3.2.dev111.dist-info → dataface-0.3.2.dev150.dist-info}/entry_points.txt +0 -0
- {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:
|
|
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,
|
|
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
|
-
#
|
|
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
|
|
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
|
|
47
|
-
#
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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,
|
|
140
|
-
# Cache miss → read + parse + store. A hit never opens
|
|
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 =
|
|
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
|
|
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,
|
|
175
|
-
|
|
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(
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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(
|
|
213
|
-
except
|
|
214
|
-
raise ValueError(f"JSON file is not valid
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
| {".
|
|
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
|
|