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.
- pandify/__init__.py +36 -0
- pandify/api/__init__.py +24 -0
- pandify/api/auth.py +46 -0
- pandify/api/chat.py +118 -0
- pandify/api/configure.py +55 -0
- pandify/api/help.py +158 -0
- pandify/api/history.py +62 -0
- pandify/api/keys.py +179 -0
- pandify/api/memory.py +118 -0
- pandify/api/session.py +59 -0
- pandify/api/upload.py +45 -0
- pandify/auth/__init__.py +7 -0
- pandify/auth/hasher.py +41 -0
- pandify/auth/service.py +155 -0
- pandify/cli.py +26 -0
- pandify/config/__init__.py +20 -0
- pandify/config/settings.py +157 -0
- pandify/core/__init__.py +6 -0
- pandify/core/session.py +116 -0
- pandify/errors/__init__.py +42 -0
- pandify/errors/exceptions.py +97 -0
- pandify/files/__init__.py +8 -0
- pandify/files/extractors.py +133 -0
- pandify/files/models.py +30 -0
- pandify/files/staging.py +34 -0
- pandify/files/validator.py +122 -0
- pandify/gemini/__init__.py +6 -0
- pandify/gemini/client.py +172 -0
- pandify/keys/__init__.py +8 -0
- pandify/keys/manager.py +341 -0
- pandify/keys/models.py +35 -0
- pandify/keys/store.py +47 -0
- pandify/security/__init__.py +7 -0
- pandify/security/path_guard.py +31 -0
- pandify/security/redactor.py +61 -0
- pandify/storage/__init__.py +8 -0
- pandify/storage/conversations.py +154 -0
- pandify/storage/database.py +103 -0
- pandify/storage/templates.py +91 -0
- pandify/ui/__init__.py +18 -0
- pandify/ui/renderer.py +70 -0
- pandify/ui/terminal.py +73 -0
- pandify-0.1.0.dist-info/METADATA +160 -0
- pandify-0.1.0.dist-info/RECORD +47 -0
- pandify-0.1.0.dist-info/WHEEL +5 -0
- pandify-0.1.0.dist-info/licenses/LICENSE +22 -0
- 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
|
+
|
pandify/api/__init__.py
ADDED
|
@@ -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
|
+
|
pandify/api/configure.py
ADDED
|
@@ -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
|
+
|