pandify 0.1.0__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.
Files changed (47) hide show
  1. pandify/__init__.py +36 -0
  2. pandify/api/__init__.py +24 -0
  3. pandify/api/auth.py +46 -0
  4. pandify/api/chat.py +118 -0
  5. pandify/api/configure.py +55 -0
  6. pandify/api/help.py +158 -0
  7. pandify/api/history.py +62 -0
  8. pandify/api/keys.py +179 -0
  9. pandify/api/memory.py +118 -0
  10. pandify/api/session.py +59 -0
  11. pandify/api/upload.py +45 -0
  12. pandify/auth/__init__.py +7 -0
  13. pandify/auth/hasher.py +41 -0
  14. pandify/auth/service.py +155 -0
  15. pandify/cli.py +26 -0
  16. pandify/config/__init__.py +20 -0
  17. pandify/config/settings.py +157 -0
  18. pandify/core/__init__.py +6 -0
  19. pandify/core/session.py +116 -0
  20. pandify/errors/__init__.py +42 -0
  21. pandify/errors/exceptions.py +97 -0
  22. pandify/files/__init__.py +8 -0
  23. pandify/files/extractors.py +133 -0
  24. pandify/files/models.py +30 -0
  25. pandify/files/staging.py +34 -0
  26. pandify/files/validator.py +122 -0
  27. pandify/gemini/__init__.py +6 -0
  28. pandify/gemini/client.py +172 -0
  29. pandify/keys/__init__.py +8 -0
  30. pandify/keys/manager.py +341 -0
  31. pandify/keys/models.py +35 -0
  32. pandify/keys/store.py +47 -0
  33. pandify/security/__init__.py +7 -0
  34. pandify/security/path_guard.py +31 -0
  35. pandify/security/redactor.py +61 -0
  36. pandify/storage/__init__.py +8 -0
  37. pandify/storage/conversations.py +154 -0
  38. pandify/storage/database.py +103 -0
  39. pandify/storage/templates.py +91 -0
  40. pandify/ui/__init__.py +18 -0
  41. pandify/ui/renderer.py +70 -0
  42. pandify/ui/terminal.py +73 -0
  43. pandify-0.1.0.dist-info/METADATA +160 -0
  44. pandify-0.1.0.dist-info/RECORD +47 -0
  45. pandify-0.1.0.dist-info/WHEEL +5 -0
  46. pandify-0.1.0.dist-info/licenses/LICENSE +22 -0
  47. pandify-0.1.0.dist-info/top_level.txt +1 -0
pandify/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Pandify: Private AI chat library powered by Google Gemini.
2
+
3
+ Importable Python library providing secure, multimodal conversational workflows with minimal terminal UI.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ from pandify.api.auth import auth
9
+ from pandify.api.chat import chat
10
+ from pandify.api.configure import configure
11
+ from pandify.api.help import pandify_help
12
+ from pandify.api.history import history
13
+ from pandify.api.keys import clear_keys, download_keys, keys
14
+ from pandify.api.memory import clear_memory, remember, templates
15
+ from pandify.api.session import clear, exit, new
16
+ from pandify.api.upload import upload
17
+
18
+ __all__ = [
19
+ "__version__",
20
+ "auth",
21
+ "chat",
22
+ "upload",
23
+ "new",
24
+ "clear",
25
+ "exit",
26
+ "history",
27
+ "keys",
28
+ "download_keys",
29
+ "clear_keys",
30
+ "configure",
31
+ "pandify_help",
32
+ "remember",
33
+ "templates",
34
+ "clear_memory",
35
+ ]
36
+
@@ -0,0 +1,24 @@
1
+ """API package for Pandify."""
2
+
3
+ from pandify.api.auth import auth
4
+ from pandify.api.chat import chat
5
+ from pandify.api.configure import configure
6
+ from pandify.api.help import pandify_help
7
+ from pandify.api.history import history
8
+ from pandify.api.keys import keys
9
+ from pandify.api.session import clear, exit, new
10
+ from pandify.api.upload import upload
11
+
12
+ __all__ = [
13
+ "auth",
14
+ "chat",
15
+ "upload",
16
+ "new",
17
+ "clear",
18
+ "exit",
19
+ "history",
20
+ "keys",
21
+ "configure",
22
+ "pandify_help",
23
+ ]
24
+
pandify/api/auth.py ADDED
@@ -0,0 +1,46 @@
1
+ """Public API: pandify.auth()"""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from pandify.core.session import SessionManager
6
+
7
+
8
+ def auth(
9
+ password: Optional[str] = None,
10
+ key: Optional[str] = None,
11
+ keys_file: Optional[str] = None,
12
+ ) -> bool:
13
+ """Authenticate the user session with optional direct key or keys_file injection.
14
+
15
+ Args:
16
+ password (str, optional): Explicit master password.
17
+ key (str, optional): Direct Gemini API key secret to register for this session.
18
+ keys_file (str, optional): Path to a .txt file containing Gemini API key(s).
19
+ """
20
+ session = SessionManager.get_instance()
21
+ if not session.is_authenticated:
22
+ session.ensure_authenticated(explicit_password=password)
23
+
24
+ if key:
25
+ session.key_manager.add_key(secret=key.strip(), label="inline-auth-key")
26
+ session.renderer.print_info("Loaded API key from auth argument.")
27
+
28
+ if keys_file:
29
+ p = Path(keys_file)
30
+ if p.exists():
31
+ loaded_count = 0
32
+ with open(p, "r", encoding="utf-8") as f:
33
+ content = f.read()
34
+ for line in content.splitlines():
35
+ for part in line.split(","):
36
+ k = part.strip()
37
+ if k and not k.startswith("#"):
38
+ session.key_manager.add_key(secret=k, label=f"file-key-{loaded_count+1}")
39
+ loaded_count += 1
40
+ if loaded_count > 0:
41
+ session.renderer.print_info(f"Loaded {loaded_count} API key(s) from '{keys_file}'.")
42
+ else:
43
+ session.renderer.print_error(f"Keys file '{keys_file}' not found.")
44
+
45
+ return session.is_authenticated
46
+
pandify/api/chat.py ADDED
@@ -0,0 +1,118 @@
1
+ """Public API: pandify.chat()"""
2
+
3
+ from typing import Optional
4
+ from pandify.core.session import SessionManager
5
+ from pandify.errors.exceptions import PandifyError
6
+
7
+
8
+ def chat(message: str, stream: Optional[bool] = None) -> str:
9
+ """Send a message to Gemini with conversation context and any staged attachments.
10
+
11
+ Parameters:
12
+ message (str): The prompt message to send.
13
+ stream (bool, optional): Stream response tokens to terminal. Defaults to config setting.
14
+
15
+ Returns:
16
+ str: The full generated assistant response.
17
+ """
18
+ if not message or not message.strip():
19
+ raise ValueError("Message cannot be empty.")
20
+
21
+ session = SessionManager.get_instance()
22
+ session.ensure_authenticated()
23
+
24
+ renderer = session.renderer
25
+ conv_repo = session.conv_repo
26
+ conv_id = session.get_active_conversation_id()
27
+
28
+ # Flush any staged attachments
29
+ staged = session.staging.flush()
30
+ attachments_meta = [att.to_dict() for att in staged] if staged else None
31
+
32
+ # Print user prompt in minimal format
33
+ renderer.print_user_message(message.strip())
34
+
35
+ # Record user message in repository
36
+ conv_repo.add_message(
37
+ conversation_id=conv_id,
38
+ role="user",
39
+ content=message.strip(),
40
+ attachments=attachments_meta,
41
+ )
42
+
43
+ # Fetch prior conversation history
44
+ history = conv_repo.get_messages(conv_id)
45
+ # Exclude current user message from prior history as build_contents appends it with attachments
46
+ prior_messages = history[:-1] if len(history) > 1 else []
47
+
48
+ # Determine streaming preference
49
+ is_streaming = stream if stream is not None else bool(session.config_mgr.get("general", "stream", True))
50
+ current_model = str(session.config_mgr.get("general", "model", "gemini-3.5-flash"))
51
+ max_retries = int(session.config_mgr.get("gemini", "max_retries", 3))
52
+
53
+ def _notify_key_switch(label: str, msg: str) -> None:
54
+ renderer.print_info(f"[{msg}]")
55
+
56
+ # Inspect persistent SQLite code templates
57
+ active_templates = session.template_repo.get_active_templates()
58
+ if active_templates:
59
+ template_blocks = []
60
+ for tpl in active_templates:
61
+ template_blocks.append(
62
+ f"--- MANDATORY CODE TEMPLATE: {tpl['name']} ---\n{tpl['content']}"
63
+ )
64
+ template_context = "\n\n".join(template_blocks)
65
+ effective_prompt = (
66
+ f"[MANDATORY REFERENCE TEMPLATE & ARCHITECTURE]\n"
67
+ f"{template_context}\n\n"
68
+ f"[STRICT INSTRUCTION]\n"
69
+ f"You must strictly adhere to the architecture, imports, class structure, and naming conventions provided in the template above. "
70
+ f"Only provide the missing logic or syntax fitted directly into this template format.\n\n"
71
+ f"[USER REQUEST]\n"
72
+ f"{message.strip()}"
73
+ )
74
+ else:
75
+ effective_prompt = message.strip()
76
+
77
+ try:
78
+ if is_streaming:
79
+ tokens_gen = session.gemini_client.generate_chat_stream(
80
+ history_messages=prior_messages,
81
+ prompt=effective_prompt,
82
+ attachments=staged,
83
+ model=current_model,
84
+ max_retries=max_retries,
85
+ notify_switch=_notify_key_switch,
86
+ )
87
+ response_text = renderer.print_assistant_response(
88
+ text="",
89
+ stream=True,
90
+ token_generator=tokens_gen,
91
+ )
92
+ else:
93
+ response_text = session.gemini_client.generate_chat_sync(
94
+ history_messages=prior_messages,
95
+ prompt=effective_prompt,
96
+ attachments=staged,
97
+ model=current_model,
98
+ max_retries=max_retries,
99
+ notify_switch=_notify_key_switch,
100
+ )
101
+ renderer.print_assistant_response(response_text, stream=False)
102
+
103
+ # Record assistant turn in repository
104
+ conv_repo.add_message(
105
+ conversation_id=conv_id,
106
+ role="model",
107
+ content=response_text,
108
+ )
109
+
110
+ return response_text
111
+
112
+ except PandifyError as e:
113
+ renderer.print_error(str(e))
114
+ raise
115
+ except Exception as e:
116
+ renderer.print_error(f"Unexpected error: {e}")
117
+ raise PandifyError(f"Chat operation failed: {e}") from e
118
+
@@ -0,0 +1,55 @@
1
+ """Public API: pandify.configure()"""
2
+
3
+ from typing import Any
4
+ from rich.table import Table
5
+ from pandify.core.session import SessionManager
6
+
7
+
8
+ def configure(**kwargs: Any) -> dict[str, Any]:
9
+ """Inspect or update Pandify configuration parameters.
10
+
11
+ Supported parameters:
12
+ model (str): Default Gemini model (e.g. 'gemini-2.5-flash', 'gemini-2.5-pro')
13
+ stream (bool): Whether to stream chat tokens
14
+ max_retries (int): Maximum key rotation retries
15
+ max_file_size_mb (int): Maximum upload file size in MB
16
+ theme (str): UI theme styling
17
+ session_timeout_minutes (int): Inactivity minutes before re-authentication
18
+ retention_days (int): Days to retain conversation logs
19
+
20
+ Returns:
21
+ dict: The updated active configuration dictionary.
22
+ """
23
+ session = SessionManager.get_instance()
24
+ session.ensure_authenticated()
25
+
26
+ config_mgr = session.config_mgr
27
+ console = session.renderer.console
28
+
29
+ if kwargs:
30
+ updated = config_mgr.update(**kwargs)
31
+ # Propagate changes to active components
32
+ if "model" in kwargs:
33
+ session.gemini_client.default_model = str(kwargs["model"])
34
+ if "max_file_size_mb" in kwargs:
35
+ session.file_validator.max_size_mb = int(kwargs["max_file_size_mb"])
36
+ session.renderer.print_info("Configuration updated successfully.")
37
+ return updated
38
+
39
+ # If no arguments provided, print current settings
40
+ current = config_mgr.as_dict()
41
+ table = Table(title="Pandify Active Configuration", show_header=True, header_style="bold blue")
42
+ table.add_column("Section", style="dim")
43
+ table.add_column("Setting", style="bold white")
44
+ table.add_column("Value", style="cyan")
45
+
46
+ for section, values in current.items():
47
+ if isinstance(values, dict):
48
+ for k, v in values.items():
49
+ table.add_row(section, k, str(v))
50
+ else:
51
+ table.add_row("root", section, str(values))
52
+
53
+ console.print(table)
54
+ return current
55
+
pandify/api/help.py ADDED
@@ -0,0 +1,158 @@
1
+ """Public API: pandify.pandify_help()"""
2
+
3
+ from rich.markdown import Markdown
4
+ from pandify.core.session import SessionManager
5
+
6
+ HELP_MARKDOWN = """
7
+ # Pandify API Reference & Guide
8
+
9
+ Pandify is a private AI chat library powered by Google Gemini designed to be imported directly into Python applications.
10
+
11
+ ---
12
+
13
+ ## 1. Quick Start
14
+
15
+ ```python
16
+ import pandify
17
+
18
+ # Authenticate or complete initial master password setup
19
+ pandify.auth()
20
+
21
+ # Normal conversation
22
+ pandify.chat("Hello, how are you?")
23
+
24
+ # Attach code, documents, or images
25
+ pandify.upload("main.py")
26
+ pandify.upload("diagram.png")
27
+ pandify.chat("Explain this code and the architecture shown in the diagram.")
28
+
29
+ # Check documentation
30
+ pandify.pandify_help()
31
+
32
+ # Safely close session
33
+ pandify.exit()
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 2. Core Functions
39
+
40
+ ### `pandify.auth(password: Optional[str] = None, key: Optional[str] = None, keys_file: Optional[str] = None) -> bool`
41
+ - **Purpose**: Authenticates the current process session using Argon2id.
42
+ - **Arguments**:
43
+ - `password`: Explicit master password (if omitted, prompts on a clean blank line).
44
+ - `key`: Direct API key string to load into in-memory session (useful on restricted lab PCs).
45
+ - `keys_file`: Path to a text file (e.g. `'keys.txt'`) containing API key(s) to load.
46
+
47
+ ### `pandify.chat(message: str, stream: Optional[bool] = None) -> str`
48
+ - **Purpose**: Sends prompt and any staged attachments to Gemini.
49
+ - **Returns**: Complete generated assistant response text.
50
+ - **Features**: Automatic API key rotation on 429/errors, persistent context history, token streaming.
51
+
52
+ ### `pandify.upload(path: Union[str, Path, Iterable]) -> list[str]`
53
+ - **Purpose**: Validates and stages files to be included with the next `chat()` prompt.
54
+ - **Supported Formats**:
55
+ - Documents: `.pdf`, `.docx`, `.xlsx`, `.csv`, `.txt`
56
+ - Images: `.png`, `.jpg`, `.jpeg`, `.webp`
57
+ - Code & Markup: `.py`, `.js`, `.ts`, `.jsx`, `.tsx`, `.java`, `.c`, `.cpp`, `.html`, `.css`, `.json`, `.xml`, `.sql`, `.php`, `.go`, `.rs`, `.md`, `.log`
58
+ - **Note**: Attachments are automatically sent with your next message and cleared after sending.
59
+
60
+ ### `pandify.new(title: Optional[str] = None) -> str`
61
+ - **Purpose**: Initiates a fresh conversation thread with a new UUID and resets context.
62
+
63
+ ### `pandify.history(limit: int = 20, export_path: Optional[str] = None) -> list[dict]`
64
+ - **Purpose**: View past conversations and message counts, or export to Markdown (`.md`) or JSON (`.json`).
65
+
66
+ ### `pandify.keys(action: Optional[str] = None, **kwargs) -> Any`
67
+ - **Purpose**: Securely configure and rotate $N$ Gemini API keys.
68
+ - **Actions**:
69
+ - `pandify.keys('add', key='...', label='work', project_id='proj-1')`
70
+ - `pandify.keys('sync', url='https://krishnahere.pythonanywhere.com', password='...')`: Download keys from remote Flask server into OS Keyring.
71
+ - `pandify.keys('list')` or `pandify.keys()`
72
+ - `pandify.keys('remove', key_id='...')`
73
+ - `pandify.keys('clear')` or `pandify.clear_keys()`: **Purge all API keys** completely from the OS Keyring and local database after your exam.
74
+ - **Shortcut**: `pandify.download_keys(url='https://krishnahere.pythonanywhere.com', password='...', save_file='keys.txt')`, `pandify.clear_keys()`
75
+ - **Security**: Raw API keys are stored exclusively in the OS secure keyring and never stored in `.env`, SQLite, or logs.
76
+
77
+ ### `pandify.configure(**kwargs) -> dict`
78
+ - **Purpose**: Inspect or adjust settings (e.g. `model='gemini-2.5-pro'`, `stream=True`, `max_file_size_mb=30`).
79
+
80
+ ### `pandify.remember(content, name='mentor_template') -> str`
81
+ - **Purpose**: Saves a mentor code template or architecture boilerplate into SQLite memory.
82
+ - **Effect**: All subsequent queries automatically ground Gemini in this exact code format.
83
+
84
+ ### `pandify.templates(action='list', **kwargs) -> Any`
85
+ - **Purpose**: View, activate, deactivate, or delete stored templates.
86
+
87
+ ### `pandify.clear_memory(name: Optional[str] = None) -> bool`
88
+ - **Purpose**: Clears a specific template or all templates from SQLite memory.
89
+
90
+ ### `pandify.clear() -> bool`
91
+ - **Purpose**: Clears messages and unstages files for the current conversation.
92
+
93
+ ### `pandify.exit(delete_files: Optional[Union[str, list[str]]] = None) -> None`
94
+ - **Purpose**: Cleans up runtime memory, invalidates active session token, restores terminal, and safely deletes temporary files (e.g. `delete_files=['keys.txt']`).
95
+
96
+ ---
97
+
98
+ ## 3. Exam & Lab PC Workflow Guide
99
+
100
+ ### A. Pre-Saving Mentor Format (Tonight)
101
+ ```python
102
+ pandify.remember('''
103
+ # PASTE MENTOR BOILERPLATE HERE
104
+ class BaseHandler: pass
105
+ ''', name="mentor_template")
106
+ ```
107
+
108
+ ### B. Remote Key Sync (Exam Morning)
109
+ ```python
110
+ # Pulls keys from your PythonAnywhere server directly into OS Keyring or In-Memory RAM:
111
+ pandify.download_keys(url="https://krishnahere.pythonanywhere.com", password="pandify123")
112
+ ```
113
+
114
+ ### C. College PC Restricted Keyring Fallback
115
+ If the college PC blocks Windows Credential Manager:
116
+ - Pandify automatically keeps keys in Python RAM with zero errors.
117
+ - Or pass keys directly at login:
118
+ ```python
119
+ pandify.auth(keys_file="keys.txt") # or pandify.auth(key="AIzaSy...")
120
+ ```
121
+
122
+ ### D. Asking Exam Questions
123
+ ```python
124
+ # stream=False renders beautiful syntax-highlighted code blocks:
125
+ pandify.chat("Write a handler for UDP packets", stream=False)
126
+ ```
127
+
128
+ ### E. Complete Post-Exam Cleanup
129
+ Wipes all keys, mentor templates, and temporary files when you finish:
130
+ ```python
131
+ pandify.clear_keys()
132
+ pandify.clear_memory()
133
+ pandify.clear()
134
+ pandify.exit(delete_files=["keys.txt"])
135
+ ```
136
+
137
+ ---
138
+
139
+ ## 4. Error Handling
140
+
141
+ Pandify raises structured exceptions under `pandify.errors`:
142
+ - `AuthenticationError`: Invalid password or account lockout.
143
+ - `NoAPIKeysConfiguredError`: No keys registered in system.
144
+ - `AllKeysExhaustedError`: All keys are rate-limited (HTTP 429) or cooling down.
145
+ - `FileValidationError`: File not found, unsupported format, or size exceeded.
146
+ - `PathTraversalError`: Attempted directory traversal detected.
147
+ """
148
+
149
+
150
+ def pandify_help() -> None:
151
+ """Display authenticated Pandify documentation and API reference."""
152
+ session = SessionManager.get_instance()
153
+ # Requirement: Authenticate before showing help if no valid session exists
154
+ session.ensure_authenticated()
155
+
156
+ md = Markdown(HELP_MARKDOWN)
157
+ session.renderer.console.print(md)
158
+
pandify/api/history.py ADDED
@@ -0,0 +1,62 @@
1
+ """Public API: pandify.history()"""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Union
5
+ from rich.table import Table
6
+ from pandify.core.session import SessionManager
7
+ from pandify.errors.exceptions import StorageError
8
+
9
+
10
+ def history(limit: int = 20, export_path: Optional[Union[str, Path]] = None) -> list[dict]:
11
+ """View recent conversations or export the active conversation history.
12
+
13
+ Parameters:
14
+ limit (int): Maximum number of recent conversations to display. Defaults to 20.
15
+ export_path (str or Path, optional): File destination to export active conversation (.json or .md).
16
+
17
+ Returns:
18
+ list[dict]: List of conversation summaries.
19
+ """
20
+ session = SessionManager.get_instance()
21
+ session.ensure_authenticated()
22
+
23
+ conv_repo = session.conv_repo
24
+ active_conv_id = session.get_active_conversation_id()
25
+
26
+ # If export requested
27
+ if export_path:
28
+ target = Path(export_path)
29
+ ext = target.suffix.lower()
30
+ fmt = "markdown" if ext in (".md", ".markdown") else "json"
31
+ exported_str = conv_repo.export_conversation(active_conv_id, format=fmt)
32
+ if not exported_str:
33
+ raise StorageError(f"No conversation history found to export for ID '{active_conv_id}'.")
34
+ target.parent.mkdir(parents=True, exist_ok=True)
35
+ target.write_text(exported_str, encoding="utf-8")
36
+ session.renderer.print_info(f"Exported conversation to: {target.resolve()}")
37
+ return [{"id": active_conv_id, "exported_to": str(target.resolve())}]
38
+
39
+ # Otherwise list recent conversations
40
+ conversations = conv_repo.list_conversations(limit=limit)
41
+
42
+ if not conversations:
43
+ session.renderer.print_info("No conversation history found.")
44
+ return []
45
+
46
+ table = Table(title="Conversation History", show_header=True, header_style="bold cyan")
47
+ table.add_column("ID", style="dim", width=36)
48
+ table.add_column("Title", style="bold white")
49
+ table.add_column("Messages", justify="right")
50
+ table.add_column("Last Active", style="dim")
51
+
52
+ for c in conversations:
53
+ table.add_row(
54
+ c["id"],
55
+ c["title"],
56
+ str(c.get("message_count", 0)),
57
+ str(c.get("updated_at", "")),
58
+ )
59
+
60
+ session.renderer.console.print(table)
61
+ return conversations
62
+
pandify/api/keys.py ADDED
@@ -0,0 +1,179 @@
1
+ """Public API: pandify.keys()"""
2
+
3
+ import getpass
4
+ import json
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Any, Optional
8
+ from rich.table import Table
9
+ from pandify.core.session import SessionManager
10
+ from pandify.errors.exceptions import AuthenticationError, PandifyError
11
+ from pandify.security.redactor import mask_secret
12
+
13
+
14
+ def keys(action: Optional[str] = None, **kwargs: Any) -> Any:
15
+ """Manage Gemini API keys securely with automatic rotation and status tracking.
16
+
17
+ Actions:
18
+ - None / 'list': Display active keys, projects, and usage statistics.
19
+ - 'add': Register a new key (requires 'secret' or prompts securely, optional 'label', 'project_id').
20
+ - 'remove': Remove a key by 'key_id'.
21
+ """
22
+ session = SessionManager.get_instance()
23
+ session.ensure_authenticated()
24
+
25
+ key_mgr = session.key_manager
26
+ console = session.renderer.console
27
+
28
+ action_norm = (action or "list").lower()
29
+
30
+ if action_norm == "add":
31
+ secret = kwargs.get("secret") or kwargs.get("key")
32
+ label = kwargs.get("label")
33
+ project_id = kwargs.get("project_id")
34
+
35
+ if not secret:
36
+ secret = getpass.getpass("Enter Gemini API Key Secret: ")
37
+ if not label:
38
+ label = input("Enter Key Label (e.g. personal-dev): ").strip() or "default"
39
+
40
+ rec = key_mgr.add_key(secret=secret, label=label, project_id=project_id)
41
+ session.renderer.print_info(f"Registered API key '{rec.label}' (ID: {rec.id}).")
42
+ return rec
43
+
44
+ elif action_norm in ("sync", "download"):
45
+ url = kwargs.get("url")
46
+ password = kwargs.get("password")
47
+ save_file = kwargs.get("save_file") or kwargs.get("file")
48
+ if not url:
49
+ raise ValueError("Backend URL is required. Usage: pandify.keys('sync', url='http://...', password='...')")
50
+ if not password:
51
+ password = getpass.getpass("Enter Server Download Password: ")
52
+ return download_keys(url=url, password=password, save_file=save_file)
53
+
54
+ elif action_norm in ("clear", "purge", "wipe"):
55
+ return clear_keys()
56
+
57
+ elif action_norm == "remove" or action_norm == "delete":
58
+ key_id = kwargs.get("key_id") or kwargs.get("id")
59
+ if not key_id:
60
+ raise ValueError("Key ID is required for deletion. Usage: pandify.keys('remove', key_id='...')")
61
+ success = key_mgr.remove_key(key_id)
62
+ if success:
63
+ session.renderer.print_info(f"Key '{key_id}' removed.")
64
+ else:
65
+ session.renderer.print_info(f"Key '{key_id}' not found.")
66
+ return success
67
+
68
+ else:
69
+ # Default: list registered keys
70
+ records = key_mgr.list_keys()
71
+ if not records:
72
+ session.renderer.print_info("No API keys registered. Add one with: pandify.keys('add')")
73
+ return []
74
+
75
+ table = Table(title="Registered Gemini API Keys", show_header=True, header_style="bold magenta")
76
+ table.add_column("Key ID", style="dim", width=36)
77
+ table.add_column("Label", style="bold white")
78
+ table.add_column("Project ID", style="cyan")
79
+ table.add_column("Status", style="green")
80
+ table.add_column("Requests", justify="right")
81
+ table.add_column("Failures", justify="right")
82
+ table.add_column("Last Used", style="dim")
83
+
84
+ for r in records:
85
+ status_style = "green" if r["status"] == "active" else ("yellow" if r["status"] == "cooling_down" else "red")
86
+ table.add_row(
87
+ r["id"],
88
+ r["label"],
89
+ r.get("project_id") or "-",
90
+ f"[{status_style}]{r['status']}[/]",
91
+ str(r.get("request_count", 0)),
92
+ str(r.get("failure_count", 0)),
93
+ str(r.get("last_used_at") or "-"),
94
+ )
95
+
96
+ console.print(table)
97
+ return records
98
+
99
+
100
+ def download_keys(url: str, password: Optional[str] = None, save_file: Optional[str] = None) -> list[str]:
101
+ """Download and register Gemini API keys from a remote Pandify key server.
102
+
103
+ Args:
104
+ url (str): The base URL of the remote key server (e.g. 'http://127.0.0.1:5000').
105
+ password (str, optional): The server's download authorization password.
106
+ save_file (str, optional): Optional local text file path to also save downloaded keys into (e.g. 'keys.txt').
107
+
108
+ Returns:
109
+ list[str]: The registered key UUIDs.
110
+ """
111
+ session = SessionManager.get_instance()
112
+ session.ensure_authenticated()
113
+
114
+ clean_url = url.rstrip("/")
115
+ endpoint = f"{clean_url}/api/keys/download"
116
+
117
+ if not password:
118
+ password = getpass.getpass("Enter Server Download Password: ")
119
+
120
+ payload = json.dumps({"password": password}).encode("utf-8")
121
+ req = urllib.request.Request(
122
+ endpoint,
123
+ data=payload,
124
+ headers={"Content-Type": "application/json", "User-Agent": "Pandify-Client/0.1.0"},
125
+ method="POST",
126
+ )
127
+
128
+ try:
129
+ with urllib.request.urlopen(req, timeout=15) as resp:
130
+ data = json.loads(resp.read().decode("utf-8"))
131
+ except urllib.error.HTTPError as e:
132
+ if e.code == 401:
133
+ raise AuthenticationError("Remote key server authentication failed: Incorrect password.")
134
+ err_body = e.read().decode("utf-8", errors="ignore")
135
+ raise PandifyError(f"Remote key server returned HTTP {e.code}: {err_body}")
136
+ except Exception as e:
137
+ raise PandifyError(f"Failed to connect to remote key server at '{endpoint}': {e}")
138
+
139
+ keys_list = data.get("keys", [])
140
+ if not keys_list:
141
+ session.renderer.print_info("No keys returned by remote server.")
142
+ return []
143
+
144
+ registered_ids = []
145
+ raw_keys_to_save = []
146
+ for k in keys_list:
147
+ secret = k.get("key")
148
+ label = k.get("label") or "remote-synced-key"
149
+ project_id = k.get("project_id")
150
+ if secret:
151
+ rec = session.key_manager.add_key(secret=secret, label=label, project_id=project_id)
152
+ registered_ids.append(rec.id)
153
+ raw_keys_to_save.append(secret)
154
+
155
+ if save_file and raw_keys_to_save:
156
+ try:
157
+ with open(save_file, "w", encoding="utf-8") as f:
158
+ f.write("\n".join(raw_keys_to_save) + "\n")
159
+ session.renderer.print_info(f"Saved {len(raw_keys_to_save)} key(s) to backup file '{save_file}'.")
160
+ except Exception as e:
161
+ session.renderer.print_error(f"Failed to write keys to backup file '{save_file}': {e}")
162
+
163
+ session.renderer.print_info(f"Successfully loaded and registered {len(registered_ids)} key(s).")
164
+ return registered_ids
165
+
166
+
167
+ def clear_keys() -> int:
168
+ """Purge all stored Gemini API keys completely from the OS Keyring and SQLite database.
169
+
170
+ Returns:
171
+ int: Number of keys deleted.
172
+ """
173
+ session = SessionManager.get_instance()
174
+ session.ensure_authenticated()
175
+
176
+ count = session.key_manager.purge_all_keys()
177
+ session.renderer.print_info(f"Purged {count} API key(s) completely from OS Keyring and system database.")
178
+ return count
179
+