dataface 0.2.1.dev647__py3-none-any.whl → 0.2.1.dev691__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 (55) hide show
  1. dataface/_docs_site.py +1 -3
  2. dataface/_install_hint.py +2 -2
  3. dataface/agent_api/_paths.py +78 -27
  4. dataface/agent_api/cache.py +7 -18
  5. dataface/agent_api/data_paths.py +1 -3
  6. dataface/agent_api/describe.py +36 -22
  7. dataface/agent_api/docs/yaml-reference.md +0 -1
  8. dataface/agent_api/project_session.py +1 -1
  9. dataface/agent_api/query.py +10 -5
  10. dataface/agent_api/validate.py +46 -37
  11. dataface/ai/agent.py +13 -15
  12. dataface/ai/context.py +1 -1
  13. dataface/ai/llm.py +4 -2
  14. dataface/ai/prompts.py +2 -2
  15. dataface/ai/tool_schemas.py +6 -6
  16. dataface/ai/tools/__init__.py +1 -11
  17. dataface/cli/_extras.py +12 -20
  18. dataface/cli/commands/init.py +20 -17
  19. dataface/cli/commands/mcp.py +53 -0
  20. dataface/cli/commands/render.py +106 -75
  21. dataface/cli/filesystem_project.py +4 -0
  22. dataface/cli/main.py +33 -129
  23. dataface/core/compile/config.py +4 -0
  24. dataface/core/compile/models/cache.py +44 -3
  25. dataface/core/compile/models/face/authored.py +0 -8
  26. dataface/core/compile/normalize_queries.py +28 -10
  27. dataface/core/execute/_duckdb_cache_base.py +79 -19
  28. dataface/core/execute/adapters/adapter_registry.py +2 -2
  29. dataface/core/execute/cache_backend.py +28 -0
  30. dataface/core/execute/executor.py +74 -21
  31. dataface/core/execute/trivial_local_cache.py +2 -2
  32. dataface/core/project.py +13 -0
  33. dataface/core/project_roots.py +0 -5
  34. dataface/core/render/chart/auto_link.py +218 -63
  35. dataface/core/render/chart/emitters/_overlay.py +69 -28
  36. dataface/core/render/chart/rendering.py +2 -12
  37. dataface/core/render/chart/sql_grain.py +571 -0
  38. dataface/core/render/dir_context.py +3 -3
  39. dataface/core/render/faces.py +29 -10
  40. dataface/core/render/svg_utils.py +22 -0
  41. dataface/core/serve/embedded.py +14 -13
  42. dataface/core/serve/server.py +13 -19
  43. dataface/data/highlighting/face/v1.json +0 -1
  44. {dataface-0.2.1.dev647.dist-info → dataface-0.2.1.dev691.dist-info}/METADATA +1 -14
  45. {dataface-0.2.1.dev647.dist-info → dataface-0.2.1.dev691.dist-info}/RECORD +48 -53
  46. dataface/agent_api/_session_store.py +0 -489
  47. dataface/agent_api/_state.py +0 -28
  48. dataface/agent_api/chat.py +0 -224
  49. dataface/ai/external_mcp.py +0 -610
  50. dataface/cli/commands/_agent_input.py +0 -212
  51. dataface/cli/commands/_agent_server.py +0 -126
  52. dataface/cli/commands/chat.py +0 -774
  53. {dataface-0.2.1.dev647.dist-info → dataface-0.2.1.dev691.dist-info}/WHEEL +0 -0
  54. {dataface-0.2.1.dev647.dist-info → dataface-0.2.1.dev691.dist-info}/entry_points.txt +0 -0
  55. {dataface-0.2.1.dev647.dist-info → dataface-0.2.1.dev691.dist-info}/licenses/LICENSE +0 -0
dataface/_docs_site.py CHANGED
@@ -12,9 +12,7 @@ def docs_site_url() -> str:
12
12
 
13
13
  ``DFT_DOCS_URL`` overrides the shipped default when set to a non-empty
14
14
  value. Used by ``dft docs`` (web link on the topic index) and by structured
15
- error ``doc_url``s. The playground gallery reads ``DFT_DOCS_URL`` directly
16
- and hides its ``docs:`` links when unset, so it does not inherit this
17
- default — see ``dataface_playground.routes.get_runtime_config``.
15
+ error ``doc_url``s.
18
16
  """
19
17
  raw = os.environ.get("DFT_DOCS_URL", DEFAULT_DOCS_SITE_URL).strip()
20
18
  if not raw:
dataface/_install_hint.py CHANGED
@@ -57,8 +57,8 @@ def install_hint(extra: str | None = None) -> str:
57
57
  """Return the install one-liner that matches how this Dataface got installed.
58
58
 
59
59
  ``extra`` is an optional optional-dependency group (``"mcp"``,
60
- ``"chat"``, ``"playground"``, ``"bigquery"``); when set, the
61
- returned command installs Dataface with that extras bracket.
60
+ ``"playground"``, ``"bigquery"``); when set, the returned command
61
+ installs Dataface with that extras bracket.
62
62
  """
63
63
  spec = f"dataface[{extra}]" if extra else "dataface"
64
64
  if _is_uv_tool_install():
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  from dataclasses import dataclass
7
- from pathlib import Path
7
+ from pathlib import Path, PurePosixPath
8
8
 
9
9
  # tach-ignore(agent_api->cli: runtime host-type guard in no_project_hint; the local-filesystem check needs a non-cli signal on Project — deferred)
10
10
  from dataface.cli.filesystem_project import FilesystemProject
@@ -75,40 +75,91 @@ def no_project_hint(project: Project) -> str:
75
75
  )
76
76
 
77
77
 
78
- def resolve_face_path(path: Path, project: Project) -> ProjectPath:
79
- """User/agent-supplied face path -> ProjectPath.
78
+ def resolve_face_relpath(relpath: PurePosixPath, project: Project) -> ProjectPath:
79
+ """Project-relative face identity -> ProjectPath, with the faces-first retry.
80
80
 
81
- Absolute and ``..``-leading paths are explicit locations: they relativize
82
- through the containment seam (``project.path_for_fspath``, a leading
83
- ``..`` resolving against cwd for CLI shell-nav feel) and are returned
84
- as-is no ``faces/`` retry, even when the exact path doesn't exist. A
85
- bare relative name (no explicit location) gets the faces-first retry: for
86
- a relpath that doesn't exist in the project store and has no explicit
87
- ``faces/`` prefix, retries under ``FACES_SUBDIR``. This lets a
88
- ``search_dashboards`` ``face_path`` of ``'looker/x.yaml'`` (``faces/``
89
- prefix stripped at search time) resolve to ``faces/looker/x.yaml``
90
- without producing a ``faces/faces/...`` double prefix when the caller
91
- already supplies the full ``faces/looker/x.yaml``.
81
+ The relpath-native counterpart to ``resolve_face_path``: for callers that
82
+ already hold a project-relative face identity (Cloud's context strings)
83
+ rather than a real filesystem path, this is the single owner of the
84
+ face-relative vs root-relative reconciliation a bare name like
85
+ ``looker/x.yaml`` retries under ``FACES_SUBDIR`` when it doesn't exist at
86
+ root, without producing a ``faces/faces/...`` double prefix when the
87
+ caller already supplies the full ``faces/looker/x.yaml``.
92
88
 
93
89
  Existence is checked via ``project.exists(...)`` so it works for all
94
90
  ``Project`` implementations (FilesystemProject, CloudManagedProject
95
91
  git-blob store, etc.) — not just the local filesystem. Paths that exist
96
92
  at root resolve from root — non-faces paths like ``models/schema.sql``
97
- are not rewritten under faces/.
93
+ are not rewritten under faces/. ``str`` is used only at the identity seam
94
+ (``project.path`` / ``project.exists``); everything else is real
95
+ ``PurePosixPath`` operations.
96
+ """
97
+ relpath_str = str(relpath)
98
+ assert_relpath(relpath_str)
99
+ if project.exists(relpath_str):
100
+ return project.path(relpath_str)
101
+ if not relpath.parts or relpath.parts[0] != FACES_SUBDIR:
102
+ candidate = PurePosixPath(FACES_SUBDIR) / relpath
103
+ candidate_str = str(candidate)
104
+ if project.exists(candidate_str):
105
+ return project.path(candidate_str)
106
+ return project.path(relpath_str) # caller reports not-found
107
+
108
+
109
+ def _relpath_for_fs_location(path: Path, project: Project) -> str:
110
+ """Relativize an absolute, on-disk face location against a local project root.
111
+
112
+ Absolute and ``..``-leading face paths are explicit filesystem locations —
113
+ the one place this boundary genuinely needs a real ``Path`` op. Only
114
+ ``FilesystemProject`` ever receives them: Cloud/MCP always supply
115
+ project-relative identity strings, never a real filesystem path, so any
116
+ other ``Project`` implementation here is a caller bug, not a store to
117
+ fall back through.
118
+
119
+ Two-try containment check (mirrors the pre-refactor behavior): ``resolve()``
120
+ follows symlinks (handles e.g. ``/var`` -> ``/private/var`` on macOS); a
121
+ symlink-blind ``normpath`` fallback accepts paths through intentional
122
+ project-internal symlinks (e.g. ``faces/tasks/`` -> ``../../tasks/``).
123
+ Raises ``ValueError`` if neither check places *path* inside the root.
124
+ """
125
+ if not isinstance(project, FilesystemProject):
126
+ raise ValueError(
127
+ "Absolute or '..'-relative face paths require a local filesystem "
128
+ f"project; got {type(project).__name__}."
129
+ )
130
+ root = project.root
131
+ resolved = path.resolve()
132
+ try:
133
+ return str(resolved.relative_to(root))
134
+ except ValueError:
135
+ pass
136
+ normalized = Path(os.path.normpath(path.absolute()))
137
+ try:
138
+ return str(normalized.relative_to(root))
139
+ except ValueError:
140
+ raise ValueError(f"Path {path!r} is outside project root {root!r}") from None
141
+
142
+
143
+ def resolve_face_path(path: Path, project: Project) -> ProjectPath:
144
+ """User/agent-supplied filesystem-shaped face path -> ProjectPath.
145
+
146
+ Absolute and ``..``-leading paths are explicit locations: they relativize
147
+ against a concrete ``FilesystemProject`` root (``_relpath_for_fs_location``,
148
+ a leading ``..`` resolving against cwd for CLI shell-nav feel) and are
149
+ returned as-is — no ``faces/`` retry, even when the exact path doesn't
150
+ exist. A bare relative name (no explicit location) delegates to
151
+ ``resolve_face_relpath`` for the faces-first retry, which works against
152
+ any ``Project`` implementation.
98
153
  """
99
154
  if path.is_absolute():
100
- return project.path_for_fspath(path)
101
- if ".." in path.parts:
102
- return project.path_for_fspath(Path(os.path.normpath(Path.cwd() / path)))
103
- relpath = str(path)
104
- assert_relpath(relpath)
105
- if project.exists(relpath):
106
- return project.path(relpath)
107
- if not relpath.startswith(f"{FACES_SUBDIR}/"):
108
- candidate = f"{FACES_SUBDIR}/{relpath}"
109
- if project.exists(candidate):
110
- return project.path(candidate)
111
- return project.path(relpath) # caller reports not-found
155
+ relpath = _relpath_for_fs_location(path, project)
156
+ elif ".." in path.parts:
157
+ relpath = _relpath_for_fs_location(
158
+ Path(os.path.normpath(Path.cwd() / path)), project
159
+ )
160
+ else:
161
+ return resolve_face_relpath(PurePosixPath(path), project)
162
+ return project.path(relpath)
112
163
 
113
164
 
114
165
  def resolve_face_or_error(path: Path, project: Project) -> FaceFile | Diagnostic:
@@ -21,7 +21,6 @@ if TYPE_CHECKING:
21
21
  __all__ = [
22
22
  "TrivialDuckDBCache",
23
23
  "open_cache",
24
- "open_project_cache",
25
24
  "project_cache_ctx",
26
25
  ]
27
26
 
@@ -32,18 +31,19 @@ def open_cache(path: Path | None) -> TrivialDuckDBCache:
32
31
  ``path=None`` opens an in-memory cache — ephemeral, discarded when closed.
33
32
  A path opens (or creates, if absent) a persistent DuckDB file, reused
34
33
  across invocations. Callers own the returned cache's lifecycle (close it).
35
- Does not consult project config — use ``open_project_cache`` for that.
34
+ Does not consult project config — use ``project_cache_ctx`` for that.
36
35
  """
37
36
  return TrivialDuckDBCache(db_path=path)
38
37
 
39
38
 
40
- def open_project_cache(
39
+ @contextmanager
40
+ def project_cache_ctx(
41
41
  project: FilesystemProject,
42
42
  *,
43
43
  no_cache: bool = False,
44
44
  cache_path: Path | None = None,
45
- ) -> TrivialDuckDBCache | None:
46
- """Open the query-result cache honoring project config, with overrides.
45
+ ) -> Generator[TrivialDuckDBCache | None]:
46
+ """Open the query-result cache honoring project config, closing it on exit.
47
47
 
48
48
  The caller owns the project instance. Pair this with
49
49
  ``ProjectSession.from_project(project, cache=...)`` rather than
@@ -59,23 +59,12 @@ def open_project_cache(
59
59
  to off without closing the door on a nearer scope's opt-in, so it still
60
60
  opens a store; the resolved per-query policy decides what gets written.
61
61
 
62
- Returns:
62
+ Yields:
63
63
  ``None`` only when ``no_cache=True`` — callers pass this straight
64
64
  through as ``ProjectSession(cache=...)``.
65
65
  """
66
66
  boot = resolve_cache_boot(project, no_cache=no_cache, cache_path=cache_path)
67
- return open_cache(boot.path) if boot.enabled else None
68
-
69
-
70
- @contextmanager
71
- def project_cache_ctx(
72
- project: FilesystemProject,
73
- *,
74
- no_cache: bool = False,
75
- cache_path: Path | None = None,
76
- ) -> Generator[TrivialDuckDBCache | None]:
77
- """Context-managed ``open_project_cache``; closes the cache on exit if opened."""
78
- cache = open_project_cache(project, no_cache=no_cache, cache_path=cache_path)
67
+ cache = open_cache(boot.path) if boot.enabled else None
79
68
  try:
80
69
  yield cache
81
70
  finally:
@@ -83,9 +83,7 @@ def data_alias_errors_for_file(
83
83
  """
84
84
  from dataface.core.serve.alias_index import read_aliases_from_file
85
85
 
86
- # path_for_fspath raises ValueError when face_file is outside project.root —
87
- # that is a caller bug, not a parse error, and must NOT be swallowed.
88
- project_path = project.path_for_fspath(face_file)
86
+ project_path = project.path(face_file.as_posix())
89
87
  try:
90
88
  aliases = read_aliases_from_file(project_path)
91
89
  except ValueError:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pathlib import Path
5
+ from pathlib import Path, PurePosixPath
6
6
  from typing import Any
7
7
 
8
8
  from pydantic import BaseModel, ConfigDict, Field
@@ -22,7 +22,7 @@ from dataface.core.diagnostics import (
22
22
  ERR_INTERNAL,
23
23
  Diagnostic,
24
24
  )
25
- from dataface.core.project import Project
25
+ from dataface.core.project import Project, ProjectPath
26
26
 
27
27
  # Chart-encoding fields surfaced to agents. Covers the stable channel fields
28
28
  # across chart families. `label` is excluded — it's a KPI-specific render hint,
@@ -99,7 +99,7 @@ class DescribeFaceResult(BaseModel):
99
99
  model_config = ConfigDict(frozen=True)
100
100
 
101
101
  success: bool
102
- path: Path
102
+ path: Path = Field(description="Project-relative path to the described face.")
103
103
  title: str | None = None
104
104
  description: str | None = None
105
105
  queries: list[QueryDescription] = Field(
@@ -183,8 +183,7 @@ def _encoding_for_chart(chart: Chart) -> dict[str, Any]:
183
183
 
184
184
  def describe_face(path: Path, *, project: Project) -> DescribeFaceResult:
185
185
  """Describe the structure of a face: queries, charts, variables, layout."""
186
- from dataface.agent_api._paths import no_project_hint, resolve_face_path
187
- from dataface.core.compile.compiler import compile_file
186
+ from dataface.agent_api._paths import resolve_face_path
188
187
  from dataface.core.diagnostics.base import DatafaceError
189
188
 
190
189
  try:
@@ -198,12 +197,22 @@ def describe_face(path: Path, *, project: Project) -> DescribeFaceResult:
198
197
  ],
199
198
  )
200
199
 
201
- resolved_display = project.root / resolved.relpath
200
+ return _describe_resolved(resolved, project=project)
201
+
202
+
203
+ def _describe_resolved(
204
+ resolved: ProjectPath, *, project: Project
205
+ ) -> DescribeFaceResult:
206
+ """Describe an already-resolved face path — no re-resolution."""
207
+ from dataface.agent_api._paths import no_project_hint
208
+ from dataface.core.compile.compiler import compile_file
209
+ from dataface.core.diagnostics.base import DatafaceError
210
+
202
211
  if not resolved.exists():
203
212
  hint = no_project_hint(project)
204
213
  return DescribeFaceResult(
205
214
  success=False,
206
- path=path,
215
+ path=Path(resolved.relpath),
207
216
  errors=[
208
217
  DatafaceError.from_code(
209
218
  ERR_FILE_NOT_FOUND,
@@ -218,7 +227,7 @@ def describe_face(path: Path, *, project: Project) -> DescribeFaceResult:
218
227
  except OSError as exc:
219
228
  return DescribeFaceResult(
220
229
  success=False,
221
- path=resolved_display,
230
+ path=Path(resolved.relpath),
222
231
  errors=[
223
232
  DatafaceError.from_code(ERR_INTERNAL, message=str(exc)).to_diagnostic(
224
233
  file=resolved.relpath
@@ -229,7 +238,7 @@ def describe_face(path: Path, *, project: Project) -> DescribeFaceResult:
229
238
  if result.errors:
230
239
  return DescribeFaceResult(
231
240
  success=False,
232
- path=resolved_display,
241
+ path=Path(resolved.relpath),
233
242
  errors=list(result.errors),
234
243
  )
235
244
 
@@ -237,7 +246,7 @@ def describe_face(path: Path, *, project: Project) -> DescribeFaceResult:
237
246
  if face is None:
238
247
  return DescribeFaceResult(
239
248
  success=False,
240
- path=resolved_display,
249
+ path=Path(resolved.relpath),
241
250
  errors=[
242
251
  DatafaceError.from_code(
243
252
  ERR_INTERNAL, message="Compilation produced no face"
@@ -285,7 +294,7 @@ def describe_face(path: Path, *, project: Project) -> DescribeFaceResult:
285
294
 
286
295
  return DescribeFaceResult(
287
296
  success=True,
288
- path=resolved_display,
297
+ path=Path(resolved.relpath),
289
298
  title=face.title or None,
290
299
  description=face.description or None,
291
300
  queries=query_descs,
@@ -339,28 +348,33 @@ def _describe_one_path(
339
348
  )
340
349
  ]
341
350
 
342
- resolved_display = project.root / resolved.relpath
343
- if not resolved_display.is_dir():
344
- return [describe_face(resolved_display, project=project)]
351
+ if resolved.is_yaml:
352
+ return [_describe_resolved(resolved, project=project)]
345
353
 
346
354
  faces = sorted(
347
- project.root / pf.relpath
348
- for pf in project.iter_faces(under=resolved.relpath, recursive=True)
349
- if pf.is_yaml
350
- and not pf.is_private
351
- and not (pf.parent / INSPECT_TEMPLATE_MANIFEST).exists()
355
+ (
356
+ pf
357
+ for pf in project.iter_faces(under=resolved.relpath, recursive=True)
358
+ if pf.is_yaml
359
+ and not pf.is_private
360
+ and not (pf.parent / INSPECT_TEMPLATE_MANIFEST).exists()
361
+ ),
362
+ # Relpaths under `under` share a common prefix, so a PurePosixPath key
363
+ # reproduces the old absolute-Path sort order exactly on 3.10 and
364
+ # 3.13 — don't "simplify" this to a str/.parts key.
365
+ key=lambda pf: PurePosixPath(pf.relpath),
352
366
  )
353
367
  if not faces:
354
368
  return [
355
369
  DescribeFaceResult(
356
370
  success=False,
357
- path=resolved_display,
371
+ path=Path(resolved.relpath),
358
372
  errors=[
359
373
  DatafaceError.from_code(
360
374
  ERR_INTERNAL,
361
- message=f"No face files found in {resolved_display}",
375
+ message=f"No face files found in {resolved.relpath}",
362
376
  ).to_diagnostic()
363
377
  ],
364
378
  )
365
379
  ]
366
- return [describe_face(f, project=project) for f in faces]
380
+ return [_describe_resolved(pf, project=project) for pf in faces]
@@ -10,7 +10,6 @@ AuthoredFace (dataface) definition from YAML.
10
10
  | `description` | str | ✓ | Description text for the dashboard. |
11
11
  | `tags` | list[str] | ✓ | Tags for categorization and search. |
12
12
  | `aliases` | list[str] | ✓ | Additional URLs that redirect to this face's canonical file-path URL. Each entry must be absolute (leading /). Requests to these URLs are redirected (302) to the face's real path, query string preserved. Valid on .yml, .yaml, .md, and folder index.* faces. |
13
- | `docs` | str | ✓ | Relative path under the docs site for the canonical doc page that explains this face. Surfaced as a 'Docs →' link in the playground gallery when DFT_DOCS_URL is set. |
14
13
  | `text` | str | ✓ | Markdown text content for text-only sections. |
15
14
  | `allow_html` | bool | ✓ | Render the face's body text as raw HTML via foreignObject instead of markdown. TRUSTED-CONTENT ONLY: the HTML (including any Jinja-interpolated values) is rendered as-authored — this is NOT a security sandbox. mdsvg strips <script>/event-handlers as a best-effort guard, not a guarantee. Enable only on first-party faces you fully control. |
16
15
  | `source` | str | ✓ | Default source name for all queries in this face. Inheritable via meta.yaml cascade. |
@@ -120,7 +120,7 @@ class ProjectSession:
120
120
  (or another cache backend) when caching is desired, otherwise omit and the
121
121
  project will run uncached. ``close()`` does not touch the cache — whoever
122
122
  opened it closes it. A caller deriving the cache from project config via
123
- ``open_project_cache(project)`` already holds a project — use
123
+ ``project_cache_ctx(project)`` already holds a project — use
124
124
  ``from_project`` instead, or this builds a second one.
125
125
 
126
126
  The adapter registry is built lazily on first access to ``adapter_registry``.
@@ -1,6 +1,6 @@
1
1
  """Typed query verbs for the agent API."""
2
2
 
3
- from pathlib import Path
3
+ from pathlib import Path, PurePosixPath
4
4
  from typing import Any
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict, Field
@@ -233,7 +233,7 @@ class QueryFaceResult(BaseModel):
233
233
 
234
234
  success: bool
235
235
  name: str
236
- path: Path
236
+ path: PurePosixPath
237
237
  query_type: str | None = None
238
238
  sql: str | None = None
239
239
  columns: list[str] = []
@@ -245,7 +245,10 @@ class QueryFaceResult(BaseModel):
245
245
 
246
246
 
247
247
  def _fail(
248
- name: str, path: Path, errors: list[str], available: list[str] | None = None
248
+ name: str,
249
+ path: PurePosixPath,
250
+ errors: list[str],
251
+ available: list[str] | None = None,
249
252
  ) -> QueryFaceResult:
250
253
  return QueryFaceResult(
251
254
  success=False,
@@ -271,9 +274,11 @@ def query_face(
271
274
  try:
272
275
  file_path = resolve_face_path(path, project)
273
276
  except ValueError as e:
274
- return _fail(name, path, [str(e)])
277
+ # Raw-input echo — resolution failed, so this is the caller's rejected
278
+ # path, not a validated project-relative identity.
279
+ return _fail(name, PurePosixPath(path), [str(e)])
275
280
 
276
- resolved_display = project.root / file_path.relpath
281
+ resolved_display = PurePosixPath(file_path.relpath)
277
282
  if not file_path.exists():
278
283
  from dataface.agent_api._paths import no_project_hint
279
284
 
@@ -5,7 +5,7 @@ No warehouse connection, no query execution.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from pathlib import Path
8
+ from pathlib import Path, PurePosixPath
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +21,7 @@ from dataface.core.diagnostics import (
21
21
  Diagnostic,
22
22
  )
23
23
  from dataface.core.diagnostics.base import DatafaceError
24
- from dataface.core.project import FACES_SUBDIR, META_FILENAMES, Project
24
+ from dataface.core.project import FACES_SUBDIR, META_FILENAMES, Project, ProjectPath
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from dataface.agent_api.project_session import ProjectSession
@@ -49,7 +49,7 @@ class ValidateResult(BaseModel):
49
49
  model_config = ConfigDict(frozen=True)
50
50
 
51
51
  success: bool
52
- path: Path
52
+ path: Path = Field(description="Project-relative path to the validated face.")
53
53
  errors: list[Diagnostic] = Field(
54
54
  default_factory=list, description="Validation errors found in the face file."
55
55
  )
@@ -131,35 +131,40 @@ def _validate_one_path(
131
131
  )
132
132
  ]
133
133
 
134
- resolved_display = project.root / resolved.relpath
135
- if not resolved_display.is_dir():
136
- return [validate(resolved_display, project=project)]
134
+ if resolved.is_yaml:
135
+ return [_validate_resolved(resolved, project=project)]
137
136
 
138
137
  faces = sorted(
139
- project.root / pf.relpath
140
- for pf in project.iter_faces(under=resolved.relpath, recursive=True)
141
- if pf.is_yaml
142
- and not pf.is_private
143
- and Path(pf.relpath).name not in META_FILENAMES
144
- and not (pf.parent / INSPECT_TEMPLATE_MANIFEST).exists()
138
+ (
139
+ pf
140
+ for pf in project.iter_faces(under=resolved.relpath, recursive=True)
141
+ if pf.is_yaml
142
+ and not pf.is_private
143
+ and PurePosixPath(pf.relpath).name not in META_FILENAMES
144
+ and not (pf.parent / INSPECT_TEMPLATE_MANIFEST).exists()
145
+ ),
146
+ # Relpaths under `under` share a common prefix, so a PurePosixPath key
147
+ # reproduces the old absolute-Path sort order exactly on 3.10 and
148
+ # 3.13 — don't "simplify" this to a str/.parts key.
149
+ key=lambda pf: PurePosixPath(pf.relpath),
145
150
  )
146
151
  if not faces:
147
152
  return [
148
153
  ValidateResult(
149
154
  success=False,
150
- path=resolved_display,
155
+ path=Path(resolved.relpath),
151
156
  errors=[
152
157
  DatafaceError.from_code(
153
158
  ERR_INTERNAL,
154
- message=f"No face files found in {resolved_display}",
159
+ message=f"No face files found in {resolved.relpath}",
155
160
  ).to_diagnostic()
156
161
  ],
157
162
  )
158
163
  ]
159
- return [validate(f, project=project) for f in faces]
164
+ return [_validate_resolved(pf, project=project) for pf in faces]
160
165
 
161
166
 
162
- def _validate_meta_file(path: Path, *, project: Project) -> ValidateResult:
167
+ def _validate_meta_file(resolved: ProjectPath) -> ValidateResult:
163
168
  """Validate a meta.yaml as a FacePatch fragment — no layout required."""
164
169
  from pydantic import ValidationError as PydanticValidationError
165
170
 
@@ -167,16 +172,17 @@ def _validate_meta_file(path: Path, *, project: Project) -> ValidateResult:
167
172
  from dataface.core.compile.meta import load_meta_file
168
173
  from dataface.core.compile.models.face.patch import FacePatch
169
174
 
175
+ relpath = resolved.relpath
170
176
  try:
171
- meta_data, _ = load_meta_file(project.path_for_fspath(path))
177
+ meta_data, _ = load_meta_file(resolved)
172
178
  except CompilationError as exc:
173
179
  return ValidateResult(
174
180
  success=False,
175
- path=path,
181
+ path=Path(relpath),
176
182
  errors=[
177
183
  DatafaceError.from_code(
178
184
  ERR_META_SCHEMA, message=str(exc)
179
- ).to_diagnostic(file=str(path))
185
+ ).to_diagnostic(file=relpath)
180
186
  ],
181
187
  )
182
188
 
@@ -191,18 +197,16 @@ def _validate_meta_file(path: Path, *, project: Project) -> ValidateResult:
191
197
  if e["loc"]
192
198
  else e["msg"]
193
199
  ),
194
- ).to_diagnostic(file=str(path))
200
+ ).to_diagnostic(file=relpath)
195
201
  for e in exc.errors()
196
202
  ]
197
- return ValidateResult(success=False, path=path, errors=errors)
203
+ return ValidateResult(success=False, path=Path(relpath), errors=errors)
198
204
 
199
- return ValidateResult(success=True, path=path)
205
+ return ValidateResult(success=True, path=Path(relpath))
200
206
 
201
207
 
202
208
  def validate(path: Path, *, project: Project) -> ValidateResult:
203
209
  """Fast YAML schema + cross-reference validation. No warehouse, no execute."""
204
- from dataface.core.compile.compiler import compile_file
205
-
206
210
  try:
207
211
  resolved = resolve_face_path(path, project)
208
212
  except ValueError as exc:
@@ -214,11 +218,18 @@ def validate(path: Path, *, project: Project) -> ValidateResult:
214
218
  ],
215
219
  )
216
220
 
221
+ return _validate_resolved(resolved, project=project)
222
+
223
+
224
+ def _validate_resolved(resolved: ProjectPath, *, project: Project) -> ValidateResult:
225
+ """Validate an already-resolved face path — no re-resolution."""
226
+ from dataface.core.compile.compiler import compile_file
227
+
217
228
  if not resolved.exists():
218
229
  hint = no_project_hint(project)
219
230
  return ValidateResult(
220
231
  success=False,
221
- path=path,
232
+ path=Path(resolved.relpath),
222
233
  errors=[
223
234
  DatafaceError.from_code(
224
235
  ERR_FILE_NOT_FOUND,
@@ -228,16 +239,15 @@ def validate(path: Path, *, project: Project) -> ValidateResult:
228
239
  ],
229
240
  )
230
241
 
231
- resolved_display = project.root / resolved.relpath
232
- if resolved_display.name in META_FILENAMES:
233
- return _validate_meta_file(resolved_display, project=project)
242
+ if PurePosixPath(resolved.relpath).name in META_FILENAMES:
243
+ return _validate_meta_file(resolved)
234
244
 
235
245
  try:
236
246
  result = compile_file(resolved.read_face())
237
247
  except OSError as exc:
238
248
  return ValidateResult(
239
249
  success=False,
240
- path=resolved_display,
250
+ path=Path(resolved.relpath),
241
251
  errors=[
242
252
  DatafaceError.from_code(ERR_INTERNAL, message=str(exc)).to_diagnostic(
243
253
  file=resolved.relpath
@@ -248,7 +258,7 @@ def validate(path: Path, *, project: Project) -> ValidateResult:
248
258
  errors = list(result.errors)
249
259
  return ValidateResult(
250
260
  success=len(errors) == 0,
251
- path=resolved_display,
261
+ path=Path(resolved.relpath),
252
262
  errors=errors,
253
263
  warnings=list(result.warnings),
254
264
  )
@@ -297,18 +307,17 @@ def annotate_with_data_lint(
297
307
 
298
308
  annotated: list[ValidateResult] = []
299
309
  for result in results:
310
+ project_path = project_session.project.path(result.path.as_posix())
300
311
  try:
301
- project_path = project_session.project.path_for_fspath(result.path)
312
+ path_exists = project_path.exists()
302
313
  except ValueError:
303
314
  # Fires for a genuine root-escape (an earlier resolve_face_path
304
- # failure) AND for a valid project-relative result.path e.g. the
305
- # original caller-supplied path on a not-found result whose
306
- # cwd-anchored resolution inside path_for_fspath just doesn't land
307
- # under project.root. Either way there's nothing to lint through
308
- # the seam; keep the result unchanged.
315
+ # failure) or a not-found result echoing a raw caller-supplied
316
+ # path that isn't project-relative. Either way there's nothing to
317
+ # lint through the seam; keep the result unchanged.
309
318
  annotated.append(result)
310
319
  continue
311
- if not project_path.exists():
320
+ if not path_exists:
312
321
  annotated.append(result)
313
322
  continue
314
323
  alias_msgs = data_alias_errors_for_file(