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,83 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
import ibis
|
|
4
|
+
from ibis import BaseBackend
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from rich.prompt import Prompt
|
|
7
|
+
|
|
8
|
+
from nao_core.config.exceptions import InitError
|
|
9
|
+
|
|
10
|
+
from .base import DatabaseConfig, console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PostgresConfig(DatabaseConfig):
|
|
14
|
+
"""PostgreSQL-specific configuration."""
|
|
15
|
+
|
|
16
|
+
type: Literal["postgres"] = "postgres"
|
|
17
|
+
host: str = Field(description="PostgreSQL host")
|
|
18
|
+
port: int = Field(default=5432, description="PostgreSQL port")
|
|
19
|
+
database: str = Field(description="Database name")
|
|
20
|
+
user: str = Field(description="Username")
|
|
21
|
+
password: str = Field(description="Password")
|
|
22
|
+
schema_name: str | None = Field(default=None, description="Default schema (optional, uses 'public' if not set)")
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def promptConfig(cls) -> "PostgresConfig":
|
|
26
|
+
"""Interactively prompt the user for PostgreSQL configuration."""
|
|
27
|
+
console.print("\n[bold cyan]PostgreSQL Configuration[/bold cyan]\n")
|
|
28
|
+
|
|
29
|
+
name = Prompt.ask("[bold]Connection name[/bold]", default="postgres-prod")
|
|
30
|
+
|
|
31
|
+
host = Prompt.ask("[bold]Host[/bold]", default="localhost")
|
|
32
|
+
|
|
33
|
+
port = Prompt.ask("[bold]Port[/bold]", default="5432")
|
|
34
|
+
if not port.isdigit():
|
|
35
|
+
raise InitError("Port must be a valid integer.")
|
|
36
|
+
|
|
37
|
+
database = Prompt.ask("[bold]Database name[/bold]")
|
|
38
|
+
if not database:
|
|
39
|
+
raise InitError("Database name cannot be empty.")
|
|
40
|
+
|
|
41
|
+
user = Prompt.ask("[bold]Username[/bold]")
|
|
42
|
+
if not user:
|
|
43
|
+
raise InitError("Username cannot be empty.")
|
|
44
|
+
|
|
45
|
+
password = Prompt.ask("[bold]Password[/bold]", password=True)
|
|
46
|
+
|
|
47
|
+
schema_name = Prompt.ask(
|
|
48
|
+
"[bold]Default schema[/bold] [dim](optional, uses 'public' if empty)[/dim]",
|
|
49
|
+
default="",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return PostgresConfig(
|
|
53
|
+
name=name,
|
|
54
|
+
host=host,
|
|
55
|
+
port=int(port),
|
|
56
|
+
database=database,
|
|
57
|
+
user=user,
|
|
58
|
+
password=password,
|
|
59
|
+
schema_name=schema_name or None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def connect(self) -> BaseBackend:
|
|
63
|
+
"""Create an Ibis PostgreSQL connection."""
|
|
64
|
+
|
|
65
|
+
kwargs: dict = {
|
|
66
|
+
"host": self.host,
|
|
67
|
+
"port": self.port,
|
|
68
|
+
"database": self.database,
|
|
69
|
+
"user": self.user,
|
|
70
|
+
"password": self.password,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if self.schema_name:
|
|
74
|
+
kwargs["schema"] = self.schema_name
|
|
75
|
+
|
|
76
|
+
return ibis.postgres.connect(
|
|
77
|
+
**kwargs,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def get_database_name(self) -> str:
|
|
81
|
+
"""Get the database name for Postgres."""
|
|
82
|
+
|
|
83
|
+
return self.database
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
import ibis
|
|
4
|
+
from cryptography.hazmat.backends import default_backend
|
|
5
|
+
from cryptography.hazmat.primitives import serialization
|
|
6
|
+
from ibis import BaseBackend
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from rich.prompt import Confirm, Prompt
|
|
9
|
+
|
|
10
|
+
from nao_core.config.exceptions import InitError
|
|
11
|
+
|
|
12
|
+
from .base import DatabaseConfig, console
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SnowflakeConfig(DatabaseConfig):
|
|
16
|
+
"""Snowflake-specific configuration."""
|
|
17
|
+
|
|
18
|
+
type: Literal["snowflake"] = "snowflake"
|
|
19
|
+
username: str = Field(description="Snowflake username")
|
|
20
|
+
account_id: str = Field(description="Snowflake account identifier (e.g., 'xy12345.us-east-1')")
|
|
21
|
+
password: str | None = Field(default=None, description="Snowflake password")
|
|
22
|
+
database: str = Field(description="Snowflake database")
|
|
23
|
+
schema_name: str | None = Field(
|
|
24
|
+
default=None,
|
|
25
|
+
validation_alias="schema",
|
|
26
|
+
serialization_alias="schema",
|
|
27
|
+
description="Snowflake schema (optional)",
|
|
28
|
+
)
|
|
29
|
+
warehouse: str | None = Field(default=None, description="Snowflake warehouse to use (optional)")
|
|
30
|
+
private_key_path: str | None = Field(
|
|
31
|
+
default=None,
|
|
32
|
+
description="Path to private key file for key-pair authentication",
|
|
33
|
+
)
|
|
34
|
+
passphrase: str | None = Field(
|
|
35
|
+
default=None,
|
|
36
|
+
description="Passphrase for the private key if it is encrypted",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def promptConfig(cls) -> "SnowflakeConfig":
|
|
41
|
+
"""Interactively prompt the user for Snowflake configuration."""
|
|
42
|
+
console.print("\n[bold cyan]Snowflake Configuration[/bold cyan]\n")
|
|
43
|
+
|
|
44
|
+
name = Prompt.ask("[bold]Connection name[/bold]", default="snowflake-prod")
|
|
45
|
+
|
|
46
|
+
username = Prompt.ask("[bold]Snowflake username[/bold]")
|
|
47
|
+
if not username:
|
|
48
|
+
raise InitError("Snowflake username cannot be empty.")
|
|
49
|
+
|
|
50
|
+
account_id = Prompt.ask("[bold]Snowflake account identifier[/bold]")
|
|
51
|
+
if not account_id:
|
|
52
|
+
raise InitError("Snowflake account identifier cannot be empty.")
|
|
53
|
+
|
|
54
|
+
database = Prompt.ask("[bold]Snowflake database[/bold]")
|
|
55
|
+
if not database:
|
|
56
|
+
raise InitError("Snowflake database cannot be empty.")
|
|
57
|
+
|
|
58
|
+
warehouse = Prompt.ask(
|
|
59
|
+
"[bold]Snowflake warehouse[/bold] [dim](optional, press Enter to skip)[/dim]", default=None
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
schema = Prompt.ask("[bold]Default schema[/bold] [dim](optional, press Enter to skip)[/dim]", default=None)
|
|
63
|
+
|
|
64
|
+
key_pair_auth = Confirm.ask("[bold]Use key-pair authentication for authentication?[/bold]", default=False)
|
|
65
|
+
|
|
66
|
+
if key_pair_auth:
|
|
67
|
+
private_key_path = Prompt.ask("[bold]Path to private key file[/bold]")
|
|
68
|
+
if not private_key_path:
|
|
69
|
+
raise InitError("Path to private key file cannot be empty.")
|
|
70
|
+
passphrase = Prompt.ask(
|
|
71
|
+
"[bold]Passphrase for the private key[/bold] [dim](optional, press Enter to skip)[/dim]",
|
|
72
|
+
default=None,
|
|
73
|
+
password=True,
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
password = Prompt.ask("[bold]Snowflake password[/bold]", password=True)
|
|
77
|
+
if not password:
|
|
78
|
+
raise InitError("Snowflake password cannot be empty.")
|
|
79
|
+
|
|
80
|
+
return SnowflakeConfig(
|
|
81
|
+
name=name,
|
|
82
|
+
username=username,
|
|
83
|
+
password=password if not key_pair_auth else None,
|
|
84
|
+
account_id=account_id,
|
|
85
|
+
database=database,
|
|
86
|
+
warehouse=warehouse,
|
|
87
|
+
schema_name=schema,
|
|
88
|
+
private_key_path=private_key_path if key_pair_auth else None,
|
|
89
|
+
passphrase=passphrase if key_pair_auth else None,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def connect(self) -> BaseBackend:
|
|
93
|
+
"""Create an Ibis Snowflake connection."""
|
|
94
|
+
kwargs: dict = {"user": self.username}
|
|
95
|
+
kwargs["account"] = self.account_id
|
|
96
|
+
|
|
97
|
+
if self.database and self.schema_name:
|
|
98
|
+
kwargs["database"] = f"{self.database}/{self.schema_name}"
|
|
99
|
+
elif self.database:
|
|
100
|
+
kwargs["database"] = self.database
|
|
101
|
+
|
|
102
|
+
if self.warehouse:
|
|
103
|
+
kwargs["warehouse"] = self.warehouse
|
|
104
|
+
|
|
105
|
+
if self.private_key_path:
|
|
106
|
+
with open(self.private_key_path, "rb") as key_file:
|
|
107
|
+
private_key = serialization.load_pem_private_key(
|
|
108
|
+
key_file.read(),
|
|
109
|
+
password=self.passphrase.encode() if self.passphrase else None,
|
|
110
|
+
backend=default_backend(),
|
|
111
|
+
)
|
|
112
|
+
# Convert to DER format which Snowflake expects
|
|
113
|
+
kwargs["private_key"] = private_key.private_bytes(
|
|
114
|
+
encoding=serialization.Encoding.DER,
|
|
115
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
116
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
117
|
+
)
|
|
118
|
+
kwargs["password"] = self.password
|
|
119
|
+
|
|
120
|
+
return ibis.snowflake.connect(**kwargs)
|
|
121
|
+
|
|
122
|
+
def get_database_name(self) -> str:
|
|
123
|
+
"""Get the database name for Snowflake."""
|
|
124
|
+
|
|
125
|
+
return self.database
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LLMProvider(str, Enum):
|
|
7
|
+
"""Supported LLM providers."""
|
|
8
|
+
|
|
9
|
+
OPENAI = "openai"
|
|
10
|
+
ANTHROPIC = "anthropic"
|
|
11
|
+
MISTRAL = "mistral"
|
|
12
|
+
GEMINI = "gemini"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMConfig(BaseModel):
|
|
16
|
+
"""LLM configuration."""
|
|
17
|
+
|
|
18
|
+
provider: LLMProvider = Field(description="The LLM provider to use")
|
|
19
|
+
api_key: str = Field(description="The API key to use")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RepoConfig(BaseModel):
|
|
7
|
+
"""Repository configuration."""
|
|
8
|
+
|
|
9
|
+
name: str = Field(description="The name of the repository")
|
|
10
|
+
url: str = Field(description="The URL of the repository")
|
|
11
|
+
branch: Optional[str] = Field(default=None, description="The branch of the repository")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SlackConfig(BaseModel):
|
|
5
|
+
"""Slack configuration."""
|
|
6
|
+
|
|
7
|
+
bot_token: str = Field(description="The bot token to use")
|
|
8
|
+
signing_secret: str = Field(description="The signing secret for verifying requests")
|
|
9
|
+
post_message_url: str = Field(
|
|
10
|
+
default="https://slack.com/api/chat.postMessage",
|
|
11
|
+
description="The Slack API URL for posting messages",
|
|
12
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Context provider module for loading nao project context from various sources."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .base import ContextProvider
|
|
7
|
+
from .git import GitContextProvider
|
|
8
|
+
from .local import LocalContextProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_context_provider() -> ContextProvider:
|
|
12
|
+
"""Factory function to create the appropriate context provider based on environment variables.
|
|
13
|
+
|
|
14
|
+
Environment variables:
|
|
15
|
+
NAO_CONTEXT_SOURCE: 'local' (default) or 'git'
|
|
16
|
+
NAO_DEFAULT_PROJECT_PATH: Target path for context (required)
|
|
17
|
+
|
|
18
|
+
For git source:
|
|
19
|
+
NAO_CONTEXT_GIT_URL: Git repository URL (required)
|
|
20
|
+
NAO_CONTEXT_GIT_BRANCH: Branch to clone/pull (default: 'main')
|
|
21
|
+
NAO_CONTEXT_GIT_TOKEN: Auth token for private repos (optional)
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
ContextProvider instance based on configuration
|
|
25
|
+
"""
|
|
26
|
+
source = os.environ.get("NAO_CONTEXT_SOURCE", "local").lower()
|
|
27
|
+
target_path = Path(os.environ.get("NAO_DEFAULT_PROJECT_PATH", "/app/context"))
|
|
28
|
+
|
|
29
|
+
if source == "git":
|
|
30
|
+
git_url = os.environ.get("NAO_CONTEXT_GIT_URL")
|
|
31
|
+
if not git_url:
|
|
32
|
+
raise ValueError("NAO_CONTEXT_GIT_URL is required when NAO_CONTEXT_SOURCE=git")
|
|
33
|
+
|
|
34
|
+
branch = os.environ.get("NAO_CONTEXT_GIT_BRANCH", "main")
|
|
35
|
+
token = os.environ.get("NAO_CONTEXT_GIT_TOKEN")
|
|
36
|
+
|
|
37
|
+
return GitContextProvider(
|
|
38
|
+
repo_url=git_url,
|
|
39
|
+
target_path=target_path,
|
|
40
|
+
branch=branch,
|
|
41
|
+
token=token,
|
|
42
|
+
)
|
|
43
|
+
elif source == "local":
|
|
44
|
+
return LocalContextProvider(target_path=target_path)
|
|
45
|
+
else:
|
|
46
|
+
raise ValueError(f"Unknown NAO_CONTEXT_SOURCE: {source}. Must be 'local' or 'git'")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"ContextProvider",
|
|
51
|
+
"GitContextProvider",
|
|
52
|
+
"LocalContextProvider",
|
|
53
|
+
"get_context_provider",
|
|
54
|
+
]
|
nao_core/context/base.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Base class for context providers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ContextProvider(ABC):
|
|
8
|
+
"""Abstract base class for context providers.
|
|
9
|
+
|
|
10
|
+
A context provider is responsible for loading the nao project context
|
|
11
|
+
from a source (local filesystem, git repository, etc.) to the target path.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, target_path: Path):
|
|
15
|
+
"""Initialize the context provider.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
target_path: The local filesystem path where context should be available.
|
|
19
|
+
"""
|
|
20
|
+
self.target_path = target_path
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def init(self) -> None:
|
|
24
|
+
"""Initialize the context.
|
|
25
|
+
|
|
26
|
+
This is called on container startup to ensure context is available.
|
|
27
|
+
For local provider, this validates the path exists.
|
|
28
|
+
For git provider, this clones or pulls the repository.
|
|
29
|
+
"""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def refresh(self) -> bool:
|
|
34
|
+
"""Refresh the context from the source.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
True if context was updated, False if no changes.
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def is_initialized(self) -> bool:
|
|
43
|
+
"""Check if context has been initialized.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if context is available and ready.
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def validate(self) -> bool:
|
|
51
|
+
"""Validate that the context contains required files.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
True if nao_config.yaml exists in target path.
|
|
55
|
+
"""
|
|
56
|
+
config_file = self.target_path / "nao_config.yaml"
|
|
57
|
+
return config_file.exists()
|
nao_core/context/git.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Git-based context provider."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from .base import ContextProvider
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitContextProvider(ContextProvider):
|
|
14
|
+
"""Context provider that clones/pulls from a git repository.
|
|
15
|
+
|
|
16
|
+
This provider enables containerized deployments without volume mounts
|
|
17
|
+
by fetching context from a git repository on startup and refresh.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
repo_url: str,
|
|
23
|
+
target_path: Path,
|
|
24
|
+
branch: str = "main",
|
|
25
|
+
token: str | None = None,
|
|
26
|
+
):
|
|
27
|
+
"""Initialize the git context provider.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
repo_url: Git repository URL (https:// or git@).
|
|
31
|
+
target_path: Local path where repo will be cloned.
|
|
32
|
+
branch: Branch to clone/pull (default: 'main').
|
|
33
|
+
token: Auth token for private repos (optional).
|
|
34
|
+
"""
|
|
35
|
+
super().__init__(target_path)
|
|
36
|
+
self.repo_url = repo_url
|
|
37
|
+
self.branch = branch
|
|
38
|
+
self.token = token
|
|
39
|
+
|
|
40
|
+
def _get_auth_url(self) -> str:
|
|
41
|
+
"""Inject token into URL for private repos.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Repository URL with token if provided, original URL otherwise.
|
|
45
|
+
"""
|
|
46
|
+
if not self.token:
|
|
47
|
+
return self.repo_url
|
|
48
|
+
|
|
49
|
+
# Handle HTTPS URLs
|
|
50
|
+
if self.repo_url.startswith("https://"):
|
|
51
|
+
# https://github.com/org/repo → https://token@github.com/org/repo
|
|
52
|
+
return self.repo_url.replace("https://", f"https://{self.token}@")
|
|
53
|
+
|
|
54
|
+
# For SSH URLs or other formats, return as-is
|
|
55
|
+
return self.repo_url
|
|
56
|
+
|
|
57
|
+
def init(self) -> None:
|
|
58
|
+
"""Clone the repository if not exists, otherwise pull.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
subprocess.CalledProcessError: If git command fails.
|
|
62
|
+
ValueError: If cloned repo doesn't contain nao_config.yaml.
|
|
63
|
+
"""
|
|
64
|
+
if self.is_initialized():
|
|
65
|
+
console.print(f"[dim]Context already initialized at {self.target_path}[/dim]")
|
|
66
|
+
self.refresh()
|
|
67
|
+
else:
|
|
68
|
+
self._clone()
|
|
69
|
+
|
|
70
|
+
if not self.validate():
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"nao_config.yaml not found in cloned repository.\n"
|
|
73
|
+
"Ensure the repository contains a valid nao project at its root."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _clone(self) -> None:
|
|
77
|
+
"""Clone the repository.
|
|
78
|
+
|
|
79
|
+
Uses shallow clone (--depth 1) for faster initial setup.
|
|
80
|
+
"""
|
|
81
|
+
console.print(f"[cyan]Cloning context from {self.repo_url}...[/cyan]")
|
|
82
|
+
|
|
83
|
+
# Ensure parent directory exists
|
|
84
|
+
self.target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
# Remove target if it exists but isn't a git repo
|
|
87
|
+
if self.target_path.exists() and not (self.target_path / ".git").exists():
|
|
88
|
+
import shutil
|
|
89
|
+
|
|
90
|
+
shutil.rmtree(self.target_path)
|
|
91
|
+
|
|
92
|
+
cmd = [
|
|
93
|
+
"git",
|
|
94
|
+
"clone",
|
|
95
|
+
"--branch",
|
|
96
|
+
self.branch,
|
|
97
|
+
"--depth",
|
|
98
|
+
"1",
|
|
99
|
+
"--single-branch",
|
|
100
|
+
self._get_auth_url(),
|
|
101
|
+
str(self.target_path),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
subprocess.run(
|
|
106
|
+
cmd,
|
|
107
|
+
check=True,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
)
|
|
111
|
+
console.print(f"[green]✓[/green] Context cloned to {self.target_path}")
|
|
112
|
+
except subprocess.CalledProcessError as e:
|
|
113
|
+
# Sanitize error message to not expose token
|
|
114
|
+
error_msg = e.stderr.replace(self.token, "***") if self.token else e.stderr
|
|
115
|
+
console.print(f"[red]✗[/red] Failed to clone repository: {error_msg}")
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
def refresh(self) -> bool:
|
|
119
|
+
"""Pull latest changes from the repository.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if changes were pulled, False if already up-to-date.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
subprocess.CalledProcessError: If git pull fails.
|
|
126
|
+
"""
|
|
127
|
+
if not self.is_initialized():
|
|
128
|
+
console.print("[yellow]Context not initialized, running init instead[/yellow]")
|
|
129
|
+
self.init()
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
console.print(f"[cyan]Refreshing context from {self.repo_url}...[/cyan]")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
# Fetch with the auth URL
|
|
136
|
+
subprocess.run(
|
|
137
|
+
["git", "fetch", self._get_auth_url(), self.branch],
|
|
138
|
+
cwd=self.target_path,
|
|
139
|
+
check=True,
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Check if there are changes
|
|
145
|
+
diff_result = subprocess.run(
|
|
146
|
+
["git", "diff", "HEAD..FETCH_HEAD", "--stat"],
|
|
147
|
+
cwd=self.target_path,
|
|
148
|
+
capture_output=True,
|
|
149
|
+
text=True,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if diff_result.stdout.strip():
|
|
153
|
+
# There are changes, do a hard reset to FETCH_HEAD
|
|
154
|
+
subprocess.run(
|
|
155
|
+
["git", "reset", "--hard", "FETCH_HEAD"],
|
|
156
|
+
cwd=self.target_path,
|
|
157
|
+
check=True,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
)
|
|
160
|
+
console.print("[green]✓[/green] Context updated")
|
|
161
|
+
return True
|
|
162
|
+
else:
|
|
163
|
+
console.print("[dim]Context already up-to-date[/dim]")
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
except subprocess.CalledProcessError as e:
|
|
167
|
+
error_msg = e.stderr.replace(self.token, "***") if self.token else e.stderr
|
|
168
|
+
console.print(f"[red]✗[/red] Failed to refresh context: {error_msg}")
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
def is_initialized(self) -> bool:
|
|
172
|
+
"""Check if repository has been cloned.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True if .git directory exists at target path.
|
|
176
|
+
"""
|
|
177
|
+
return (self.target_path / ".git").exists()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Local filesystem context provider."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .base import ContextProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LocalContextProvider(ContextProvider):
|
|
9
|
+
"""Context provider for local filesystem.
|
|
10
|
+
|
|
11
|
+
This is the default provider that expects context to already exist
|
|
12
|
+
at the target path (e.g., via Docker volume mount).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, target_path: Path):
|
|
16
|
+
"""Initialize the local context provider.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
target_path: Path where context should exist.
|
|
20
|
+
"""
|
|
21
|
+
super().__init__(target_path)
|
|
22
|
+
|
|
23
|
+
def init(self) -> None:
|
|
24
|
+
"""Validate that the local context path exists.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
FileNotFoundError: If target path does not exist.
|
|
28
|
+
ValueError: If nao_config.yaml is not found.
|
|
29
|
+
"""
|
|
30
|
+
if not self.target_path.exists():
|
|
31
|
+
raise FileNotFoundError(
|
|
32
|
+
f"Context path does not exist: {self.target_path}\n"
|
|
33
|
+
"For local mode, ensure the path is mounted as a Docker volume "
|
|
34
|
+
"or use NAO_CONTEXT_SOURCE=git for git-based context."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if not self.validate():
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"nao_config.yaml not found in {self.target_path}\n"
|
|
40
|
+
"Ensure the context path contains a valid nao project."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def refresh(self) -> bool:
|
|
44
|
+
"""Refresh is a no-op for local provider.
|
|
45
|
+
|
|
46
|
+
Local context is managed externally (e.g., volume mount updates).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
False (no refresh performed)
|
|
50
|
+
"""
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def is_initialized(self) -> bool:
|
|
54
|
+
"""Check if local context is available.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if target path exists and contains nao_config.yaml.
|
|
58
|
+
"""
|
|
59
|
+
return self.target_path.exists() and self.validate()
|
nao_core/main.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Template engine module for nao providers.
|
|
2
|
+
|
|
3
|
+
This module provides a Jinja2-based templating system that allows users
|
|
4
|
+
to customize the output of sync providers (databases, repos, etc.).
|
|
5
|
+
|
|
6
|
+
Default templates are stored in this package and can be overridden by
|
|
7
|
+
placing templates with the same name in the project's `templates/` directory.
|
|
8
|
+
|
|
9
|
+
Additionally, this module supports rendering user Jinja templates in the
|
|
10
|
+
context folder, making the `nao` object available for accessing provider data.
|
|
11
|
+
|
|
12
|
+
Example user template (docs/report.md.j2):
|
|
13
|
+
# {{ nao.config.project_name }}
|
|
14
|
+
|
|
15
|
+
{{ nao.notion.page('https://notion.so/...').content }}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .context import NaoContext, NotionPage, NotionProvider, create_nao_context
|
|
19
|
+
from .engine import TemplateEngine, get_template_engine
|
|
20
|
+
from .render import (
|
|
21
|
+
TemplateRenderResult,
|
|
22
|
+
discover_templates,
|
|
23
|
+
render_all_templates,
|
|
24
|
+
render_template,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# Engine
|
|
29
|
+
"TemplateEngine",
|
|
30
|
+
"get_template_engine",
|
|
31
|
+
# Context
|
|
32
|
+
"NaoContext",
|
|
33
|
+
"NotionPage",
|
|
34
|
+
"NotionProvider",
|
|
35
|
+
"create_nao_context",
|
|
36
|
+
# Render
|
|
37
|
+
"TemplateRenderResult",
|
|
38
|
+
"discover_templates",
|
|
39
|
+
"render_template",
|
|
40
|
+
"render_all_templates",
|
|
41
|
+
]
|