diffctx 1.8.1__cp310-abi3-win_amd64.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.
diffctx/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .diffctx import build_diff_context
9
+ from .ignore import get_ignore_specs, get_whitelist_spec
10
+ from .main import run
11
+ from .tree import TreeBuildContext, build_tree
12
+ from .version import __version__
13
+ from .writer import write_tree_json, write_tree_markdown, write_tree_text, write_tree_yaml
14
+
15
+ logging.getLogger("diffctx").addHandler(logging.NullHandler())
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "build_diff_context",
20
+ "map_directory",
21
+ "run",
22
+ "to_json",
23
+ "to_markdown",
24
+ "to_text",
25
+ "to_yaml",
26
+ ]
27
+
28
+
29
+ def _root_display_name(user_path: str | Path, resolved: Path) -> str:
30
+ original_name = Path(user_path).name
31
+ if original_name:
32
+ return original_name
33
+ return str(resolved)
34
+
35
+
36
+ def _resolve_path_if_exists(path: str | Path | None, label: str) -> Path | None:
37
+ if path is None:
38
+ return None
39
+ resolved = Path(path).resolve()
40
+ if not resolved.is_file():
41
+ raise FileNotFoundError(f"{label} '{path}' does not exist")
42
+ return resolved
43
+
44
+
45
+ def map_directory(
46
+ path: str | Path,
47
+ *,
48
+ max_depth: int | None = None,
49
+ no_content: bool = False,
50
+ max_file_bytes: int | None = None,
51
+ ignore_file: str | Path | None = None,
52
+ no_default_ignores: bool = False,
53
+ whitelist_file: str | Path | None = None,
54
+ ) -> dict[str, Any]:
55
+ root_dir = Path(path).resolve()
56
+ if not root_dir.is_dir():
57
+ raise ValueError(f"'{path}' is not a directory")
58
+
59
+ ignore_path = _resolve_path_if_exists(ignore_file, "Ignore file")
60
+ whitelist_path = _resolve_path_if_exists(whitelist_file, "Whitelist file")
61
+
62
+ ctx = TreeBuildContext(
63
+ base_dir=root_dir,
64
+ combined_spec=get_ignore_specs(root_dir, ignore_path, no_default_ignores, None),
65
+ output_file=None,
66
+ max_depth=max_depth,
67
+ no_content=no_content,
68
+ max_file_bytes=max_file_bytes,
69
+ whitelist_spec=get_whitelist_spec(whitelist_path, root_dir),
70
+ )
71
+
72
+ return {
73
+ "name": _root_display_name(path, root_dir),
74
+ "type": "directory",
75
+ "children": build_tree(root_dir, ctx),
76
+ }
77
+
78
+
79
+ def to_yaml(tree: dict[str, Any]) -> str:
80
+ buf = io.StringIO()
81
+ write_tree_yaml(buf, tree)
82
+ return buf.getvalue()
83
+
84
+
85
+ def to_json(tree: dict[str, Any]) -> str:
86
+ buf = io.StringIO()
87
+ write_tree_json(buf, tree)
88
+ return buf.getvalue()
89
+
90
+
91
+ def to_text(tree: dict[str, Any]) -> str:
92
+ buf = io.StringIO()
93
+ write_tree_text(buf, tree)
94
+ return buf.getvalue()
95
+
96
+
97
+ def to_markdown(tree: dict[str, Any]) -> str:
98
+ buf = io.StringIO()
99
+ write_tree_markdown(buf, tree)
100
+ return buf.getvalue()
diffctx/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ import logging
2
+ import sys
3
+
4
+ # Initialize logging early to avoid Python 3.13 issues with argparse
5
+ # This ensures logging's internal state is fully set up before argparse runs
6
+ try:
7
+ logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.ERROR)
8
+ # Force initialization of logging internals
9
+ logging.getLogger()
10
+ except (OSError, ValueError) as e:
11
+ # If logging initialization fails, warn but continue
12
+ print(f"Warning: logging init failed: {e}", file=sys.stderr)
13
+
14
+ from diffctx.main import main
15
+
16
+ if __name__ == "__main__":
17
+ main()
diffctx/_diffctx.pyd ADDED
Binary file
diffctx/cli.py ADDED
@@ -0,0 +1,491 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import NoReturn
9
+
10
+ from .version import __version__
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_MAX_FILE_BYTES = 256 * 1024 # 256 KB
15
+ _DEFAULT_ALPHA = 0.60
16
+ _DEFAULT_TAU = 0.08
17
+
18
+
19
+ class _Unset:
20
+ def __repr__(self) -> str:
21
+ return "<unset>"
22
+
23
+
24
+ _UNSET: _Unset = _Unset()
25
+ _DIFF_SENTINEL = "__DIFFCTX_DIFF_BARE__"
26
+
27
+
28
+ def _exit_error(message: str) -> NoReturn:
29
+ print(f"Error: {message}", file=sys.stderr)
30
+ sys.exit(1)
31
+
32
+
33
+ def _validate_max_depth(max_depth: int | None) -> None:
34
+ if max_depth is not None and max_depth < 0:
35
+ _exit_error(f"--max-depth must be non-negative, got {max_depth}")
36
+ if max_depth == 0:
37
+ print("Warning: --max-depth 0 produces empty tree (root only, no children)", file=sys.stderr)
38
+
39
+
40
+ def _validate_max_file_bytes(max_file_bytes: int, no_file_size_limit: bool) -> int | None:
41
+ if no_file_size_limit:
42
+ return None
43
+ if max_file_bytes < 0:
44
+ _exit_error(f"--max-file-bytes must be non-negative, got {max_file_bytes}")
45
+ if max_file_bytes == 0:
46
+ _exit_error("--max-file-bytes 0 is ambiguous. Use --no-file-size-limit to include all files regardless of size")
47
+ return max_file_bytes
48
+
49
+
50
+ def _validate_budget(budget: int | None) -> None:
51
+ if budget is not None and budget < -1:
52
+ _exit_error(f"--budget must be >= -1 (0=auto, -1=unlimited), got {budget}")
53
+
54
+
55
+ def _validate_alpha(alpha: float) -> None:
56
+ if not (0 < alpha < 1):
57
+ _exit_error(f"--alpha must be between 0 and 1 (exclusive), got {alpha}")
58
+
59
+
60
+ def _validate_tau(tau: float) -> None:
61
+ if tau < 0:
62
+ _exit_error(f"--tau must be non-negative, got {tau}")
63
+
64
+
65
+ def _resolve_root_dir(directory: str) -> Path:
66
+ try:
67
+ root_dir = Path(directory).resolve(strict=True)
68
+ if not root_dir.is_dir():
69
+ _exit_error(f"'{root_dir}' is not a directory")
70
+ return root_dir
71
+ except FileNotFoundError:
72
+ _exit_error(f"Directory '{directory}' does not exist")
73
+ except OSError as e:
74
+ _exit_error(f"Cannot access '{directory}': {e}")
75
+
76
+
77
+ def _resolve_glob_pattern(pattern: str) -> list[str]:
78
+ import glob as globmod
79
+
80
+ matches = sorted(globmod.glob(pattern, recursive=True))
81
+ if matches:
82
+ return matches
83
+ try:
84
+ p = Path(pattern).resolve(strict=True)
85
+ except FileNotFoundError:
86
+ _exit_error(f"No matches for '{pattern}'")
87
+ except OSError as e:
88
+ _exit_error(f"Cannot access '{pattern}': {e}")
89
+ return [str(p)]
90
+
91
+
92
+ def _classify_resolved(resolved: Path, dirs: list[Path], files: list[Path]) -> None:
93
+ if resolved.is_dir():
94
+ dirs.append(resolved)
95
+ elif resolved.is_file():
96
+ files.append(resolved)
97
+
98
+
99
+ def _expand_paths(raw_paths: list[str]) -> tuple[list[Path], list[Path]]:
100
+ dirs: list[Path] = []
101
+ files: list[Path] = []
102
+ seen: set[Path] = set()
103
+ for pattern in raw_paths:
104
+ for m in _resolve_glob_pattern(pattern):
105
+ try:
106
+ resolved = Path(m).resolve()
107
+ except OSError as e:
108
+ _exit_error(f"Cannot access '{m}': {e}")
109
+ if resolved in seen:
110
+ continue
111
+ seen.add(resolved)
112
+ _classify_resolved(resolved, dirs, files)
113
+ return dirs, files
114
+
115
+
116
+ def _resolve_output_file(output_file_arg: str | None, save: bool, output_format: str) -> tuple[Path | None, bool]:
117
+ if save and output_file_arg is not None:
118
+ _exit_error("--save and -o/--output-file are mutually exclusive")
119
+
120
+ if save:
121
+ ext = "yaml" if output_format == "yaml" else output_format
122
+ return Path(f"tree.{ext}").resolve(), False
123
+
124
+ if output_file_arg is None:
125
+ return None, False
126
+ if output_file_arg == "-":
127
+ return None, True
128
+
129
+ output_file = Path(output_file_arg).resolve()
130
+ if output_file.is_dir():
131
+ _exit_error(f"'{output_file_arg}' is a directory, not a file")
132
+ return output_file, False
133
+
134
+
135
+ def _find_in_diffctx_dir(arg: str, root_dir: Path, extra_exts: tuple[str, ...]) -> Path | None:
136
+ if Path(arg).parent != Path("."):
137
+ return None
138
+ stem = Path(arg).stem if Path(arg).suffix else arg
139
+ base = root_dir / ".diffctx"
140
+ for name in (arg, *(f"{stem}{ext}" for ext in extra_exts if f"{stem}{ext}" != arg)):
141
+ candidate = base / name
142
+ if candidate.is_file():
143
+ return candidate
144
+ return None
145
+
146
+
147
+ def _resolve_config_file(file_arg: str | None, root_dir: Path, extensions: tuple[str, ...], label: str) -> Path | None:
148
+ if not file_arg:
149
+ return None
150
+ found = _find_in_diffctx_dir(file_arg, root_dir, extensions)
151
+ if found:
152
+ return found
153
+ resolved = Path(file_arg).resolve()
154
+ if not resolved.is_file():
155
+ _exit_error(f"{label} file '{file_arg}' does not exist")
156
+ return resolved
157
+
158
+
159
+ def _resolve_ignore_file(ignore_file_arg: str | None, root_dir: Path) -> Path | None:
160
+ return _resolve_config_file(ignore_file_arg, root_dir, (".ignore", ".txt"), "Ignore")
161
+
162
+
163
+ def _resolve_whitelist_file(whitelist_file_arg: str | None, root_dir: Path) -> Path | None:
164
+ return _resolve_config_file(whitelist_file_arg, root_dir, (".whitelist", ".txt"), "Whitelist")
165
+
166
+
167
+ @dataclass
168
+ class GraphArgs:
169
+ format: str = "mermaid"
170
+ summary: bool = False
171
+ level: str = "directory"
172
+
173
+
174
+ @dataclass
175
+ class ParsedArgs:
176
+ root_dir: Path
177
+ ignore_file: Path | None
178
+ whitelist_file: Path | None
179
+ output_file: Path | None
180
+ no_default_ignores: bool
181
+ verbosity: int | str
182
+ output_format: str
183
+ max_depth: int | None
184
+ no_content: bool
185
+ max_file_bytes: int | None
186
+ copy: bool
187
+ force_stdout: bool
188
+ quiet: bool = False
189
+ diff_range: str | None = None
190
+ budget: int | None = None
191
+ alpha: float = _DEFAULT_ALPHA
192
+ tau: float = _DEFAULT_TAU
193
+ scoring: str = "ego"
194
+ full_diff: bool = False
195
+ command: str | None = None
196
+ graph: GraphArgs | None = None
197
+ extra_dirs: list[Path] | None = None
198
+ extra_files: list[Path] | None = None
199
+
200
+
201
+ DEFAULT_IGNORES_HELP = """
202
+ Default ignored patterns (use --no-default-ignores to disable built-in patterns;
203
+ project-level .gitignore and .diffctx/ignore still apply):
204
+ .git/, .svn/, .hg/ Version control directories
205
+ __pycache__/, *.py[cod], *.so, venv/, .venv/, .tox/, .nox/ Python
206
+ node_modules/, .npm/ JavaScript/Node
207
+ package-lock.json, yarn.lock, pnpm-lock.yaml JS lock files
208
+ Pipfile.lock, poetry.lock, Cargo.lock, Gemfile.lock Other lock files
209
+ target/, .gradle/ Java/Maven/Gradle
210
+ bin/, obj/ .NET
211
+ vendor/ Go/PHP
212
+ dist/, build/, out/ Generic build output
213
+ .*_cache/ All cache dirs (.pytest_cache, .mypy_cache, etc.)
214
+ .idea/, .vscode/ IDE configurations
215
+ .DS_Store, Thumbs.db OS-specific files
216
+ tree.{yaml,json,md,txt} Default output files (auto-ignored)
217
+
218
+ Ignore files (hierarchical, like git):
219
+ .gitignore Standard git ignore patterns
220
+ .diffctx/ignore diffctx-specific patterns
221
+
222
+ Whitelist files (auto-discovered):
223
+ .diffctx/whitelist Include-only filter
224
+
225
+ Examples:
226
+ diffctx . Map current directory to YAML
227
+ diffctx /path/to/project Map a specific directory
228
+ diffctx . -f json Output as JSON
229
+ diffctx . -f md --save Save as tree.md
230
+ diffctx . --diff HEAD~1 Show context for last commit
231
+ diffctx . -c Copy output to clipboard
232
+ diffctx . --no-content Structure only, no file contents
233
+
234
+ Output routing:
235
+ Default: stdout
236
+ -o FILE: write to FILE
237
+ --save: write to tree.{ext} (e.g., tree.yaml)
238
+ -c: copy to clipboard, suppress stdout
239
+ -c -o FILE: copy to clipboard AND write to FILE
240
+ """
241
+
242
+
243
+ def _build_shared_parser() -> argparse.ArgumentParser:
244
+ shared = argparse.ArgumentParser(add_help=False)
245
+ shared.add_argument("-o", "--output-file", default=None, help="Write output to FILE")
246
+ shared.add_argument("-i", "--ignore", default=None, help="Path to custom ignore file")
247
+ shared.add_argument("-w", "--whitelist", default=None, help="Path to whitelist file (only matching files are included)")
248
+ shared.add_argument(
249
+ "--no-default-ignores",
250
+ action="store_true",
251
+ help="Disable built-in ignore patterns (project .gitignore and .diffctx/ignore still apply)",
252
+ )
253
+ shared.add_argument("-c", "--copy", action="store_true", help="Copy to clipboard")
254
+ shared.add_argument("-q", "--quiet", action="store_true", help="Suppress all non-error output")
255
+ shared.add_argument(
256
+ "--log-level",
257
+ choices=["error", "warning", "info", "debug"],
258
+ default="error",
259
+ help="Log level (default: error)",
260
+ )
261
+ return shared
262
+
263
+
264
+ def _build_graph_parser(prog: str = "diffctx graph") -> argparse.ArgumentParser:
265
+ graph_parser = argparse.ArgumentParser(
266
+ prog=prog,
267
+ description="Build and analyze the project dependency graph",
268
+ formatter_class=argparse.RawDescriptionHelpFormatter,
269
+ parents=[_build_shared_parser()],
270
+ )
271
+ graph_parser.add_argument("directory", nargs="?", default=".", help="The directory to analyze")
272
+ graph_parser.add_argument(
273
+ "-f",
274
+ "--format",
275
+ choices=["mermaid", "json", "graphml"],
276
+ default="mermaid",
277
+ help="Graph output format (default: mermaid)",
278
+ )
279
+ graph_parser.add_argument(
280
+ "--summary", action="store_true", help="Print graph statistics (cycles, hotspots, coupling metrics)"
281
+ )
282
+ graph_parser.add_argument(
283
+ "--level",
284
+ choices=["fragment", "file", "directory"],
285
+ default="directory",
286
+ help="Granularity level for graph operations (default: directory)",
287
+ )
288
+ return graph_parser
289
+
290
+
291
+ def _build_main_parser(prog: str = "diffctx", version: str = __version__) -> argparse.ArgumentParser:
292
+ parser = argparse.ArgumentParser(
293
+ prog=prog,
294
+ description=(
295
+ "Generate a structured representation of a directory tree (YAML, JSON, text, or Markdown). "
296
+ "Supports diff context mode (--diff) for intelligent code change analysis.\n\n"
297
+ "Subcommands:\n"
298
+ " graph Build and analyze the project dependency graph"
299
+ ),
300
+ epilog=DEFAULT_IGNORES_HELP,
301
+ formatter_class=argparse.RawDescriptionHelpFormatter,
302
+ parents=[_build_shared_parser()],
303
+ )
304
+
305
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {version}")
306
+ parser.add_argument("paths", nargs="*", default=["."], help="Directories, files, or glob patterns to analyze")
307
+ parser.add_argument(
308
+ "-f",
309
+ "--format",
310
+ choices=["yaml", "json", "txt", "md"],
311
+ default="yaml",
312
+ help="Output format (default: yaml)",
313
+ )
314
+ parser.add_argument(
315
+ "--save",
316
+ action="store_true",
317
+ help="Save output to tree.{ext} (e.g., tree.yaml, tree.json)",
318
+ )
319
+ parser.add_argument("--max-depth", type=int, default=None, metavar="N", help="Maximum traversal depth")
320
+ parser.add_argument("--no-content", action="store_true", help="Skip file contents (structure only)")
321
+ parser.add_argument(
322
+ "--max-file-bytes",
323
+ type=int,
324
+ default=DEFAULT_MAX_FILE_BYTES,
325
+ metavar="N",
326
+ help=f"Truncate per-file content at N bytes (default: {DEFAULT_MAX_FILE_BYTES // 1024} KB). Use --no-file-size-limit to disable.",
327
+ )
328
+ parser.add_argument(
329
+ "--no-file-size-limit",
330
+ action="store_true",
331
+ help="Include all files regardless of size",
332
+ )
333
+
334
+ diff_group = parser.add_argument_group("diff context mode")
335
+ diff_group.add_argument(
336
+ "--diff",
337
+ dest="diff_range",
338
+ nargs="?",
339
+ const=_DIFF_SENTINEL,
340
+ default=None,
341
+ metavar="RANGE",
342
+ help="Git diff range (e.g., HEAD~1..HEAD, main..feature). Bare --diff defaults to HEAD.",
343
+ )
344
+ diff_group.add_argument(
345
+ "--budget",
346
+ type=int,
347
+ default=_UNSET,
348
+ metavar="N",
349
+ help="Token budget: 0=auto (default), -1=unlimited, N=fixed budget",
350
+ )
351
+ diff_group.add_argument(
352
+ "--alpha",
353
+ type=float,
354
+ default=_UNSET,
355
+ metavar="F",
356
+ help="How tightly context clusters around changes, 0-1 (default: 0.60, higher = more focused)",
357
+ )
358
+ diff_group.add_argument(
359
+ "--tau",
360
+ type=float,
361
+ default=_UNSET,
362
+ metavar="F",
363
+ help="Minimum relevance to include a fragment (default: 0.08, lower = more context)",
364
+ )
365
+ diff_group.add_argument(
366
+ "--scoring",
367
+ choices=["ppr", "ego", "bm25"],
368
+ default=_UNSET,
369
+ help="Scoring mode: ego (bounded ego-network expansion, default), ppr (Personalized PageRank), bm25 (lexical fragment retrieval)",
370
+ )
371
+ diff_group.add_argument(
372
+ "--full",
373
+ action="store_true",
374
+ default=False,
375
+ help="Include all changed code (skip smart selection algorithm)",
376
+ )
377
+ return parser
378
+
379
+
380
+ def _warn_diff_only_flags(args: argparse.Namespace) -> None:
381
+ if args.diff_range:
382
+ return
383
+ used = []
384
+ if args.budget is not _UNSET:
385
+ used.append("--budget")
386
+ if args.alpha is not _UNSET:
387
+ used.append("--alpha")
388
+ if args.tau is not _UNSET:
389
+ used.append("--tau")
390
+ if args.full:
391
+ used.append("--full")
392
+ if args.scoring is not _UNSET:
393
+ used.append("--scoring")
394
+ if used:
395
+ flags = ", ".join(used)
396
+ print(f"Warning: diff-mode flags ignored without --diff: {flags}", file=sys.stderr)
397
+
398
+
399
+ def _build_graph_parsed_args(args: argparse.Namespace) -> ParsedArgs:
400
+ root_dir = _resolve_root_dir(args.directory)
401
+ output_file_path = Path(args.output_file).resolve() if args.output_file else None
402
+ ignore_file = _resolve_ignore_file(args.ignore, root_dir)
403
+ whitelist_file = _resolve_whitelist_file(args.whitelist, root_dir)
404
+ verbosity = "error" if args.quiet else args.log_level
405
+
406
+ return ParsedArgs(
407
+ root_dir=root_dir,
408
+ ignore_file=ignore_file,
409
+ whitelist_file=whitelist_file,
410
+ output_file=output_file_path,
411
+ no_default_ignores=args.no_default_ignores,
412
+ verbosity=verbosity,
413
+ output_format="yaml",
414
+ max_depth=None,
415
+ no_content=False,
416
+ max_file_bytes=None,
417
+ copy=args.copy,
418
+ force_stdout=False,
419
+ quiet=args.quiet,
420
+ command="graph",
421
+ graph=GraphArgs(
422
+ format=args.format,
423
+ summary=args.summary,
424
+ level=args.level,
425
+ ),
426
+ )
427
+
428
+
429
+ def _build_tree_parsed_args(args: argparse.Namespace) -> ParsedArgs:
430
+ _validate_max_depth(args.max_depth)
431
+ max_file_bytes = _validate_max_file_bytes(args.max_file_bytes, args.no_file_size_limit)
432
+
433
+ budget = None if args.budget is _UNSET else args.budget
434
+ alpha = _DEFAULT_ALPHA if args.alpha is _UNSET else args.alpha
435
+ tau = _DEFAULT_TAU if args.tau is _UNSET else args.tau
436
+ scoring = "ego" if args.scoring is _UNSET else args.scoring
437
+
438
+ _validate_budget(budget)
439
+ _validate_alpha(alpha)
440
+ _validate_tau(tau)
441
+ _warn_diff_only_flags(args)
442
+
443
+ diff_range = args.diff_range
444
+ if diff_range == _DIFF_SENTINEL:
445
+ diff_range = "HEAD"
446
+
447
+ dirs, files = _expand_paths(args.paths)
448
+ root_dir = dirs[0] if dirs else Path(".").resolve()
449
+ extra_dirs = dirs or None
450
+ extra_files = files or None
451
+
452
+ output_format = args.format
453
+ output_file, force_stdout = _resolve_output_file(args.output_file, args.save, output_format)
454
+ ignore_file = _resolve_ignore_file(args.ignore, root_dir)
455
+ whitelist_file = _resolve_whitelist_file(args.whitelist, root_dir)
456
+ verbosity = "error" if args.quiet else args.log_level
457
+
458
+ return ParsedArgs(
459
+ root_dir=root_dir,
460
+ ignore_file=ignore_file,
461
+ whitelist_file=whitelist_file,
462
+ output_file=output_file,
463
+ no_default_ignores=args.no_default_ignores,
464
+ verbosity=verbosity,
465
+ output_format=output_format,
466
+ max_depth=args.max_depth,
467
+ no_content=args.no_content,
468
+ max_file_bytes=max_file_bytes,
469
+ copy=args.copy,
470
+ force_stdout=force_stdout,
471
+ quiet=args.quiet,
472
+ diff_range=diff_range,
473
+ budget=budget,
474
+ alpha=alpha,
475
+ tau=tau,
476
+ scoring=scoring,
477
+ full_diff=args.full,
478
+ extra_dirs=extra_dirs,
479
+ extra_files=extra_files,
480
+ )
481
+
482
+
483
+ def parse_args(argv: list[str] | None = None, *, prog: str = "diffctx", version: str = __version__) -> ParsedArgs:
484
+ raw_args = sys.argv[1:] if argv is None else argv
485
+
486
+ if raw_args and raw_args[0] == "graph":
487
+ args = _build_graph_parser(prog=f"{prog} graph").parse_args(raw_args[1:])
488
+ return _build_graph_parsed_args(args)
489
+
490
+ args = _build_main_parser(prog=prog, version=version).parse_args(raw_args)
491
+ return _build_tree_parsed_args(args)
diffctx/clipboard.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+
8
+
9
+ class ClipboardError(Exception):
10
+ pass
11
+
12
+
13
+ def _detect_darwin_clipboard() -> list[str] | None:
14
+ return ["pbcopy"] if shutil.which("pbcopy") else None
15
+
16
+
17
+ def _detect_windows_clipboard() -> list[str] | None:
18
+ return ["clip"] if shutil.which("clip") else None
19
+
20
+
21
+ def _detect_linux_clipboard() -> list[str] | None:
22
+ if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"):
23
+ return ["wl-copy", "--type", "text/plain"]
24
+ if os.environ.get("DISPLAY"):
25
+ if shutil.which("xclip"):
26
+ return ["xclip", "-selection", "clipboard"]
27
+ if shutil.which("xsel"):
28
+ return ["xsel", "--clipboard", "--input"]
29
+ return None
30
+
31
+
32
+ _CLIPBOARD_DETECTORS = {
33
+ "Darwin": _detect_darwin_clipboard,
34
+ "Windows": _detect_windows_clipboard,
35
+ "Linux": _detect_linux_clipboard,
36
+ "FreeBSD": _detect_linux_clipboard,
37
+ }
38
+
39
+ _INSTALL_HINTS = {
40
+ "Darwin": "pbcopy should be available by default on macOS",
41
+ "Windows": "clip.exe should be available by default on Windows",
42
+ "Linux": "Install wl-copy (Wayland) or xclip/xsel (X11): sudo apt install wl-clipboard or sudo apt install xclip",
43
+ "FreeBSD": "Install xclip or xsel: pkg install xclip",
44
+ }
45
+
46
+
47
+ def detect_clipboard_command() -> list[str] | None:
48
+ detector = _CLIPBOARD_DETECTORS.get(platform.system())
49
+ return detector() if detector else None
50
+
51
+
52
+ def copy_to_clipboard(text: str) -> None:
53
+ cmd = detect_clipboard_command()
54
+ if cmd is None:
55
+ system = platform.system()
56
+ hint = _INSTALL_HINTS.get(system, f"No clipboard support for {system}")
57
+ raise ClipboardError(f"No clipboard tool found. {hint}")
58
+
59
+ encoding = "utf-16le" if platform.system() == "Windows" else "utf-8"
60
+ encoded = text.encode(encoding)
61
+
62
+ try:
63
+ subprocess.run(
64
+ cmd,
65
+ input=encoded,
66
+ stdout=subprocess.DEVNULL,
67
+ stderr=subprocess.PIPE,
68
+ timeout=5,
69
+ check=True,
70
+ )
71
+ except subprocess.TimeoutExpired as e:
72
+ raise ClipboardError("Clipboard operation timed out") from e
73
+ except subprocess.CalledProcessError as e:
74
+ stderr_msg = e.stderr.decode(errors="replace").strip() if e.stderr else ""
75
+ raise ClipboardError(stderr_msg or f"Command failed with code {e.returncode}") from e
76
+ except OSError as e:
77
+ raise ClipboardError(f"Failed to execute clipboard command: {e}") from e
78
+
79
+
80
+ def clipboard_available() -> bool:
81
+ return detect_clipboard_command() is not None
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from diffctx._diffctx import GitError
4
+
5
+ from .pipeline import build_diff_context, compute_scored_state, select_with_params
6
+
7
+ __all__ = ["GitError", "build_diff_context", "compute_scored_state", "select_with_params"]