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
@@ -111,29 +111,14 @@ def reject_credential_literals(source_name: str, raw_config: Mapping[str, Any])
111
111
  )
112
112
 
113
113
 
114
- def _validate_relative_path(class_name: str, field_label: str, value: str) -> str:
115
- """Reject an empty, absolute, or root-escaping project-relative path.
116
-
117
- Delegates the escape rule to ``assert_relpath`` — the single project-relative
118
- path guard — and re-raises with the class/field context. Shared by every field
119
- that stores a project-relative filesystem path (LookML model/lookml_path fields
120
- today).
121
- """
122
- try:
123
- assert_relpath(value)
124
- except ValueError as e:
125
- raise ValueError(f"{class_name}: {field_label} {e}") from e
126
- return value
127
-
128
-
129
114
  def _validate_files_mapping(
130
- class_name: str, files: dict[str, str], expected_extension: str
115
+ class_name: str, files: dict[str, str], expected_extension: str | tuple[str, ...]
131
116
  ) -> None:
132
117
  """Validate all keys and values in a file source's ``files:`` mapping.
133
118
 
134
119
  Keys must be valid SQL identifiers (letters, digits, underscores; no
135
120
  leading digit). Values must be non-empty relative POSIX paths that do not
136
- escape above the project root after normalization, and must end in
121
+ escape above the project root after normalization, and must end in one of
137
122
  ``expected_extension`` (case-insensitive) — parsing dispatches on the
138
123
  declared source ``type``, not the file extension, so this is what
139
124
  guarantees every servable data file's extension matches its type (and
@@ -143,12 +128,18 @@ def _validate_files_mapping(
143
128
  Args:
144
129
  class_name: Source config class name (for error messages).
145
130
  files: The ``files:`` mapping from the authored YAML.
146
- expected_extension: The canonical extension for this source type
147
- (e.g. ``".csv"``), including the leading dot.
131
+ expected_extension: The canonical extension or tuple of extensions for
132
+ this source type (e.g. ``".csv"`` or ``(".json", ".jsonl")``),
133
+ including the leading dot.
148
134
 
149
135
  Raises:
150
136
  ValueError: On an invalid key, path value, or extension mismatch.
151
137
  """
138
+ exts = (
139
+ (expected_extension,)
140
+ if isinstance(expected_extension, str)
141
+ else expected_extension
142
+ )
152
143
  for table_name, file_path in files.items():
153
144
  if not _SQL_IDENTIFIER_RE.match(table_name):
154
145
  raise ValueError(
@@ -160,10 +151,11 @@ def _validate_files_mapping(
160
151
  assert_relpath(file_path)
161
152
  except ValueError as e:
162
153
  raise ValueError(f"{class_name}: files[{table_name!r}] {e}") from e
163
- if not file_path.lower().endswith(expected_extension):
154
+ if not any(file_path.lower().endswith(ext) for ext in exts):
155
+ ext_msg = repr(exts[0]) if len(exts) == 1 else f"one of {list(exts)}"
164
156
  raise ValueError(
165
157
  f"{class_name}: files[{table_name!r}] path {file_path!r} must end in "
166
- f"{expected_extension!r} to match source type."
158
+ f"{ext_msg} to match source type."
167
159
  )
168
160
 
169
161
 
@@ -373,125 +365,12 @@ class DuckDBRetriesConfig(BaseModel):
373
365
  ] = Field(default_factory=_default_retryable_exceptions)
374
366
 
375
367
 
376
- # ============================================================================
377
- # LOOKML MODELS (nested under warehouse sources)
378
- # ============================================================================
379
-
380
-
381
- class LookMLModelConfig(BaseModel):
382
- """One LookML model: a named collection of explores under a warehouse source.
383
-
384
- Exactly one selector chooses which explores belong to the collection:
385
-
386
- - ``lkml``: a specific ``.lkml`` model file. Validated as a relative path
387
- that does not escape the project root. File-scoping within likeml is not
388
- yet implemented, so using this selector raises a clear error at compile
389
- time directing authors to use an explicit ``explores:`` list instead.
390
- Disk existence is not checked at parse time.
391
- - ``group_label``: explores tagged with this label in LookML. Accepted at
392
- parse time but likeml carries no group_label metadata yet, so using it
393
- raises a clear error at compile time.
394
- - ``explores``: an explicit list of explore names. The only selector
395
- enforced at compile time: an explore not in the list is rejected.
396
- """
397
-
398
- model_config = ConfigDict(extra="forbid")
399
-
400
- lkml: str | None = Field(
401
- default=None,
402
- description="Path to a specific .lkml model file, relative to the project root.",
403
- )
404
- group_label: str | None = Field(
405
- default=None,
406
- description="LookML group_label selecting this model's explores. Not yet enforced at compile time.",
407
- )
408
- explores: list[str] | None = Field(
409
- default=None, description="Explicit list of explore names in this model."
410
- )
411
-
412
- @field_validator("lkml")
413
- @classmethod
414
- def _validate_lkml(cls, v: str | None) -> str | None:
415
- if v is None:
416
- return v
417
- return _validate_relative_path("LookMLModelConfig", "lkml", v)
418
-
419
- @model_validator(mode="after")
420
- def _require_one_selector(self) -> LookMLModelConfig:
421
- selectors = [
422
- name
423
- for name, value in (
424
- ("lkml", self.lkml),
425
- ("group_label", self.group_label),
426
- ("explores", self.explores),
427
- )
428
- if value
429
- ]
430
- if len(selectors) != 1:
431
- got = ", ".join(selectors) if selectors else "none"
432
- raise ValueError(
433
- "LookMLModelConfig must set exactly one of 'lkml', 'group_label', "
434
- f"'explores' (got {got})"
435
- )
436
- return self
437
-
438
-
439
- class LookMLModelsMixin(BaseModel):
440
- """Shared nested ``models:`` field for warehouse sources with LookML explores.
441
-
442
- A warehouse source with ``models:`` also declares ``lookml_path`` — the
443
- directory of ``.lkml`` files the models' explores are parsed from. Each
444
- warehouse source owns its own nested ``models:`` map; a model name must be
445
- unique across the project (one model = one warehouse connection).
446
-
447
- Example:
448
- sources:
449
- bigquery_prod:
450
- type: bigquery
451
- project: digital-arbor-400
452
- dataset: transforms_bi
453
- lookml_path: lkml
454
- models:
455
- finance: {lkml: fivetran.model.lkml}
456
- core_kpis: {explores: [revenue, goals]}
457
- """
458
-
459
- model_config = ConfigDict(extra="forbid")
460
-
461
- lookml_path: str | None = Field(
462
- default=None,
463
- description="Relative path to the directory of .lkml files backing this source's models.",
464
- )
465
- models: dict[str, LookMLModelConfig] = Field(
466
- default_factory=dict,
467
- description="Named LookML model collections nested under this source.",
468
- )
469
-
470
- @field_validator("lookml_path")
471
- @classmethod
472
- def _validate_lookml_path(cls, v: str | None) -> str | None:
473
- if v is None:
474
- return v
475
- return _validate_relative_path("LookMLModelsMixin", "lookml_path", v)
476
-
477
- @model_validator(mode="after")
478
- def _require_lookml_path_with_models(self) -> LookMLModelsMixin:
479
- if self.models and not self.lookml_path:
480
- raise ValueError(
481
- "'lookml_path' is required when 'models' is set — it names the "
482
- "directory of .lkml files the models' explores are parsed from."
483
- )
484
- return self
485
-
486
-
487
368
  # ============================================================================
488
369
  # DATABASE SOURCE CONFIGS
489
370
  # ============================================================================
490
371
 
491
372
 
492
- class PostgresSourceConfig(
493
- AttributedSourceConfig, LookMLModelsMixin, DatabaseSourceConfig
494
- ):
373
+ class PostgresSourceConfig(AttributedSourceConfig, DatabaseSourceConfig):
495
374
  """Postgres source configuration.
496
375
 
497
376
  Field names match dbt profiles.yml exactly.
@@ -567,9 +446,7 @@ class PostgresSourceConfig(
567
446
  ] = 1
568
447
 
569
448
 
570
- class SnowflakeSourceConfig(
571
- AttributedSourceConfig, LookMLModelsMixin, DatabaseSourceConfig
572
- ):
449
+ class SnowflakeSourceConfig(AttributedSourceConfig, DatabaseSourceConfig):
573
450
  """Snowflake source configuration.
574
451
 
575
452
  Field names match dbt profiles.yml exactly.
@@ -702,9 +579,7 @@ class SnowflakeSourceConfig(
702
579
  ] = 0.0
703
580
 
704
581
 
705
- class BigQuerySourceConfig(
706
- AttributedSourceConfig, LookMLModelsMixin, DatabaseSourceConfig
707
- ):
582
+ class BigQuerySourceConfig(AttributedSourceConfig, DatabaseSourceConfig):
708
583
  """BigQuery source configuration.
709
584
 
710
585
  Field names match dbt profiles.yml exactly.
@@ -870,9 +745,7 @@ class BigQuerySourceConfig(
870
745
  return self
871
746
 
872
747
 
873
- class RedshiftSourceConfig(
874
- AttributedSourceConfig, LookMLModelsMixin, DatabaseSourceConfig
875
- ):
748
+ class RedshiftSourceConfig(AttributedSourceConfig, DatabaseSourceConfig):
876
749
  """Redshift source configuration.
877
750
 
878
751
  Field names match dbt profiles.yml exactly, except `autocommit`: Dataface
@@ -1025,9 +898,7 @@ class RedshiftSourceConfig(
1025
898
  ] = None
1026
899
 
1027
900
 
1028
- class MySQLSourceConfig(
1029
- AttributedSourceConfig, LookMLModelsMixin, DatabaseSourceConfig
1030
- ):
901
+ class MySQLSourceConfig(AttributedSourceConfig, DatabaseSourceConfig):
1031
902
  """MySQL source configuration.
1032
903
 
1033
904
  Field names match dbt profiles.yml exactly.
@@ -1048,9 +919,7 @@ class MySQLSourceConfig(
1048
919
  password: str = Field(description="MySQL password.")
1049
920
 
1050
921
 
1051
- class TrinoSourceConfig(
1052
- AttributedSourceConfig, LookMLModelsMixin, DatabaseSourceConfig
1053
- ):
922
+ class TrinoSourceConfig(AttributedSourceConfig, DatabaseSourceConfig):
1054
923
  """Trino / Presto source configuration.
1055
924
 
1056
925
  `database` names the Trino catalog; dbt-trino aliases `catalog` → `database`,
@@ -1195,7 +1064,7 @@ class TrinoSourceConfig(
1195
1064
  ] = None
1196
1065
 
1197
1066
 
1198
- class DuckDBSourceConfig(LookMLModelsMixin, DatabaseSourceConfig):
1067
+ class DuckDBSourceConfig(DatabaseSourceConfig):
1199
1068
  """DuckDB source configuration.
1200
1069
 
1201
1070
  Read-only posture: handled in-process. DuckDBAdapter opens file-based DuckDB
@@ -1326,7 +1195,7 @@ class DuckDBSourceConfig(LookMLModelsMixin, DatabaseSourceConfig):
1326
1195
  return data
1327
1196
 
1328
1197
 
1329
- class SQLiteSourceConfig(BaseSourceConfig, LookMLModelsMixin):
1198
+ class SQLiteSourceConfig(BaseSourceConfig):
1330
1199
  """SQLite source configuration.
1331
1200
 
1332
1201
  Read-only posture: handled in-process. SqliteAdapter opens SQLite connections
@@ -1354,6 +1223,9 @@ class CsvSourceConfig(BaseSourceConfig):
1354
1223
 
1355
1224
  A file source is a namespace of relations: each key in `files` is a table
1356
1225
  name; the value is the path to the CSV file (relative to the project root).
1226
+ Glob patterns (``*`` and ``?``) are supported: every matched file is loaded
1227
+ and its rows are concatenated into one table. All matched files must share
1228
+ the same column schema. A glob that matches no files is an error.
1357
1229
 
1358
1230
  Example:
1359
1231
  sources:
@@ -1362,14 +1234,22 @@ class CsvSourceConfig(BaseSourceConfig):
1362
1234
  files:
1363
1235
  sales: assets/data/sales.csv
1364
1236
  returns: assets/data/returns.csv
1237
+ monthly: assets/data/monthly_*.csv
1365
1238
  """
1366
1239
 
1367
1240
  source_category: ClassVar[str] = "file"
1368
1241
  type: Literal["csv"] = Field(description="Source type identifier.")
1369
1242
  files: dict[str, str] = Field(
1370
1243
  description=(
1371
- "Mapping of table_name → file path. "
1372
- "Each key is the SQL table name; the value is the path relative to the project root. "
1244
+ "Mapping of table_name → relative path or glob pattern. "
1245
+ "Each key is the SQL table name. "
1246
+ "The value is a path relative to the project root, or a glob "
1247
+ "pattern using ``*`` (matches within a directory) and ``?`` "
1248
+ "(matches one character). "
1249
+ "A glob must match at least one file; all matched files must share "
1250
+ "the same column schema and are concatenated into one table. "
1251
+ "The fan-out limit is ``execution.max_glob_file_count`` in "
1252
+ "``dataface.yml`` (default 1000). "
1373
1253
  "Required and must not be empty."
1374
1254
  )
1375
1255
  )
@@ -1392,6 +1272,9 @@ class ParquetSourceConfig(BaseSourceConfig):
1392
1272
 
1393
1273
  A file source is a namespace of relations: each key in `files` is a table
1394
1274
  name; the value is the path to the Parquet file (relative to the project root).
1275
+ Glob patterns (``*`` and ``?``) are supported: every matched file is loaded
1276
+ and its rows are concatenated into one table. All matched files must share
1277
+ the same column schema. A glob that matches no files is an error.
1395
1278
 
1396
1279
  Example:
1397
1280
  sources:
@@ -1399,14 +1282,22 @@ class ParquetSourceConfig(BaseSourceConfig):
1399
1282
  type: parquet
1400
1283
  files:
1401
1284
  events: assets/data/events.parquet
1285
+ archive: assets/data/archive_*.parquet
1402
1286
  """
1403
1287
 
1404
1288
  source_category: ClassVar[str] = "file"
1405
1289
  type: Literal["parquet"] = Field(description="Source type identifier.")
1406
1290
  files: dict[str, str] = Field(
1407
1291
  description=(
1408
- "Mapping of table_name → file path. "
1409
- "Each key is the SQL table name; the value is the path relative to the project root. "
1292
+ "Mapping of table_name → relative path or glob pattern. "
1293
+ "Each key is the SQL table name. "
1294
+ "The value is a path relative to the project root, or a glob "
1295
+ "pattern using ``*`` (matches within a directory) and ``?`` "
1296
+ "(matches one character). "
1297
+ "A glob must match at least one file; all matched files must share "
1298
+ "the same column schema and are concatenated into one table. "
1299
+ "The fan-out limit is ``execution.max_glob_file_count`` in "
1300
+ "``dataface.yml`` (default 1000). "
1410
1301
  "Required and must not be empty."
1411
1302
  )
1412
1303
  )
@@ -1427,6 +1318,12 @@ class JsonSourceConfig(BaseSourceConfig):
1427
1318
 
1428
1319
  A file source is a namespace of relations: each key in `files` is a table
1429
1320
  name; the value is the path to the JSON file (relative to the project root).
1321
+ Both ``.json`` and ``.jsonl`` (newline-delimited JSON) extensions are accepted;
1322
+ the parser is chosen by extension: ``.json`` → standard JSON array/object,
1323
+ ``.jsonl`` → newline-delimited JSON (one object per line).
1324
+ Glob patterns (``*`` and ``?``) are supported: every matched file is loaded
1325
+ and its rows are concatenated into one table. A glob that matches no files
1326
+ is an error.
1430
1327
 
1431
1328
  Example:
1432
1329
  sources:
@@ -1434,17 +1331,34 @@ class JsonSourceConfig(BaseSourceConfig):
1434
1331
  type: json
1435
1332
  files:
1436
1333
  products: assets/data/products.json
1334
+ events: assets/data/events_*.jsonl
1437
1335
  """
1438
1336
 
1439
1337
  source_category: ClassVar[str] = "file"
1440
1338
  type: Literal["json"] = Field(description="Source type identifier.")
1441
1339
  files: dict[str, str] = Field(
1442
1340
  description=(
1443
- "Mapping of table_name → file path. "
1444
- "Each key is the SQL table name; the value is the path relative to the project root. "
1341
+ "Mapping of table_name → relative path or glob pattern. "
1342
+ "Each key is the SQL table name. "
1343
+ "The value is a path relative to the project root, or a glob "
1344
+ "pattern using ``*`` (matches within a directory) and ``?`` "
1345
+ "(matches one character). "
1346
+ "A glob must match at least one file and all matched files are "
1347
+ "concatenated into one table. "
1348
+ "The fan-out limit is ``execution.max_glob_file_count`` in "
1349
+ "``dataface.yml`` (default 1000). "
1445
1350
  "Required and must not be empty."
1446
1351
  )
1447
1352
  )
1353
+ union_by_name: bool = Field(
1354
+ default=False,
1355
+ description=(
1356
+ "When True, rows from different files in a glob set are unified by "
1357
+ "column name: columns absent in a particular file are filled with "
1358
+ "NULL. When False (default), all files in a glob set must share the "
1359
+ "same column schema — a mismatch raises an error."
1360
+ ),
1361
+ )
1448
1362
 
1449
1363
  @model_validator(mode="after")
1450
1364
  def _require_nonempty_files(self) -> JsonSourceConfig:
@@ -1453,7 +1367,7 @@ class JsonSourceConfig(BaseSourceConfig):
1453
1367
  "JsonSourceConfig: files must not be empty — "
1454
1368
  "provide at least one table_name → path entry."
1455
1369
  )
1456
- _validate_files_mapping("JsonSourceConfig", self.files, ".json")
1370
+ _validate_files_mapping("JsonSourceConfig", self.files, (".json", ".jsonl"))
1457
1371
  return self
1458
1372
 
1459
1373
 
@@ -107,9 +107,6 @@ class VariablesStyle(BaseModel):
107
107
  placeholder: VariablesPlaceholderStyle = Field(
108
108
  description="Style for unselected/hint text in variable inputs."
109
109
  )
110
- line_height: float = Field(
111
- description="Line height for variable control text in pixels."
112
- )
113
110
  padding: float = Field(
114
111
  description="Outer padding around the variables panel in pixels."
115
112
  )
@@ -14,11 +14,9 @@ from typing import TYPE_CHECKING, Any, get_args
14
14
  if TYPE_CHECKING:
15
15
  from dataface.core.project import ProjectDirectory
16
16
 
17
- from collections.abc import Callable
18
17
 
19
18
  from pydantic import BaseModel, TypeAdapter, ValidationError
20
19
 
21
- from dataface.core.compile.channel import is_dotted_lookml_ref
22
20
  from dataface.core.compile.errors import CompilationError, ReferenceError
23
21
  from dataface.core.compile.jinja import extract_variable_dependencies
24
22
  from dataface.core.compile.models.cache import INHERIT_CACHE, CachePatch
@@ -46,8 +44,6 @@ from dataface.core.compile.models.face.authored import QueryOrRef
46
44
  from dataface.core.compile.models.face.normalized import Layout
47
45
  from dataface.core.compile.models.primitives import FormatConfig
48
46
  from dataface.core.compile.models.query.authored import (
49
- AuthoredLookmlMergeQuery,
50
- AuthoredLookmlQuery,
51
47
  AuthoredMetricflowQuery,
52
48
  TimeGrain,
53
49
  _BaseQueryFields,
@@ -661,8 +657,8 @@ def _collect_charts_from_layout(
661
657
 
662
658
  # Chart keys that carry data-binding field references (axis, encoding, geo, etc.).
663
659
  # Presentation-only keys (title, subtitle, description, link, format, ...) are
664
- # excluded — a dotted string in those positions is not a LookML field ref.
665
- _LOOKML_CHANNEL_KEYS: frozenset[str] = frozenset(
660
+ # excluded — a dotted string in those positions is not a field ref.
661
+ _FIELD_CHANNEL_KEYS: frozenset[str] = frozenset(
666
662
  {
667
663
  "x",
668
664
  "y",
@@ -681,150 +677,6 @@ _LOOKML_CHANNEL_KEYS: frozenset[str] = frozenset(
681
677
  )
682
678
 
683
679
 
684
- def _rewrite_channel_value(
685
- value: object, collected: set[str], channel_alias: Callable[[str, str], str]
686
- ) -> object:
687
- """Rewrite dotted `view.field` refs in a channel value to their SQL alias.
688
-
689
- A channel is a scalar ref OR a list of refs (multi-series `y: [a, b]`).
690
- Every dotted ref in either shape is collected+rewritten — a list-valued
691
- channel whose refs went uncollected would silently drop those measures
692
- from the query while reporting success.
693
-
694
- Returns the rewritten value, or None if `value` had no dotted ref to
695
- rewrite (including when `value` itself is None).
696
- """
697
- if value is None:
698
- return None
699
- items = value if isinstance(value, list) else [value]
700
- rewritten: list[object] = []
701
- changed = False
702
- for item in items:
703
- if isinstance(item, str) and is_dotted_lookml_ref(item):
704
- collected.add(item)
705
- view, field = item.split(".", 1)
706
- rewritten.append(channel_alias(view, field))
707
- changed = True
708
- else:
709
- rewritten.append(item)
710
- if not changed:
711
- return None
712
- return rewritten if isinstance(value, list) else rewritten[0]
713
-
714
-
715
- def collect_lookml_channel_refs(
716
- charts: dict[str, Any],
717
- query_registry: dict[str, QueryOrRef],
718
- lookml_model_names: set[str],
719
- ) -> None:
720
- """Collect dotted view.field chart-channel refs into fields-less lookml queries.
721
-
722
- A lookml query may omit `fields:`; authors instead name fields by dotted
723
- `view.field` ref directly in chart channel positions (x, y, color, theta,
724
- value, ...). This pass finds every named lookml query with no `fields`,
725
- gathers the dotted refs from charts bound to it, injects them as the
726
- query's `fields`, and rewrites each chart's channel value to the
727
- resulting `view_field` alias so it matches the lowered SQL's output
728
- column (the standard T1.2 lowering pass runs after this, unaware that
729
- `fields` was collected rather than authored).
730
-
731
- Must run before query normalization (which lowers lookml queries to
732
- SqlQuery) and only sees named queries — inline per-chart queries own
733
- their own `fields` and are not eligible for collection.
734
-
735
- Raises:
736
- CompilationError: A lookml query has no `fields` and no chart bound
737
- to it (by name) uses a dotted-ref channel.
738
- """
739
- try:
740
- # Lazy import: likeml is optional (pip install dataface[lookml]).
741
- # It is an internal workspace package and not published to PyPI.
742
- from likeml import channel_alias # noqa: PLC0415
743
- except ImportError as e:
744
- raise CompilationError(
745
- "likeml is not installed. Install with: pip install dataface[lookml]"
746
- ) from e
747
-
748
- for query_name, query_def in query_registry.items():
749
- if not isinstance(query_def, (AuthoredLookmlQuery, AuthoredLookmlMergeQuery)):
750
- continue
751
- # source may be an inline dict (unhashable) — a `dict not in set` membership
752
- # test would raise TypeError, so gate on str first. A non-str source is never
753
- # a lookml source ref, so skip it.
754
- if not isinstance(query_def.source, str):
755
- continue
756
- if query_def.source not in lookml_model_names:
757
- continue
758
- if query_def.fields:
759
- continue
760
-
761
- collected: set[str] = set()
762
- for chart_def in charts.values():
763
- is_model = isinstance(chart_def, BaseModel)
764
- chart_dict = (
765
- chart_def.model_dump(exclude_none=True) if is_model else chart_def
766
- )
767
- bound_to_query = chart_dict.get("query") == query_name
768
- if bound_to_query:
769
- for field_name, value in chart_dict.items():
770
- if field_name not in _LOOKML_CHANNEL_KEYS:
771
- continue
772
- new_value = _rewrite_channel_value(value, collected, channel_alias)
773
- if new_value is None:
774
- continue
775
- if is_model:
776
- setattr(chart_def, field_name, new_value)
777
- else:
778
- chart_dict[field_name] = new_value
779
-
780
- # Layered charts carry their own channels inside `layers[]`, one query
781
- # binding per layer (falls back to the chart-level query when a layer
782
- # omits its own). Each layer is walked independently, mutating the
783
- # actual Layer object (model or dict) in place, not the model_dump()
784
- # copy — model_dump() flattens `layers` into plain dicts, so writes must
785
- # go through the real chart_def.layers list to reach the pipeline.
786
- layers = chart_dict.get("layers")
787
- if not layers:
788
- continue
789
- # Use the live typed layer objects from the model so setattr writes
790
- # propagate (model_dump() produces plain dicts — mutations to those are lost).
791
- # `layers` is not on all chart types, so getattr is appropriate.
792
- model_layers: list[CartesianLayer] | None = (
793
- getattr(chart_def, "layers", None) if is_model else None
794
- )
795
- real_layers: list[CartesianLayer] | list[dict[str, Any]] = (
796
- model_layers if model_layers else layers
797
- )
798
- for idx, layer_dict in enumerate(layers):
799
- layer_query_name = layer_dict.get("query")
800
- if layer_query_name is None:
801
- if not bound_to_query:
802
- continue
803
- elif layer_query_name != query_name:
804
- continue
805
- real_layer = real_layers[idx]
806
- for field_name in ("x", "y", "color"):
807
- new_value = _rewrite_channel_value(
808
- layer_dict.get(field_name), collected, channel_alias
809
- )
810
- if new_value is None:
811
- continue
812
- if isinstance(real_layer, dict):
813
- real_layer[field_name] = new_value
814
- else:
815
- setattr(real_layer, field_name, new_value)
816
-
817
- if not collected:
818
- raise CompilationError(
819
- f"lookml query '{query_name}' has no fields and no charts bind "
820
- "dotted-ref channels to it. Either add `fields: [view.field, ...]` "
821
- "to the query, or bind a chart channel (x, y, color, ...) to a "
822
- "dotted `view.field` ref."
823
- )
824
-
825
- query_def.fields = sorted(collected)
826
-
827
-
828
680
  # Maps a raw grain token ("month") to the typed TimeGrain literal, so the
829
681
  # classifier returns a real TimeGrain without a cast.
830
682
  _GRAIN_BY_NAME: dict[str, TimeGrain] = {name: name for name in get_args(TimeGrain)}
@@ -1002,7 +854,7 @@ def collect_metricflow_channel_refs(
1002
854
  ) -> None:
1003
855
  """Populate a `model:`-synthesized metricflow query from its charts' channels.
1004
856
 
1005
- The MetricFlow companion to `collect_lookml_channel_refs`: `model_by_query`
857
+ `model_by_query`
1006
858
  maps each synthesized query name to the semantic model named in the chart's
1007
859
  `model: <source>.<semantic_model>`. For every chart bound to that query, each
1008
860
  bare channel field is classified against the manifest into a metric, an
@@ -1040,7 +892,7 @@ def collect_metricflow_channel_refs(
1040
892
  if chart_dict.get("query") != query_name:
1041
893
  continue
1042
894
  for field_name, value in chart_dict.items():
1043
- if field_name not in _LOOKML_CHANNEL_KEYS:
895
+ if field_name not in _FIELD_CHANNEL_KEYS:
1044
896
  continue
1045
897
  if not isinstance(value, (str, list)):
1046
898
  continue