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.
Files changed (98) hide show
  1. nao_core/__init__.py +2 -0
  2. nao_core/__init__.py.bak +2 -0
  3. nao_core/bin/build-info.json +5 -0
  4. nao_core/bin/fastapi/main.py +268 -0
  5. nao_core/bin/fastapi/test_main.py +156 -0
  6. nao_core/bin/migrations-postgres/0000_user_auth_and_chat_tables.sql +98 -0
  7. nao_core/bin/migrations-postgres/0001_message_feedback.sql +9 -0
  8. nao_core/bin/migrations-postgres/0002_chat_message_stop_reason_and_error_message.sql +2 -0
  9. nao_core/bin/migrations-postgres/0003_handle_slack_with_thread.sql +2 -0
  10. nao_core/bin/migrations-postgres/0004_input_and_output_tokens.sql +8 -0
  11. nao_core/bin/migrations-postgres/0005_add_project_tables.sql +39 -0
  12. nao_core/bin/migrations-postgres/0006_llm_model_ids.sql +4 -0
  13. nao_core/bin/migrations-postgres/0007_chat_message_llm_info.sql +2 -0
  14. nao_core/bin/migrations-postgres/meta/0000_snapshot.json +707 -0
  15. nao_core/bin/migrations-postgres/meta/0001_snapshot.json +766 -0
  16. nao_core/bin/migrations-postgres/meta/0002_snapshot.json +778 -0
  17. nao_core/bin/migrations-postgres/meta/0003_snapshot.json +799 -0
  18. nao_core/bin/migrations-postgres/meta/0004_snapshot.json +847 -0
  19. nao_core/bin/migrations-postgres/meta/0005_snapshot.json +1129 -0
  20. nao_core/bin/migrations-postgres/meta/0006_snapshot.json +1141 -0
  21. nao_core/bin/migrations-postgres/meta/_journal.json +62 -0
  22. nao_core/bin/migrations-sqlite/0000_user_auth_and_chat_tables.sql +98 -0
  23. nao_core/bin/migrations-sqlite/0001_message_feedback.sql +8 -0
  24. nao_core/bin/migrations-sqlite/0002_chat_message_stop_reason_and_error_message.sql +2 -0
  25. nao_core/bin/migrations-sqlite/0003_handle_slack_with_thread.sql +2 -0
  26. nao_core/bin/migrations-sqlite/0004_input_and_output_tokens.sql +8 -0
  27. nao_core/bin/migrations-sqlite/0005_add_project_tables.sql +38 -0
  28. nao_core/bin/migrations-sqlite/0006_llm_model_ids.sql +4 -0
  29. nao_core/bin/migrations-sqlite/0007_chat_message_llm_info.sql +2 -0
  30. nao_core/bin/migrations-sqlite/meta/0000_snapshot.json +674 -0
  31. nao_core/bin/migrations-sqlite/meta/0001_snapshot.json +735 -0
  32. nao_core/bin/migrations-sqlite/meta/0002_snapshot.json +749 -0
  33. nao_core/bin/migrations-sqlite/meta/0003_snapshot.json +763 -0
  34. nao_core/bin/migrations-sqlite/meta/0004_snapshot.json +819 -0
  35. nao_core/bin/migrations-sqlite/meta/0005_snapshot.json +1086 -0
  36. nao_core/bin/migrations-sqlite/meta/0006_snapshot.json +1100 -0
  37. nao_core/bin/migrations-sqlite/meta/_journal.json +62 -0
  38. nao_core/bin/nao-chat-server +0 -0
  39. nao_core/bin/public/assets/code-block-F6WJLWQG-CV0uOmNJ.js +153 -0
  40. nao_core/bin/public/assets/index-DcbndLHo.css +1 -0
  41. nao_core/bin/public/assets/index-t1hZI3nl.js +560 -0
  42. nao_core/bin/public/favicon.ico +0 -0
  43. nao_core/bin/public/index.html +18 -0
  44. nao_core/bin/rg +0 -0
  45. nao_core/commands/__init__.py +6 -0
  46. nao_core/commands/chat.py +225 -0
  47. nao_core/commands/debug.py +158 -0
  48. nao_core/commands/init.py +358 -0
  49. nao_core/commands/sync/__init__.py +124 -0
  50. nao_core/commands/sync/accessors.py +290 -0
  51. nao_core/commands/sync/cleanup.py +156 -0
  52. nao_core/commands/sync/providers/__init__.py +32 -0
  53. nao_core/commands/sync/providers/base.py +113 -0
  54. nao_core/commands/sync/providers/databases/__init__.py +17 -0
  55. nao_core/commands/sync/providers/databases/bigquery.py +79 -0
  56. nao_core/commands/sync/providers/databases/databricks.py +79 -0
  57. nao_core/commands/sync/providers/databases/duckdb.py +78 -0
  58. nao_core/commands/sync/providers/databases/postgres.py +79 -0
  59. nao_core/commands/sync/providers/databases/provider.py +129 -0
  60. nao_core/commands/sync/providers/databases/snowflake.py +79 -0
  61. nao_core/commands/sync/providers/notion/__init__.py +5 -0
  62. nao_core/commands/sync/providers/notion/provider.py +205 -0
  63. nao_core/commands/sync/providers/repositories/__init__.py +5 -0
  64. nao_core/commands/sync/providers/repositories/provider.py +134 -0
  65. nao_core/commands/sync/registry.py +23 -0
  66. nao_core/config/__init__.py +30 -0
  67. nao_core/config/base.py +100 -0
  68. nao_core/config/databases/__init__.py +55 -0
  69. nao_core/config/databases/base.py +85 -0
  70. nao_core/config/databases/bigquery.py +99 -0
  71. nao_core/config/databases/databricks.py +79 -0
  72. nao_core/config/databases/duckdb.py +41 -0
  73. nao_core/config/databases/postgres.py +83 -0
  74. nao_core/config/databases/snowflake.py +125 -0
  75. nao_core/config/exceptions.py +7 -0
  76. nao_core/config/llm/__init__.py +19 -0
  77. nao_core/config/notion/__init__.py +8 -0
  78. nao_core/config/repos/__init__.py +3 -0
  79. nao_core/config/repos/base.py +11 -0
  80. nao_core/config/slack/__init__.py +12 -0
  81. nao_core/context/__init__.py +54 -0
  82. nao_core/context/base.py +57 -0
  83. nao_core/context/git.py +177 -0
  84. nao_core/context/local.py +59 -0
  85. nao_core/main.py +13 -0
  86. nao_core/templates/__init__.py +41 -0
  87. nao_core/templates/context.py +193 -0
  88. nao_core/templates/defaults/databases/columns.md.j2 +23 -0
  89. nao_core/templates/defaults/databases/description.md.j2 +32 -0
  90. nao_core/templates/defaults/databases/preview.md.j2 +22 -0
  91. nao_core/templates/defaults/databases/profiling.md.j2 +34 -0
  92. nao_core/templates/engine.py +133 -0
  93. nao_core/templates/render.py +196 -0
  94. nao_core-0.0.38.dist-info/METADATA +150 -0
  95. nao_core-0.0.38.dist-info/RECORD +98 -0
  96. nao_core-0.0.38.dist-info/WHEEL +4 -0
  97. nao_core-0.0.38.dist-info/entry_points.txt +2 -0
  98. 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
+ ]