penpal-cli 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.
penpal/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
penpal/auth.py ADDED
@@ -0,0 +1,118 @@
1
+ """API key storage and retrieval via keyring with file fallback."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import stat
6
+ from pathlib import Path
7
+
8
+ import keyring
9
+ import keyring.errors
10
+
11
+ from penpal.config import credentials_file
12
+
13
+ KEYRING_SERVICE = "penpal"
14
+ KEYRING_USERNAME = "anthropic_api_key"
15
+
16
+
17
+ class AuthError(Exception):
18
+ pass
19
+
20
+
21
+ def _file_creds_path() -> Path:
22
+ return credentials_file()
23
+
24
+
25
+ def _store_in_file(key: str) -> None:
26
+ path = _file_creds_path()
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ path.write_text(key)
29
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
30
+
31
+
32
+ def _read_from_file() -> str | None:
33
+ path = _file_creds_path()
34
+ if path.exists():
35
+ return path.read_text().strip() or None
36
+ return None
37
+
38
+
39
+ def store_api_key(key: str) -> str:
40
+ """Store the API key. Returns 'keyring' or 'file' indicating storage method."""
41
+ try:
42
+ keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, key)
43
+ return "keyring"
44
+ except (keyring.errors.NoKeyringError, Exception):
45
+ _store_in_file(key)
46
+ return "file"
47
+
48
+
49
+ def get_api_key() -> str:
50
+ """Retrieve stored API key. Raises AuthError if not configured."""
51
+ # Env var takes precedence
52
+ key = os.environ.get("ANTHROPIC_API_KEY")
53
+ if key:
54
+ return key
55
+
56
+ # Try keyring
57
+ try:
58
+ key = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
59
+ if key:
60
+ return key
61
+ except (keyring.errors.NoKeyringError, Exception):
62
+ pass
63
+
64
+ # Try file fallback
65
+ key = _read_from_file()
66
+ if key:
67
+ return key
68
+
69
+ raise AuthError(
70
+ "No API key configured. Run `penpal auth` or set ANTHROPIC_API_KEY."
71
+ )
72
+
73
+
74
+ def delete_api_key() -> bool:
75
+ """Remove the stored API key. Returns True if something was deleted."""
76
+ deleted = False
77
+ try:
78
+ existing = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
79
+ if existing:
80
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
81
+ deleted = True
82
+ except (keyring.errors.NoKeyringError, Exception):
83
+ pass
84
+
85
+ path = _file_creds_path()
86
+ if path.exists():
87
+ path.unlink()
88
+ deleted = True
89
+
90
+ return deleted
91
+
92
+
93
+ def get_key_status() -> dict:
94
+ """Return info about the stored key without revealing it."""
95
+ env_key = os.environ.get("ANTHROPIC_API_KEY")
96
+
97
+ keyring_key = None
98
+ try:
99
+ keyring_key = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
100
+ except (keyring.errors.NoKeyringError, Exception):
101
+ pass
102
+
103
+ file_key = _read_from_file()
104
+
105
+ active_key = env_key or keyring_key or file_key
106
+
107
+ def mask(k: str | None) -> str:
108
+ if not k:
109
+ return "(not set)"
110
+ return k[:12] + "..." + k[-4:]
111
+
112
+ return {
113
+ "env_var": mask(env_key) if env_key else "(not set)",
114
+ "keyring": mask(keyring_key) if keyring_key else "(not set)",
115
+ "file": mask(file_key) if file_key else "(not set)",
116
+ "active": mask(active_key) if active_key else "(not set)",
117
+ "source": "env" if env_key else ("keyring" if keyring_key else ("file" if file_key else "none")),
118
+ }
penpal/builder.py ADDED
@@ -0,0 +1,166 @@
1
+ """Constructs Messages API payloads from CLI inputs."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import mimetypes
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from penpal.config import MODEL_ALIASES
11
+
12
+ # Supported file types
13
+ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
14
+ PDF_EXTENSIONS = {".pdf"}
15
+ TEXT_EXTENSIONS = {
16
+ ".txt", ".csv", ".tsv", ".md", ".html", ".json", ".xml",
17
+ ".yaml", ".yml", ".py", ".js", ".ts", ".java", ".c", ".cpp",
18
+ ".rs", ".go", ".rb", ".sh", ".sql", ".r", ".swift", ".kt",
19
+ ".log", ".rtf",
20
+ }
21
+
22
+ MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5 MB
23
+ MAX_PDF_SIZE = 32 * 1024 * 1024 # 32 MB
24
+ MAX_TEXT_SIZE = 512 * 1024 # 512 KB
25
+
26
+
27
+ def resolve_model(name: str) -> str:
28
+ """Resolve alias to full model string. Pass through if already full."""
29
+ return MODEL_ALIASES.get(name.lower(), name)
30
+
31
+
32
+ def build_file_content_block(file_path: Path) -> tuple[str | None, dict]:
33
+ """
34
+ Build a content block for the given file.
35
+ Returns (text_prefix, content_block) where text_prefix is non-None only for
36
+ text files (to be prepended to the user prompt string).
37
+ Raises ValueError for unsupported or oversized files.
38
+ """
39
+ suffix = file_path.suffix.lower()
40
+
41
+ if suffix in IMAGE_EXTENSIONS:
42
+ data = file_path.read_bytes()
43
+ if len(data) > MAX_IMAGE_SIZE:
44
+ raise ValueError(
45
+ f"Image '{file_path.name}' is {len(data) / 1024 / 1024:.1f} MB "
46
+ f"(max 5 MB)."
47
+ )
48
+ media_type, _ = mimetypes.guess_type(str(file_path))
49
+ if not media_type:
50
+ media_type = "image/jpeg"
51
+ encoded = base64.standard_b64encode(data).decode()
52
+ return (None, {
53
+ "type": "image",
54
+ "source": {
55
+ "type": "base64",
56
+ "media_type": media_type,
57
+ "data": encoded,
58
+ },
59
+ })
60
+
61
+ elif suffix in PDF_EXTENSIONS:
62
+ data = file_path.read_bytes()
63
+ if len(data) > MAX_PDF_SIZE:
64
+ raise ValueError(
65
+ f"PDF '{file_path.name}' is {len(data) / 1024 / 1024:.1f} MB "
66
+ f"(max 32 MB)."
67
+ )
68
+ encoded = base64.standard_b64encode(data).decode()
69
+ return (None, {
70
+ "type": "document",
71
+ "source": {
72
+ "type": "base64",
73
+ "media_type": "application/pdf",
74
+ "data": encoded,
75
+ },
76
+ "title": file_path.name,
77
+ })
78
+
79
+ elif suffix in TEXT_EXTENSIONS:
80
+ data = file_path.read_bytes()
81
+ if len(data) > MAX_TEXT_SIZE:
82
+ raise ValueError(
83
+ f"Text file '{file_path.name}' is {len(data) / 1024:.1f} KB "
84
+ f"(max 512 KB)."
85
+ )
86
+ text = data.decode("utf-8", errors="replace")
87
+ size_str = f"{len(data) / 1024:.1f} KB"
88
+ prefix = (
89
+ f"--- Attached file: {file_path.name} ({size_str}) ---\n"
90
+ f"{text}\n"
91
+ f"--- End of attachment ---\n\n"
92
+ )
93
+ return (prefix, {}) # Empty dict signals text file (no block needed)
94
+
95
+ else:
96
+ supported = sorted(IMAGE_EXTENSIONS | PDF_EXTENSIONS | TEXT_EXTENSIONS)
97
+ raise ValueError(
98
+ f"Unsupported file type '{suffix}'. Supported: {', '.join(supported)}"
99
+ )
100
+
101
+
102
+ def build_batch_requests(
103
+ template_prompt: str,
104
+ files: list[Path],
105
+ model: str,
106
+ max_tokens: int,
107
+ system_prompt: Optional[str] = None,
108
+ ) -> list[dict]:
109
+ """Build one batch request per file, applying template_prompt to each."""
110
+ requests = []
111
+ for fp in files:
112
+ requests.append(
113
+ build_single_request(
114
+ prompt=template_prompt,
115
+ model=model,
116
+ max_tokens=max_tokens,
117
+ system_prompt=system_prompt,
118
+ custom_id=fp.name,
119
+ files=[fp],
120
+ )
121
+ )
122
+ return requests
123
+
124
+
125
+ def build_single_request(
126
+ prompt: str,
127
+ model: str,
128
+ max_tokens: int,
129
+ system_prompt: Optional[str] = None,
130
+ custom_id: Optional[str] = None,
131
+ files: Optional[list[Path]] = None,
132
+ ) -> dict:
133
+ """Build a single batch request object."""
134
+ if custom_id is None:
135
+ custom_id = f"req-{uuid.uuid4().hex[:8]}"
136
+
137
+ # Build content blocks
138
+ content: list[dict] | str
139
+ text_prefixes: list[str] = []
140
+ content_blocks: list[dict] = []
141
+
142
+ for fp in (files or []):
143
+ text_prefix, block = build_file_content_block(fp)
144
+ if text_prefix is not None:
145
+ text_prefixes.append(text_prefix)
146
+ else:
147
+ content_blocks.append(block)
148
+
149
+ full_prompt = "".join(text_prefixes) + prompt
150
+
151
+ if content_blocks:
152
+ # Mixed content: blocks first, then the text prompt
153
+ content_blocks.append({"type": "text", "text": full_prompt})
154
+ content = content_blocks
155
+ else:
156
+ content = full_prompt
157
+
158
+ params: dict = {
159
+ "model": model,
160
+ "max_tokens": max_tokens,
161
+ "messages": [{"role": "user", "content": content}],
162
+ }
163
+ if system_prompt:
164
+ params["system"] = system_prompt
165
+
166
+ return {"custom_id": custom_id, "params": params}