nogic 0.0.1__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.
@@ -0,0 +1,203 @@
1
+ """
2
+ Symbol storage service for managing code symbols in SQLite.
3
+ """
4
+
5
+ import json
6
+ import sqlite3
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ from nogic.parsing.types import ExtractedFunction, ExtractedClass
11
+
12
+
13
+ @dataclass
14
+ class StoredSymbol:
15
+ """A symbol retrieved from the database."""
16
+
17
+ id: str
18
+ name: str
19
+ file_id: int
20
+ kind: str
21
+ parent_symbol_id: Optional[str]
22
+ exported: bool
23
+ line_start: Optional[int]
24
+ line_end: Optional[int]
25
+ signature: Optional[str]
26
+ docstring: Optional[str]
27
+ extra_data: Optional[dict]
28
+
29
+
30
+ class SymbolStorageService:
31
+ """Service for storing and querying code symbols."""
32
+
33
+ def __init__(self, conn: sqlite3.Connection):
34
+ self.conn = conn
35
+ self.conn.row_factory = sqlite3.Row
36
+
37
+ def _make_symbol_id(
38
+ self, file_path: str, name: str, parent_name: Optional[str] = None
39
+ ) -> str:
40
+ """Create a unique symbol ID from file path and names."""
41
+ if parent_name:
42
+ return f"{file_path}::{parent_name}::{name}"
43
+ return f"{file_path}::{name}"
44
+
45
+ def store_function(
46
+ self,
47
+ file_id: int,
48
+ file_path: str,
49
+ func: ExtractedFunction,
50
+ parent_symbol_id: Optional[str] = None,
51
+ ) -> str:
52
+ """Store a function or method symbol. Returns the symbol ID."""
53
+ if func.class_name:
54
+ symbol_id = self._make_symbol_id(file_path, func.name, func.class_name)
55
+ kind = "method"
56
+ else:
57
+ symbol_id = self._make_symbol_id(file_path, func.name)
58
+ kind = "function"
59
+
60
+ extra_data = {
61
+ "decorators": func.decorators,
62
+ "parameters": func.parameters,
63
+ "return_type": func.return_type,
64
+ "is_async": func.is_async,
65
+ }
66
+
67
+ self.conn.execute(
68
+ """
69
+ INSERT OR REPLACE INTO symbols
70
+ (id, name, file_id, kind, parent_symbol_id, exported, line_start, line_end, signature, docstring, extra_data)
71
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
72
+ """,
73
+ (
74
+ symbol_id,
75
+ func.name,
76
+ file_id,
77
+ kind,
78
+ parent_symbol_id,
79
+ 0, # exported - would need export detection
80
+ func.start_line,
81
+ func.end_line,
82
+ func.signature,
83
+ func.docstring,
84
+ json.dumps(extra_data),
85
+ ),
86
+ )
87
+
88
+ return symbol_id
89
+
90
+ def store_class(
91
+ self,
92
+ file_id: int,
93
+ file_path: str,
94
+ cls: ExtractedClass,
95
+ ) -> str:
96
+ """Store a class symbol and its methods. Returns the class symbol ID."""
97
+ symbol_id = self._make_symbol_id(file_path, cls.name)
98
+
99
+ extra_data = {
100
+ "decorators": cls.decorators,
101
+ "bases": cls.bases,
102
+ }
103
+
104
+ self.conn.execute(
105
+ """
106
+ INSERT OR REPLACE INTO symbols
107
+ (id, name, file_id, kind, parent_symbol_id, exported, line_start, line_end, signature, docstring, extra_data)
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ """,
110
+ (
111
+ symbol_id,
112
+ cls.name,
113
+ file_id,
114
+ "class",
115
+ None,
116
+ 0, # exported
117
+ cls.start_line,
118
+ cls.end_line,
119
+ f"class {cls.name}",
120
+ cls.docstring,
121
+ json.dumps(extra_data),
122
+ ),
123
+ )
124
+
125
+ # Store methods with parent reference
126
+ for method in cls.methods:
127
+ self.store_function(file_id, file_path, method, parent_symbol_id=symbol_id)
128
+
129
+ return symbol_id
130
+
131
+ def delete_file_symbols(self, file_id: int) -> int:
132
+ """Delete all symbols for a file. Returns count deleted."""
133
+ cursor = self.conn.execute(
134
+ "DELETE FROM symbols WHERE file_id = ?", (file_id,)
135
+ )
136
+ return cursor.rowcount
137
+
138
+ def get_symbol(self, symbol_id: str) -> Optional[StoredSymbol]:
139
+ """Get a symbol by ID."""
140
+ row = self.conn.execute(
141
+ "SELECT * FROM symbols WHERE id = ?", (symbol_id,)
142
+ ).fetchone()
143
+
144
+ if not row:
145
+ return None
146
+
147
+ return self._row_to_symbol(row)
148
+
149
+ def get_symbols_by_file(self, file_id: int) -> list[StoredSymbol]:
150
+ """Get all symbols in a file."""
151
+ rows = self.conn.execute(
152
+ "SELECT * FROM symbols WHERE file_id = ?", (file_id,)
153
+ ).fetchall()
154
+
155
+ return [self._row_to_symbol(row) for row in rows]
156
+
157
+ def get_symbols_by_name(self, name: str) -> list[StoredSymbol]:
158
+ """Get all symbols with a given name."""
159
+ rows = self.conn.execute(
160
+ "SELECT * FROM symbols WHERE name = ?", (name,)
161
+ ).fetchall()
162
+
163
+ return [self._row_to_symbol(row) for row in rows]
164
+
165
+ def get_symbols_by_kind(self, kind: str) -> list[StoredSymbol]:
166
+ """Get all symbols of a given kind (class, function, method)."""
167
+ rows = self.conn.execute(
168
+ "SELECT * FROM symbols WHERE kind = ?", (kind,)
169
+ ).fetchall()
170
+
171
+ return [self._row_to_symbol(row) for row in rows]
172
+
173
+ def search_symbols(self, query: str, limit: int = 50) -> list[StoredSymbol]:
174
+ """Search symbols by name pattern."""
175
+ rows = self.conn.execute(
176
+ "SELECT * FROM symbols WHERE name LIKE ? LIMIT ?",
177
+ (f"%{query}%", limit),
178
+ ).fetchall()
179
+
180
+ return [self._row_to_symbol(row) for row in rows]
181
+
182
+ def _row_to_symbol(self, row: sqlite3.Row) -> StoredSymbol:
183
+ """Convert a database row to a StoredSymbol."""
184
+ extra_data = None
185
+ if row["extra_data"]:
186
+ try:
187
+ extra_data = json.loads(row["extra_data"])
188
+ except json.JSONDecodeError:
189
+ pass
190
+
191
+ return StoredSymbol(
192
+ id=row["id"],
193
+ name=row["name"],
194
+ file_id=row["file_id"],
195
+ kind=row["kind"],
196
+ parent_symbol_id=row["parent_symbol_id"],
197
+ exported=bool(row["exported"]),
198
+ line_start=row["line_start"],
199
+ line_end=row["line_end"],
200
+ signature=row["signature"],
201
+ docstring=row["docstring"],
202
+ extra_data=extra_data,
203
+ )
nogic/telemetry.py ADDED
@@ -0,0 +1,142 @@
1
+ """Telemetry module for Nogic CLI — sends events to backend telemetry endpoints."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import platform
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ from nogic import __version__
12
+ from .config import GLOBAL_CONFIG_DIR, CONFIG_FILE
13
+
14
+ _client: httpx.Client | None = None
15
+ _disabled: bool = False
16
+
17
+
18
+ def _get_api_url() -> str:
19
+ """Get the backend API URL from config."""
20
+ from .config import Config
21
+
22
+ config = Config.load()
23
+ return config.api_url
24
+
25
+
26
+ def _get_anonymous_id() -> str:
27
+ """Get anonymous ID based on API key (if logged in) or machine."""
28
+ from .config import Config
29
+
30
+ config = Config.load()
31
+
32
+ if config.api_key:
33
+ return hashlib.sha256(config.api_key.encode()).hexdigest()[:16]
34
+
35
+ try:
36
+ login = os.getlogin()
37
+ except OSError:
38
+ login = os.getenv("USER", os.getenv("USERNAME", "unknown"))
39
+ machine_info = f"{platform.node()}-{platform.machine()}-{login}"
40
+ return hashlib.sha256(machine_info.encode()).hexdigest()[:16]
41
+
42
+
43
+ def _is_telemetry_enabled() -> bool:
44
+ """Check if telemetry is enabled (opt-out via env var or config)."""
45
+ if os.getenv("NOGIC_TELEMETRY_DISABLED", "").lower() in ("1", "true", "yes"):
46
+ return False
47
+ if os.getenv("DO_NOT_TRACK", "").lower() in ("1", "true", "yes"):
48
+ return False
49
+
50
+ config_file = GLOBAL_CONFIG_DIR / CONFIG_FILE
51
+ if config_file.exists():
52
+ try:
53
+ with open(config_file, encoding="utf-8") as f:
54
+ data = json.load(f)
55
+ if data.get("telemetry_enabled") is False:
56
+ return False
57
+ except (json.JSONDecodeError, OSError):
58
+ pass
59
+ return True
60
+
61
+
62
+ def _get_client() -> httpx.Client | None:
63
+ """Get or create the httpx client. Returns None if telemetry is disabled."""
64
+ global _client, _disabled
65
+ if _disabled:
66
+ return None
67
+
68
+ if not _is_telemetry_enabled():
69
+ _disabled = True
70
+ return None
71
+
72
+ if _client is None:
73
+ _client = httpx.Client(timeout=5.0)
74
+
75
+ return _client
76
+
77
+
78
+ def capture(event: str, properties: dict | None = None):
79
+ """Capture a telemetry event by sending it to the backend."""
80
+ client = _get_client()
81
+ if client is None:
82
+ return
83
+
84
+ try:
85
+ props = properties or {}
86
+ props.update({
87
+ "cli_version": __version__,
88
+ "os": platform.system(),
89
+ "os_version": platform.release(),
90
+ "python_version": platform.python_version(),
91
+ })
92
+
93
+ api_url = _get_api_url()
94
+ client.post(
95
+ f"{api_url}/v1/telemetry",
96
+ json={
97
+ "event": event,
98
+ "properties": props,
99
+ "anonymous_id": _get_anonymous_id(),
100
+ },
101
+ )
102
+ except Exception:
103
+ pass
104
+
105
+
106
+ def identify(user_id: str | None = None, properties: dict | None = None):
107
+ """Identify a user by sending to the backend."""
108
+ client = _get_client()
109
+ if client is None:
110
+ return
111
+
112
+ if not user_id:
113
+ return
114
+
115
+ try:
116
+ hashed_user_id = hashlib.sha256(user_id.encode()).hexdigest()[:16]
117
+
118
+ props = properties or {}
119
+ props.update({"os": platform.system()})
120
+
121
+ api_url = _get_api_url()
122
+ client.post(
123
+ f"{api_url}/v1/telemetry/identify",
124
+ json={
125
+ "anonymous_id": _get_anonymous_id(),
126
+ "user_id": hashed_user_id,
127
+ "properties": props,
128
+ },
129
+ )
130
+ except Exception:
131
+ pass
132
+
133
+
134
+ def shutdown():
135
+ """Close the HTTP client."""
136
+ global _client
137
+ if _client is not None:
138
+ try:
139
+ _client.close()
140
+ except Exception:
141
+ pass
142
+ _client = None
nogic/ui.py ADDED
@@ -0,0 +1,60 @@
1
+ """CLI output styling utilities."""
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.rule import Rule
6
+
7
+ console = Console()
8
+ error_console = Console(stderr=True)
9
+
10
+
11
+ def banner(title: str, subtitle: str | None = None):
12
+ """Display a styled command header."""
13
+ console.print()
14
+ console.print(Rule(f"[bold cyan]{title}[/]", style="dim"))
15
+ if subtitle:
16
+ dim(f" {subtitle}")
17
+
18
+
19
+ def dev_banner(url: str):
20
+ """Display dev mode warning."""
21
+ console.print(Panel(
22
+ f"[yellow bold]DEV MODE[/] Using {url}",
23
+ border_style="yellow",
24
+ padding=(0, 1),
25
+ ))
26
+
27
+
28
+ def success(msg: str):
29
+ console.print(f"[green bold]>[/] {msg}")
30
+
31
+
32
+ def info(msg: str):
33
+ console.print(f"[blue]>[/] {msg}")
34
+
35
+
36
+ def warn(msg: str):
37
+ console.print(f"[yellow bold]![/] {msg}")
38
+
39
+
40
+ def error(msg: str):
41
+ error_console.print(f"[red bold]![/] {msg}")
42
+
43
+
44
+ def dim(msg: str):
45
+ console.print(f"[dim]{msg}[/]")
46
+
47
+
48
+ def kv(key: str, value: str, indent: int = 2):
49
+ """Print a key-value pair."""
50
+ pad = " " * indent
51
+ console.print(f"{pad}[dim]{key}:[/] {value}")
52
+
53
+
54
+ def section(title: str):
55
+ console.print(f"\n[bold]{title}[/]")
56
+
57
+
58
+ def status_spinner(msg: str):
59
+ """Return a Rich status context manager for spinners."""
60
+ return console.status(f"[dim]{msg}[/]", spinner="dots")
@@ -0,0 +1,7 @@
1
+ """File watcher module for syncing files."""
2
+
3
+ from nogic.watcher.storage import FileStorage
4
+ from nogic.watcher.monitor import FileMonitor
5
+ from nogic.watcher.sync import SyncService
6
+
7
+ __all__ = ["FileStorage", "FileMonitor", "SyncService"]
@@ -0,0 +1,80 @@
1
+ """File system monitoring with watchdog."""
2
+
3
+ from pathlib import Path
4
+ from typing import Callable
5
+
6
+ from watchdog.observers import Observer
7
+ from watchdog.events import (
8
+ FileSystemEventHandler,
9
+ FileCreatedEvent,
10
+ FileModifiedEvent,
11
+ FileDeletedEvent,
12
+ FileMovedEvent,
13
+ )
14
+
15
+ __all__ = ["FileEventHandler", "FileMonitor"]
16
+
17
+
18
+ class FileEventHandler(FileSystemEventHandler):
19
+ def __init__(
20
+ self,
21
+ on_change: Callable[[Path], None],
22
+ on_delete: Callable[[Path], None],
23
+ should_ignore: Callable[[Path], bool],
24
+ ):
25
+ self.on_change = on_change
26
+ self.on_delete = on_delete
27
+ self.should_ignore = should_ignore
28
+
29
+ def _handle_change(self, path: Path):
30
+ if path.is_file() and not self.should_ignore(path):
31
+ self.on_change(path)
32
+
33
+ def _handle_delete(self, path: Path):
34
+ if not self.should_ignore(path):
35
+ self.on_delete(path)
36
+
37
+ def on_created(self, event: FileCreatedEvent):
38
+ if not event.is_directory:
39
+ self._handle_change(Path(event.src_path))
40
+
41
+ def on_modified(self, event: FileModifiedEvent):
42
+ if not event.is_directory:
43
+ self._handle_change(Path(event.src_path))
44
+
45
+ def on_deleted(self, event: FileDeletedEvent):
46
+ if not event.is_directory:
47
+ self._handle_delete(Path(event.src_path))
48
+
49
+ def on_moved(self, event: FileMovedEvent):
50
+ if not event.is_directory:
51
+ self._handle_delete(Path(event.src_path))
52
+ self._handle_change(Path(event.dest_path))
53
+
54
+
55
+ class FileMonitor:
56
+ def __init__(
57
+ self,
58
+ root_path: Path,
59
+ on_change: Callable[[Path], None],
60
+ on_delete: Callable[[Path], None],
61
+ should_ignore: Callable[[Path], bool],
62
+ ):
63
+ self.root_path = root_path.resolve()
64
+ self.handler = FileEventHandler(
65
+ on_change=on_change,
66
+ on_delete=on_delete,
67
+ should_ignore=should_ignore,
68
+ )
69
+ self.observer = Observer()
70
+
71
+ def start(self):
72
+ self.observer.schedule(self.handler, str(self.root_path), recursive=True)
73
+ self.observer.start()
74
+
75
+ def stop(self):
76
+ self.observer.stop()
77
+ self.observer.join(timeout=5)
78
+
79
+ def is_alive(self) -> bool:
80
+ return self.observer.is_alive()
@@ -0,0 +1,185 @@
1
+ """SQLite storage layer for file tracking."""
2
+
3
+ import os
4
+ import sqlite3
5
+ import hashlib
6
+ from pathlib import Path
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+ import time
10
+
11
+ from nogic.storage.schema import (
12
+ create_schema,
13
+ get_schema_version,
14
+ set_schema_version,
15
+ migrate_schema,
16
+ SCHEMA_VERSION,
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class FileRecord:
22
+ id: int
23
+ path: str
24
+ content_hash: str
25
+ last_modified: float
26
+ synced_at: Optional[float]
27
+ status: str # 'active' or 'deleted'
28
+
29
+
30
+ class FileStorage:
31
+ def __init__(self, db_path: Path):
32
+ self.db_path = db_path
33
+ self._conn: Optional[sqlite3.Connection] = None
34
+ self._init_db()
35
+
36
+ def _init_db(self):
37
+ conn = self._connect()
38
+ current_version = get_schema_version(conn)
39
+
40
+ if current_version < SCHEMA_VERSION:
41
+ create_schema(conn)
42
+ set_schema_version(conn, SCHEMA_VERSION)
43
+ conn.commit()
44
+
45
+ # Restrict database file permissions
46
+ os.chmod(self.db_path, 0o600)
47
+
48
+ def _connect(self) -> sqlite3.Connection:
49
+ """Return the shared connection, creating it if needed."""
50
+ if self._conn is None:
51
+ self._conn = sqlite3.connect(self.db_path)
52
+ self._conn.row_factory = sqlite3.Row
53
+ return self._conn
54
+
55
+ def close(self):
56
+ """Close the database connection."""
57
+ if self._conn is not None:
58
+ self._conn.close()
59
+ self._conn = None
60
+
61
+ @staticmethod
62
+ def compute_hash(content: str) -> str:
63
+ return hashlib.sha256(content.encode()).hexdigest()
64
+
65
+ def get_file(self, path: str) -> Optional[FileRecord]:
66
+ conn = self._connect()
67
+ row = conn.execute(
68
+ "SELECT * FROM files WHERE path = ?", (path,)
69
+ ).fetchone()
70
+ if row:
71
+ return FileRecord(**dict(row))
72
+ return None
73
+
74
+ def upsert_file(
75
+ self, path: str, content_hash: str, last_modified: float
76
+ ) -> tuple[int, str]:
77
+ """Insert or update file. Returns (file_id, event_type)."""
78
+ existing = self.get_file(path)
79
+ now = time.time()
80
+
81
+ conn = self._connect()
82
+ if existing is None:
83
+ cursor = conn.execute(
84
+ """INSERT INTO files (path, content_hash, last_modified, status)
85
+ VALUES (?, ?, ?, 'active')""",
86
+ (path, content_hash, last_modified),
87
+ )
88
+ file_id = cursor.lastrowid
89
+ event_type = "created"
90
+ elif existing.status == "deleted":
91
+ conn.execute(
92
+ """UPDATE files
93
+ SET content_hash = ?, last_modified = ?, status = 'active'
94
+ WHERE id = ?""",
95
+ (content_hash, last_modified, existing.id),
96
+ )
97
+ file_id = existing.id
98
+ event_type = "created"
99
+ elif existing.content_hash != content_hash:
100
+ conn.execute(
101
+ """UPDATE files
102
+ SET content_hash = ?, last_modified = ?
103
+ WHERE id = ?""",
104
+ (content_hash, last_modified, existing.id),
105
+ )
106
+ file_id = existing.id
107
+ event_type = "modified"
108
+ else:
109
+ return existing.id, "unchanged"
110
+
111
+ conn.execute(
112
+ """INSERT INTO sync_events (file_id, event_type, content_hash, timestamp)
113
+ VALUES (?, ?, ?, ?)""",
114
+ (file_id, event_type, content_hash, now),
115
+ )
116
+ conn.commit()
117
+ return file_id, event_type
118
+
119
+ def mark_deleted(self, path: str) -> Optional[int]:
120
+ """Mark file as deleted. Returns file_id if found."""
121
+ existing = self.get_file(path)
122
+ if existing and existing.status == "active":
123
+ now = time.time()
124
+ conn = self._connect()
125
+ conn.execute(
126
+ "UPDATE files SET status = 'deleted' WHERE id = ?",
127
+ (existing.id,),
128
+ )
129
+ conn.execute(
130
+ """INSERT INTO sync_events (file_id, event_type, content_hash, timestamp)
131
+ VALUES (?, 'deleted', ?, ?)""",
132
+ (existing.id, existing.content_hash, now),
133
+ )
134
+ conn.commit()
135
+ return existing.id
136
+ return None
137
+
138
+ def mark_synced(self, file_id: int):
139
+ """Mark file as synced."""
140
+ conn = self._connect()
141
+ conn.execute(
142
+ "UPDATE files SET synced_at = ? WHERE id = ?",
143
+ (time.time(), file_id),
144
+ )
145
+ conn.commit()
146
+
147
+ def get_pending_sync(self) -> list[FileRecord]:
148
+ """Get files that need syncing (modified after last sync)."""
149
+ conn = self._connect()
150
+ rows = conn.execute(
151
+ """SELECT * FROM files
152
+ WHERE status = 'active'
153
+ AND (synced_at IS NULL OR last_modified > synced_at)"""
154
+ ).fetchall()
155
+ return [FileRecord(**dict(row)) for row in rows]
156
+
157
+ def get_all_active(self) -> list[FileRecord]:
158
+ """Get all active files."""
159
+ conn = self._connect()
160
+ rows = conn.execute(
161
+ "SELECT * FROM files WHERE status = 'active'"
162
+ ).fetchall()
163
+ return [FileRecord(**dict(row)) for row in rows]
164
+
165
+ def get_stats(self) -> dict:
166
+ """Get storage statistics."""
167
+ conn = self._connect()
168
+ total = conn.execute(
169
+ "SELECT COUNT(*) FROM files WHERE status = 'active'"
170
+ ).fetchone()[0]
171
+ synced = conn.execute(
172
+ "SELECT COUNT(*) FROM files WHERE status = 'active' AND synced_at IS NOT NULL"
173
+ ).fetchone()[0]
174
+ return {
175
+ "total_files": total,
176
+ "synced_files": synced,
177
+ "pending_files": total - synced,
178
+ }
179
+
180
+ def clear_all(self):
181
+ """Clear all file records (for reindexing)."""
182
+ conn = self._connect()
183
+ conn.execute("DELETE FROM sync_events")
184
+ conn.execute("DELETE FROM files")
185
+ conn.commit()