codecrate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codecrate/__init__.py +0 -0
- codecrate/_version.py +34 -0
- codecrate/cli.py +250 -0
- codecrate/config.py +98 -0
- codecrate/diffgen.py +110 -0
- codecrate/discover.py +113 -0
- codecrate/ids.py +17 -0
- codecrate/manifest.py +31 -0
- codecrate/markdown.py +457 -0
- codecrate/mdparse.py +145 -0
- codecrate/model.py +51 -0
- codecrate/packer.py +108 -0
- codecrate/parse.py +133 -0
- codecrate/stubber.py +82 -0
- codecrate/token_budget.py +388 -0
- codecrate/udiff.py +187 -0
- codecrate/unpacker.py +149 -0
- codecrate/validate.py +120 -0
- codecrate-0.1.0.dist-info/METADATA +357 -0
- codecrate-0.1.0.dist-info/RECORD +24 -0
- codecrate-0.1.0.dist-info/WHEEL +5 -0
- codecrate-0.1.0.dist-info/entry_points.txt +2 -0
- codecrate-0.1.0.dist-info/licenses/LICENSE +21 -0
- codecrate-0.1.0.dist-info/top_level.txt +1 -0
codecrate/__init__.py
ADDED
|
File without changes
|
codecrate/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
codecrate/cli.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .config import load_config
|
|
7
|
+
from .diffgen import generate_patch_markdown
|
|
8
|
+
from .discover import discover_files
|
|
9
|
+
from .markdown import render_markdown
|
|
10
|
+
from .packer import pack_repo
|
|
11
|
+
from .token_budget import split_by_max_chars
|
|
12
|
+
from .udiff import apply_file_diffs, parse_unified_diff
|
|
13
|
+
from .unpacker import unpack_to_dir
|
|
14
|
+
from .validate import validate_pack_markdown
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
p = argparse.ArgumentParser(
|
|
19
|
+
prog="codecrate",
|
|
20
|
+
description="Pack/unpack/patch/apply for repositories (Python + text files).",
|
|
21
|
+
)
|
|
22
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
23
|
+
|
|
24
|
+
# pack
|
|
25
|
+
pack = sub.add_parser("pack", help="Pack a repository/directory into Markdown.")
|
|
26
|
+
pack.add_argument("root", type=Path, help="Root directory to scan")
|
|
27
|
+
pack.add_argument(
|
|
28
|
+
"-o",
|
|
29
|
+
"--output",
|
|
30
|
+
type=Path,
|
|
31
|
+
default=None,
|
|
32
|
+
help="Output markdown path (default: config 'output' or context.md)",
|
|
33
|
+
)
|
|
34
|
+
pack.add_argument(
|
|
35
|
+
"--dedupe", action="store_true", help="Deduplicate identical function bodies"
|
|
36
|
+
)
|
|
37
|
+
pack.add_argument(
|
|
38
|
+
"--layout",
|
|
39
|
+
choices=["auto", "stubs", "full"],
|
|
40
|
+
default=None,
|
|
41
|
+
help="Output layout: auto|stubs|full (default: auto via config)",
|
|
42
|
+
)
|
|
43
|
+
pack.add_argument(
|
|
44
|
+
"--keep-docstrings",
|
|
45
|
+
action=argparse.BooleanOptionalAction,
|
|
46
|
+
default=None,
|
|
47
|
+
help="Keep docstrings in stubbed file view (default: true via config)",
|
|
48
|
+
)
|
|
49
|
+
pack.add_argument(
|
|
50
|
+
"--respect-gitignore",
|
|
51
|
+
action=argparse.BooleanOptionalAction,
|
|
52
|
+
default=None,
|
|
53
|
+
help="Respect .gitignore (default: true via config)",
|
|
54
|
+
)
|
|
55
|
+
pack.add_argument(
|
|
56
|
+
"--manifest",
|
|
57
|
+
action=argparse.BooleanOptionalAction,
|
|
58
|
+
default=None,
|
|
59
|
+
help="Include Manifest section (default: true via config)",
|
|
60
|
+
)
|
|
61
|
+
pack.add_argument(
|
|
62
|
+
"--include", action="append", default=None, help="Include glob (repeatable)"
|
|
63
|
+
)
|
|
64
|
+
pack.add_argument(
|
|
65
|
+
"--exclude", action="append", default=None, help="Exclude glob (repeatable)"
|
|
66
|
+
)
|
|
67
|
+
pack.add_argument(
|
|
68
|
+
"--split-max-chars",
|
|
69
|
+
type=int,
|
|
70
|
+
default=None,
|
|
71
|
+
help="Split output into .partN.md files",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# unpack
|
|
75
|
+
unpack = sub.add_parser(
|
|
76
|
+
"unpack", help="Reconstruct files from a packed context Markdown."
|
|
77
|
+
)
|
|
78
|
+
unpack.add_argument("markdown", type=Path, help="Packed Markdown file from `pack`")
|
|
79
|
+
unpack.add_argument(
|
|
80
|
+
"-o",
|
|
81
|
+
"--out-dir",
|
|
82
|
+
type=Path,
|
|
83
|
+
required=True,
|
|
84
|
+
help="Output directory for reconstructed files",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# patch
|
|
88
|
+
patch = sub.add_parser(
|
|
89
|
+
"patch",
|
|
90
|
+
help="Generate a diff-only patch Markdown from old pack + current repo.",
|
|
91
|
+
)
|
|
92
|
+
patch.add_argument(
|
|
93
|
+
"old_markdown", type=Path, help="Older packed Markdown (baseline)"
|
|
94
|
+
)
|
|
95
|
+
patch.add_argument("root", type=Path, help="Current repo root to compare against")
|
|
96
|
+
patch.add_argument(
|
|
97
|
+
"-o",
|
|
98
|
+
"--output",
|
|
99
|
+
type=Path,
|
|
100
|
+
default=Path("patch.md"),
|
|
101
|
+
help="Output patch markdown",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# apply
|
|
105
|
+
apply = sub.add_parser("apply", help="Apply a diff-only patch Markdown to a repo.")
|
|
106
|
+
apply.add_argument(
|
|
107
|
+
"patch_markdown", type=Path, help="Patch Markdown containing ```diff blocks"
|
|
108
|
+
)
|
|
109
|
+
apply.add_argument("root", type=Path, help="Repo root to apply patch to")
|
|
110
|
+
# validate-pack
|
|
111
|
+
vpack = sub.add_parser(
|
|
112
|
+
"validate-pack",
|
|
113
|
+
help="Validate a packed context Markdown (sha/markers/canonical consistency).",
|
|
114
|
+
)
|
|
115
|
+
vpack.add_argument("markdown", type=Path, help="Packed Markdown to validate")
|
|
116
|
+
vpack.add_argument(
|
|
117
|
+
"--root",
|
|
118
|
+
type=Path,
|
|
119
|
+
default=None,
|
|
120
|
+
help="Optional repo root to compare reconstructed files against",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return p
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _extract_diff_blocks(md_text: str) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Extract only diff fences from markdown and concatenate to a unified diff string.
|
|
129
|
+
"""
|
|
130
|
+
lines = md_text.splitlines()
|
|
131
|
+
out: list[str] = []
|
|
132
|
+
i = 0
|
|
133
|
+
while i < len(lines):
|
|
134
|
+
if lines[i].strip() == "```diff":
|
|
135
|
+
i += 1
|
|
136
|
+
while i < len(lines) and lines[i].strip() != "```":
|
|
137
|
+
out.append(lines[i])
|
|
138
|
+
i += 1
|
|
139
|
+
i += 1
|
|
140
|
+
return "\n".join(out) + "\n"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: list[str] | None = None) -> None:
|
|
144
|
+
parser = build_parser()
|
|
145
|
+
args = parser.parse_args(argv)
|
|
146
|
+
|
|
147
|
+
if args.cmd == "pack":
|
|
148
|
+
root: Path = args.root.resolve()
|
|
149
|
+
cfg = load_config(root)
|
|
150
|
+
|
|
151
|
+
include = args.include if args.include is not None else cfg.include
|
|
152
|
+
exclude = args.exclude if args.exclude is not None else cfg.exclude
|
|
153
|
+
|
|
154
|
+
keep_docstrings = (
|
|
155
|
+
cfg.keep_docstrings
|
|
156
|
+
if args.keep_docstrings is None
|
|
157
|
+
else bool(args.keep_docstrings)
|
|
158
|
+
)
|
|
159
|
+
include_manifest = (
|
|
160
|
+
cfg.manifest if args.manifest is None else bool(args.manifest)
|
|
161
|
+
)
|
|
162
|
+
respect_gitignore = (
|
|
163
|
+
cfg.respect_gitignore
|
|
164
|
+
if args.respect_gitignore is None
|
|
165
|
+
else bool(args.respect_gitignore)
|
|
166
|
+
)
|
|
167
|
+
dedupe = bool(args.dedupe) or bool(cfg.dedupe)
|
|
168
|
+
split_max_chars = (
|
|
169
|
+
cfg.split_max_chars
|
|
170
|
+
if args.split_max_chars is None
|
|
171
|
+
else int(args.split_max_chars or 0)
|
|
172
|
+
)
|
|
173
|
+
layout = (
|
|
174
|
+
str(args.layout).strip().lower()
|
|
175
|
+
if args.layout is not None
|
|
176
|
+
else str(getattr(cfg, "layout", "auto")).strip().lower()
|
|
177
|
+
)
|
|
178
|
+
out_path = (
|
|
179
|
+
args.output
|
|
180
|
+
if args.output is not None
|
|
181
|
+
else Path(getattr(cfg, "output", "context.md"))
|
|
182
|
+
)
|
|
183
|
+
disc = discover_files(
|
|
184
|
+
root=root,
|
|
185
|
+
include=include,
|
|
186
|
+
exclude=exclude,
|
|
187
|
+
respect_gitignore=respect_gitignore,
|
|
188
|
+
)
|
|
189
|
+
pack, canonical = pack_repo(
|
|
190
|
+
disc.root, disc.files, keep_docstrings=keep_docstrings, dedupe=dedupe
|
|
191
|
+
)
|
|
192
|
+
md = render_markdown(
|
|
193
|
+
pack, canonical, layout=layout, include_manifest=include_manifest
|
|
194
|
+
)
|
|
195
|
+
# Always write the canonical, unsplit pack
|
|
196
|
+
# for machine parsing (unpack/validate).
|
|
197
|
+
out_path.write_text(md, encoding="utf-8")
|
|
198
|
+
|
|
199
|
+
# Additionally, write split parts for LLM consumption, if requested.
|
|
200
|
+
parts = split_by_max_chars(md, out_path, split_max_chars)
|
|
201
|
+
extra = [p for p in parts if p.path != out_path]
|
|
202
|
+
for part in extra:
|
|
203
|
+
part.path.write_text(part.content, encoding="utf-8")
|
|
204
|
+
|
|
205
|
+
if extra:
|
|
206
|
+
print(f"Wrote {out_path} and {len(extra)} split part file(s).")
|
|
207
|
+
else:
|
|
208
|
+
print(f"Wrote {out_path}.")
|
|
209
|
+
elif args.cmd == "unpack":
|
|
210
|
+
md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
|
|
211
|
+
unpack_to_dir(md_text, args.out_dir)
|
|
212
|
+
print(f"Unpacked into {args.out_dir}")
|
|
213
|
+
|
|
214
|
+
elif args.cmd == "patch":
|
|
215
|
+
old_md = args.old_markdown.read_text(encoding="utf-8", errors="replace")
|
|
216
|
+
cfg = load_config(args.root)
|
|
217
|
+
patch_md = generate_patch_markdown(
|
|
218
|
+
old_md,
|
|
219
|
+
args.root,
|
|
220
|
+
include=cfg.include,
|
|
221
|
+
exclude=cfg.exclude,
|
|
222
|
+
respect_gitignore=cfg.respect_gitignore,
|
|
223
|
+
)
|
|
224
|
+
args.output.write_text(patch_md, encoding="utf-8")
|
|
225
|
+
print(f"Wrote {args.output}")
|
|
226
|
+
|
|
227
|
+
elif args.cmd == "validate-pack":
|
|
228
|
+
md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
|
|
229
|
+
report = validate_pack_markdown(md_text, root=args.root)
|
|
230
|
+
if report.warnings:
|
|
231
|
+
print("Warnings:")
|
|
232
|
+
for w in report.warnings:
|
|
233
|
+
print(f"- {w}")
|
|
234
|
+
if report.errors:
|
|
235
|
+
print("Errors:")
|
|
236
|
+
for e in report.errors:
|
|
237
|
+
print(f"- {e}")
|
|
238
|
+
raise SystemExit(1)
|
|
239
|
+
print("OK: pack is internally consistent.")
|
|
240
|
+
|
|
241
|
+
elif args.cmd == "apply":
|
|
242
|
+
md_text = args.patch_markdown.read_text(encoding="utf-8", errors="replace")
|
|
243
|
+
diff_text = _extract_diff_blocks(md_text)
|
|
244
|
+
diffs = parse_unified_diff(diff_text)
|
|
245
|
+
changed = apply_file_diffs(diffs, args.root)
|
|
246
|
+
print(f"Applied patch to {len(changed)} file(s).")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
main()
|
codecrate/config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import tomllib # py311+
|
|
9
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
10
|
+
import tomli as tomllib # type: ignore
|
|
11
|
+
|
|
12
|
+
CONFIG_FILENAMES: tuple[str, ...] = (".codecrate.toml", "codecrate.toml")
|
|
13
|
+
|
|
14
|
+
DEFAULT_INCLUDES: list[str] = [
|
|
15
|
+
"**/*.py",
|
|
16
|
+
# Common packaging + repo metadata
|
|
17
|
+
"pyproject.toml",
|
|
18
|
+
"project.toml",
|
|
19
|
+
"setup.cfg",
|
|
20
|
+
"README*",
|
|
21
|
+
"LICENSE*",
|
|
22
|
+
# Docs
|
|
23
|
+
"docs/**/*.rst",
|
|
24
|
+
"docs/**/*.md",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Config:
|
|
30
|
+
# Default output path for `codecrate pack` when CLI does not specify -o/--output
|
|
31
|
+
output: str = "context.md"
|
|
32
|
+
keep_docstrings: bool = True
|
|
33
|
+
dedupe: bool = False
|
|
34
|
+
respect_gitignore: bool = True
|
|
35
|
+
include: list[str] = field(default_factory=lambda: DEFAULT_INCLUDES.copy())
|
|
36
|
+
exclude: list[str] = field(default_factory=list)
|
|
37
|
+
split_max_chars: int = 0 # 0 means no splitting
|
|
38
|
+
# Emit the `## Manifest` section (required for unpack/patch/validate-pack).
|
|
39
|
+
# Disable only for LLM-only packs to save tokens.
|
|
40
|
+
manifest: bool = True
|
|
41
|
+
# Output layout:
|
|
42
|
+
# - "stubs": always emit stubbed files + Function Library (current format)
|
|
43
|
+
# - "full": emit full file contents (no Function Library)
|
|
44
|
+
# - "auto": use "stubs" only if dedupe actually collapses something,
|
|
45
|
+
# otherwise use "full" (best token efficiency when no duplicates)
|
|
46
|
+
layout: Literal["auto", "stubs", "full"] = "auto"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _find_config_path(root: Path) -> Path | None:
|
|
50
|
+
root = root.resolve()
|
|
51
|
+
for name in CONFIG_FILENAMES:
|
|
52
|
+
p = root / name
|
|
53
|
+
if p.exists():
|
|
54
|
+
return p
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(root: Path) -> Config:
|
|
59
|
+
cfg_path = _find_config_path(root)
|
|
60
|
+
if cfg_path is None:
|
|
61
|
+
return Config()
|
|
62
|
+
|
|
63
|
+
data = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
|
|
64
|
+
section: dict[str, Any] = (
|
|
65
|
+
data.get("codecrate", {}) if isinstance(data, dict) else {}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
cfg = Config()
|
|
69
|
+
out = section.get("output", cfg.output)
|
|
70
|
+
if isinstance(out, str) and out.strip():
|
|
71
|
+
cfg.output = out.strip()
|
|
72
|
+
cfg.keep_docstrings = bool(section.get("keep_docstrings", cfg.keep_docstrings))
|
|
73
|
+
cfg.dedupe = bool(section.get("dedupe", cfg.dedupe))
|
|
74
|
+
cfg.respect_gitignore = bool(
|
|
75
|
+
section.get("respect_gitignore", cfg.respect_gitignore)
|
|
76
|
+
)
|
|
77
|
+
man = section.get("manifest", section.get("include_manifest", cfg.manifest))
|
|
78
|
+
cfg.manifest = bool(man)
|
|
79
|
+
layout = section.get("layout", cfg.layout)
|
|
80
|
+
if isinstance(layout, str):
|
|
81
|
+
layout = layout.strip().lower()
|
|
82
|
+
if layout in {"auto", "stubs", "full"}:
|
|
83
|
+
cfg.layout = layout # type: ignore[assignment]
|
|
84
|
+
|
|
85
|
+
inc = section.get("include", cfg.include)
|
|
86
|
+
exc = section.get("exclude", cfg.exclude)
|
|
87
|
+
if isinstance(inc, list):
|
|
88
|
+
cfg.include = [str(x) for x in inc]
|
|
89
|
+
if isinstance(exc, list):
|
|
90
|
+
cfg.exclude = [str(x) for x in exc]
|
|
91
|
+
|
|
92
|
+
split = section.get("split_max_chars", cfg.split_max_chars)
|
|
93
|
+
try:
|
|
94
|
+
cfg.split_max_chars = int(split)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
return cfg
|
codecrate/diffgen.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .config import DEFAULT_INCLUDES
|
|
7
|
+
from .discover import discover_files
|
|
8
|
+
from .mdparse import parse_packed_markdown
|
|
9
|
+
from .udiff import normalize_newlines
|
|
10
|
+
from .unpacker import _apply_canonical_into_stub
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_patch_markdown(
|
|
14
|
+
old_pack_md: str,
|
|
15
|
+
root: Path,
|
|
16
|
+
*,
|
|
17
|
+
include: list[str] | None = None,
|
|
18
|
+
exclude: list[str] | None = None,
|
|
19
|
+
respect_gitignore: bool = True,
|
|
20
|
+
) -> str:
|
|
21
|
+
# If caller doesn't pass include/exclude, use the same defaults as Config.
|
|
22
|
+
include = DEFAULT_INCLUDES.copy() if include is None else list(include)
|
|
23
|
+
exclude = [] if exclude is None else list(exclude)
|
|
24
|
+
|
|
25
|
+
packed = parse_packed_markdown(old_pack_md)
|
|
26
|
+
manifest = packed.manifest
|
|
27
|
+
root = root.resolve()
|
|
28
|
+
|
|
29
|
+
blocks: list[str] = []
|
|
30
|
+
blocks.append("# Codecrate Patch\n\n")
|
|
31
|
+
# Do not leak absolute local paths; keep the header root stable + relative.
|
|
32
|
+
blocks.append("Root: `.`\n\n")
|
|
33
|
+
blocks.append("This file contains unified diffs inside ```diff code fences.\n\n")
|
|
34
|
+
|
|
35
|
+
any_changes = False
|
|
36
|
+
|
|
37
|
+
old_paths = {f["path"] for f in manifest.get("files", []) if "path" in f}
|
|
38
|
+
for f in manifest.get("files", []):
|
|
39
|
+
rel = f["path"]
|
|
40
|
+
stub = packed.stubbed_files.get(rel)
|
|
41
|
+
if stub is None:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
old_text = _apply_canonical_into_stub(
|
|
45
|
+
stub, f.get("defs", []), packed.canonical_sources
|
|
46
|
+
)
|
|
47
|
+
old_text = normalize_newlines(old_text)
|
|
48
|
+
|
|
49
|
+
cur_path = root / rel
|
|
50
|
+
if not cur_path.exists():
|
|
51
|
+
# treat as deleted in current
|
|
52
|
+
diff = difflib.unified_diff(
|
|
53
|
+
old_text.splitlines(),
|
|
54
|
+
[],
|
|
55
|
+
fromfile=f"a/{rel}",
|
|
56
|
+
tofile="/dev/null",
|
|
57
|
+
lineterm="",
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
new_text = normalize_newlines(
|
|
61
|
+
cur_path.read_text(encoding="utf-8", errors="replace")
|
|
62
|
+
)
|
|
63
|
+
diff = difflib.unified_diff(
|
|
64
|
+
old_text.splitlines(),
|
|
65
|
+
new_text.splitlines(),
|
|
66
|
+
fromfile=f"a/{rel}",
|
|
67
|
+
tofile=f"b/{rel}",
|
|
68
|
+
lineterm="",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
diff_lines = list(diff)
|
|
72
|
+
if diff_lines:
|
|
73
|
+
any_changes = True
|
|
74
|
+
blocks.append(f"## `{rel}`\n\n")
|
|
75
|
+
blocks.append("```diff\n")
|
|
76
|
+
blocks.append("\n".join(diff_lines) + "\n")
|
|
77
|
+
blocks.append("```\n\n")
|
|
78
|
+
|
|
79
|
+
# Added files (present in current repo, not in baseline manifest)
|
|
80
|
+
disc = discover_files(
|
|
81
|
+
root,
|
|
82
|
+
include=include,
|
|
83
|
+
exclude=exclude,
|
|
84
|
+
respect_gitignore=respect_gitignore,
|
|
85
|
+
)
|
|
86
|
+
for p in disc.files:
|
|
87
|
+
rel = p.relative_to(root).as_posix()
|
|
88
|
+
if rel in old_paths:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
new_text = normalize_newlines(p.read_text(encoding="utf-8", errors="replace"))
|
|
92
|
+
diff = difflib.unified_diff(
|
|
93
|
+
[],
|
|
94
|
+
new_text.splitlines(),
|
|
95
|
+
fromfile="/dev/null",
|
|
96
|
+
tofile=f"b/{rel}",
|
|
97
|
+
lineterm="",
|
|
98
|
+
)
|
|
99
|
+
diff_lines = list(diff)
|
|
100
|
+
if diff_lines:
|
|
101
|
+
any_changes = True
|
|
102
|
+
blocks.append(f"## `{rel}`\n\n")
|
|
103
|
+
blocks.append("```diff\n")
|
|
104
|
+
blocks.append("\n".join(diff_lines) + "\n")
|
|
105
|
+
blocks.append("```\n\n")
|
|
106
|
+
|
|
107
|
+
if not any_changes:
|
|
108
|
+
blocks.append("_No changes detected._\n")
|
|
109
|
+
|
|
110
|
+
return "".join(blocks)
|
codecrate/discover.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pathspec
|
|
7
|
+
|
|
8
|
+
DEFAULT_EXCLUDES = [
|
|
9
|
+
"**/__pycache__/**",
|
|
10
|
+
"**/*.pyc",
|
|
11
|
+
"**/.git/**",
|
|
12
|
+
"**/.venv/**",
|
|
13
|
+
"**/venv/**",
|
|
14
|
+
"**/.tox/**",
|
|
15
|
+
"**/.pytest_cache/**",
|
|
16
|
+
"**/build/**",
|
|
17
|
+
"**/dist/**",
|
|
18
|
+
"**/_version.py",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Discovery:
|
|
24
|
+
files: list[Path]
|
|
25
|
+
root: Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_gitignore(root: Path) -> pathspec.PathSpec:
|
|
29
|
+
gi = root / ".gitignore"
|
|
30
|
+
if not gi.exists():
|
|
31
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", [])
|
|
32
|
+
return pathspec.PathSpec.from_lines(
|
|
33
|
+
"gitwildmatch", gi.read_text(encoding="utf-8").splitlines()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def discover_files(
|
|
38
|
+
root: Path,
|
|
39
|
+
include: list[str] | None,
|
|
40
|
+
exclude: list[str] | None,
|
|
41
|
+
respect_gitignore: bool = True,
|
|
42
|
+
) -> Discovery:
|
|
43
|
+
"""Discover repository files matching include/exclude patterns.
|
|
44
|
+
|
|
45
|
+
Unlike discover_python_files, this scans *all* files (not just *.py). This is
|
|
46
|
+
useful for packing metadata and docs files (e.g. pyproject.toml, *.rst).
|
|
47
|
+
"""
|
|
48
|
+
root = root.resolve()
|
|
49
|
+
|
|
50
|
+
gi = (
|
|
51
|
+
_load_gitignore(root)
|
|
52
|
+
if respect_gitignore
|
|
53
|
+
else pathspec.PathSpec.from_lines("gitwildmatch", [])
|
|
54
|
+
)
|
|
55
|
+
inc = pathspec.PathSpec.from_lines("gitwildmatch", include or ["**/*.py"])
|
|
56
|
+
|
|
57
|
+
effective_exclude = DEFAULT_EXCLUDES + (exclude or [])
|
|
58
|
+
exc = pathspec.PathSpec.from_lines("gitwildmatch", effective_exclude)
|
|
59
|
+
|
|
60
|
+
out: list[Path] = []
|
|
61
|
+
for p in root.rglob("*"):
|
|
62
|
+
if not p.is_file():
|
|
63
|
+
continue
|
|
64
|
+
rel = p.relative_to(root)
|
|
65
|
+
rel_s = rel.as_posix()
|
|
66
|
+
|
|
67
|
+
if respect_gitignore and gi.match_file(rel_s):
|
|
68
|
+
continue
|
|
69
|
+
if not inc.match_file(rel_s):
|
|
70
|
+
continue
|
|
71
|
+
if exc.match_file(rel_s):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
out.append(p)
|
|
75
|
+
|
|
76
|
+
out.sort()
|
|
77
|
+
return Discovery(files=out, root=root)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def discover_python_files(
|
|
81
|
+
root: Path,
|
|
82
|
+
include: list[str] | None,
|
|
83
|
+
exclude: list[str] | None,
|
|
84
|
+
respect_gitignore: bool = True,
|
|
85
|
+
) -> Discovery:
|
|
86
|
+
root = root.resolve()
|
|
87
|
+
|
|
88
|
+
gi = (
|
|
89
|
+
_load_gitignore(root)
|
|
90
|
+
if respect_gitignore
|
|
91
|
+
else pathspec.PathSpec.from_lines("gitwildmatch", [])
|
|
92
|
+
)
|
|
93
|
+
inc = pathspec.PathSpec.from_lines("gitwildmatch", include or ["**/*.py"])
|
|
94
|
+
|
|
95
|
+
effective_exclude = DEFAULT_EXCLUDES + (exclude or [])
|
|
96
|
+
exc = pathspec.PathSpec.from_lines("gitwildmatch", effective_exclude)
|
|
97
|
+
|
|
98
|
+
out: list[Path] = []
|
|
99
|
+
for p in root.rglob("*.py"):
|
|
100
|
+
rel = p.relative_to(root)
|
|
101
|
+
rel_s = rel.as_posix()
|
|
102
|
+
|
|
103
|
+
if respect_gitignore and gi.match_file(rel_s):
|
|
104
|
+
continue
|
|
105
|
+
if not inc.match_file(rel_s):
|
|
106
|
+
continue
|
|
107
|
+
if exc.match_file(rel_s):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
out.append(p)
|
|
111
|
+
|
|
112
|
+
out.sort()
|
|
113
|
+
return Discovery(files=out, root=root)
|
codecrate/ids.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def stable_location_id(path: Path, qualname: str, lineno: int) -> str:
|
|
8
|
+
payload = f"{path.as_posix()}::{qualname}::{lineno}".encode()
|
|
9
|
+
return hashlib.sha1(payload).hexdigest()[:8].upper()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stable_body_hash(code: str) -> str:
|
|
13
|
+
norm = "\n".join(
|
|
14
|
+
line.rstrip()
|
|
15
|
+
for line in code.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
16
|
+
).strip()
|
|
17
|
+
return hashlib.sha1(norm.encode("utf-8")).hexdigest().upper()
|
codecrate/manifest.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .model import PackResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_manifest(pack: PackResult, *, minimal: bool = False) -> dict[str, Any]:
|
|
11
|
+
def sha256_text(s: str) -> str:
|
|
12
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
13
|
+
|
|
14
|
+
files = []
|
|
15
|
+
for fp in pack.files:
|
|
16
|
+
rel = fp.path.relative_to(pack.root).as_posix()
|
|
17
|
+
entry: dict[str, Any] = {
|
|
18
|
+
"path": rel,
|
|
19
|
+
"line_count": fp.line_count,
|
|
20
|
+
"sha256_original": sha256_text(fp.original_text),
|
|
21
|
+
}
|
|
22
|
+
if not minimal:
|
|
23
|
+
entry |= {
|
|
24
|
+
"module": fp.module,
|
|
25
|
+
"sha256_stubbed": sha256_text(fp.stubbed_text),
|
|
26
|
+
"classes": [asdict(c) | {"path": rel} for c in fp.classes],
|
|
27
|
+
"defs": [asdict(d) | {"path": rel} for d in fp.defs],
|
|
28
|
+
}
|
|
29
|
+
files.append(entry)
|
|
30
|
+
# Root is already shown at the top of the pack; keep manifest root stable + short.
|
|
31
|
+
return {"format": "codecrate.v4", "root": ".", "files": files}
|