contextzip 0.4.4__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.
- {contextzip-0.4.4 → contextzip-0.4.6}/PKG-INFO +6 -2
- {contextzip-0.4.4 → contextzip-0.4.6}/README.md +5 -1
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/__init__.py +1 -1
- contextzip-0.4.6/contextzip/_x11_clip.py +338 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/api.py +52 -2
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cli.py +17 -11
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cli_ai.py +1 -28
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cli_display.py +10 -3
- contextzip-0.4.6/contextzip/clipboard.py +552 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/error_parser.py +12 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/filters.py +116 -8
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/watcher.py +8 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/PKG-INFO +6 -2
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/SOURCES.txt +1 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/pyproject.toml +1 -1
- contextzip-0.4.4/contextzip/clipboard.py +0 -223
- {contextzip-0.4.4 → contextzip-0.4.6}/LICENSE +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/ai/selector.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/applier.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cleanup.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/config.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/detector.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/git.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/packager.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/project_config.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/redact.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/report.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/base.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/go.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/node.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/python.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/rules/rust.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/updater.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/assets.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/persist.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/server.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.4.4 → contextzip-0.4.6}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.4.4 → 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.
|
|
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
|
|
@@ -27,7 +27,11 @@ Requires-Dist: pathspec>=0.12
|
|
|
27
27
|
Requires-Dist: httpx>=0.27
|
|
28
28
|
Dynamic: license-file
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
https://github.com/user-attachments/assets/91913ad6-6161-430d-9075-b0fa76498021
|
|
33
|
+
|
|
34
|
+
<!-- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" /> -->
|
|
31
35
|
|
|
32
36
|
# contextzip
|
|
33
37
|
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
https://github.com/user-attachments/assets/91913ad6-6161-430d-9075-b0fa76498021
|
|
4
|
+
|
|
5
|
+
<!-- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" /> -->
|
|
2
6
|
|
|
3
7
|
# contextzip
|
|
4
8
|
|
|
@@ -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))
|
|
@@ -43,12 +43,15 @@ from contextzip.applier import ApplyResult
|
|
|
43
43
|
from contextzip.detector import DetectionResult, detect
|
|
44
44
|
from contextzip.filters import (
|
|
45
45
|
ResolveResult,
|
|
46
|
+
build_force_include_spec,
|
|
46
47
|
build_spec,
|
|
48
|
+
normalize_pattern,
|
|
47
49
|
resolve_files,
|
|
48
50
|
resolve_files_from_git,
|
|
49
51
|
)
|
|
50
52
|
from contextzip.git import GitError, GitErrorKind, get_changed_files
|
|
51
53
|
from contextzip.packager import PackageResult, create_zip_silent
|
|
54
|
+
from contextzip.project_config import load_project_config
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
# ---------------------------------------------------------------------------
|
|
@@ -139,19 +142,27 @@ class ZipNotFoundError(ContextzipError):
|
|
|
139
142
|
|
|
140
143
|
def get_git_changes(
|
|
141
144
|
path: str | Path | None = None,
|
|
145
|
+
*,
|
|
146
|
+
use_project_config: bool = True,
|
|
142
147
|
) -> FileCollection:
|
|
143
148
|
"""
|
|
144
149
|
Return the files that git reports as modified, added, or untracked.
|
|
145
150
|
|
|
146
151
|
The git root is found automatically by walking up from *path* (or the
|
|
147
152
|
current working directory). Files are filtered through contextzip's base
|
|
148
|
-
safety rules so secrets, binaries, and similar files are always excluded
|
|
153
|
+
safety rules so secrets, binaries, and similar files are always excluded,
|
|
154
|
+
and through the project's ``.contextzip/config.json``
|
|
155
|
+
(``always_include`` / ``always_exclude`` / ``limits``) — the same as the
|
|
156
|
+
CLI's ``--git-changes``.
|
|
149
157
|
|
|
150
158
|
Parameters
|
|
151
159
|
----------
|
|
152
160
|
path:
|
|
153
161
|
Directory to start from. Defaults to ``Path.cwd()``. The actual
|
|
154
162
|
git root may be a parent of this directory.
|
|
163
|
+
use_project_config:
|
|
164
|
+
Apply the project's ``.contextzip/config.json``. Defaults to
|
|
165
|
+
``True``; pass ``False`` to use only the built-in rules.
|
|
155
166
|
|
|
156
167
|
Returns
|
|
157
168
|
-------
|
|
@@ -192,9 +203,12 @@ def get_git_changes(
|
|
|
192
203
|
if git_result.is_empty:
|
|
193
204
|
return FileCollection(project_dir=project_dir)
|
|
194
205
|
|
|
206
|
+
cfg = load_project_config(project_dir) if use_project_config else None
|
|
207
|
+
|
|
195
208
|
resolved = resolve_files_from_git(
|
|
196
209
|
git_files=git_result.files,
|
|
197
210
|
project_dir=project_dir,
|
|
211
|
+
**_config_kwargs(cfg),
|
|
198
212
|
)
|
|
199
213
|
|
|
200
214
|
detection = detect(project_dir)
|
|
@@ -212,6 +226,7 @@ def get_files(
|
|
|
212
226
|
include: list[str] | None = None,
|
|
213
227
|
exclude: list[str] | None = None,
|
|
214
228
|
use_gitignore: bool = True,
|
|
229
|
+
use_project_config: bool = True,
|
|
215
230
|
) -> FileCollection:
|
|
216
231
|
"""
|
|
217
232
|
Return all project files after applying contextzip's standard exclusion rules.
|
|
@@ -229,6 +244,11 @@ def get_files(
|
|
|
229
244
|
use_gitignore:
|
|
230
245
|
Whether to apply the project's ``.gitignore`` file.
|
|
231
246
|
Defaults to ``True``.
|
|
247
|
+
use_project_config:
|
|
248
|
+
Apply the project's ``.contextzip/config.json`` (``always_include``,
|
|
249
|
+
``always_exclude``, ``limits``), the same as the CLI. Defaults to
|
|
250
|
+
``True``; pass ``False`` to use only the built-in rules and the
|
|
251
|
+
arguments above.
|
|
232
252
|
|
|
233
253
|
Returns
|
|
234
254
|
-------
|
|
@@ -256,9 +276,12 @@ def get_files(
|
|
|
256
276
|
|
|
257
277
|
gitignore_path = (project_dir / ".gitignore") if use_gitignore else None
|
|
258
278
|
|
|
279
|
+
cfg = load_project_config(project_dir) if use_project_config else None
|
|
280
|
+
cfg_kwargs = _config_kwargs(cfg, extra_exclude=exclude)
|
|
281
|
+
|
|
259
282
|
spec = build_spec(
|
|
260
283
|
rule_modules=detection.rule_modules,
|
|
261
|
-
extra_exclude=
|
|
284
|
+
extra_exclude=cfg_kwargs["extra_exclude"],
|
|
262
285
|
gitignore_path=gitignore_path,
|
|
263
286
|
)
|
|
264
287
|
|
|
@@ -266,6 +289,8 @@ def get_files(
|
|
|
266
289
|
project_dir=project_dir,
|
|
267
290
|
spec=spec,
|
|
268
291
|
include_only=include or None,
|
|
292
|
+
force_include=cfg_kwargs["force_include"],
|
|
293
|
+
large_file_warn_bytes=cfg_kwargs["large_file_warn_bytes"],
|
|
269
294
|
)
|
|
270
295
|
|
|
271
296
|
return _resolve_result_to_collection(resolved, project_dir, detection)
|
|
@@ -447,6 +472,31 @@ def detect_ecosystem(
|
|
|
447
472
|
# ---------------------------------------------------------------------------
|
|
448
473
|
|
|
449
474
|
|
|
475
|
+
def _config_kwargs(cfg, extra_exclude: list[str] | None = None) -> dict:
|
|
476
|
+
"""
|
|
477
|
+
Translate a ProjectConfig (or None) into the shared filter inputs:
|
|
478
|
+
extra_exclude (caller patterns + always_exclude), force_include
|
|
479
|
+
(always_include) and large_file_warn_bytes (limits.max_file_size_mb).
|
|
480
|
+
"""
|
|
481
|
+
from contextzip.filters import LARGE_FILE_WARN_BYTES
|
|
482
|
+
|
|
483
|
+
patterns = [normalize_pattern(p) for p in extra_exclude or []]
|
|
484
|
+
if cfg is None:
|
|
485
|
+
return {
|
|
486
|
+
"extra_exclude": patterns or None,
|
|
487
|
+
"force_include": None,
|
|
488
|
+
"large_file_warn_bytes": LARGE_FILE_WARN_BYTES,
|
|
489
|
+
}
|
|
490
|
+
patterns += [normalize_pattern(p) for p in cfg.always_exclude]
|
|
491
|
+
return {
|
|
492
|
+
"extra_exclude": patterns or None,
|
|
493
|
+
"force_include": build_force_include_spec(
|
|
494
|
+
[normalize_pattern(p) for p in cfg.always_include]
|
|
495
|
+
),
|
|
496
|
+
"large_file_warn_bytes": int(cfg.limits.max_file_size_mb * 1024 * 1024),
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
|
|
450
500
|
def _resolve_dir(path: str | Path | None) -> Path:
|
|
451
501
|
if path is None:
|
|
452
502
|
return Path(os.getcwd()).resolve()
|
|
@@ -927,6 +927,18 @@ def _run(
|
|
|
927
927
|
if saved:
|
|
928
928
|
project_cfg = load_project_config(project_dir)
|
|
929
929
|
|
|
930
|
+
# ── Include/exclude inputs shared by every scan mode ─────────────────────
|
|
931
|
+
# CLI --exclude/-e patterns plus any standing always_exclude patterns from
|
|
932
|
+
# project config — both are additive, persistent behavior comes from
|
|
933
|
+
# config.json so it doesn't need to be re-typed every run. always_include
|
|
934
|
+
# is a standing negation. Built once here so standard and --git-changes
|
|
935
|
+
# runs honour project config identically.
|
|
936
|
+
normalized_exclude = [normalize_pattern(p) for p in extra_exclude or []]
|
|
937
|
+
normalized_exclude += [normalize_pattern(p) for p in project_cfg.always_exclude]
|
|
938
|
+
force_include = build_force_include_spec(
|
|
939
|
+
[normalize_pattern(p) for p in project_cfg.always_include]
|
|
940
|
+
)
|
|
941
|
+
|
|
930
942
|
# ── Git-changes mode ─────────────────────────────────────────────────────
|
|
931
943
|
if git_changes:
|
|
932
944
|
with console.status("[cyan]Querying git for changed files…[/]", spinner="dots"):
|
|
@@ -939,7 +951,7 @@ def _run(
|
|
|
939
951
|
print_git_deleted_and_submodules(git_result)
|
|
940
952
|
|
|
941
953
|
if git_result.is_empty:
|
|
942
|
-
info("Nothing to
|
|
954
|
+
info("Nothing to compress in this one — working tree is clean.")
|
|
943
955
|
return
|
|
944
956
|
|
|
945
957
|
scan_label, scan_detail = git_scan_label_and_detail(git_result)
|
|
@@ -953,27 +965,21 @@ def _run(
|
|
|
953
965
|
large_file_warn_bytes=int(
|
|
954
966
|
project_cfg.limits.max_file_size_mb * 1024 * 1024
|
|
955
967
|
),
|
|
968
|
+
extra_exclude=normalized_exclude if normalized_exclude else None,
|
|
969
|
+
include_only=include_only if include_only else None,
|
|
970
|
+
force_include=force_include,
|
|
956
971
|
)
|
|
957
972
|
|
|
958
973
|
else:
|
|
959
974
|
# ── Build exclusion spec ─────────────────────────────────────────────
|
|
960
975
|
gitignore_path = None if no_gitignore else (project_dir / ".gitignore")
|
|
961
976
|
|
|
962
|
-
# CLI --exclude/-e patterns plus any standing always_exclude patterns
|
|
963
|
-
# from project config — both are additive, persistent behavior comes
|
|
964
|
-
# from config.json so it doesn't need to be re-typed every run.
|
|
965
|
-
normalized_exclude = [normalize_pattern(p) for p in extra_exclude or []]
|
|
966
|
-
normalized_exclude += [normalize_pattern(p) for p in project_cfg.always_exclude]
|
|
967
|
-
|
|
968
977
|
with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"):
|
|
969
978
|
spec = build_spec(
|
|
970
979
|
rule_modules=detection.rule_modules,
|
|
971
980
|
extra_exclude=normalized_exclude if normalized_exclude else None,
|
|
972
981
|
gitignore_path=gitignore_path,
|
|
973
982
|
)
|
|
974
|
-
force_include = build_force_include_spec(
|
|
975
|
-
[normalize_pattern(p) for p in project_cfg.always_include]
|
|
976
|
-
)
|
|
977
983
|
|
|
978
984
|
# ── Resolve files ─────────────────────────────────────────────────────
|
|
979
985
|
with console.status("[cyan]Scanning project files…[/]", spinner="dots"):
|
|
@@ -1027,7 +1033,7 @@ def _run(
|
|
|
1027
1033
|
return
|
|
1028
1034
|
|
|
1029
1035
|
if not resolved.included:
|
|
1030
|
-
err("Nothing to
|
|
1036
|
+
err("Nothing to compress in this one — all files were excluded.")
|
|
1031
1037
|
info("Try contextzip include PATH or -i PATH to override.")
|
|
1032
1038
|
return
|
|
1033
1039
|
|
|
@@ -15,38 +15,11 @@ from pathlib import Path
|
|
|
15
15
|
from rich.console import Console
|
|
16
16
|
|
|
17
17
|
from contextzip.cli_display import print_ai_selection, err, warn, info
|
|
18
|
+
from contextzip.filters import normalize_pattern # noqa: F401 (re-exported for cli.py)
|
|
18
19
|
|
|
19
20
|
console = Console()
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
# ---------------------------------------------------------------------------
|
|
23
|
-
# Pattern normalisation
|
|
24
|
-
# ---------------------------------------------------------------------------
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def normalize_pattern(p: str) -> str:
|
|
28
|
-
"""
|
|
29
|
-
Canonicalise a user-supplied exclusion pattern.
|
|
30
|
-
|
|
31
|
-
Rules applied in order:
|
|
32
|
-
1. Strip a leading ``./`` or ``.\\`` so that ``./CHANGELOG.md``
|
|
33
|
-
and ``CHANGELOG.md`` are treated identically.
|
|
34
|
-
2. Replace every backslash with a forward slash for cross-platform
|
|
35
|
-
consistency (Windows paths entered on the CLI).
|
|
36
|
-
3. Collapse ``folder/*`` → ``folder/`` so that gitignore-style
|
|
37
|
-
directory globs work as expected.
|
|
38
|
-
"""
|
|
39
|
-
# 1. Strip leading ./ or .\\
|
|
40
|
-
if p.startswith("./") or p.startswith(".\\"):
|
|
41
|
-
p = p[2:]
|
|
42
|
-
# 2. Normalise path separators
|
|
43
|
-
p = p.replace("\\", "/")
|
|
44
|
-
# 3. folder/* → folder/
|
|
45
|
-
if p.endswith("/*"):
|
|
46
|
-
p = p[:-1]
|
|
47
|
-
return p
|
|
48
|
-
|
|
49
|
-
|
|
50
23
|
# ---------------------------------------------------------------------------
|
|
51
24
|
# AI selection (full run — produces ZIP)
|
|
52
25
|
# ---------------------------------------------------------------------------
|
|
@@ -64,6 +64,10 @@ def print_detection(detection, *, con: Console = console) -> None:
|
|
|
64
64
|
"""One line: what was detected and how confident contextzip is."""
|
|
65
65
|
if detection.is_unknown:
|
|
66
66
|
ok("Detected", "unknown ecosystem, base rules only", con=con)
|
|
67
|
+
info(
|
|
68
|
+
"Don't see your language? Add it here: https://example.com",
|
|
69
|
+
con=con,
|
|
70
|
+
)
|
|
67
71
|
return
|
|
68
72
|
|
|
69
73
|
parts = []
|
|
@@ -295,13 +299,16 @@ def print_ai_selection(
|
|
|
295
299
|
|
|
296
300
|
|
|
297
301
|
def print_clipboard_result(cb, *, con: Console = console) -> None:
|
|
298
|
-
"""One line
|
|
302
|
+
"""One line when the ZIP is on the clipboard; otherwise say why it isn't."""
|
|
299
303
|
if cb.tier == Tier.FILE_ON_CLIPBOARD:
|
|
300
304
|
ok("Ready to paste", con=con)
|
|
301
|
-
|
|
302
|
-
|
|
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)
|
|
303
308
|
else:
|
|
304
309
|
info(cb.message, con=con)
|
|
310
|
+
if cb.hint:
|
|
311
|
+
info(cb.hint, con=con)
|
|
305
312
|
|
|
306
313
|
|
|
307
314
|
# ---------------------------------------------------------------------------
|