contextzip 0.4.5__tar.gz → 0.4.6__tar.gz

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 (52) hide show
  1. {contextzip-0.4.5 → contextzip-0.4.6}/PKG-INFO +1 -1
  2. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/__init__.py +1 -1
  3. contextzip-0.4.6/contextzip/_x11_clip.py +338 -0
  4. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/cli_display.py +6 -3
  5. contextzip-0.4.6/contextzip/clipboard.py +552 -0
  6. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip.egg-info/PKG-INFO +1 -1
  7. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip.egg-info/SOURCES.txt +1 -0
  8. {contextzip-0.4.5 → contextzip-0.4.6}/pyproject.toml +1 -1
  9. contextzip-0.4.5/contextzip/clipboard.py +0 -223
  10. {contextzip-0.4.5 → contextzip-0.4.6}/LICENSE +0 -0
  11. {contextzip-0.4.5 → contextzip-0.4.6}/README.md +0 -0
  12. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/ai/__init__.py +0 -0
  13. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/ai/gemini.py +0 -0
  14. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/ai/heuristic.py +0 -0
  15. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/ai/selector.py +0 -0
  16. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/api.py +0 -0
  17. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/applier.py +0 -0
  18. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/cleanup.py +0 -0
  19. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/cli.py +0 -0
  20. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/cli_ai.py +0 -0
  21. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/cli_onboard.py +0 -0
  22. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/config.py +0 -0
  23. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/detector.py +0 -0
  24. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/error_parser.py +0 -0
  25. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/filters.py +0 -0
  26. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/git.py +0 -0
  27. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/packager.py +0 -0
  28. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/project_config.py +0 -0
  29. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/redact.py +0 -0
  30. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/report.py +0 -0
  31. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/__init__.py +0 -0
  32. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/base.py +0 -0
  33. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/errors/__init__.py +0 -0
  34. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/errors/node.py +0 -0
  35. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/errors/python.py +0 -0
  36. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/go.py +0 -0
  37. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/node.py +0 -0
  38. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/python.py +0 -0
  39. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/ruby.py +0 -0
  40. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/rules/rust.py +0 -0
  41. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/updater.py +0 -0
  42. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/watcher.py +0 -0
  43. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/webui/__init__.py +0 -0
  44. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/webui/assets.py +0 -0
  45. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/webui/persist.py +0 -0
  46. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/webui/server.py +0 -0
  47. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip/webui/suggestions.py +0 -0
  48. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip.egg-info/dependency_links.txt +0 -0
  49. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip.egg-info/entry_points.txt +0 -0
  50. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip.egg-info/requires.txt +0 -0
  51. {contextzip-0.4.5 → contextzip-0.4.6}/contextzip.egg-info/top_level.txt +0 -0
  52. {contextzip-0.4.5 → contextzip-0.4.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.4.5
3
+ Version: 0.4.6
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.4.5"
3
+ __version__ = "0.4.6"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -0,0 +1,338 @@
1
+ """
2
+ _x11_clip.py — a tiny X11 CLIPBOARD owner written against the raw X protocol.
3
+
4
+ Why this exists: on Linux, "a file on the clipboard" means owning the X
5
+ CLIPBOARD selection and answering requests for ``text/uri-list`` (what
6
+ browsers read when you paste) with a ``file:///…`` URL. Normally that needs
7
+ ``xclip`` — a system package that ``pip install`` can't provide. The protocol
8
+ subset needed is small, so contextzip speaks it itself: standard library only,
9
+ no dependencies.
10
+
11
+ It offers the same targets a file manager offers when you press Ctrl+C on a
12
+ file, so it works for browsers (text/uri-list), GNOME-family file managers
13
+ (x-special/gnome-copied-files) and KDE (application/x-kde-cutselection).
14
+
15
+ Runs two ways:
16
+ * imported: ``read_selection()`` reads the clipboard back (verification)
17
+ * as a script ``python _x11_clip.py serve <abs-path>`` — the background
18
+ process that keeps owning the selection until another app copies
19
+ something (SelectionClear) or the X connection closes. It is launched by
20
+ path, so this file must stay importable without the contextzip package.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import socket
27
+ import struct
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ URI_LIST = "text/uri-list"
32
+ GNOME_COPIED = "x-special/gnome-copied-files"
33
+ KDE_CUT = "application/x-kde-cutselection"
34
+
35
+ _ATOM = 4 # predefined atom: ATOM
36
+ _SELECTION_CLEAR = 29
37
+ _SELECTION_REQUEST = 30
38
+ _SELECTION_NOTIFY = 31
39
+
40
+
41
+ class X11Error(Exception):
42
+ pass
43
+
44
+
45
+ def _pad(n: int) -> int:
46
+ return (4 - n % 4) % 4
47
+
48
+
49
+ def payloads_for(path: str) -> dict[str, bytes]:
50
+ """Clipboard targets (and their data) for one file, as a file manager sets them."""
51
+ uri = Path(path).as_uri()
52
+ return {
53
+ URI_LIST: (uri + "\r\n").encode(),
54
+ GNOME_COPIED: ("copy\n" + uri).encode(),
55
+ KDE_CUT: b"0",
56
+ }
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Connection
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def _parse_display(display: str) -> tuple[str, int]:
65
+ host, sep, rest = display.rpartition(":")
66
+ if not sep or not rest:
67
+ raise X11Error(f"unusable DISPLAY {display!r}")
68
+ try:
69
+ return host, int(rest.split(".")[0])
70
+ except ValueError:
71
+ raise X11Error(f"unusable DISPLAY {display!r}") from None
72
+
73
+
74
+ def _open_socket(host: str, num: int, timeout: float | None) -> socket.socket:
75
+ if host in ("", "unix"):
76
+ # Xorg/XWayland listen on a filesystem socket and (on Linux) an abstract one.
77
+ for addr in (f"/tmp/.X11-unix/X{num}", f"\0/tmp/.X11-unix/X{num}"):
78
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
79
+ sock.settimeout(timeout)
80
+ try:
81
+ sock.connect(addr)
82
+ return sock
83
+ except OSError:
84
+ sock.close()
85
+ raise X11Error(f"cannot connect to X display :{num}")
86
+ try:
87
+ return socket.create_connection((host, 6000 + num), timeout)
88
+ except OSError as exc:
89
+ raise X11Error(f"cannot connect to X display {host}:{num}") from exc
90
+
91
+
92
+ def _read_xauthority() -> list[tuple[int, bytes, bytes, bytes, bytes]]:
93
+ path = os.environ.get("XAUTHORITY") or os.path.join(os.path.expanduser("~"), ".Xauthority")
94
+ try:
95
+ data = Path(path).read_bytes()
96
+ except OSError:
97
+ return []
98
+ entries, i = [], 0
99
+ try:
100
+ while i + 2 <= len(data):
101
+ (family,) = struct.unpack(">H", data[i : i + 2])
102
+ i += 2
103
+ fields = []
104
+ for _ in range(4): # address, display number, auth name, auth data
105
+ (n,) = struct.unpack(">H", data[i : i + 2])
106
+ i += 2
107
+ fields.append(data[i : i + n])
108
+ i += n
109
+ entries.append((family, *fields))
110
+ except struct.error:
111
+ pass
112
+ return entries
113
+
114
+
115
+ def _auth_for(host: str, num: int) -> tuple[bytes, bytes]:
116
+ """MIT-MAGIC-COOKIE-1 for this display from ~/.Xauthority ($XAUTHORITY), if any."""
117
+ cookies = [
118
+ e
119
+ for e in _read_xauthority()
120
+ if e[3] == b"MIT-MAGIC-COOKIE-1" and e[2] in (b"", str(num).encode())
121
+ ]
122
+ local = host in ("", "unix")
123
+ me = socket.gethostname().encode()
124
+ for family, addr, _number, name, value in cookies:
125
+ if local and (family == 65535 or (family == 256 and addr == me)):
126
+ return name, value
127
+ if cookies: # hostname changed (containers, renamed machines): number match is enough
128
+ return cookies[0][3], cookies[0][4]
129
+ return b"", b"" # open display (xhost +local:, Xvfb -ac)
130
+
131
+
132
+ class Connection:
133
+ """Just enough X11 client for selection handling. Little-endian, core protocol only."""
134
+
135
+ def __init__(self, display: str | None = None, timeout: float | None = 5.0):
136
+ display = display if display is not None else os.environ.get("DISPLAY", "")
137
+ host, num = _parse_display(display)
138
+ self.sock = _open_socket(host, num, timeout)
139
+ self.sock.settimeout(timeout)
140
+ self.seq = 0
141
+ self.events: list[tuple[int, bytes]] = []
142
+ self._handshake(*_auth_for(host, num))
143
+ self._next_id = 0
144
+
145
+ # -- wire helpers -------------------------------------------------------
146
+
147
+ def _recv(self, n: int) -> bytes:
148
+ buf = b""
149
+ while len(buf) < n:
150
+ chunk = self.sock.recv(n - len(buf))
151
+ if not chunk:
152
+ raise X11Error("X connection closed")
153
+ buf += chunk
154
+ return buf
155
+
156
+ def _handshake(self, auth_name: bytes, auth_data: bytes) -> None:
157
+ req = struct.pack("<BxHHHH2x", 0x6C, 11, 0, len(auth_name), len(auth_data))
158
+ req += auth_name + b"\0" * _pad(len(auth_name))
159
+ req += auth_data + b"\0" * _pad(len(auth_data))
160
+ self.sock.sendall(req)
161
+ status, reason_len, _maj, _min, words = struct.unpack("<BBHHH", self._recv(8))
162
+ body = self._recv(words * 4)
163
+ if status != 1:
164
+ reason = body[:reason_len].decode("latin-1", "replace") or "authorization failed"
165
+ raise X11Error(f"X server refused connection: {reason}")
166
+ self.id_base, self.id_mask = struct.unpack("<II", body[4:12])
167
+ (vendor_len,) = struct.unpack("<H", body[16:18])
168
+ n_formats = body[21]
169
+ off = 32 + vendor_len + _pad(vendor_len) + n_formats * 8
170
+ (self.root,) = struct.unpack("<I", body[off : off + 4])
171
+
172
+ def _request(self, opcode: int, data_byte: int, body: bytes) -> None:
173
+ assert len(body) % 4 == 0
174
+ self.sock.sendall(struct.pack("<BBH", opcode, data_byte, 1 + len(body) // 4) + body)
175
+ self.seq = (self.seq + 1) & 0xFFFF
176
+
177
+ def _read_packet(self) -> tuple[int, bytes]:
178
+ head = self._recv(32)
179
+ code = head[0] & 0x7F
180
+ if code == 1: # reply: may carry extra data
181
+ (extra,) = struct.unpack("<I", head[4:8])
182
+ head += self._recv(extra * 4)
183
+ return code, head
184
+
185
+ def _reply(self) -> bytes:
186
+ while True:
187
+ code, pkt = self._read_packet()
188
+ if code == 0:
189
+ raise X11Error(f"X error {pkt[1]} (request {struct.unpack('<H', pkt[2:4])[0]})")
190
+ if code == 1:
191
+ return pkt
192
+ self.events.append((code, pkt))
193
+
194
+ def next_event(self) -> tuple[int, bytes]:
195
+ """Next event; asynchronous errors are ignored."""
196
+ while True:
197
+ if self.events:
198
+ return self.events.pop(0)
199
+ code, pkt = self._read_packet()
200
+ if code not in (0, 1):
201
+ return code, pkt
202
+
203
+ # -- requests -----------------------------------------------------------
204
+
205
+ def intern(self, name: str) -> int:
206
+ raw = name.encode()
207
+ self._request(16, 0, struct.pack("<HH", len(raw), 0) + raw + b"\0" * _pad(len(raw)))
208
+ return struct.unpack("<I", self._reply()[8:12])[0]
209
+
210
+ def create_window(self) -> int:
211
+ """A 1x1 InputOnly window: selections need a window to be owned from."""
212
+ step = self.id_mask & -self.id_mask
213
+ wid = self.id_base | (step * (self._next_id + 1))
214
+ self._next_id += 1
215
+ # wid, parent, x, y, w, h, border, class=InputOnly, visual=CopyFromParent, mask=0
216
+ self._request(1, 0, struct.pack("<IIhhHHHHII", wid, self.root, 0, 0, 1, 1, 0, 2, 0, 0))
217
+ return wid
218
+
219
+ def set_selection_owner(self, window: int, selection: int) -> None:
220
+ self._request(22, 0, struct.pack("<III", window, selection, 0)) # CurrentTime
221
+
222
+ def get_selection_owner(self, selection: int) -> int:
223
+ self._request(23, 0, struct.pack("<I", selection))
224
+ return struct.unpack("<I", self._reply()[8:12])[0]
225
+
226
+ def change_property(self, window: int, prop: int, typ: int, fmt: int, data: bytes) -> None:
227
+ units = len(data) // (fmt // 8)
228
+ body = struct.pack("<IIIB3xI", window, prop, typ, fmt, units)
229
+ self._request(18, 0, body + data + b"\0" * _pad(len(data))) # mode Replace
230
+
231
+ def send_selection_notify(
232
+ self, requestor: int, selection: int, target: int, prop: int, time: int
233
+ ) -> None:
234
+ event = struct.pack("<BBHIIIII", _SELECTION_NOTIFY, 0, 0, time, requestor, selection, target, prop)
235
+ self._request(25, 0, struct.pack("<II", requestor, 0) + event + b"\0" * 8)
236
+
237
+ def convert_selection(self, requestor: int, selection: int, target: int, prop: int) -> None:
238
+ self._request(24, 0, struct.pack("<IIIII", requestor, selection, target, prop, 0))
239
+
240
+ def get_property(self, window: int, prop: int) -> tuple[int, bytes]:
241
+ self._request(20, 1, struct.pack("<IIIII", window, prop, 0, 0, 0x1FFFFF)) # delete=True
242
+ pkt = self._reply()
243
+ fmt = pkt[1]
244
+ (typ,) = struct.unpack("<I", pkt[8:12])
245
+ (units,) = struct.unpack("<I", pkt[16:20])
246
+ return typ, pkt[32 : 32 + units * (fmt // 8 if fmt else 0)]
247
+
248
+ def close(self) -> None:
249
+ try:
250
+ self.sock.close()
251
+ except OSError:
252
+ pass
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Reading (used to verify the copy, and by the tests)
257
+ # ---------------------------------------------------------------------------
258
+
259
+
260
+ def read_selection(
261
+ target: str = URI_LIST, *, display: str | None = None, timeout: float = 2.0
262
+ ) -> bytes | None:
263
+ """Ask the current CLIPBOARD owner for *target*. None if nobody offers it."""
264
+ conn = Connection(display, timeout=timeout)
265
+ try:
266
+ win = conn.create_window()
267
+ clipboard, dest = conn.intern("CLIPBOARD"), conn.intern("CZ_CLIPBOARD_READ")
268
+ conn.convert_selection(win, clipboard, conn.intern(target), dest)
269
+ while True:
270
+ code, pkt = conn.next_event()
271
+ if code == _SELECTION_NOTIFY:
272
+ (prop,) = struct.unpack("<I", pkt[20:24])
273
+ if not prop:
274
+ return None
275
+ return conn.get_property(win, prop)[1]
276
+ except (OSError, X11Error):
277
+ return None
278
+ finally:
279
+ conn.close()
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Owning (the background process)
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ def serve(path: str, display: str | None = None, on_ready=None) -> None:
288
+ """
289
+ Own the CLIPBOARD selection for *path* until something else takes it.
290
+ *on_ready* is called once ownership is confirmed (the launcher waits for it).
291
+ """
292
+ conn = Connection(display, timeout=5.0)
293
+ win = conn.create_window()
294
+ clipboard, targets = conn.intern("CLIPBOARD"), conn.intern("TARGETS")
295
+ offered = {conn.intern(name): data for name, data in payloads_for(path).items()}
296
+
297
+ conn.set_selection_owner(win, clipboard)
298
+ if conn.get_selection_owner(clipboard) != win:
299
+ raise X11Error("could not become CLIPBOARD owner")
300
+ conn.sock.settimeout(None) # from here on: block until the server says something
301
+ if on_ready:
302
+ on_ready()
303
+
304
+ while True:
305
+ code, pkt = conn.next_event()
306
+ if code == _SELECTION_CLEAR:
307
+ return # someone else copied something — our job is done
308
+ if code != _SELECTION_REQUEST:
309
+ continue
310
+ time, _owner, requestor, selection, target, prop = struct.unpack("<IIIIII", pkt[4:28])
311
+ reply_prop = prop or target # obsolete clients send no property
312
+ if selection == clipboard and target == targets:
313
+ atoms = [targets, *offered]
314
+ conn.change_property(requestor, reply_prop, _ATOM, 32, struct.pack(f"<{len(atoms)}I", *atoms))
315
+ elif selection == clipboard and target in offered:
316
+ conn.change_property(requestor, reply_prop, target, 8, offered[target])
317
+ else:
318
+ reply_prop = 0 # refuse (TIMESTAMP, MULTIPLE, other selections, …)
319
+ conn.send_selection_notify(requestor, selection, target, reply_prop, time)
320
+
321
+
322
+ def main(argv: list[str]) -> int:
323
+ if len(argv) == 3 and argv[1] == "serve":
324
+ def ready() -> None:
325
+ sys.stdout.write("ready\n")
326
+ sys.stdout.flush()
327
+
328
+ try:
329
+ serve(argv[2], on_ready=ready)
330
+ except (OSError, X11Error):
331
+ return 1
332
+ return 0
333
+ sys.stderr.write("usage: _x11_clip.py serve <absolute-path>\n")
334
+ return 2
335
+
336
+
337
+ if __name__ == "__main__":
338
+ sys.exit(main(sys.argv))
@@ -299,13 +299,16 @@ def print_ai_selection(
299
299
 
300
300
 
301
301
  def print_clipboard_result(cb, *, con: Console = console) -> None:
302
- """One line reflecting whichever clipboard tier fired — folder-opened is silent."""
302
+ """One line when the ZIP is on the clipboard; otherwise say why it isn't."""
303
303
  if cb.tier == Tier.FILE_ON_CLIPBOARD:
304
304
  ok("Ready to paste", con=con)
305
- elif cb.tier == Tier.FOLDER_OPENED:
306
- pass # opening the folder is a convenience, not worth a log line
305
+ return
306
+ if cb.tier == Tier.FOLDER_OPENED:
307
+ warn("Couldn't copy the ZIP to the clipboard — opened its folder instead", con=con)
307
308
  else:
308
309
  info(cb.message, con=con)
310
+ if cb.hint:
311
+ info(cb.hint, con=con)
309
312
 
310
313
 
311
314
  # ---------------------------------------------------------------------------