dataface 0.1.5.dev151__py3-none-any.whl → 0.1.5.dev206__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 +6 -5
- dataface/agent_api/_paths.py +16 -20
- dataface/agent_api/cache.py +24 -0
- dataface/agent_api/chat.py +23 -18
- dataface/agent_api/dashboards.py +4 -2
- dataface/agent_api/describe.py +1 -1
- dataface/agent_api/docs/yaml-reference.md +22 -3
- dataface/agent_api/files.py +262 -0
- dataface/agent_api/project.py +133 -45
- dataface/agent_api/render_face.py +100 -0
- dataface/agent_api/surface_aliases.yaml +4 -0
- dataface/agent_api/validate.py +1 -1
- dataface/ai/__init__.py +1 -2
- dataface/ai/agent.py +33 -13
- dataface/ai/context.py +5 -6
- dataface/ai/generate_sql.py +9 -24
- dataface/ai/mcp/server.py +26 -22
- dataface/ai/prompts/sql-guidance.md +8 -0
- dataface/ai/prompts/sql-system.md +13 -0
- dataface/ai/prompts/system.md +19 -0
- dataface/ai/prompts.py +77 -64
- dataface/ai/schema_context.py +39 -14
- dataface/ai/skills/dashboard-build/SKILL.md +7 -0
- dataface/ai/skills/data-exploration/SKILL.md +75 -0
- dataface/ai/tool_schemas.py +28 -0
- dataface/ai/tools/__init__.py +56 -15
- dataface/cli/commands/chat.py +171 -21
- dataface/cli/commands/describe.py +11 -6
- dataface/cli/commands/query.py +105 -105
- dataface/cli/commands/render.py +42 -27
- dataface/cli/commands/validate.py +11 -6
- dataface/core/compile/authoring_warnings.py +115 -0
- dataface/core/compile/compiler.py +11 -8
- dataface/core/compile/config.py +10 -92
- dataface/core/compile/models/config.py +2 -17
- dataface/core/compile/models/face/authored.py +10 -0
- dataface/core/compile/models/face/normalized.py +10 -0
- dataface/core/compile/models/face/resolved.py +1 -0
- dataface/core/compile/models/style/resolved.py +4 -0
- dataface/core/compile/models/style/theme.py +45 -2
- dataface/core/compile/models/variable/authored.py +0 -4
- dataface/core/compile/normalizer.py +1 -0
- dataface/core/compile/sizing.py +10 -12
- dataface/core/dashboard.py +7 -14
- dataface/core/defaults/default_config.yml +2 -7
- dataface/core/defaults/themes/_base.yaml +10 -1
- dataface/core/defaults/themes/cream.yaml +3 -1
- dataface/core/defaults/themes/editorial.yaml +4 -1
- dataface/core/execute/adapters/adapter_registry.py +21 -2
- dataface/core/execute/source_registry.py +17 -43
- dataface/core/inspect/renderer.py +5 -5
- dataface/core/inspect/resolver.py +28 -1
- dataface/core/render/face_api.py +28 -129
- dataface/core/render/faces.py +17 -14
- dataface/core/render/nav.py +181 -0
- dataface/core/render/renderer.py +7 -4
- dataface/core/render/text/case.py +21 -0
- dataface/core/render/variable_controls.py +3 -5
- dataface/core/resolve_face.py +7 -2
- dataface/core/serve/server.py +261 -37
- dataface/core/serve/templates/nav.yml +12 -0
- dataface/integrations/markdown.py +77 -68
- {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/METADATA +1 -1
- {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/RECORD +67 -57
- {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/WHEEL +0 -0
- {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/entry_points.txt +0 -0
- {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/licenses/LICENSE +0 -0
dataface/DATAFACE_SYNTAX.md
CHANGED
|
@@ -597,12 +597,14 @@ support: # Optional support line (same shape: value/label/format/
|
|
|
597
597
|
glyph: "▲"
|
|
598
598
|
tone: positive
|
|
599
599
|
|
|
600
|
-
# table — renders all query columns unless `style.columns` selects a subset
|
|
600
|
+
# table — renders all query columns unless `style.columns` selects a subset.
|
|
601
|
+
# `columns` is a MAPPING keyed by column name (NOT a list). Omit it to show
|
|
602
|
+
# every query column; include it to choose a subset and/or style columns.
|
|
601
603
|
type: table
|
|
602
604
|
style:
|
|
603
605
|
columns:
|
|
604
|
-
|
|
605
|
-
|
|
606
|
+
order_id: {} # include with default styling
|
|
607
|
+
amount: # the key is the column name
|
|
606
608
|
label: Amount
|
|
607
609
|
format: currency_whole
|
|
608
610
|
align: right # left | center | right
|
|
@@ -876,7 +878,6 @@ Common variable fields:
|
|
|
876
878
|
| `default` | any | Default value when no URL param is set |
|
|
877
879
|
| `placeholder` | string | Placeholder text |
|
|
878
880
|
| `required` | bool | Block rendering until a value exists |
|
|
879
|
-
| `allow_null` | bool | `null` is a valid selection |
|
|
880
881
|
| `visible` | bool | Hidden when `false`; still settable via URL param |
|
|
881
882
|
| `disabled` | bool \| string \| `{query, column}` | Static, Jinja expr, or query-backed disable |
|
|
882
883
|
| `data_type` | string | Upstream type hint (informational; preserved through migrations) |
|
|
@@ -905,7 +906,7 @@ variables:
|
|
|
905
906
|
|
|
906
907
|
Top-level option-source binding (alternative to `options:`): `column`, `query`, `dimension` (MetricFlow), `measure` (MetricFlow), `model` (dbt).
|
|
907
908
|
|
|
908
|
-
For `select` and `multiselect`, omitting `default` starts the variable unset. The renderer adds a blank `-- All --` option for that unset state; `{{ filter('column', variable) }}` emits `1=1` unless the SQL call site opts into `none='deny'`.
|
|
909
|
+
Variables are optional by default. For `select` and `multiselect`, omitting `default` starts the variable unset. The renderer adds a blank `-- All --` option for that unset state; `{{ filter('column', variable) }}` emits `1=1` unless the SQL call site opts into `none='deny'`. Add `required: true` only when the dashboard cannot render without a value.
|
|
909
910
|
|
|
910
911
|
Disabled forms:
|
|
911
912
|
|
dataface/agent_api/_paths.py
CHANGED
|
@@ -5,10 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
-
from dataface.core.execute.adapters import (
|
|
9
|
-
AdapterRegistry as AdapterRegistry,
|
|
10
|
-
build_adapter_registry as build_adapter_registry,
|
|
11
|
-
)
|
|
12
8
|
from dataface.core.project_roots import (
|
|
13
9
|
discover_render_context as discover_render_context,
|
|
14
10
|
discovery_boundary_for_face as discovery_boundary_for_face,
|
|
@@ -75,23 +71,26 @@ def no_project_hint(project_dir: Path | None) -> str:
|
|
|
75
71
|
|
|
76
72
|
@dataclass(frozen=True)
|
|
77
73
|
class FaceRenderContext:
|
|
78
|
-
"""
|
|
74
|
+
"""Path resolution result from a face path + project root.
|
|
75
|
+
|
|
76
|
+
Adapter registry is built by `Project.open`, not by the context — call sites
|
|
77
|
+
open a `Project` with `project_root` + `dbt_project_path` and read the
|
|
78
|
+
registry off `project.adapter_registry`.
|
|
79
|
+
"""
|
|
79
80
|
|
|
80
81
|
face_file: Path
|
|
81
82
|
scoped_path: Path | None
|
|
82
83
|
scoped_base: Path
|
|
83
84
|
project_root: Path
|
|
84
85
|
output_dir: Path
|
|
85
|
-
|
|
86
|
+
dbt_project_path: Path | None = None
|
|
86
87
|
|
|
87
88
|
|
|
88
89
|
def build_face_render_context(
|
|
89
90
|
face_path: Path,
|
|
90
91
|
project_dir: Path | None = None,
|
|
91
|
-
*,
|
|
92
|
-
read_only: bool = True,
|
|
93
92
|
) -> FaceRenderContext:
|
|
94
|
-
"""Resolve a face path
|
|
93
|
+
"""Resolve a face path and walk for dbt context.
|
|
95
94
|
|
|
96
95
|
``project_dir=None`` means "walk freely from the face's parent" — used by
|
|
97
96
|
the CLI when ``--project-dir`` is omitted so a face under a dbt sub-project
|
|
@@ -127,27 +126,26 @@ def build_face_render_context(
|
|
|
127
126
|
scoped_base=project_root,
|
|
128
127
|
project_root=project_root,
|
|
129
128
|
output_dir=project_root,
|
|
130
|
-
|
|
131
|
-
project_root, read_only=read_only, dbt_project_path=dbt_project_path
|
|
132
|
-
),
|
|
129
|
+
dbt_project_path=dbt_project_path,
|
|
133
130
|
)
|
|
134
131
|
|
|
135
132
|
|
|
136
133
|
@dataclass(frozen=True)
|
|
137
134
|
class YamlRenderContext:
|
|
138
|
-
"""
|
|
135
|
+
"""Path resolution result for rendering inline YAML against a project root.
|
|
136
|
+
|
|
137
|
+
Adapter registry is built by `Project.open`, not by the context.
|
|
138
|
+
"""
|
|
139
139
|
|
|
140
140
|
project_root: Path
|
|
141
141
|
output_dir: Path
|
|
142
|
-
|
|
142
|
+
dbt_project_path: Path | None = None
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
def build_yaml_render_context(
|
|
146
146
|
project_dir: Path | None = None,
|
|
147
|
-
*,
|
|
148
|
-
read_only: bool = True,
|
|
149
147
|
) -> YamlRenderContext:
|
|
150
|
-
"""Walk for dbt context
|
|
148
|
+
"""Walk for dbt context anchored at the given project root.
|
|
151
149
|
|
|
152
150
|
``project_dir=None`` walks from cwd to discover the project root; a given
|
|
153
151
|
``project_dir`` is authoritative.
|
|
@@ -160,7 +158,5 @@ def build_yaml_render_context(
|
|
|
160
158
|
return YamlRenderContext(
|
|
161
159
|
project_root=project_root,
|
|
162
160
|
output_dir=project_root,
|
|
163
|
-
|
|
164
|
-
project_root, read_only=read_only, dbt_project_path=dbt_project_path
|
|
165
|
-
),
|
|
161
|
+
dbt_project_path=dbt_project_path,
|
|
166
162
|
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Cache constructors at the agent_api boundary."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
|
|
6
|
+
from dataface.core.execute.duckdb_cache import DuckDBCache, open_cache_from_env
|
|
7
|
+
|
|
8
|
+
__all__ = ["cache_from_env", "open_cache_from_env"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def cache_from_env() -> Generator[DuckDBCache | None]:
|
|
13
|
+
"""Open DFT_CACHE_PATH-backed cache (or None) and close it on exit.
|
|
14
|
+
|
|
15
|
+
Use for CLI verbs and composition roots that need a cache scoped to
|
|
16
|
+
a single command invocation. Long-lived processes (server boot, MCP
|
|
17
|
+
server) use ``open_cache_from_env()`` directly and own the lifecycle.
|
|
18
|
+
"""
|
|
19
|
+
cache = open_cache_from_env()
|
|
20
|
+
try:
|
|
21
|
+
yield cache
|
|
22
|
+
finally:
|
|
23
|
+
if cache is not None:
|
|
24
|
+
cache.close()
|
dataface/agent_api/chat.py
CHANGED
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
from collections.abc import Generator
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import TYPE_CHECKING, Any
|
|
10
11
|
|
|
12
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
from typing import Self
|
|
14
|
+
else:
|
|
15
|
+
from typing_extensions import Self
|
|
16
|
+
|
|
11
17
|
from dataface.agent_api._session_store import (
|
|
12
18
|
SessionIndex,
|
|
13
19
|
SessionWriter,
|
|
@@ -40,6 +46,17 @@ class ChatSession:
|
|
|
40
46
|
client: Any = field(repr=False)
|
|
41
47
|
writer: SessionWriter = field(repr=False)
|
|
42
48
|
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
"""Flush the session log and release the project's resources."""
|
|
51
|
+
self.writer.close()
|
|
52
|
+
self.context.project.close()
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> Self:
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
58
|
+
self.close()
|
|
59
|
+
|
|
43
60
|
|
|
44
61
|
@dataclass
|
|
45
62
|
class ChatSessionSummary:
|
|
@@ -64,19 +81,13 @@ def start_session(
|
|
|
64
81
|
project_dir: Working directory for the session. Defaults to cwd.
|
|
65
82
|
server_port: Port of the embedded HTTP preview server, if running.
|
|
66
83
|
"""
|
|
67
|
-
from dataface.
|
|
68
|
-
|
|
69
|
-
build_adapter_registry,
|
|
70
|
-
)
|
|
84
|
+
from dataface.agent_api.project import Project
|
|
85
|
+
from dataface.core.execute.adapters import LOCAL_AUTHORING_REGISTRY_KWARGS
|
|
71
86
|
|
|
72
87
|
cwd = (project_dir or Path.cwd()).resolve()
|
|
73
88
|
client = create_client(model=model)
|
|
74
89
|
context = DatafaceAIContext(
|
|
75
|
-
|
|
76
|
-
cwd,
|
|
77
|
-
**LOCAL_AUTHORING_REGISTRY_KWARGS,
|
|
78
|
-
),
|
|
79
|
-
default_project_dir=cwd,
|
|
90
|
+
project=Project.open(cwd, **LOCAL_AUTHORING_REGISTRY_KWARGS),
|
|
80
91
|
server_port=server_port,
|
|
81
92
|
)
|
|
82
93
|
writer, _ = new_session(cwd, provider=client.provider, model=client.model)
|
|
@@ -112,10 +123,8 @@ def resume_session(
|
|
|
112
123
|
Raises:
|
|
113
124
|
ValueError: If session not found, provider mismatch, or context window exceeded.
|
|
114
125
|
"""
|
|
115
|
-
from dataface.
|
|
116
|
-
|
|
117
|
-
build_adapter_registry,
|
|
118
|
-
)
|
|
126
|
+
from dataface.agent_api.project import Project
|
|
127
|
+
from dataface.core.execute.adapters import LOCAL_AUTHORING_REGISTRY_KWARGS
|
|
119
128
|
|
|
120
129
|
cwd = (project_dir or Path.cwd()).resolve()
|
|
121
130
|
client = create_client(model=model)
|
|
@@ -134,11 +143,7 @@ def resume_session(
|
|
|
134
143
|
)
|
|
135
144
|
|
|
136
145
|
context = DatafaceAIContext(
|
|
137
|
-
|
|
138
|
-
cwd,
|
|
139
|
-
**LOCAL_AUTHORING_REGISTRY_KWARGS,
|
|
140
|
-
),
|
|
141
|
-
default_project_dir=cwd,
|
|
146
|
+
project=Project.open(cwd, **LOCAL_AUTHORING_REGISTRY_KWARGS),
|
|
142
147
|
server_port=server_port,
|
|
143
148
|
)
|
|
144
149
|
writer, _ = new_session(cwd, provider=client.provider, model=client.model)
|
dataface/agent_api/dashboards.py
CHANGED
|
@@ -79,7 +79,7 @@ class RenderDashboardArgs(BaseModel):
|
|
|
79
79
|
variables: dict[str, Any] | None = Field(
|
|
80
80
|
None, description="Variable values to apply to the dashboard"
|
|
81
81
|
)
|
|
82
|
-
format: Literal["json", "text", "yaml", "svg"] | None = Field(
|
|
82
|
+
format: Literal["json", "text", "yaml", "svg", "terminal"] | None = Field(
|
|
83
83
|
None,
|
|
84
84
|
description=(
|
|
85
85
|
"Output format. 'json' (default) returns resolved chart "
|
|
@@ -89,7 +89,9 @@ class RenderDashboardArgs(BaseModel):
|
|
|
89
89
|
"YAML with inline data — valid input for re-compilation, "
|
|
90
90
|
"ideal for round-trip editing. 'svg' returns the rendered "
|
|
91
91
|
"dashboard as inline SVG (under result['data']) for hosts "
|
|
92
|
-
"that embed the rendered output directly."
|
|
92
|
+
"that embed the rendered output directly. 'terminal' returns "
|
|
93
|
+
"the charts as ANSI text (under result['data']) for display "
|
|
94
|
+
"in a terminal."
|
|
93
95
|
),
|
|
94
96
|
)
|
|
95
97
|
as_link: bool = Field(
|
dataface/agent_api/describe.py
CHANGED
|
@@ -87,7 +87,7 @@ class DescribeFaceArgs(BaseModel):
|
|
|
87
87
|
default=None,
|
|
88
88
|
description=(
|
|
89
89
|
"Project root for resolving relative paths. Optional on the wire; "
|
|
90
|
-
"the MCP server injects ctx.
|
|
90
|
+
"the MCP server injects ctx.project.project_root when omitted."
|
|
91
91
|
),
|
|
92
92
|
)
|
|
93
93
|
|
|
@@ -11,6 +11,7 @@ AuthoredFace (dataface) definition from YAML.
|
|
|
11
11
|
| `tags` | list[str] | ✓ | Tags for categorization and search. |
|
|
12
12
|
| `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. |
|
|
13
13
|
| `text` | str | ✓ | Markdown text content for text-only sections. |
|
|
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. |
|
|
14
15
|
| `sources` | [SourcesSection](#sourcessection) | ✓ | Database source configuration. Use 'default:' to set the default source for all queries. |
|
|
15
16
|
| `source` | str | ✓ | Default source name shorthand (equivalent to sources.default). Sets the connection for all queries. |
|
|
16
17
|
| `variables` | dict[str, [Variable](#variable) \| VariableRef] | ✓ | Variable definitions for dynamic filtering and UI controls. |
|
|
@@ -51,7 +52,6 @@ Variable definition from YAML.
|
|
|
51
52
|
| `default` | Any | ✓ | Default value used when no URL param is set. |
|
|
52
53
|
| `placeholder` | str | ✓ | Placeholder text shown in the input when empty. |
|
|
53
54
|
| `required` | bool | ✓ | When True, a value must be provided before queries execute. |
|
|
54
|
-
| `allow_null` | bool | ✓ | When True, 'null' is a valid selection (useful for optional filters). |
|
|
55
55
|
| `visible` | bool | ✓ | When False, the variable is not rendered in the UI but can still be set via URL params. |
|
|
56
56
|
| `disabled` | bool \| str \| [SingleRowBoolProbe](#singlerowboolprobe) | ✓ | Disable this control. Accepts: static bool; a variable name or Jinja boolean expression string (no {{ }} required — bare names auto-wrap); or a {query, column} form that reads a single boolean cell from a named query. Absent variable in a string expression raises (use a default). |
|
|
57
57
|
| `column` | str | ✓ | Column name in the query result to use as option values. |
|
|
@@ -491,6 +491,8 @@ Authored overlay for Style — all fields optional. Adds CSS shorthand coercers.
|
|
|
491
491
|
| `layout` | [LayoutStyle](#layoutstyle) | ✓ | Layout container styles (rows, cols, grid, tabs, details). |
|
|
492
492
|
| `variables` | [VariablesStyle](#variablesstyle) | ✓ | Variable controls chrome style. |
|
|
493
493
|
| `page` | [PageStyle](#pagestyle) | ✓ | Page-level canvas style (behind the board). |
|
|
494
|
+
| `footer` | [FooterStyle](#footerstyle) | ✓ | Page footer chrome visibility. |
|
|
495
|
+
| `timestamp` | [TimestampStyle](#timestampstyle) | ✓ | Render-timestamp chrome: visibility, format, and font. |
|
|
494
496
|
| `formats` | dict[str, str] | ✓ | Format alias map; None means no aliases at this cascade level. |
|
|
495
497
|
| `palettes` | dict[str, str] | ✓ | Theme palette role assignments: open dict mapping role name to palette file name. Default seed: chrome, negative, positive, warning, category, sequence, diverge. |
|
|
496
498
|
| `roles` | dict[str, str] | ✓ | Optional top-level theme role aliases: bare name → role.alias. e.g. ink: chrome.heading |
|
|
@@ -1064,8 +1066,7 @@ Authored overlay for BoardStyle. Face-level structural dimensions. Do NOT cascad
|
|
|
1064
1066
|
| Field | Type | Optional | Description |
|
|
1065
1067
|
|-------|------|:--------:|-------------|
|
|
1066
1068
|
| `width` | float | ✓ | Board width in pixels. |
|
|
1067
|
-
| `
|
|
1068
|
-
| `min_height` | float | ✓ | Minimum row height in pixels. |
|
|
1069
|
+
| `min_height` | float | ✓ | Minimum board height in pixels. |
|
|
1069
1070
|
| `margin` | float | ✓ | Board outer margin in pixels. |
|
|
1070
1071
|
| `card_padding` | float | ✓ | Padding added to each card side in pixels. |
|
|
1071
1072
|
| `card_gap` | float | ✓ | Gap between cards in pixels. |
|
|
@@ -1222,6 +1223,24 @@ Authored overlay for PageStyle. Page-level (outer HTML canvas) styling.
|
|
|
1222
1223
|
|-------|------|:--------:|-------------|
|
|
1223
1224
|
| `background` | str | ✓ | Page canvas background color (behind the face board). |
|
|
1224
1225
|
|
|
1226
|
+
<a id="footerstyle"></a>
|
|
1227
|
+
## FooterStyle
|
|
1228
|
+
Authored overlay for FooterStyle. Authored visibility toggle for the page footer chrome.
|
|
1229
|
+
|
|
1230
|
+
| Field | Type | Optional | Description |
|
|
1231
|
+
|-------|------|:--------:|-------------|
|
|
1232
|
+
| `visible` | bool | ✓ | Show the footer attribution line. |
|
|
1233
|
+
|
|
1234
|
+
<a id="timestampstyle"></a>
|
|
1235
|
+
## TimestampStyle
|
|
1236
|
+
Authored overlay for TimestampStyle. Authored timestamp chrome: visibility, strftime format, and font.
|
|
1237
|
+
|
|
1238
|
+
| Field | Type | Optional | Description |
|
|
1239
|
+
|-------|------|:--------:|-------------|
|
|
1240
|
+
| `visible` | bool | ✓ | Show the render-timestamp line. |
|
|
1241
|
+
| `format` | str | ✓ | strftime format string for the render timestamp. |
|
|
1242
|
+
| `font` | [FontStyle](#fontstyle) | ✓ | Timestamp font style overrides (size, color, weight, ...). |
|
|
1243
|
+
|
|
1225
1244
|
<a id="spacingvalues"></a>
|
|
1226
1245
|
## SpacingValues
|
|
1227
1246
|
Pre-parsed CSS spacing (margin/padding).
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""General project-file tools for the chat agent.
|
|
2
|
+
|
|
3
|
+
Claude-Code-style primitives — read, write, edit, glob, grep — that let an
|
|
4
|
+
agent author and save faces directly, rather than via a domain-specific
|
|
5
|
+
"write a dashboard" verb. The agent composes these with the typed dft tools
|
|
6
|
+
(``validate_dashboard``, ``render_dashboard``, ``schema``, ``execute_query``).
|
|
7
|
+
|
|
8
|
+
Every path is resolved against and confined to the project root. A path that
|
|
9
|
+
escapes the root (``..`` traversal, absolute path outside, symlink out) is
|
|
10
|
+
refused — these tools never touch the wider filesystem.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
# Cap grep/glob payloads — read_file returns full content (model must handle size).
|
|
20
|
+
_MAX_GREP_MATCHES = 200
|
|
21
|
+
_MAX_GLOB_MATCHES = 500
|
|
22
|
+
|
|
23
|
+
# Directory names to skip on whole-tree scans — avoids traversing .venv,
|
|
24
|
+
# .git, node_modules, target, etc. which can be huge and contain no user code.
|
|
25
|
+
_SKIP_DIRS = frozenset(
|
|
26
|
+
{".git", ".venv", "venv", "node_modules", "target", "__pycache__", ".tox"}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ReadFileArgs(BaseModel):
|
|
31
|
+
"""Read a UTF-8 text file from the project. Path is relative to the project root."""
|
|
32
|
+
|
|
33
|
+
path: str = Field(description="File path relative to the project root.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WriteFileArgs(BaseModel):
|
|
37
|
+
"""Write a UTF-8 text file in the project, creating parent directories.
|
|
38
|
+
|
|
39
|
+
Overwrites an existing file. To change part of an existing file, prefer
|
|
40
|
+
edit_file so you don't clobber content you haven't read. Path is relative
|
|
41
|
+
to the project root and may not escape it.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
path: str = Field(description="File path relative to the project root.")
|
|
45
|
+
content: str = Field(description="Full file contents to write.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EditFileArgs(BaseModel):
|
|
49
|
+
"""Replace an exact string in a project file. old_string must occur exactly once.
|
|
50
|
+
|
|
51
|
+
Fails (writing nothing) if old_string is absent or appears more than once —
|
|
52
|
+
include enough surrounding context to make it unique. Path is relative to
|
|
53
|
+
the project root and may not escape it.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
path: str = Field(description="File path relative to the project root.")
|
|
57
|
+
old_string: str = Field(description="Exact text to replace (must be unique).")
|
|
58
|
+
new_string: str = Field(description="Replacement text.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GlobFilesArgs(BaseModel):
|
|
62
|
+
"""List files matching a glob pattern under the project root (e.g. 'faces/*.yml')."""
|
|
63
|
+
|
|
64
|
+
pattern: str = Field(description="Glob pattern relative to the project root.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GrepFilesArgs(BaseModel):
|
|
68
|
+
"""Search file contents for a substring under the project root.
|
|
69
|
+
|
|
70
|
+
Optionally restrict to files matching a glob (e.g. only 'faces/**/*.yml').
|
|
71
|
+
Skips .git, .venv, node_modules, target, and other large non-user directories.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
pattern: str = Field(description="Substring to search for (case-sensitive).")
|
|
75
|
+
glob: str | None = Field(
|
|
76
|
+
default=None, description="Optional glob to restrict which files are searched."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ReadFileResult(BaseModel):
|
|
81
|
+
success: bool
|
|
82
|
+
path: str
|
|
83
|
+
content: str | None = None
|
|
84
|
+
error: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class WriteFileResult(BaseModel):
|
|
88
|
+
success: bool
|
|
89
|
+
path: str
|
|
90
|
+
bytes_written: int = 0
|
|
91
|
+
error: str | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EditFileResult(BaseModel):
|
|
95
|
+
success: bool
|
|
96
|
+
path: str
|
|
97
|
+
replacements: int = 0
|
|
98
|
+
error: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GlobResult(BaseModel):
|
|
102
|
+
success: bool
|
|
103
|
+
matches: list[str] = Field(default_factory=list)
|
|
104
|
+
truncated: bool = False
|
|
105
|
+
error: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class GrepMatch(BaseModel):
|
|
109
|
+
path: str
|
|
110
|
+
line_number: int
|
|
111
|
+
line: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class GrepResult(BaseModel):
|
|
115
|
+
success: bool
|
|
116
|
+
matches: list[GrepMatch] = Field(default_factory=list)
|
|
117
|
+
truncated: bool = False
|
|
118
|
+
error: str | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_within(project_dir: Path, raw: str) -> Path:
|
|
122
|
+
"""Resolve ``raw`` against the project root, refusing anything that escapes it."""
|
|
123
|
+
root = project_dir.resolve()
|
|
124
|
+
candidate = Path(raw)
|
|
125
|
+
candidate = candidate if candidate.is_absolute() else root / candidate
|
|
126
|
+
resolved = candidate.resolve()
|
|
127
|
+
if resolved != root and root not in resolved.parents:
|
|
128
|
+
raise ValueError(f"path {raw!r} escapes the project root")
|
|
129
|
+
return resolved
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _rel(project_dir: Path, p: Path) -> str:
|
|
133
|
+
return str(p.resolve().relative_to(project_dir.resolve()))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _walk_project(root: Path) -> list[Path]:
|
|
137
|
+
"""Return files under root, skipping known large/irrelevant directories."""
|
|
138
|
+
result: list[Path] = []
|
|
139
|
+
for item in sorted(root.iterdir()):
|
|
140
|
+
if item.is_dir():
|
|
141
|
+
if item.name in _SKIP_DIRS or item.name.startswith("."):
|
|
142
|
+
continue
|
|
143
|
+
result.extend(_walk_project(item))
|
|
144
|
+
elif item.is_file():
|
|
145
|
+
result.append(item)
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def read_file(path: str, project_dir: Path) -> ReadFileResult:
|
|
150
|
+
try:
|
|
151
|
+
target = _resolve_within(project_dir, path)
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
return ReadFileResult(success=False, path=path, error=str(exc))
|
|
154
|
+
if not target.is_file():
|
|
155
|
+
return ReadFileResult(success=False, path=path, error=f"file not found: {path}")
|
|
156
|
+
try:
|
|
157
|
+
content = target.read_text(encoding="utf-8")
|
|
158
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
159
|
+
return ReadFileResult(success=False, path=path, error=str(exc))
|
|
160
|
+
return ReadFileResult(success=True, path=_rel(project_dir, target), content=content)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_file(path: str, content: str, project_dir: Path) -> WriteFileResult:
|
|
164
|
+
try:
|
|
165
|
+
target = _resolve_within(project_dir, path)
|
|
166
|
+
except ValueError as exc:
|
|
167
|
+
return WriteFileResult(success=False, path=path, error=str(exc))
|
|
168
|
+
try:
|
|
169
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
target.write_text(content, encoding="utf-8")
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
return WriteFileResult(success=False, path=path, error=str(exc))
|
|
173
|
+
return WriteFileResult(
|
|
174
|
+
success=True,
|
|
175
|
+
path=_rel(project_dir, target),
|
|
176
|
+
bytes_written=len(content.encode("utf-8")),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def edit_file(
|
|
181
|
+
path: str, old_string: str, new_string: str, project_dir: Path
|
|
182
|
+
) -> EditFileResult:
|
|
183
|
+
try:
|
|
184
|
+
target = _resolve_within(project_dir, path)
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
return EditFileResult(success=False, path=path, error=str(exc))
|
|
187
|
+
if not target.is_file():
|
|
188
|
+
return EditFileResult(success=False, path=path, error=f"file not found: {path}")
|
|
189
|
+
try:
|
|
190
|
+
original = target.read_text(encoding="utf-8")
|
|
191
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
192
|
+
return EditFileResult(success=False, path=path, error=str(exc))
|
|
193
|
+
count = original.count(old_string)
|
|
194
|
+
if count == 0:
|
|
195
|
+
return EditFileResult(
|
|
196
|
+
success=False, path=path, error="old_string not found in file"
|
|
197
|
+
)
|
|
198
|
+
if count > 1:
|
|
199
|
+
return EditFileResult(
|
|
200
|
+
success=False,
|
|
201
|
+
path=path,
|
|
202
|
+
error=f"old_string is not unique ({count} occurrences) — add surrounding context",
|
|
203
|
+
)
|
|
204
|
+
try:
|
|
205
|
+
target.write_text(original.replace(old_string, new_string), encoding="utf-8")
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
return EditFileResult(success=False, path=path, error=str(exc))
|
|
208
|
+
return EditFileResult(success=True, path=_rel(project_dir, target), replacements=1)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def glob_files(pattern: str, project_dir: Path) -> GlobResult:
|
|
212
|
+
root = project_dir.resolve()
|
|
213
|
+
matches: list[str] = []
|
|
214
|
+
try:
|
|
215
|
+
candidates = root.glob(pattern)
|
|
216
|
+
except (ValueError, OSError, NotImplementedError) as exc:
|
|
217
|
+
return GlobResult(success=False, error=str(exc))
|
|
218
|
+
for p in sorted(candidates):
|
|
219
|
+
if not p.is_file():
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
rel = p.resolve().relative_to(root)
|
|
223
|
+
except ValueError:
|
|
224
|
+
continue # symlink escaping the root
|
|
225
|
+
matches.append(str(rel))
|
|
226
|
+
if len(matches) >= _MAX_GLOB_MATCHES:
|
|
227
|
+
return GlobResult(success=True, matches=matches, truncated=True)
|
|
228
|
+
return GlobResult(success=True, matches=matches)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def grep_files(pattern: str, project_dir: Path, glob: str | None = None) -> GrepResult:
|
|
232
|
+
root = project_dir.resolve()
|
|
233
|
+
matches: list[GrepMatch] = []
|
|
234
|
+
try:
|
|
235
|
+
candidates: list[Path] = (
|
|
236
|
+
sorted(root.glob(glob)) if glob else _walk_project(root)
|
|
237
|
+
)
|
|
238
|
+
except (ValueError, OSError, NotImplementedError) as exc:
|
|
239
|
+
return GrepResult(success=False, error=str(exc))
|
|
240
|
+
for p in candidates:
|
|
241
|
+
if not p.is_file():
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
rel = p.resolve().relative_to(root)
|
|
245
|
+
except ValueError:
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
with p.open(encoding="utf-8") as fh:
|
|
249
|
+
for i, line in enumerate(fh, start=1):
|
|
250
|
+
if pattern in line:
|
|
251
|
+
matches.append(
|
|
252
|
+
GrepMatch(
|
|
253
|
+
path=str(rel), line_number=i, line=line.rstrip("\n")
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
if len(matches) >= _MAX_GREP_MATCHES:
|
|
257
|
+
return GrepResult(
|
|
258
|
+
success=True, matches=matches, truncated=True
|
|
259
|
+
)
|
|
260
|
+
except (OSError, UnicodeDecodeError):
|
|
261
|
+
continue # skip binary / unreadable files
|
|
262
|
+
return GrepResult(success=True, matches=matches)
|