nao-core 0.0.38__py3-none-manylinux2014_aarch64.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.
- nao_core/__init__.py +2 -0
- nao_core/__init__.py.bak +2 -0
- nao_core/bin/build-info.json +5 -0
- nao_core/bin/fastapi/main.py +268 -0
- nao_core/bin/fastapi/test_main.py +156 -0
- nao_core/bin/migrations-postgres/0000_user_auth_and_chat_tables.sql +98 -0
- nao_core/bin/migrations-postgres/0001_message_feedback.sql +9 -0
- nao_core/bin/migrations-postgres/0002_chat_message_stop_reason_and_error_message.sql +2 -0
- nao_core/bin/migrations-postgres/0003_handle_slack_with_thread.sql +2 -0
- nao_core/bin/migrations-postgres/0004_input_and_output_tokens.sql +8 -0
- nao_core/bin/migrations-postgres/0005_add_project_tables.sql +39 -0
- nao_core/bin/migrations-postgres/0006_llm_model_ids.sql +4 -0
- nao_core/bin/migrations-postgres/0007_chat_message_llm_info.sql +2 -0
- nao_core/bin/migrations-postgres/meta/0000_snapshot.json +707 -0
- nao_core/bin/migrations-postgres/meta/0001_snapshot.json +766 -0
- nao_core/bin/migrations-postgres/meta/0002_snapshot.json +778 -0
- nao_core/bin/migrations-postgres/meta/0003_snapshot.json +799 -0
- nao_core/bin/migrations-postgres/meta/0004_snapshot.json +847 -0
- nao_core/bin/migrations-postgres/meta/0005_snapshot.json +1129 -0
- nao_core/bin/migrations-postgres/meta/0006_snapshot.json +1141 -0
- nao_core/bin/migrations-postgres/meta/_journal.json +62 -0
- nao_core/bin/migrations-sqlite/0000_user_auth_and_chat_tables.sql +98 -0
- nao_core/bin/migrations-sqlite/0001_message_feedback.sql +8 -0
- nao_core/bin/migrations-sqlite/0002_chat_message_stop_reason_and_error_message.sql +2 -0
- nao_core/bin/migrations-sqlite/0003_handle_slack_with_thread.sql +2 -0
- nao_core/bin/migrations-sqlite/0004_input_and_output_tokens.sql +8 -0
- nao_core/bin/migrations-sqlite/0005_add_project_tables.sql +38 -0
- nao_core/bin/migrations-sqlite/0006_llm_model_ids.sql +4 -0
- nao_core/bin/migrations-sqlite/0007_chat_message_llm_info.sql +2 -0
- nao_core/bin/migrations-sqlite/meta/0000_snapshot.json +674 -0
- nao_core/bin/migrations-sqlite/meta/0001_snapshot.json +735 -0
- nao_core/bin/migrations-sqlite/meta/0002_snapshot.json +749 -0
- nao_core/bin/migrations-sqlite/meta/0003_snapshot.json +763 -0
- nao_core/bin/migrations-sqlite/meta/0004_snapshot.json +819 -0
- nao_core/bin/migrations-sqlite/meta/0005_snapshot.json +1086 -0
- nao_core/bin/migrations-sqlite/meta/0006_snapshot.json +1100 -0
- nao_core/bin/migrations-sqlite/meta/_journal.json +62 -0
- nao_core/bin/nao-chat-server +0 -0
- nao_core/bin/public/assets/code-block-F6WJLWQG-CV0uOmNJ.js +153 -0
- nao_core/bin/public/assets/index-DcbndLHo.css +1 -0
- nao_core/bin/public/assets/index-t1hZI3nl.js +560 -0
- nao_core/bin/public/favicon.ico +0 -0
- nao_core/bin/public/index.html +18 -0
- nao_core/bin/rg +0 -0
- nao_core/commands/__init__.py +6 -0
- nao_core/commands/chat.py +225 -0
- nao_core/commands/debug.py +158 -0
- nao_core/commands/init.py +358 -0
- nao_core/commands/sync/__init__.py +124 -0
- nao_core/commands/sync/accessors.py +290 -0
- nao_core/commands/sync/cleanup.py +156 -0
- nao_core/commands/sync/providers/__init__.py +32 -0
- nao_core/commands/sync/providers/base.py +113 -0
- nao_core/commands/sync/providers/databases/__init__.py +17 -0
- nao_core/commands/sync/providers/databases/bigquery.py +79 -0
- nao_core/commands/sync/providers/databases/databricks.py +79 -0
- nao_core/commands/sync/providers/databases/duckdb.py +78 -0
- nao_core/commands/sync/providers/databases/postgres.py +79 -0
- nao_core/commands/sync/providers/databases/provider.py +129 -0
- nao_core/commands/sync/providers/databases/snowflake.py +79 -0
- nao_core/commands/sync/providers/notion/__init__.py +5 -0
- nao_core/commands/sync/providers/notion/provider.py +205 -0
- nao_core/commands/sync/providers/repositories/__init__.py +5 -0
- nao_core/commands/sync/providers/repositories/provider.py +134 -0
- nao_core/commands/sync/registry.py +23 -0
- nao_core/config/__init__.py +30 -0
- nao_core/config/base.py +100 -0
- nao_core/config/databases/__init__.py +55 -0
- nao_core/config/databases/base.py +85 -0
- nao_core/config/databases/bigquery.py +99 -0
- nao_core/config/databases/databricks.py +79 -0
- nao_core/config/databases/duckdb.py +41 -0
- nao_core/config/databases/postgres.py +83 -0
- nao_core/config/databases/snowflake.py +125 -0
- nao_core/config/exceptions.py +7 -0
- nao_core/config/llm/__init__.py +19 -0
- nao_core/config/notion/__init__.py +8 -0
- nao_core/config/repos/__init__.py +3 -0
- nao_core/config/repos/base.py +11 -0
- nao_core/config/slack/__init__.py +12 -0
- nao_core/context/__init__.py +54 -0
- nao_core/context/base.py +57 -0
- nao_core/context/git.py +177 -0
- nao_core/context/local.py +59 -0
- nao_core/main.py +13 -0
- nao_core/templates/__init__.py +41 -0
- nao_core/templates/context.py +193 -0
- nao_core/templates/defaults/databases/columns.md.j2 +23 -0
- nao_core/templates/defaults/databases/description.md.j2 +32 -0
- nao_core/templates/defaults/databases/preview.md.j2 +22 -0
- nao_core/templates/defaults/databases/profiling.md.j2 +34 -0
- nao_core/templates/engine.py +133 -0
- nao_core/templates/render.py +196 -0
- nao_core-0.0.38.dist-info/METADATA +150 -0
- nao_core-0.0.38.dist-info/RECORD +98 -0
- nao_core-0.0.38.dist-info/WHEEL +4 -0
- nao_core-0.0.38.dist-info/entry_points.txt +2 -0
- nao_core-0.0.38.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Context object for Jinja templates in the nao context folder.
|
|
2
|
+
|
|
3
|
+
This module provides the `nao` object that is exposed to user Jinja templates,
|
|
4
|
+
allowing them to access data from various providers like Notion, databases, etc.
|
|
5
|
+
|
|
6
|
+
Example template usage:
|
|
7
|
+
{{ nao.notion.page('https://notion.so/...').content }}
|
|
8
|
+
{{ nao.notion.page('abc123').title }}
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import cached_property
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from nao_core.config.base import NaoConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class NotionPage:
|
|
23
|
+
"""Represents a Notion page with lazy-loaded content."""
|
|
24
|
+
|
|
25
|
+
page_url_or_id: str
|
|
26
|
+
api_key: str
|
|
27
|
+
_data: dict[str, Any] | None = None
|
|
28
|
+
|
|
29
|
+
def _load(self) -> dict[str, Any]:
|
|
30
|
+
"""Lazily load page data from Notion API."""
|
|
31
|
+
if self._data is None:
|
|
32
|
+
from notion2md.exporter.block import StringExporter
|
|
33
|
+
from notion_client import Client
|
|
34
|
+
|
|
35
|
+
from nao_core.commands.sync.providers.notion.provider import (
|
|
36
|
+
extract_page_id,
|
|
37
|
+
get_page_title,
|
|
38
|
+
strip_images,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
page_id = extract_page_id(self.page_url_or_id)
|
|
42
|
+
client = Client(auth=self.api_key)
|
|
43
|
+
title = get_page_title(client, page_id)
|
|
44
|
+
|
|
45
|
+
# Export to markdown
|
|
46
|
+
md_exporter = StringExporter(block_id=page_id, token=self.api_key)
|
|
47
|
+
markdown = md_exporter.export()
|
|
48
|
+
markdown = strip_images(markdown)
|
|
49
|
+
|
|
50
|
+
self._data = {
|
|
51
|
+
"id": page_id,
|
|
52
|
+
"title": title,
|
|
53
|
+
"content": markdown,
|
|
54
|
+
"url": f"https://notion.so/{page_id}",
|
|
55
|
+
}
|
|
56
|
+
return self._data
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def id(self) -> str:
|
|
60
|
+
"""The Notion page ID."""
|
|
61
|
+
return self._load()["id"]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def title(self) -> str:
|
|
65
|
+
"""The page title."""
|
|
66
|
+
return self._load()["title"]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def content(self) -> str:
|
|
70
|
+
"""The page content as markdown (without frontmatter)."""
|
|
71
|
+
return self._load()["content"]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def url(self) -> str:
|
|
75
|
+
"""The Notion page URL."""
|
|
76
|
+
return self._load()["url"]
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
"""Return the content when used directly in a template."""
|
|
80
|
+
return self.content
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class NotionProvider:
|
|
84
|
+
"""Provider interface for accessing Notion data in templates."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, config: NaoConfig):
|
|
87
|
+
self._config = config
|
|
88
|
+
self._page_cache: dict[str, NotionPage] = {}
|
|
89
|
+
|
|
90
|
+
def _get_api_key_for_page(self, page_url_or_id: str) -> str:
|
|
91
|
+
"""Find the API key that can access a given page.
|
|
92
|
+
|
|
93
|
+
First checks if the page is in any configured Notion config's pages list,
|
|
94
|
+
otherwise uses the first available API key.
|
|
95
|
+
"""
|
|
96
|
+
from nao_core.commands.sync.providers.notion.provider import extract_page_id
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
page_id = extract_page_id(page_url_or_id)
|
|
100
|
+
except ValueError:
|
|
101
|
+
page_id = page_url_or_id
|
|
102
|
+
|
|
103
|
+
# Check if page is in any config
|
|
104
|
+
if self._config.notion is None or self._config.notion.pages is None:
|
|
105
|
+
raise ValueError("No Notion configuration found")
|
|
106
|
+
|
|
107
|
+
for configured_page in self._config.notion.pages:
|
|
108
|
+
try:
|
|
109
|
+
if extract_page_id(configured_page) == page_id:
|
|
110
|
+
return self._config.notion.api_key
|
|
111
|
+
except ValueError:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Fallback to the configured API key (page not in explicit list, but config exists)
|
|
115
|
+
return self._config.notion.api_key
|
|
116
|
+
|
|
117
|
+
def page(self, page_url_or_id: str) -> NotionPage:
|
|
118
|
+
"""Get a Notion page by URL or ID.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
page_url_or_id: Either a full Notion URL or a 32-character page ID.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
NotionPage object with lazy-loaded content.
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
{{ nao.notion.page('https://notion.so/My-Page-abc123').content }}
|
|
128
|
+
{{ nao.notion.page('abc123def456...').title }}
|
|
129
|
+
"""
|
|
130
|
+
if page_url_or_id not in self._page_cache:
|
|
131
|
+
api_key = self._get_api_key_for_page(page_url_or_id)
|
|
132
|
+
self._page_cache[page_url_or_id] = NotionPage(
|
|
133
|
+
page_url_or_id=page_url_or_id,
|
|
134
|
+
api_key=api_key,
|
|
135
|
+
)
|
|
136
|
+
return self._page_cache[page_url_or_id]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class NaoContext:
|
|
140
|
+
"""The main context object exposed as `nao` in user templates.
|
|
141
|
+
|
|
142
|
+
This object provides access to data from various providers like Notion,
|
|
143
|
+
databases, and repositories. Data is lazy-loaded to avoid unnecessary
|
|
144
|
+
API calls.
|
|
145
|
+
|
|
146
|
+
Example template usage:
|
|
147
|
+
{{ nao.notion.page('url').content }}
|
|
148
|
+
{{ nao.config.project_name }}
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, config: NaoConfig):
|
|
152
|
+
self._config = config
|
|
153
|
+
|
|
154
|
+
@cached_property
|
|
155
|
+
def notion(self) -> NotionProvider:
|
|
156
|
+
"""Access Notion pages and databases.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
{{ nao.notion.page('https://notion.so/...').content }}
|
|
160
|
+
"""
|
|
161
|
+
return NotionProvider(self._config)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def config(self) -> NaoConfig:
|
|
165
|
+
"""Access the nao configuration.
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
{{ nao.config.project_name }}
|
|
169
|
+
"""
|
|
170
|
+
return self._config
|
|
171
|
+
|
|
172
|
+
# Future providers can be added here:
|
|
173
|
+
# @cached_property
|
|
174
|
+
# def database(self) -> DatabaseProvider:
|
|
175
|
+
# """Access database tables and schemas."""
|
|
176
|
+
# return DatabaseProvider(self._config)
|
|
177
|
+
#
|
|
178
|
+
# @cached_property
|
|
179
|
+
# def repo(self) -> RepoProvider:
|
|
180
|
+
# """Access git repository files."""
|
|
181
|
+
# return RepoProvider(self._config)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def create_nao_context(config: NaoConfig) -> NaoContext:
|
|
185
|
+
"""Create a NaoContext for template rendering.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
config: The nao configuration.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A NaoContext instance to be used as `nao` in templates.
|
|
192
|
+
"""
|
|
193
|
+
return NaoContext(config)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{#
|
|
2
|
+
Template: columns.md.j2
|
|
3
|
+
Description: Generates column documentation for a database table
|
|
4
|
+
|
|
5
|
+
Available variables:
|
|
6
|
+
- table_name (str): Name of the table
|
|
7
|
+
- dataset (str): Schema/dataset name
|
|
8
|
+
- columns (list): List of column dictionaries with:
|
|
9
|
+
- name (str): Column name
|
|
10
|
+
- type (str): Data type
|
|
11
|
+
- nullable (bool): Whether the column allows nulls
|
|
12
|
+
- description (str|None): Column description if available
|
|
13
|
+
- column_count (int): Total number of columns
|
|
14
|
+
#}
|
|
15
|
+
# {{ table_name }}
|
|
16
|
+
|
|
17
|
+
**Dataset:** `{{ dataset }}`
|
|
18
|
+
|
|
19
|
+
## Columns ({{ column_count }})
|
|
20
|
+
|
|
21
|
+
{% for col in columns %}
|
|
22
|
+
- {{ col.name }} ({{ col.type }}{% if col.description %}, "{{ col.description | truncate_middle(256) }}"{% endif %})
|
|
23
|
+
{% endfor %}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{#
|
|
2
|
+
Template: description.md.j2
|
|
3
|
+
Description: Generates table metadata and description documentation
|
|
4
|
+
|
|
5
|
+
Available variables:
|
|
6
|
+
- table_name (str): Name of the table
|
|
7
|
+
- dataset (str): Schema/dataset name
|
|
8
|
+
- row_count (int): Total number of rows in the table
|
|
9
|
+
- column_count (int): Number of columns in the table
|
|
10
|
+
- description (str|None): Table description if available
|
|
11
|
+
- columns (list): List of column dictionaries with:
|
|
12
|
+
- name (str): Column name
|
|
13
|
+
- type (str): Data type
|
|
14
|
+
#}
|
|
15
|
+
# {{ table_name }}
|
|
16
|
+
|
|
17
|
+
**Dataset:** `{{ dataset }}`
|
|
18
|
+
|
|
19
|
+
## Table Metadata
|
|
20
|
+
|
|
21
|
+
| Property | Value |
|
|
22
|
+
|----------|-------|
|
|
23
|
+
| **Row Count** | {{ "{:,}".format(row_count) }} |
|
|
24
|
+
| **Column Count** | {{ column_count }} |
|
|
25
|
+
|
|
26
|
+
## Description
|
|
27
|
+
|
|
28
|
+
{% if description %}
|
|
29
|
+
{{ description }}
|
|
30
|
+
{% else %}
|
|
31
|
+
_No description available._
|
|
32
|
+
{% endif %}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{#
|
|
2
|
+
Template: preview.md.j2
|
|
3
|
+
Description: Generates a preview of table rows in JSONL format
|
|
4
|
+
|
|
5
|
+
Available variables:
|
|
6
|
+
- table_name (str): Name of the table
|
|
7
|
+
- dataset (str): Schema/dataset name
|
|
8
|
+
- rows (list): List of row dictionaries (first N rows of the table)
|
|
9
|
+
- row_count (int): Number of preview rows shown
|
|
10
|
+
- columns (list): List of column dictionaries with:
|
|
11
|
+
- name (str): Column name
|
|
12
|
+
- type (str): Data type
|
|
13
|
+
#}
|
|
14
|
+
# {{ table_name }} - Preview
|
|
15
|
+
|
|
16
|
+
**Dataset:** `{{ dataset }}`
|
|
17
|
+
|
|
18
|
+
## Rows ({{ row_count }})
|
|
19
|
+
|
|
20
|
+
{% for row in rows %}
|
|
21
|
+
- {{ row | to_json }}
|
|
22
|
+
{% endfor %}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{#
|
|
2
|
+
Template: profiling.md.j2
|
|
3
|
+
Description: Generates column-level statistics and profiling data
|
|
4
|
+
|
|
5
|
+
Available variables:
|
|
6
|
+
- table_name (str): Name of the table
|
|
7
|
+
- dataset (str): Schema/dataset name
|
|
8
|
+
- column_stats (list): List of column statistics dictionaries with:
|
|
9
|
+
- name (str): Column name
|
|
10
|
+
- type (str): Data type
|
|
11
|
+
- null_count (int): Number of null values
|
|
12
|
+
- unique_count (int): Number of unique values
|
|
13
|
+
- min_value (str|None): Minimum value (for numeric/temporal columns)
|
|
14
|
+
- max_value (str|None): Maximum value (for numeric/temporal columns)
|
|
15
|
+
- error (str|None): Error message if stats couldn't be computed
|
|
16
|
+
- columns (list): List of column dictionaries with:
|
|
17
|
+
- name (str): Column name
|
|
18
|
+
- type (str): Data type
|
|
19
|
+
#}
|
|
20
|
+
# {{ table_name }} - Profiling
|
|
21
|
+
|
|
22
|
+
**Dataset:** `{{ dataset }}`
|
|
23
|
+
|
|
24
|
+
## Column Statistics
|
|
25
|
+
|
|
26
|
+
| Column | Type | Nulls | Unique | Min | Max |
|
|
27
|
+
|--------|------|-------|--------|-----|-----|
|
|
28
|
+
{% for stat in column_stats %}
|
|
29
|
+
{% if stat.error %}
|
|
30
|
+
| `{{ stat.name }}` | `{{ stat.type }}` | Error: {{ stat.error }} | | | |
|
|
31
|
+
{% else %}
|
|
32
|
+
| `{{ stat.name }}` | `{{ stat.type }}` | {{ "{:,}".format(stat.null_count) }} | {{ "{:,}".format(stat.unique_count) }} | {{ stat.min_value or "" }} | {{ stat.max_value or "" }} |
|
|
33
|
+
{% endif %}
|
|
34
|
+
{% endfor %}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Template engine for rendering Jinja2 templates with user overrides."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
7
|
+
|
|
8
|
+
# Path to the default templates shipped with nao
|
|
9
|
+
DEFAULT_TEMPLATES_DIR = Path(__file__).parent / "defaults"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TemplateEngine:
|
|
13
|
+
"""Jinja2 template engine with support for user overrides.
|
|
14
|
+
|
|
15
|
+
Templates are looked up in the following order:
|
|
16
|
+
1. User's project `templates/` directory (if exists)
|
|
17
|
+
2. Default templates shipped with nao
|
|
18
|
+
|
|
19
|
+
This allows users to customize output by creating a `templates/` folder
|
|
20
|
+
in their nao project and adding templates with the same names as the defaults.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
If the default template is `databases/preview.md.j2`, the user can
|
|
24
|
+
override it by creating `<project_root>/templates/databases/preview.md.j2`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, project_path: Path | None = None):
|
|
28
|
+
"""Initialize the template engine.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
project_path: Path to the nao project root. If provided,
|
|
32
|
+
templates in `<project_path>/templates/` will
|
|
33
|
+
take precedence over defaults.
|
|
34
|
+
"""
|
|
35
|
+
self.project_path = project_path
|
|
36
|
+
self.user_templates_dir = project_path / "templates" if project_path else None
|
|
37
|
+
|
|
38
|
+
# Build list of template directories (user templates first for override)
|
|
39
|
+
loader_paths: list[Path] = []
|
|
40
|
+
if self.user_templates_dir and self.user_templates_dir.exists():
|
|
41
|
+
loader_paths.append(self.user_templates_dir)
|
|
42
|
+
loader_paths.append(DEFAULT_TEMPLATES_DIR)
|
|
43
|
+
|
|
44
|
+
self.env = Environment(
|
|
45
|
+
loader=FileSystemLoader([str(p) for p in loader_paths]),
|
|
46
|
+
autoescape=select_autoescape(default_for_string=False, default=False),
|
|
47
|
+
trim_blocks=True,
|
|
48
|
+
lstrip_blocks=True,
|
|
49
|
+
keep_trailing_newline=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Register custom filters
|
|
53
|
+
self._register_filters()
|
|
54
|
+
|
|
55
|
+
def _register_filters(self) -> None:
|
|
56
|
+
"""Register custom Jinja2 filters for templates."""
|
|
57
|
+
import json
|
|
58
|
+
|
|
59
|
+
def to_json(value: Any, indent: int | None = None) -> str:
|
|
60
|
+
"""Convert value to JSON string."""
|
|
61
|
+
return json.dumps(value, indent=indent, default=str)
|
|
62
|
+
|
|
63
|
+
def truncate_middle(text: str, max_length: int = 50) -> str:
|
|
64
|
+
"""Truncate text in the middle if it exceeds max_length."""
|
|
65
|
+
if len(str(text)) <= max_length:
|
|
66
|
+
return str(text)
|
|
67
|
+
half = (max_length - 3) // 2
|
|
68
|
+
text = str(text)
|
|
69
|
+
return text[:half] + "..." + text[-half:]
|
|
70
|
+
|
|
71
|
+
self.env.filters["to_json"] = to_json
|
|
72
|
+
self.env.filters["truncate_middle"] = truncate_middle
|
|
73
|
+
|
|
74
|
+
def render(self, template_name: str, **context: Any) -> str:
|
|
75
|
+
"""Render a template with the given context.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
template_name: Name of the template file (e.g., 'databases/preview.md.j2')
|
|
79
|
+
**context: Variables to pass to the template
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Rendered template string
|
|
83
|
+
"""
|
|
84
|
+
template = self.env.get_template(template_name)
|
|
85
|
+
return template.render(**context)
|
|
86
|
+
|
|
87
|
+
def has_template(self, template_name: str) -> bool:
|
|
88
|
+
"""Check if a template exists.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
template_name: Name of the template to check
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if the template exists, False otherwise
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
self.env.get_template(template_name)
|
|
98
|
+
return True
|
|
99
|
+
except Exception:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def is_user_override(self, template_name: str) -> bool:
|
|
103
|
+
"""Check if a template is a user override.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
template_name: Name of the template to check
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if the user has provided a custom template
|
|
110
|
+
"""
|
|
111
|
+
if not self.user_templates_dir:
|
|
112
|
+
return False
|
|
113
|
+
user_template = self.user_templates_dir / template_name
|
|
114
|
+
return user_template.exists()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Global template engine instance (lazily initialized)
|
|
118
|
+
_engine: TemplateEngine | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_template_engine(project_path: Path | None = None) -> TemplateEngine:
|
|
122
|
+
"""Get or create the template engine.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
project_path: Path to the nao project root.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The template engine instance
|
|
129
|
+
"""
|
|
130
|
+
global _engine
|
|
131
|
+
if _engine is None or (project_path and _engine.project_path != project_path):
|
|
132
|
+
_engine = TemplateEngine(project_path)
|
|
133
|
+
return _engine
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Render user Jinja templates in the context folder.
|
|
2
|
+
|
|
3
|
+
This module discovers and renders `.j2` files in the nao context folder,
|
|
4
|
+
making the `nao` object available for accessing provider data.
|
|
5
|
+
|
|
6
|
+
Template files are rendered to the same location without the `.j2` extension.
|
|
7
|
+
For example: `docs/report.md.j2` → `docs/report.md`
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from jinja2 import Environment, FileSystemLoader, TemplateError
|
|
17
|
+
|
|
18
|
+
from .context import create_nao_context
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
|
|
23
|
+
from nao_core.config.base import NaoConfig
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TemplateRenderResult:
|
|
28
|
+
"""Result of rendering user templates."""
|
|
29
|
+
|
|
30
|
+
templates_rendered: int
|
|
31
|
+
templates_failed: int
|
|
32
|
+
rendered_files: list[str]
|
|
33
|
+
errors: list[str]
|
|
34
|
+
|
|
35
|
+
def get_summary(self) -> str:
|
|
36
|
+
"""Get a human-readable summary of the render result."""
|
|
37
|
+
if self.templates_rendered == 0 and self.templates_failed == 0:
|
|
38
|
+
return "No templates found"
|
|
39
|
+
|
|
40
|
+
parts = []
|
|
41
|
+
if self.templates_rendered > 0:
|
|
42
|
+
parts.append(f"{self.templates_rendered} rendered")
|
|
43
|
+
if self.templates_failed > 0:
|
|
44
|
+
parts.append(f"{self.templates_failed} failed")
|
|
45
|
+
return ", ".join(parts)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def discover_templates(
|
|
49
|
+
project_path: Path,
|
|
50
|
+
exclude_dirs: set[str] | None = None,
|
|
51
|
+
) -> list[Path]:
|
|
52
|
+
"""Discover all `.j2` template files in the project.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
project_path: Path to the nao project root.
|
|
56
|
+
exclude_dirs: Directory names to exclude (default: templates, .git, node_modules, etc.)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of paths to `.j2` files relative to project_path.
|
|
60
|
+
"""
|
|
61
|
+
if exclude_dirs is None:
|
|
62
|
+
exclude_dirs = {
|
|
63
|
+
"templates", # Don't process accessor template overrides
|
|
64
|
+
".git",
|
|
65
|
+
".venv",
|
|
66
|
+
"venv",
|
|
67
|
+
"node_modules",
|
|
68
|
+
"__pycache__",
|
|
69
|
+
".nao",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
templates: list[Path] = []
|
|
73
|
+
|
|
74
|
+
for path in project_path.rglob("*.j2"):
|
|
75
|
+
# Skip excluded directories
|
|
76
|
+
if any(excluded in path.parts for excluded in exclude_dirs):
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Store relative path
|
|
80
|
+
templates.append(path.relative_to(project_path))
|
|
81
|
+
|
|
82
|
+
return sorted(templates)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_template(
|
|
86
|
+
template_path: Path,
|
|
87
|
+
project_path: Path,
|
|
88
|
+
config: NaoConfig,
|
|
89
|
+
) -> Path:
|
|
90
|
+
"""Render a single template file.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
template_path: Path to the template file (relative to project_path).
|
|
94
|
+
project_path: Path to the nao project root.
|
|
95
|
+
config: The nao configuration.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Path to the rendered output file.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
TemplateError: If template rendering fails.
|
|
102
|
+
"""
|
|
103
|
+
# Create Jinja environment with project as the loader path
|
|
104
|
+
env = Environment(
|
|
105
|
+
loader=FileSystemLoader(str(project_path)),
|
|
106
|
+
autoescape=False,
|
|
107
|
+
trim_blocks=True,
|
|
108
|
+
lstrip_blocks=True,
|
|
109
|
+
keep_trailing_newline=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Register custom filters
|
|
113
|
+
import json
|
|
114
|
+
|
|
115
|
+
env.filters["to_json"] = lambda v, indent=None: json.dumps(v, indent=indent, default=str)
|
|
116
|
+
|
|
117
|
+
# Create the nao context
|
|
118
|
+
nao = create_nao_context(config)
|
|
119
|
+
|
|
120
|
+
# Load and render the template
|
|
121
|
+
template = env.get_template(str(template_path))
|
|
122
|
+
rendered = template.render(nao=nao)
|
|
123
|
+
|
|
124
|
+
# Determine output path (remove .j2 extension)
|
|
125
|
+
output_path = project_path / str(template_path)[:-3] # Remove .j2
|
|
126
|
+
|
|
127
|
+
# Ensure parent directory exists
|
|
128
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
# Write rendered content
|
|
131
|
+
output_path.write_text(rendered)
|
|
132
|
+
|
|
133
|
+
return output_path
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def render_all_templates(
|
|
137
|
+
project_path: Path,
|
|
138
|
+
config: NaoConfig,
|
|
139
|
+
console: "Console | None" = None,
|
|
140
|
+
) -> TemplateRenderResult:
|
|
141
|
+
"""Discover and render all user templates in the project.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
project_path: Path to the nao project root.
|
|
145
|
+
config: The nao configuration.
|
|
146
|
+
console: Optional Rich console for output.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
TemplateRenderResult with statistics about what was rendered.
|
|
150
|
+
"""
|
|
151
|
+
from rich.console import Console
|
|
152
|
+
|
|
153
|
+
if console is None:
|
|
154
|
+
console = Console()
|
|
155
|
+
|
|
156
|
+
templates = discover_templates(project_path)
|
|
157
|
+
|
|
158
|
+
if not templates:
|
|
159
|
+
return TemplateRenderResult(
|
|
160
|
+
templates_rendered=0,
|
|
161
|
+
templates_failed=0,
|
|
162
|
+
rendered_files=[],
|
|
163
|
+
errors=[],
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
rendered_files: list[str] = []
|
|
167
|
+
errors: list[str] = []
|
|
168
|
+
|
|
169
|
+
for template_path in templates:
|
|
170
|
+
try:
|
|
171
|
+
output_path = render_template(template_path, project_path, config)
|
|
172
|
+
rendered_files.append(str(output_path.relative_to(project_path)))
|
|
173
|
+
console.print(f" [dim]→[/dim] {template_path} [dim]→[/dim] {output_path.name}")
|
|
174
|
+
except TemplateError as e:
|
|
175
|
+
error_msg = f"{template_path}: {e}"
|
|
176
|
+
errors.append(error_msg)
|
|
177
|
+
console.print(f" [red]✗[/red] {template_path}: {e}")
|
|
178
|
+
except Exception as e:
|
|
179
|
+
error_msg = f"{template_path}: {type(e).__name__}: {e}"
|
|
180
|
+
errors.append(error_msg)
|
|
181
|
+
console.print(f" [red]✗[/red] {template_path}: {e}")
|
|
182
|
+
|
|
183
|
+
return TemplateRenderResult(
|
|
184
|
+
templates_rendered=len(rendered_files),
|
|
185
|
+
templates_failed=len(errors),
|
|
186
|
+
rendered_files=rendered_files,
|
|
187
|
+
errors=errors,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = [
|
|
192
|
+
"TemplateRenderResult",
|
|
193
|
+
"discover_templates",
|
|
194
|
+
"render_template",
|
|
195
|
+
"render_all_templates",
|
|
196
|
+
]
|