cupt 0.6.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.
cupt/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ CUPT — ClickUp Task Management CLI and Python library.
3
+
4
+ Use as a CLI:
5
+ $ cupt list --tag urgent
6
+
7
+ Use as a library:
8
+ from cupt import ClickUpClient, TaskService
9
+
10
+ client = ClickUpClient("pk_xxxxx") # personal API token
11
+ service = TaskService(client)
12
+ tasks = service.list_tasks(team_id="123", tags=["urgent"])
13
+
14
+ Public exports are intentionally limited to the API client, services, and
15
+ typed exceptions. Internal helpers (config, CLI commands, formatting) are
16
+ not part of the public API and may change between releases.
17
+ """
18
+
19
+ from cupt.api import ClickUpClient
20
+ from cupt.exceptions import APIError, AuthError, ConfigError, CuptError
21
+ from cupt.services.note_service import NoteService
22
+ from cupt.services.task_service import TaskService
23
+ from cupt.services.time_service import TimeService
24
+
25
+ __version__ = "0.6.1"
26
+ __author__ = "Matthew Nuzum"
27
+ __email__ = "matthew@nuzum.com"
28
+
29
+ __all__ = [
30
+ "ClickUpClient",
31
+ "TaskService",
32
+ "TimeService",
33
+ "NoteService",
34
+ "CuptError",
35
+ "APIError",
36
+ "AuthError",
37
+ "ConfigError",
38
+ "__version__",
39
+ ]
cupt/ai.py ADDED
@@ -0,0 +1,82 @@
1
+ """
2
+ Local AI backend abstraction for CUPT.
3
+
4
+ Provider priority:
5
+ 1. Apple Intelligence (apple-fm-sdk, macOS 26+)
6
+
7
+ Future providers (not yet implemented):
8
+ 2. Windows Copilot+ (WinRT Microsoft.Windows.AI)
9
+ 3. Ollama (http://localhost:11434)
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _SYSTEM_INSTRUCTIONS = (
19
+ "You are a concise assistant helping a professional complete task management notes. "
20
+ "Write clear, brief, first-person completion notes. No preamble or explanation."
21
+ )
22
+
23
+
24
+ def get_ai_suggestion(prompt: str) -> Optional[str]:
25
+ """
26
+ Try available local AI backends and return a suggestion, or None if unavailable.
27
+
28
+ Returns None silently when no provider is found — callers are responsible
29
+ for surfacing a user-facing message.
30
+ """
31
+ result = _try_apple_intelligence(prompt)
32
+ if result is not None:
33
+ return result
34
+
35
+ # Future: _try_windows_copilot(prompt)
36
+ # Future: _try_ollama(prompt)
37
+
38
+ return None
39
+
40
+
41
+ def is_ai_available() -> bool:
42
+ """Return True if at least one local AI provider is available."""
43
+ return _apple_intelligence_available()
44
+
45
+
46
+ # ------------------------------------------------------------------
47
+ # Apple Intelligence
48
+ # ------------------------------------------------------------------
49
+
50
+
51
+ def _apple_intelligence_available() -> bool:
52
+ try:
53
+ import apple_fm_sdk as fm
54
+
55
+ available, _ = fm.SystemLanguageModel().is_available()
56
+ return available
57
+ except ImportError:
58
+ return False
59
+ except Exception:
60
+ return False
61
+
62
+
63
+ def _try_apple_intelligence(prompt: str) -> Optional[str]:
64
+ try:
65
+ import apple_fm_sdk as fm
66
+
67
+ model = fm.SystemLanguageModel()
68
+ available, reason = model.is_available()
69
+ if not available:
70
+ logger.debug("Apple Intelligence not available: %s", reason)
71
+ return None
72
+
73
+ session = fm.LanguageModelSession(instructions=_SYSTEM_INSTRUCTIONS)
74
+ result = asyncio.run(session.respond(prompt))
75
+ return result.strip() if result else None
76
+
77
+ except ImportError:
78
+ logger.debug("apple-fm-sdk not installed")
79
+ return None
80
+ except Exception as e:
81
+ logger.debug("Apple Intelligence error: %s", e)
82
+ return None
cupt/api.py ADDED
@@ -0,0 +1,290 @@
1
+ """
2
+ ClickUp API client
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import requests
11
+ from requests.adapters import HTTPAdapter
12
+ from urllib3.util.retry import Retry
13
+
14
+ from cupt.exceptions import APIError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ClickUpClient:
20
+ """ClickUp API client with connection pooling, retry logic, and request timeout."""
21
+
22
+ BASE_URL = "https://api.clickup.com/api/v2"
23
+ TIMEOUT = 10 # seconds — prevents the CLI from hanging on a slow/unresponsive API
24
+
25
+ def __init__(self, access_token: str):
26
+ self.access_token = access_token
27
+ self.session = requests.Session()
28
+ # Only Authorization on the session. Content-Type is set per-request
29
+ # inside _make_request so it never leaks into multipart uploads
30
+ # (which generate their own Content-Type with a boundary).
31
+ self.session.headers.update({"Authorization": self.access_token})
32
+
33
+ # Retry transient server errors with exponential backoff.
34
+ # 429 included so ClickUp rate limits trigger backoff (urllib3's
35
+ # Retry honors the Retry-After header automatically).
36
+ _retry = Retry(
37
+ total=3,
38
+ backoff_factor=0.5,
39
+ status_forcelist=[429, 500, 502, 503, 504],
40
+ respect_retry_after_header=True,
41
+ raise_on_status=False,
42
+ )
43
+ # Pool keeps persistent TCP connections open across requests in the
44
+ # same process (the default pool size of 10 is fine here).
45
+ _adapter = HTTPAdapter(max_retries=_retry)
46
+ self.session.mount("https://", _adapter)
47
+ self.session.mount("http://", _adapter)
48
+
49
+ def _make_request(
50
+ self,
51
+ method: str,
52
+ endpoint: str,
53
+ data: Optional[Dict] = None,
54
+ params: Optional[Dict] = None,
55
+ ) -> Dict[str, Any]:
56
+ """Make an API request with error handling, timeout, and retry."""
57
+ url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
58
+ logger.debug("%s %s params=%s", method.upper(), url, params)
59
+
60
+ # Per-request JSON header only on methods that carry a JSON body.
61
+ json_headers = {"Content-Type": "application/json"}
62
+ try:
63
+ if method.upper() == "GET":
64
+ response = self.session.get(url, params=params, timeout=self.TIMEOUT)
65
+ elif method.upper() == "POST":
66
+ response = self.session.post(
67
+ url, json=data, headers=json_headers, timeout=self.TIMEOUT
68
+ )
69
+ elif method.upper() == "PUT":
70
+ response = self.session.put(
71
+ url, json=data, headers=json_headers, timeout=self.TIMEOUT
72
+ )
73
+ elif method.upper() == "DELETE":
74
+ response = self.session.delete(url, timeout=self.TIMEOUT)
75
+ else:
76
+ raise ValueError(f"Unsupported HTTP method: {method}")
77
+
78
+ response.raise_for_status()
79
+ return response.json()
80
+
81
+ except requests.exceptions.HTTPError as e:
82
+ error_msg = f"HTTP {e.response.status_code}"
83
+ if e.response.text:
84
+ try:
85
+ error_data = e.response.json()
86
+ error_msg += f": {error_data.get('err', '')}"
87
+ except json.JSONDecodeError:
88
+ error_msg += f": {e.response.text[:200]}"
89
+ logger.debug("API error on %s %s: %s", method, endpoint, error_msg)
90
+ raise APIError(error_msg)
91
+ except requests.exceptions.Timeout:
92
+ msg = f"Request timed out after {self.TIMEOUT}s: {endpoint}"
93
+ logger.debug(msg)
94
+ raise APIError(msg)
95
+ except requests.exceptions.RequestException as e:
96
+ msg = f"Request failed: {e}"
97
+ logger.debug(msg)
98
+ raise APIError(msg)
99
+ except json.JSONDecodeError as e:
100
+ msg = f"Invalid JSON response: {e}"
101
+ logger.debug(msg)
102
+ raise APIError(msg)
103
+
104
+ # ------------------------------------------------------------------
105
+ # Auth / user
106
+ # ------------------------------------------------------------------
107
+
108
+ def get_user(self) -> Dict[str, Any]:
109
+ return self._make_request("GET", "/user")
110
+
111
+ def get_teams(self) -> List[Dict[str, Any]]:
112
+ return self._make_request("GET", "/team").get("teams", [])
113
+
114
+ # ------------------------------------------------------------------
115
+ # Tasks
116
+ # ------------------------------------------------------------------
117
+
118
+ def get_team_tasks(
119
+ self, team_id: str, filters: Optional[Dict] = None
120
+ ) -> List[Dict[str, Any]]:
121
+ params: Dict = {}
122
+ if filters:
123
+ params.update(filters)
124
+ return self._make_request("GET", f"/team/{team_id}/task", params=params).get(
125
+ "tasks", []
126
+ )
127
+
128
+ def get_tasks_by_ids(
129
+ self, team_id: str, task_ids: List[str]
130
+ ) -> List[Dict[str, Any]]:
131
+ """Bulk-fetch up to 100 tasks by ID."""
132
+ if not task_ids:
133
+ return []
134
+ params = {"ids[]": task_ids[:100], "include_subtasks": "true"}
135
+ return self._make_request("GET", f"/team/{team_id}/task", params=params).get(
136
+ "tasks", []
137
+ )
138
+
139
+ def get_task(self, task_id: str) -> Dict[str, Any]:
140
+ return self._make_request("GET", f"/task/{task_id}")
141
+
142
+ def get_task_children(
143
+ self, team_id: str, parent_id: str, params: Optional[Dict] = None
144
+ ) -> List[Dict[str, Any]]:
145
+ """Fetch direct subtasks of a task."""
146
+ p: Dict = {"parent": parent_id}
147
+ if params:
148
+ p.update(params)
149
+ return self._make_request("GET", f"/team/{team_id}/task", params=p).get(
150
+ "tasks", []
151
+ )
152
+
153
+ def update_task(self, task_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
154
+ return self._make_request("PUT", f"/task/{task_id}", data=data)
155
+
156
+ def add_task_tag(self, task_id: str, tag_name: str) -> Dict[str, Any]:
157
+ return self._make_request("POST", f"/task/{task_id}/tag/{tag_name}")
158
+
159
+ def remove_task_tag(self, task_id: str, tag_name: str) -> Dict[str, Any]:
160
+ return self._make_request("DELETE", f"/task/{task_id}/tag/{tag_name}")
161
+
162
+ def upload_task_attachment(
163
+ self, task_id: str, file_path: str, filename: Optional[str] = None
164
+ ) -> Dict[str, Any]:
165
+ """
166
+ Upload a file as a task attachment.
167
+
168
+ Bypasses the shared session because that session sets
169
+ Content-Type: application/json, which prevents `requests` from
170
+ generating the multipart boundary header and causes the server to
171
+ receive a malformed body (resulting in corrupted attachments).
172
+ """
173
+ import os
174
+
175
+ url = f"{self.BASE_URL}/task/{task_id}/attachment"
176
+ name = filename or os.path.basename(file_path)
177
+ headers = {"Authorization": self.access_token}
178
+
179
+ try:
180
+ with open(file_path, "rb") as fh:
181
+ response = requests.post(
182
+ url,
183
+ headers=headers,
184
+ files={"attachment": (name, fh)},
185
+ timeout=60, # uploads can legitimately take longer than reads
186
+ )
187
+ response.raise_for_status()
188
+ return response.json()
189
+ except requests.exceptions.HTTPError as e:
190
+ msg = f"HTTP {e.response.status_code}: {e.response.text[:200]}"
191
+ raise APIError(msg)
192
+ except requests.exceptions.RequestException as e:
193
+ raise APIError(f"Upload failed: {e}")
194
+
195
+ # ------------------------------------------------------------------
196
+ # Statuses
197
+ # ------------------------------------------------------------------
198
+
199
+ def get_list_statuses(self, list_id: str) -> List[Dict[str, Any]]:
200
+ return self._make_request("GET", f"/list/{list_id}").get("statuses", [])
201
+
202
+ def get_space_statuses(self, space_id: str) -> List[Dict[str, Any]]:
203
+ return self._make_request("GET", f"/space/{space_id}").get("statuses", [])
204
+
205
+ # ------------------------------------------------------------------
206
+ # Comments / notes
207
+ # ------------------------------------------------------------------
208
+
209
+ def get_task_comments(self, task_id: str) -> List[Dict[str, Any]]:
210
+ return self._make_request("GET", f"/task/{task_id}/comment").get("comments", [])
211
+
212
+ def add_task_comment(
213
+ self, task_id: str, comment_text: str, notify_all: bool = False
214
+ ) -> Dict[str, Any]:
215
+ data = {
216
+ "comment_text": comment_text,
217
+ "notify_all": notify_all,
218
+ "assignee": None,
219
+ }
220
+ return self._make_request("POST", f"/task/{task_id}/comment", data=data)
221
+
222
+ # ------------------------------------------------------------------
223
+ # Time tracking
224
+ # ------------------------------------------------------------------
225
+
226
+ def start_timer(
227
+ self, team_id: str, task_id: Optional[str] = None
228
+ ) -> Dict[str, Any]:
229
+ data: Dict = {}
230
+ if task_id:
231
+ data["task_id"] = task_id
232
+ data["tid"] = task_id
233
+ return self._make_request(
234
+ "POST", f"/team/{team_id}/time_entries/start", data=data
235
+ )
236
+
237
+ def stop_timer(self, team_id: str) -> Dict[str, Any]:
238
+ return self._make_request("POST", f"/team/{team_id}/time_entries/stop")
239
+
240
+ def get_running_timer(self, team_id: str) -> Optional[Dict[str, Any]]:
241
+ try:
242
+ return self._make_request(
243
+ "GET", f"/team/{team_id}/time_entries/current"
244
+ ).get("data")
245
+ except Exception:
246
+ return None
247
+
248
+ def get_time_entries(
249
+ self,
250
+ team_id: str,
251
+ start_date: int,
252
+ end_date: int,
253
+ user_id: Optional[str] = None,
254
+ ) -> List[Dict[str, Any]]:
255
+ """Fetch time entries within a date range (timestamps in ms)."""
256
+ params: Dict = {"start_date": start_date, "end_date": end_date}
257
+ if user_id:
258
+ params["assignee"] = user_id
259
+ return self._make_request(
260
+ "GET", f"/team/{team_id}/time_entries", params=params
261
+ ).get("data", [])
262
+
263
+ def add_time_entry(
264
+ self,
265
+ team_id: str,
266
+ task_id: str,
267
+ duration: int,
268
+ description: Optional[str] = None,
269
+ ) -> Dict[str, Any]:
270
+ now_ms = int(datetime.now().timestamp() * 1000)
271
+ data: Dict = {
272
+ "task_id": task_id,
273
+ "tid": task_id,
274
+ "duration": duration,
275
+ "start": now_ms - duration,
276
+ "end": now_ms,
277
+ }
278
+ if description:
279
+ data["description"] = description
280
+ return self._make_request("POST", f"/team/{team_id}/time_entries", data=data)
281
+
282
+ # ------------------------------------------------------------------
283
+ # Hierarchy
284
+ # ------------------------------------------------------------------
285
+
286
+ def get_spaces(self, team_id: str) -> List[Dict[str, Any]]:
287
+ return self._make_request("GET", f"/team/{team_id}/space").get("spaces", [])
288
+
289
+ def get_lists(self, space_id: str) -> List[Dict[str, Any]]:
290
+ return self._make_request("GET", f"/space/{space_id}/list").get("lists", [])
cupt/attachments.py ADDED
@@ -0,0 +1,145 @@
1
+ import os
2
+
3
+ import click
4
+ import requests
5
+
6
+ from cupt.context import get_client_context
7
+ from cupt.utils import print_error, print_success, print_warning
8
+
9
+
10
+ def _human_size(n_bytes):
11
+ if not n_bytes:
12
+ return "-"
13
+ n = float(n_bytes)
14
+ for unit in ("B", "KB", "MB", "GB"):
15
+ if n < 1024 or unit == "GB":
16
+ return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}B"
17
+ n /= 1024
18
+ return f"{n:.1f}GB"
19
+
20
+
21
+ def _resolve(attachments, selector):
22
+ """Find an attachment by 1-based index or substring of title."""
23
+ if selector.isdigit():
24
+ idx = int(selector) - 1
25
+ if 0 <= idx < len(attachments):
26
+ return attachments[idx]
27
+ return None
28
+ needle = selector.lower()
29
+ matches = [a for a in attachments if needle in (a.get("title", "")).lower()]
30
+ if len(matches) == 1:
31
+ return matches[0]
32
+ if len(matches) > 1:
33
+ raise click.ClickException(
34
+ f"'{selector}' matches {len(matches)} attachments — be more specific or use an index"
35
+ )
36
+ return None
37
+
38
+
39
+ @click.group(name="attach")
40
+ def attach_group():
41
+ """List, download, and upload task attachments"""
42
+ pass
43
+
44
+
45
+ @attach_group.command("list")
46
+ @click.argument("task_id")
47
+ def list_attachments(task_id):
48
+ """List attachments on a task"""
49
+ _, client, _ = get_client_context(need_team=False)
50
+ if not client:
51
+ return
52
+
53
+ try:
54
+ task = client.get_task(task_id)
55
+ except Exception as e:
56
+ print_error(f"Failed to fetch task: {e}")
57
+ return
58
+
59
+ attachments = task.get("attachments") or []
60
+ if not attachments:
61
+ print_warning(f"No attachments on {task_id}.")
62
+ return
63
+
64
+ click.echo(f"\n{'#':<4} {'Size':<10} {'Name'}")
65
+ click.echo("-" * 60)
66
+ for i, a in enumerate(attachments, start=1):
67
+ click.echo(
68
+ f"{i:<4} {_human_size(a.get('size')):<10} {a.get('title', '(untitled)')}"
69
+ )
70
+
71
+
72
+ @attach_group.command("get")
73
+ @click.argument("task_id")
74
+ @click.argument("selector")
75
+ @click.option(
76
+ "-o",
77
+ "--output",
78
+ type=click.Path(),
79
+ help="Save path (default: original filename in current dir)",
80
+ )
81
+ def get_attachment(task_id, selector, output):
82
+ """Download an attachment by 1-based index or filename substring"""
83
+ _, client, _ = get_client_context(need_team=False)
84
+ if not client:
85
+ return
86
+
87
+ try:
88
+ task = client.get_task(task_id)
89
+ except Exception as e:
90
+ print_error(f"Failed to fetch task: {e}")
91
+ return
92
+
93
+ attachments = task.get("attachments") or []
94
+ if not attachments:
95
+ print_warning(f"No attachments on {task_id}.")
96
+ return
97
+
98
+ target = _resolve(attachments, selector)
99
+ if not target:
100
+ print_error(f"No attachment matches '{selector}'.")
101
+ return
102
+
103
+ url = target.get("url")
104
+ if not url:
105
+ print_error("Attachment has no download URL.")
106
+ return
107
+
108
+ # No auth header — these are pre-signed S3 URLs; sending Authorization
109
+ # can invalidate the signature.
110
+ try:
111
+ response = requests.get(url, timeout=60, stream=True)
112
+ response.raise_for_status()
113
+ except requests.exceptions.RequestException as e:
114
+ print_error(f"Download failed: {e}")
115
+ return
116
+
117
+ out_path = output or target.get("title") or "attachment.bin"
118
+ try:
119
+ with open(out_path, "wb") as fh:
120
+ for chunk in response.iter_content(chunk_size=64 * 1024):
121
+ if chunk:
122
+ fh.write(chunk)
123
+ except OSError as e:
124
+ print_error(f"Could not write to {out_path}: {e}")
125
+ return
126
+
127
+ print_success(f"Downloaded {target.get('title')} -> {out_path}")
128
+
129
+
130
+ @attach_group.command("add")
131
+ @click.argument("task_id")
132
+ @click.argument("file_path", type=click.Path(exists=True, dir_okay=False))
133
+ @click.option("--name", help="Override the filename stored on ClickUp")
134
+ def add_attachment(task_id, file_path, name):
135
+ """Upload a file as a task attachment"""
136
+ _, client, _ = get_client_context(need_team=False)
137
+ if not client:
138
+ return
139
+
140
+ try:
141
+ result = client.upload_task_attachment(task_id, file_path, name)
142
+ title = result.get("title") or name or os.path.basename(file_path)
143
+ print_success(f"Attached '{title}' to {task_id}")
144
+ except Exception as e:
145
+ print_error(f"Failed to upload attachment: {e}")