patchling 0.9.0__tar.gz → 0.9.2__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.
- {patchling-0.9.0 → patchling-0.9.2}/PKG-INFO +3 -1
- {patchling-0.9.0 → patchling-0.9.2}/README.md +2 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling/__init__.py +1 -1
- {patchling-0.9.0 → patchling-0.9.2}/patchling/core.py +49 -2
- {patchling-0.9.0 → patchling-0.9.2}/patchling/gptpatch.py +3 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling/relevance.py +92 -28
- {patchling-0.9.0 → patchling-0.9.2}/patchling.egg-info/PKG-INFO +3 -1
- {patchling-0.9.0 → patchling-0.9.2}/patchling.egg-info/SOURCES.txt +1 -0
- patchling-0.9.2/tests/test_cli_version.py +24 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_packaging.py +8 -6
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_relevance.py +234 -2
- {patchling-0.9.0 → patchling-0.9.2}/LICENSE.txt +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling/applydiff.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling/env.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling/py.typed +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling.egg-info/dependency_links.txt +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling.egg-info/entry_points.txt +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling.egg-info/requires.txt +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/patchling.egg-info/top_level.txt +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/pyproject.toml +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/setup.cfg +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/setup.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_applydiff.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_applydiff_edgecases.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_argparse_interleaving.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_diff_parse.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_extract_diff_blocks.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_failing_case.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_live_llm.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_multidiff.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_parse_diff_per_file.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_public_api.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_relevance_bench.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_relevance_live.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_smartapply.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_smartapply_bugs.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_strip_bad_ouput.py +0 -0
- {patchling-0.9.0 → patchling-0.9.2}/tests/test_swallow_reasoning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchling
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Natural-language code transformation as a library: generate unified diffs with LLMs and apply them resiliently with smartapply. Formerly gptdiff.
|
|
5
5
|
Author: 255labs
|
|
6
6
|
License: MIT
|
|
@@ -77,6 +77,8 @@ Building for the browser? Start with [patchling for JS](https://github.com/255BI
|
|
|
77
77
|
pip install patchling
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
Verify the installed CLI version with `patchling --version` or `patchling-apply --version`.
|
|
81
|
+
|
|
80
82
|
### 2. Set your API key
|
|
81
83
|
|
|
82
84
|
Works with any OpenAI-compatible endpoint. Get a key at [nano-gpt.com/api](https://nano-gpt.com/api), or point `GPTDIFF_LLM_BASE_URL` at your own provider.
|
|
@@ -48,6 +48,8 @@ Building for the browser? Start with [patchling for JS](https://github.com/255BI
|
|
|
48
48
|
pip install patchling
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
Verify the installed CLI version with `patchling --version` or `patchling-apply --version`.
|
|
52
|
+
|
|
51
53
|
### 2. Set your API key
|
|
52
54
|
|
|
53
55
|
Works with any OpenAI-compatible endpoint. Get a key at [nano-gpt.com/api](https://nano-gpt.com/api), or point `GPTDIFF_LLM_BASE_URL` at your own provider.
|
|
@@ -21,6 +21,7 @@ from threading import Lock
|
|
|
21
21
|
import shutil
|
|
22
22
|
import base64
|
|
23
23
|
import mimetypes
|
|
24
|
+
import weakref
|
|
24
25
|
|
|
25
26
|
import openai
|
|
26
27
|
from openai import OpenAI
|
|
@@ -377,7 +378,7 @@ def call_llm(
|
|
|
377
378
|
if reasoning_effort:
|
|
378
379
|
request["reasoning_effort"] = reasoning_effort
|
|
379
380
|
try:
|
|
380
|
-
|
|
381
|
+
response = client.chat.completions.create(**request)
|
|
381
382
|
except TypeError as exc:
|
|
382
383
|
# Retry once, without the parameter, only when this call sent it
|
|
383
384
|
# and the error says the parameter is unsupported. A generic
|
|
@@ -385,8 +386,51 @@ def call_llm(
|
|
|
385
386
|
# reasoning_effort is made exactly once.
|
|
386
387
|
if "reasoning_effort" in request and _provider_rejected_reasoning_effort(exc):
|
|
387
388
|
request.pop("reasoning_effort", None)
|
|
388
|
-
|
|
389
|
+
response = client.chat.completions.create(**request)
|
|
390
|
+
# The caller asked for an effort this SDK refused. Record that
|
|
391
|
+
# nothing was sent so a relevance decision is not cached under
|
|
392
|
+
# the requested effort.
|
|
393
|
+
note_effort_sent(response, "")
|
|
394
|
+
return response
|
|
389
395
|
raise
|
|
396
|
+
if "reasoning_effort" in request:
|
|
397
|
+
note_effort_sent(response, request["reasoning_effort"])
|
|
398
|
+
return response
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# Responses are not always able to grow an attribute (some SDK models are
|
|
402
|
+
# slotted). The weak map covers ordinary objects; the attribute covers the rest.
|
|
403
|
+
_effort_sent_for_response: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def note_effort_sent(response: Any, effort: str | None) -> None:
|
|
407
|
+
"""Record the ``reasoning_effort`` value actually sent for ``response``.
|
|
408
|
+
|
|
409
|
+
``""`` means the parameter was omitted after the client rejected it.
|
|
410
|
+
"""
|
|
411
|
+
value = "" if not effort else str(effort)
|
|
412
|
+
try:
|
|
413
|
+
_effort_sent_for_response[response] = value
|
|
414
|
+
except TypeError:
|
|
415
|
+
pass
|
|
416
|
+
try:
|
|
417
|
+
object.__setattr__(response, "_patchling_effort_sent", value)
|
|
418
|
+
except (AttributeError, TypeError):
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def effort_sent_for(response: Any):
|
|
423
|
+
"""Effort recorded by :func:`note_effort_sent`, or ``None`` if unrecorded."""
|
|
424
|
+
if response is None:
|
|
425
|
+
return None
|
|
426
|
+
recorded = None
|
|
427
|
+
try:
|
|
428
|
+
recorded = _effort_sent_for_response.get(response)
|
|
429
|
+
except TypeError:
|
|
430
|
+
recorded = None
|
|
431
|
+
if recorded is None:
|
|
432
|
+
recorded = getattr(response, "_patchling_effort_sent", None)
|
|
433
|
+
return recorded
|
|
390
434
|
|
|
391
435
|
|
|
392
436
|
def _provider_rejected_reasoning_effort(exc: BaseException) -> bool:
|
|
@@ -808,7 +852,10 @@ def smartapply(
|
|
|
808
852
|
return result
|
|
809
853
|
|
|
810
854
|
def parse_arguments():
|
|
855
|
+
from . import __version__
|
|
856
|
+
|
|
811
857
|
parser = argparse.ArgumentParser(description='Generate and optionally apply git diffs using GPT-4.')
|
|
858
|
+
parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
|
|
812
859
|
parser.add_argument('prompt', type=str, help='Prompt that runs on the codebase.')
|
|
813
860
|
parser.add_argument('--apply', action='store_true', help='Attempt to apply the generated git diff. Uses smartapply if applying the patch fails.')
|
|
814
861
|
parser.add_argument('--prepend', type=str, default=None, help='Path to content prepended to system prompt')
|
|
@@ -16,9 +16,12 @@ from pathlib import Path
|
|
|
16
16
|
from patchling.core import apply_diff, smart_apply_patch, color_code_diff
|
|
17
17
|
|
|
18
18
|
def parse_arguments():
|
|
19
|
+
from . import __version__
|
|
20
|
+
|
|
19
21
|
parser = argparse.ArgumentParser(
|
|
20
22
|
description="Apply a unified diff to the file system using GPTDiff's patch logic."
|
|
21
23
|
)
|
|
24
|
+
parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
|
|
22
25
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
23
26
|
group.add_argument(
|
|
24
27
|
"--diff",
|
|
@@ -7,14 +7,18 @@ to the task. Only ``yes`` files are returned for ``generate_diff`` and
|
|
|
7
7
|
|
|
8
8
|
Prompt version 3. Each request uses 1024 output tokens and
|
|
9
9
|
``reasoning_effort="low"`` (one retry without that parameter on a 4xx that
|
|
10
|
-
names it
|
|
11
|
-
version, model, token limit,
|
|
12
|
-
the retry omitted it), task,
|
|
13
|
-
``binary``), the preview note, and
|
|
14
|
-
leading standalone yes/no after the
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
names it, or when the OpenAI client rejects the keyword). The cache key is
|
|
11
|
+
the SHA-256 of length-prefixed fields: prompt version, model, token limit,
|
|
12
|
+
the effort actually sent (``low``, or empty when the retry omitted it), task,
|
|
13
|
+
path, mode (``full``, ``truncated``, or ``binary``), the preview note, and
|
|
14
|
+
the exact body sent. A decision is a leading standalone yes/no after the
|
|
15
|
+
supported wrappers, including a quoted decision followed by a reason.
|
|
16
|
+
Anything else is malformed: the file is included, ``failed_open`` is set, and
|
|
17
|
+
the decision is not cached. Non-string message content is that same failure.
|
|
18
|
+
A cache read error is a miss and the model is called; a cache write error is
|
|
19
|
+
logged and does not abort the run. Invalid UTF-8 anywhere in a byte payload
|
|
20
|
+
is binary. ``on_binary="include"`` returns those original bytes unchanged.
|
|
21
|
+
A leading UTF-8 BOM (U+FEFF) stays in decoded text.
|
|
18
22
|
"""
|
|
19
23
|
|
|
20
24
|
from __future__ import annotations
|
|
@@ -625,24 +629,45 @@ def _utf8_text_or_none(sample: bytes) -> Optional[str]:
|
|
|
625
629
|
|
|
626
630
|
|
|
627
631
|
def is_binary_content(content) -> bool:
|
|
628
|
-
"""True when the
|
|
632
|
+
"""True when the payload is not text.
|
|
629
633
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
634
|
+
The first 8192 bytes are decoded as UTF-8. An incomplete sequence of at
|
|
635
|
+
most 3 bytes at that probe boundary is still text. A NUL or any other
|
|
636
|
+
invalid byte inside the window is binary, and so is a non-empty window
|
|
637
|
+
that decodes to empty text. Bytes past the window are checked too: any
|
|
638
|
+
invalid UTF-8 there is binary. A leading BOM is text.
|
|
633
639
|
"""
|
|
634
640
|
if isinstance(content, str):
|
|
635
|
-
|
|
641
|
+
if "\x00" in content:
|
|
642
|
+
return True
|
|
643
|
+
raw = content.encode("utf-8")
|
|
636
644
|
elif isinstance(content, (bytes, bytearray)):
|
|
637
|
-
|
|
645
|
+
raw = bytes(content)
|
|
638
646
|
else:
|
|
639
647
|
return False
|
|
640
|
-
|
|
648
|
+
return _bytes_are_binary(raw)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _bytes_are_binary(raw: bytes) -> bool:
|
|
652
|
+
if b"\x00" in raw:
|
|
653
|
+
return True
|
|
654
|
+
window = raw[:8192]
|
|
655
|
+
text = _utf8_text_or_none(window)
|
|
641
656
|
if text is None:
|
|
642
657
|
return True
|
|
643
|
-
if
|
|
658
|
+
if window and text == "":
|
|
659
|
+
return True
|
|
660
|
+
if _text_looks_binary(text):
|
|
644
661
|
return True
|
|
645
|
-
|
|
662
|
+
if len(raw) <= 8192:
|
|
663
|
+
return False
|
|
664
|
+
# The probe may end inside a character. The rest of the file has to be
|
|
665
|
+
# well-formed UTF-8; an invalid byte after the window is not dropped.
|
|
666
|
+
try:
|
|
667
|
+
raw.decode("utf-8")
|
|
668
|
+
except UnicodeDecodeError:
|
|
669
|
+
return True
|
|
670
|
+
return False
|
|
646
671
|
|
|
647
672
|
|
|
648
673
|
def _text_looks_binary(sample: str) -> bool:
|
|
@@ -666,23 +691,39 @@ def _coerce_text(content) -> str:
|
|
|
666
691
|
raise TypeError(f"file content must be str or bytes, got {type(content).__name__}")
|
|
667
692
|
|
|
668
693
|
|
|
694
|
+
def _decode_utf8_text(raw: bytes) -> Optional[str]:
|
|
695
|
+
"""Decode UTF-8, keeping a leading BOM (U+FEFF).
|
|
696
|
+
|
|
697
|
+
``utf-8-sig`` is not used. A buffer of at most 8192 bytes may end on an
|
|
698
|
+
incomplete sequence; those trailing bytes are dropped. A longer buffer
|
|
699
|
+
must be entirely well-formed, so a bad byte after the probe is not cut off.
|
|
700
|
+
"""
|
|
701
|
+
try:
|
|
702
|
+
return raw.decode("utf-8")
|
|
703
|
+
except UnicodeDecodeError:
|
|
704
|
+
if len(raw) > 8192:
|
|
705
|
+
return None
|
|
706
|
+
return _utf8_text_or_none(raw)
|
|
707
|
+
|
|
708
|
+
|
|
669
709
|
def _prepare_content(content) -> tuple[bool, str]:
|
|
670
710
|
"""Return ``(is_binary, text)``.
|
|
671
711
|
|
|
672
|
-
Valid UTF-8 bytes are decoded to text
|
|
673
|
-
empty string. Invalid UTF-8 or a
|
|
712
|
+
Valid UTF-8 bytes are decoded to text, including a leading U+FEFF, and
|
|
713
|
+
are never replaced with an empty string. Invalid UTF-8 anywhere, or a
|
|
714
|
+
NUL byte, takes the binary path. Callers that include a binary file pass
|
|
715
|
+
the original bytes through instead of this text.
|
|
674
716
|
"""
|
|
675
717
|
if isinstance(content, str):
|
|
676
718
|
return is_binary_content(content), content
|
|
677
719
|
if isinstance(content, (bytes, bytearray)):
|
|
678
720
|
raw = bytes(content)
|
|
679
|
-
if
|
|
721
|
+
if is_binary_content(raw):
|
|
680
722
|
return True, ""
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
except UnicodeDecodeError:
|
|
723
|
+
text = _decode_utf8_text(raw)
|
|
724
|
+
if text is None or (raw and text == ""):
|
|
684
725
|
return True, ""
|
|
685
|
-
return
|
|
726
|
+
return False, text
|
|
686
727
|
raise TypeError(f"file content must be str or bytes, got {type(content).__name__}")
|
|
687
728
|
|
|
688
729
|
|
|
@@ -709,6 +750,11 @@ def _usage_triplet(usage) -> tuple[int, int, int]:
|
|
|
709
750
|
|
|
710
751
|
|
|
711
752
|
def _content_text(content):
|
|
753
|
+
"""Message content must already be a string.
|
|
754
|
+
|
|
755
|
+
A list such as ``['no']`` is not joined or passed through ``str``. That
|
|
756
|
+
would become the cacheable word ``no``. Non-string content fails open.
|
|
757
|
+
"""
|
|
712
758
|
if content is None:
|
|
713
759
|
return ""
|
|
714
760
|
if not isinstance(content, str):
|
|
@@ -878,6 +924,13 @@ def _ask(
|
|
|
878
924
|
else:
|
|
879
925
|
raise
|
|
880
926
|
raw, prompt_tokens, completion_tokens, total_tokens = _usage_and_text(response)
|
|
927
|
+
# call_llm swallows a legacy SDK TypeError and retries with no effort.
|
|
928
|
+
# That response records effort "" so the decision is not stored as "low".
|
|
929
|
+
from .core import effort_sent_for
|
|
930
|
+
|
|
931
|
+
recorded = effort_sent_for(response)
|
|
932
|
+
if recorded is not None:
|
|
933
|
+
sent_effort = recorded or None
|
|
881
934
|
return raw, prompt_tokens, completion_tokens, total_tokens, sent_effort
|
|
882
935
|
|
|
883
936
|
|
|
@@ -918,11 +971,15 @@ def filter_relevant(
|
|
|
918
971
|
the empty effort. Empty answers, malformed answers, and errors are not
|
|
919
972
|
cached. A cache read error is a miss and the model is called; a cache
|
|
920
973
|
write error is logged and ignored. A response with missing choices, an
|
|
921
|
-
empty choice list, a missing message,
|
|
922
|
-
open and is not cached.
|
|
974
|
+
empty choice list, a missing message, non-string message content, or a
|
|
975
|
+
non-JSON body fails that file open and is not cached. When the default
|
|
976
|
+
client retries without ``reasoning_effort``, the decision is stored under
|
|
977
|
+
the empty effort.
|
|
923
978
|
|
|
924
979
|
Binary files and files over ``max_bytes`` / ``max_tokens`` are not sent in
|
|
925
|
-
full.
|
|
980
|
+
full. Invalid UTF-8 after the first 8192 bytes is still binary.
|
|
981
|
+
``on_binary="include"`` keeps the original bytes. ``on_binary`` and
|
|
982
|
+
``on_oversize`` are ``include`` (keep, no model
|
|
926
983
|
call), ``exclude`` (drop, no model call), or ``truncate`` (ask the model
|
|
927
984
|
about a truncated or redacted preview). Pass ``None`` for ``max_bytes`` or
|
|
928
985
|
``max_tokens`` to disable that limit. ``skip_patterns`` defaults to
|
|
@@ -960,6 +1017,9 @@ def filter_relevant(
|
|
|
960
1017
|
|
|
961
1018
|
by_path: dict[str, RelevanceDecision] = {}
|
|
962
1019
|
text_by_path: dict[str, str] = {}
|
|
1020
|
+
# Binary includes keep the caller's object. Decoding would replace or drop
|
|
1021
|
+
# the invalid byte and the bytes that follow it.
|
|
1022
|
+
binary_original: dict = {}
|
|
963
1023
|
# Group files that share a cache key so each distinct body is sent once.
|
|
964
1024
|
groups: dict[str, list] = {}
|
|
965
1025
|
group_order: list[str] = []
|
|
@@ -976,6 +1036,8 @@ def filter_relevant(
|
|
|
976
1036
|
binary, text = _prepare_content(content)
|
|
977
1037
|
if not binary:
|
|
978
1038
|
text_by_path[path] = text
|
|
1039
|
+
else:
|
|
1040
|
+
binary_original[path] = content
|
|
979
1041
|
if binary:
|
|
980
1042
|
if on_binary == "truncate":
|
|
981
1043
|
note = (
|
|
@@ -1215,7 +1277,9 @@ def filter_relevant(
|
|
|
1215
1277
|
for path, content in files.items():
|
|
1216
1278
|
decision = by_path[path]
|
|
1217
1279
|
if decision.decision == "yes":
|
|
1218
|
-
if path in
|
|
1280
|
+
if path in binary_original:
|
|
1281
|
+
included[path] = binary_original[path]
|
|
1282
|
+
elif path in text_by_path:
|
|
1219
1283
|
included[path] = text_by_path[path]
|
|
1220
1284
|
elif isinstance(content, str):
|
|
1221
1285
|
included[path] = content
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchling
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Natural-language code transformation as a library: generate unified diffs with LLMs and apply them resiliently with smartapply. Formerly gptdiff.
|
|
5
5
|
Author: 255labs
|
|
6
6
|
License: MIT
|
|
@@ -77,6 +77,8 @@ Building for the browser? Start with [patchling for JS](https://github.com/255BI
|
|
|
77
77
|
pip install patchling
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
Verify the installed CLI version with `patchling --version` or `patchling-apply --version`.
|
|
81
|
+
|
|
80
82
|
### 2. Set your API key
|
|
81
83
|
|
|
82
84
|
Works with any OpenAI-compatible endpoint. Get a key at [nano-gpt.com/api](https://nano-gpt.com/api), or point `GPTDIFF_LLM_BASE_URL` at your own provider.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Installed entry points report their version without required arguments."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import patchling
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("command", ["patchling", "patchling-apply", "gptdiff", "gptpatch"])
|
|
13
|
+
def test_installed_cli_version(command, tmp_path):
|
|
14
|
+
executable = Path(sys.executable).with_name(command)
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
[str(executable), "--version"],
|
|
17
|
+
cwd=tmp_path,
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
timeout=10,
|
|
21
|
+
)
|
|
22
|
+
assert result.returncode == 0, result.stderr
|
|
23
|
+
assert result.stdout.strip() == f"{command} {patchling.__version__}"
|
|
24
|
+
assert result.stderr == ""
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Package and release metadata share patchling.__version__."""
|
|
2
2
|
|
|
3
|
+
from configparser import ConfigParser
|
|
3
4
|
from importlib.metadata import version
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -9,21 +10,22 @@ import patchling
|
|
|
9
10
|
ROOT = Path(__file__).resolve().parents[1]
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
def
|
|
13
|
-
assert patchling
|
|
14
|
-
assert version("patchling") == "0.8.1"
|
|
13
|
+
def test_installed_version_matches_package():
|
|
14
|
+
assert version("patchling") == patchling.__version__
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def test_pyproject_does_not_hardcode_a_second_version():
|
|
18
18
|
text = (ROOT / "pyproject.toml").read_text()
|
|
19
19
|
assert 'dynamic = ["version"]' in text
|
|
20
|
-
assert 'version = "
|
|
20
|
+
assert f'version = "{patchling.__version__}"' not in text
|
|
21
21
|
assert 'attr = "patchling.__version__"' in text
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def test_bumpversion_tracks_the_package_constant():
|
|
25
25
|
cfg = (ROOT / ".bumpversion.cfg").read_text()
|
|
26
|
-
|
|
26
|
+
parser = ConfigParser(interpolation=None)
|
|
27
|
+
parser.read_string(cfg)
|
|
28
|
+
assert parser["bumpversion"]["current_version"] == patchling.__version__
|
|
27
29
|
assert "patchling/__init__.py" in cfg
|
|
28
30
|
assert "setup.py" not in cfg
|
|
29
31
|
|
|
@@ -1217,6 +1217,13 @@ def test_parser_wrappers_and_terminators():
|
|
|
1217
1217
|
assert parse_relevance_answer("**yes**, because it is")[0] == "yes"
|
|
1218
1218
|
|
|
1219
1219
|
|
|
1220
|
+
def test_parser_quoted_decision_followed_by_reason():
|
|
1221
|
+
assert parse_relevance_answer("**no** unrelated")[0] == "no"
|
|
1222
|
+
assert parse_relevance_answer('"no" unrelated')[0] == "no"
|
|
1223
|
+
assert parse_relevance_answer("\u201cno\u201d — unrelated")[0] == "no"
|
|
1224
|
+
assert parse_relevance_answer("**yes** the helper is here")[0] == "yes"
|
|
1225
|
+
|
|
1226
|
+
|
|
1220
1227
|
def test_full_and_truncated_abc_do_not_share_a_key():
|
|
1221
1228
|
full = RelevanceCache.make_key(
|
|
1222
1229
|
model="m", task="task", content="abc", path="a.py", mode="full", note=""
|
|
@@ -1314,6 +1321,217 @@ def test_valid_utf8_bytes_are_decoded_and_do_not_share_a_path_key():
|
|
|
1314
1321
|
assert len(bodies) == 2
|
|
1315
1322
|
|
|
1316
1323
|
|
|
1324
|
+
def test_invalid_utf8_after_probe_window_is_binary_and_include_keeps_bytes():
|
|
1325
|
+
from patchling.relevance import _prepare_content, is_binary_content
|
|
1326
|
+
|
|
1327
|
+
payload = b"a" * 8192 + b"\xff" + b"TAIL"
|
|
1328
|
+
assert is_binary_content(payload) is True
|
|
1329
|
+
assert _prepare_content(payload) == (True, "")
|
|
1330
|
+
|
|
1331
|
+
# A character split by the 8192 window is still text when the whole file is valid.
|
|
1332
|
+
split = b"a" * 8191 + "é".encode("utf-8") + b"TAIL"
|
|
1333
|
+
assert len(split) > 8192
|
|
1334
|
+
binary, text = _prepare_content(split)
|
|
1335
|
+
assert binary is False
|
|
1336
|
+
assert text.endswith("éTAIL")
|
|
1337
|
+
assert "\ufffd" not in text
|
|
1338
|
+
|
|
1339
|
+
calls = {"n": 0}
|
|
1340
|
+
|
|
1341
|
+
def fake_call_llm(api_key, base_url, model, messages, max_tokens, temperature, budget_tokens=None):
|
|
1342
|
+
calls["n"] += 1
|
|
1343
|
+
user = messages[-1]["content"]
|
|
1344
|
+
assert "TAIL" not in user
|
|
1345
|
+
return _Response("yes")
|
|
1346
|
+
|
|
1347
|
+
excluded = filter_relevant(
|
|
1348
|
+
{"a.bin": payload},
|
|
1349
|
+
"task",
|
|
1350
|
+
model="m",
|
|
1351
|
+
cache=False,
|
|
1352
|
+
api_key="k",
|
|
1353
|
+
on_binary="exclude",
|
|
1354
|
+
call_llm=fake_call_llm,
|
|
1355
|
+
)
|
|
1356
|
+
assert calls["n"] == 0
|
|
1357
|
+
assert excluded.files == {}
|
|
1358
|
+
assert excluded.decisions[0].decision == "no"
|
|
1359
|
+
assert excluded.decisions[0].skipped_reason == "binary"
|
|
1360
|
+
|
|
1361
|
+
included = filter_relevant(
|
|
1362
|
+
{"a.bin": payload},
|
|
1363
|
+
"task",
|
|
1364
|
+
model="m",
|
|
1365
|
+
cache=False,
|
|
1366
|
+
api_key="k",
|
|
1367
|
+
on_binary="include",
|
|
1368
|
+
call_llm=fake_call_llm,
|
|
1369
|
+
)
|
|
1370
|
+
assert calls["n"] == 0
|
|
1371
|
+
assert included.decisions[0].decision == "yes"
|
|
1372
|
+
assert included.files["a.bin"] is payload
|
|
1373
|
+
assert included.files["a.bin"].endswith(b"\xffTAIL")
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def test_leading_bom_is_preserved_in_decoded_text():
|
|
1377
|
+
from patchling.relevance import PROMPT_VERSION, _prepare_content, _truncate_text
|
|
1378
|
+
|
|
1379
|
+
raw = b"\xef\xbb\xbfhello"
|
|
1380
|
+
binary, text = _prepare_content(raw)
|
|
1381
|
+
assert binary is False
|
|
1382
|
+
assert text == "\ufeffhello"
|
|
1383
|
+
assert text.startswith("\ufeff")
|
|
1384
|
+
assert _truncate_text(text, None, 1, None).startswith("\ufeff")
|
|
1385
|
+
|
|
1386
|
+
with_bom = RelevanceCache.make_key(
|
|
1387
|
+
model="m",
|
|
1388
|
+
task="task",
|
|
1389
|
+
content=text,
|
|
1390
|
+
path="a.py",
|
|
1391
|
+
max_output_tokens=1024,
|
|
1392
|
+
prompt_version=PROMPT_VERSION,
|
|
1393
|
+
effort="low",
|
|
1394
|
+
mode="full",
|
|
1395
|
+
note="",
|
|
1396
|
+
)
|
|
1397
|
+
without_bom = RelevanceCache.make_key(
|
|
1398
|
+
model="m",
|
|
1399
|
+
task="task",
|
|
1400
|
+
content="hello",
|
|
1401
|
+
path="a.py",
|
|
1402
|
+
max_output_tokens=1024,
|
|
1403
|
+
prompt_version=PROMPT_VERSION,
|
|
1404
|
+
effort="low",
|
|
1405
|
+
mode="full",
|
|
1406
|
+
note="",
|
|
1407
|
+
)
|
|
1408
|
+
assert with_bom != without_bom
|
|
1409
|
+
|
|
1410
|
+
def fake_call_llm(api_key, base_url, model, messages, max_tokens, temperature, budget_tokens=None):
|
|
1411
|
+
assert "\ufeffhello" in messages[-1]["content"]
|
|
1412
|
+
return _Response("yes")
|
|
1413
|
+
|
|
1414
|
+
result = filter_relevant(
|
|
1415
|
+
{"a.py": raw},
|
|
1416
|
+
"task",
|
|
1417
|
+
model="m",
|
|
1418
|
+
cache=False,
|
|
1419
|
+
api_key="k",
|
|
1420
|
+
call_llm=fake_call_llm,
|
|
1421
|
+
)
|
|
1422
|
+
assert result.files["a.py"] == "\ufeffhello"
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def test_non_string_message_content_fails_open_uncached():
|
|
1426
|
+
class Message:
|
|
1427
|
+
content = ["no"]
|
|
1428
|
+
|
|
1429
|
+
class Choice:
|
|
1430
|
+
message = Message()
|
|
1431
|
+
|
|
1432
|
+
class Response:
|
|
1433
|
+
choices = [Choice()]
|
|
1434
|
+
usage = None
|
|
1435
|
+
|
|
1436
|
+
payloads = [
|
|
1437
|
+
{"choices": [{"message": {"content": ["no"]}}]},
|
|
1438
|
+
Response(),
|
|
1439
|
+
]
|
|
1440
|
+
for payload in payloads:
|
|
1441
|
+
calls = {"n": 0}
|
|
1442
|
+
|
|
1443
|
+
def fake_call_llm(api_key, base_url, model, messages, max_tokens, temperature, budget_tokens=None, payload=payload):
|
|
1444
|
+
calls["n"] += 1
|
|
1445
|
+
return payload
|
|
1446
|
+
|
|
1447
|
+
cache = RelevanceCache()
|
|
1448
|
+
result = filter_relevant(
|
|
1449
|
+
{"a.py": "x\n"},
|
|
1450
|
+
"task",
|
|
1451
|
+
model="m",
|
|
1452
|
+
cache=cache,
|
|
1453
|
+
api_key="k",
|
|
1454
|
+
call_llm=fake_call_llm,
|
|
1455
|
+
)
|
|
1456
|
+
decision = result.decisions[0]
|
|
1457
|
+
assert decision.failed_open is True
|
|
1458
|
+
assert decision.decision == "yes"
|
|
1459
|
+
assert decision.cached is False
|
|
1460
|
+
assert "malformed response" in (decision.error or "")
|
|
1461
|
+
assert cache.get(decision.cache_key) is None if decision.cache_key else True
|
|
1462
|
+
again = filter_relevant(
|
|
1463
|
+
{"a.py": "x\n"},
|
|
1464
|
+
"task",
|
|
1465
|
+
model="m",
|
|
1466
|
+
cache=cache,
|
|
1467
|
+
api_key="k",
|
|
1468
|
+
call_llm=fake_call_llm,
|
|
1469
|
+
)
|
|
1470
|
+
assert again.decisions[0].cached is False
|
|
1471
|
+
assert calls["n"] == 2
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def test_default_wrapper_caches_empty_effort_when_legacy_sdk_rejects_it(monkeypatch):
|
|
1475
|
+
"""The real call_llm retries a legacy SDK; the cache key uses effort ""."""
|
|
1476
|
+
from patchling.relevance import PROMPT_VERSION
|
|
1477
|
+
|
|
1478
|
+
calls = []
|
|
1479
|
+
|
|
1480
|
+
class Message:
|
|
1481
|
+
content = "no, unrelated"
|
|
1482
|
+
|
|
1483
|
+
class Choice:
|
|
1484
|
+
message = Message()
|
|
1485
|
+
|
|
1486
|
+
class Usage:
|
|
1487
|
+
prompt_tokens = 3
|
|
1488
|
+
completion_tokens = 2
|
|
1489
|
+
total_tokens = 5
|
|
1490
|
+
|
|
1491
|
+
class Response:
|
|
1492
|
+
choices = [Choice()]
|
|
1493
|
+
usage = Usage()
|
|
1494
|
+
|
|
1495
|
+
class Completions:
|
|
1496
|
+
def create(self, **kwargs):
|
|
1497
|
+
calls.append("reasoning_effort" in kwargs)
|
|
1498
|
+
if "reasoning_effort" in kwargs:
|
|
1499
|
+
raise TypeError("unexpected keyword argument 'reasoning_effort'")
|
|
1500
|
+
return Response()
|
|
1501
|
+
|
|
1502
|
+
class Chat:
|
|
1503
|
+
completions = Completions()
|
|
1504
|
+
|
|
1505
|
+
class Client:
|
|
1506
|
+
def __init__(self, **kwargs):
|
|
1507
|
+
self.chat = Chat()
|
|
1508
|
+
|
|
1509
|
+
monkeypatch.setattr("patchling.core.OpenAI", Client)
|
|
1510
|
+
cache = RelevanceCache()
|
|
1511
|
+
files = {"a.py": "x = 1\n"}
|
|
1512
|
+
first = filter_relevant(files, "task", model="m", cache=cache, api_key="k")
|
|
1513
|
+
second = filter_relevant(files, "task", model="m", cache=cache, api_key="k")
|
|
1514
|
+
assert calls == [True, False]
|
|
1515
|
+
assert first.decisions[0].cached is False
|
|
1516
|
+
assert first.decisions[0].decision == "no"
|
|
1517
|
+
assert second.decisions[0].cached is True
|
|
1518
|
+
assert second.decisions[0].decision == "no"
|
|
1519
|
+
common = dict(
|
|
1520
|
+
model="m",
|
|
1521
|
+
task="task",
|
|
1522
|
+
content="x = 1\n",
|
|
1523
|
+
path="a.py",
|
|
1524
|
+
max_output_tokens=1024,
|
|
1525
|
+
prompt_version=PROMPT_VERSION,
|
|
1526
|
+
mode="full",
|
|
1527
|
+
note="",
|
|
1528
|
+
)
|
|
1529
|
+
low = RelevanceCache.make_key(**common, effort="low")
|
|
1530
|
+
omitted = RelevanceCache.make_key(**common, effort="")
|
|
1531
|
+
assert cache.get(low) is None
|
|
1532
|
+
assert cache.get(omitted)["decision"] == "no"
|
|
1533
|
+
|
|
1534
|
+
|
|
1317
1535
|
def test_envelope_errors_fail_open_and_are_not_cached():
|
|
1318
1536
|
payloads = [
|
|
1319
1537
|
{"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}},
|
|
@@ -1322,6 +1540,7 @@ def test_envelope_errors_fail_open_and_are_not_cached():
|
|
|
1322
1540
|
{"choices": [{"message": {}}]},
|
|
1323
1541
|
"this is not json",
|
|
1324
1542
|
None,
|
|
1543
|
+
{"choices": [{"message": {"content": ["no"]}}]},
|
|
1325
1544
|
]
|
|
1326
1545
|
for payload in payloads:
|
|
1327
1546
|
calls = {"n": 0}
|
|
@@ -1492,8 +1711,8 @@ def test_cli_apply_uses_the_full_original_file_map(tmp_path, monkeypatch):
|
|
|
1492
1711
|
|
|
1493
1712
|
# sha256 of tests/fixtures/parity/relevance.json. That file is a byte-identical
|
|
1494
1713
|
# copy of test/fixtures/parity/relevance.json on 255BITS/patchling branch
|
|
1495
|
-
# cursor/relevance-filter-915b. A mismatch fails CI.
|
|
1496
|
-
RELEVANCE_FIXTURE_SHA256 = "
|
|
1714
|
+
# cursor/relevance-filter-followup-915b. A mismatch fails CI.
|
|
1715
|
+
RELEVANCE_FIXTURE_SHA256 = "6976ad71714be0138a6043d9bdeb724abe34a65515f8389913dca10a0c48c4f2"
|
|
1497
1716
|
|
|
1498
1717
|
|
|
1499
1718
|
def test_vendored_relevance_fixture_matches_library():
|
|
@@ -1625,6 +1844,19 @@ def test_vendored_relevance_fixture_matches_library():
|
|
|
1625
1844
|
expect = case.get("binary", case.get("expect_binary"))
|
|
1626
1845
|
assert is_binary_content(sample) is expect, case
|
|
1627
1846
|
|
|
1847
|
+
bom = data.get("bom")
|
|
1848
|
+
if bom:
|
|
1849
|
+
from patchling.relevance import _prepare_content
|
|
1850
|
+
|
|
1851
|
+
sample = bytes(bom["bytes"])
|
|
1852
|
+
binary, text = _prepare_content(sample)
|
|
1853
|
+
assert binary is False
|
|
1854
|
+
assert text == bom["text"]
|
|
1855
|
+
assert text.startswith("\ufeff")
|
|
1856
|
+
assert count_tokens(text) == bom["tokens"]
|
|
1857
|
+
assert _truncate_text(text, None, bom["maxTokens"], None) == bom["truncated"]
|
|
1858
|
+
assert _truncate_text(text, None, bom["maxTokens"], None).startswith("\ufeff")
|
|
1859
|
+
|
|
1628
1860
|
ignore = data["ignore"]
|
|
1629
1861
|
assert list(DEFAULT_SKIP_PATTERNS) == list(ignore["patterns"])
|
|
1630
1862
|
for example in ignore["examples"]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|