git-paoding 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.
@@ -0,0 +1,220 @@
1
+ """Pure, deterministic attribution reconciliation for Base-anchored atoms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Iterable, Sequence
7
+
8
+ from git_paoding.core.model import Atom, AtomKind, AtomState
9
+
10
+
11
+ class ReconcileResult(tuple[Atom, ...]):
12
+ """Reconciled atoms plus the ids that received the optional focus prior.
13
+
14
+ The result deliberately remains a tuple subtype so existing consumers of
15
+ ``reconcile`` keep their frozen sequence behavior. The additional report
16
+ metadata is internal and does not change a persistent or JSON model.
17
+ """
18
+
19
+ defaulted_atom_ids: tuple[str, ...]
20
+
21
+ def __new__(
22
+ cls,
23
+ atoms: Iterable[Atom] = (),
24
+ *,
25
+ defaulted_atom_ids: Iterable[str] = (),
26
+ ) -> ReconcileResult:
27
+ result = super().__new__(cls, atoms)
28
+ result.defaulted_atom_ids = tuple(defaulted_atom_ids)
29
+ return result
30
+
31
+
32
+ def _base_range_key(atom: Atom) -> tuple[str, int, int, int]:
33
+ """Return the exact, non-fuzzy identity of an atom's Base range."""
34
+
35
+ return (atom.path, atom.base_start, atom.base_len, atom.gap_seq)
36
+
37
+
38
+ def _is_insertion(atom: Atom) -> bool:
39
+ """Return whether an atom is anchored at a text-file Base gap."""
40
+
41
+ return atom.base_len == 0 and atom.kind is not AtomKind.WHOLE_FILE
42
+
43
+
44
+ def _ranges_overlap(left: Atom, right: Atom) -> bool:
45
+ """Return whether two positive-width Base ranges overlap."""
46
+
47
+ if left.path != right.path or left.base_len == 0 or right.base_len == 0:
48
+ return False
49
+ left_end = left.base_start + left.base_len
50
+ right_end = right.base_start + right.base_len
51
+ return left.base_start < right_end and right.base_start < left_end
52
+
53
+
54
+ def _with_owner(new_atom: Atom, old_atom: Atom, *, exact: bool) -> Atom:
55
+ """Carry one confident owner to a new atom and choose its visible state."""
56
+
57
+ state = (
58
+ AtomState.ASSIGNED
59
+ if exact and old_atom.content_hash == new_atom.content_hash
60
+ else AtomState.UPDATED
61
+ )
62
+ return new_atom.model_copy(update={"owner": old_atom.owner, "state": state})
63
+
64
+
65
+ def _without_owner(new_atom: Atom, state: AtomState) -> Atom:
66
+ return new_atom.model_copy(update={"owner": None, "state": state})
67
+
68
+
69
+ def _with_focus(new_atom: Atom, focus_slice: str | None) -> tuple[Atom, bool]:
70
+ if focus_slice is None:
71
+ return _without_owner(new_atom, AtomState.UNASSIGNED), False
72
+ return (
73
+ new_atom.model_copy(update={"owner": focus_slice, "state": AtomState.ASSIGNED}),
74
+ True,
75
+ )
76
+
77
+
78
+ def _match_insertions(
79
+ old_atoms: Sequence[Atom],
80
+ new_atoms: Sequence[Atom],
81
+ ) -> dict[int, Atom | None]:
82
+ """Match same-gap insertions one-to-one, preferring unchanged content.
83
+
84
+ Content matches are allocated for the complete gap before positional
85
+ ``gap_seq`` matches. This preserves attribution when another insertion at
86
+ the same gap is added, removed, or reordered. Duplicate old candidates
87
+ for the same content/sequence are treated as ambiguous instead of guessed.
88
+ """
89
+
90
+ matches: dict[int, Atom | None] = {}
91
+ old_by_gap: dict[tuple[str, int], list[Atom]] = defaultdict(list)
92
+ new_by_gap: dict[tuple[str, int], list[tuple[int, Atom]]] = defaultdict(list)
93
+ for old_atom in old_atoms:
94
+ if _is_insertion(old_atom):
95
+ old_by_gap[(old_atom.path, old_atom.base_start)].append(old_atom)
96
+ for index, new_atom in enumerate(new_atoms):
97
+ if _is_insertion(new_atom):
98
+ new_by_gap[(new_atom.path, new_atom.base_start)].append((index, new_atom))
99
+
100
+ for gap, new_group in new_by_gap.items():
101
+ old_group = sorted(
102
+ old_by_gap.get(gap, ()),
103
+ key=lambda atom: (atom.gap_seq, atom.atom_id),
104
+ )
105
+ unused = set(range(len(old_group)))
106
+
107
+ # First retain byte-identical insertions even if their sequence changed.
108
+ for new_index, new_atom in new_group:
109
+ content_candidates = [
110
+ candidate_index
111
+ for candidate_index in unused
112
+ if old_group[candidate_index].content_hash == new_atom.content_hash
113
+ ]
114
+ if not content_candidates:
115
+ continue
116
+ exact_sequence = [
117
+ candidate_index
118
+ for candidate_index in content_candidates
119
+ if old_group[candidate_index].gap_seq == new_atom.gap_seq
120
+ ]
121
+ candidates = exact_sequence or content_candidates
122
+ if len(candidates) != 1:
123
+ matches[new_index] = None
124
+ continue
125
+ candidate_index = candidates[0]
126
+ unused.remove(candidate_index)
127
+ matches[new_index] = old_group[candidate_index]
128
+
129
+ # Only then use the stable positional identity for changed content.
130
+ for new_index, new_atom in new_group:
131
+ if new_index in matches:
132
+ continue
133
+ sequence_candidates = [
134
+ candidate_index
135
+ for candidate_index in unused
136
+ if old_group[candidate_index].gap_seq == new_atom.gap_seq
137
+ ]
138
+ if len(sequence_candidates) != 1:
139
+ if len(sequence_candidates) > 1:
140
+ matches[new_index] = None
141
+ continue
142
+ candidate_index = sequence_candidates[0]
143
+ unused.remove(candidate_index)
144
+ matches[new_index] = old_group[candidate_index]
145
+
146
+ return matches
147
+
148
+
149
+ def reconcile(
150
+ old_atoms: Sequence[Atom],
151
+ new_atoms: Sequence[Atom],
152
+ *,
153
+ focus_slice: str | None = None,
154
+ ) -> ReconcileResult:
155
+ """Reconcile current atoms with prior ownership without fuzzy matching.
156
+
157
+ Exact Base ranges retain their owner, while a positive-width range that
158
+ overlaps exactly one prior owned range inherits that owner as ``updated``.
159
+ Multiple overlaps are ambiguous. New atoms remain unassigned unless a
160
+ focus prior is supplied; ids assigned by that prior are exposed on the
161
+ returned tuple's ``defaulted_atom_ids`` report field.
162
+
163
+ Previously stored atoms absent from ``new_atoms`` naturally disappear.
164
+ The function reads no repository or session state and mutates no input.
165
+ """
166
+
167
+ owned_atoms = tuple(atom for atom in old_atoms if atom.owner is not None)
168
+ insertion_matches = _match_insertions(old_atoms, new_atoms)
169
+ defaulted_atom_ids: list[str] = []
170
+ reconciled: list[Atom] = []
171
+
172
+ for index, new_atom in enumerate(new_atoms):
173
+ if _is_insertion(new_atom):
174
+ if index in insertion_matches:
175
+ matched_atom = insertion_matches[index]
176
+ if matched_atom is None:
177
+ reconciled.append(_without_owner(new_atom, AtomState.AMBIGUOUS))
178
+ elif matched_atom.owner is None:
179
+ atom, defaulted = _with_focus(new_atom, focus_slice)
180
+ reconciled.append(atom)
181
+ if defaulted:
182
+ defaulted_atom_ids.append(new_atom.atom_id)
183
+ else:
184
+ reconciled.append(_with_owner(new_atom, matched_atom, exact=True))
185
+ continue
186
+
187
+ atom, defaulted = _with_focus(new_atom, focus_slice)
188
+ reconciled.append(atom)
189
+ if defaulted:
190
+ defaulted_atom_ids.append(new_atom.atom_id)
191
+ continue
192
+
193
+ exact_matches = [
194
+ old_atom
195
+ for old_atom in owned_atoms
196
+ if not _is_insertion(old_atom)
197
+ and _base_range_key(old_atom) == _base_range_key(new_atom)
198
+ ]
199
+ overlap_matches = [
200
+ old_atom
201
+ for old_atom in owned_atoms
202
+ if not _is_insertion(old_atom) and _ranges_overlap(old_atom, new_atom)
203
+ ]
204
+ candidates = exact_matches + [
205
+ old_atom for old_atom in overlap_matches if old_atom not in exact_matches
206
+ ]
207
+
208
+ if len(candidates) == 1:
209
+ reconciled.append(
210
+ _with_owner(new_atom, candidates[0], exact=candidates[0] in exact_matches)
211
+ )
212
+ elif len(candidates) > 1:
213
+ reconciled.append(_without_owner(new_atom, AtomState.AMBIGUOUS))
214
+ else:
215
+ atom, defaulted = _with_focus(new_atom, focus_slice)
216
+ reconciled.append(atom)
217
+ if defaulted:
218
+ defaulted_atom_ids.append(new_atom.atom_id)
219
+
220
+ return ReconcileResult(reconciled, defaulted_atom_ids=defaulted_atom_ids)
@@ -0,0 +1,279 @@
1
+ """Resolve author-facing selectors into atomic attribution updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Collection, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from difflib import get_close_matches
9
+ from pathlib import PurePosixPath
10
+
11
+ from git_paoding.core.model import (
12
+ AssignmentRecord,
13
+ AssignResult,
14
+ Atom,
15
+ AtomState,
16
+ PaodingError,
17
+ )
18
+
19
+ _ATOM_ID_RE = re.compile(r"^[0-9a-f]{8}(?:-[1-9][0-9]*)?$")
20
+ _RANGE_RE = re.compile(r"^(?P<path>.+):(?P<start>[1-9][0-9]*)-(?P<end>[1-9][0-9]*)$")
21
+ _GLOB_CHARACTERS = frozenset("*?[")
22
+ _HINT_LIMIT = 3
23
+
24
+
25
+ class SelectorError(PaodingError):
26
+ """Base class for selector validation and resolution failures."""
27
+
28
+
29
+ class SelectorNotFoundError(SelectorError):
30
+ """An input could not be resolved to any atom in the current diff."""
31
+
32
+
33
+ class SelectorConflictError(SelectorError):
34
+ """Raised when one batch assigns the same atom to different slices."""
35
+
36
+
37
+ class UnknownBatchSliceError(SelectorError):
38
+ """Raised when a batch names a slice outside the active slice set."""
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class _ResolvedSelector:
43
+ indexes: tuple[int, ...]
44
+ explicit_atom_id: bool
45
+
46
+
47
+ def _display_final_range(atom: Atom) -> str:
48
+ if atom.final_len == 0:
49
+ return f"gap@{atom.final_start}"
50
+ return f"{atom.final_start}-{atom.final_start + atom.final_len - 1}"
51
+
52
+
53
+ def _repository_path(value: str) -> str:
54
+ return value[2:] if value.startswith("./") else value
55
+
56
+
57
+ def _nearby_path_hints(atoms: Sequence[Atom], selector: str) -> str:
58
+ paths = list(dict.fromkeys(atom.path for atom in atoms))
59
+ normalized = selector.rstrip("/")
60
+ parent = normalized.rpartition("/")[0]
61
+ candidates = [path for path in paths if parent and path.startswith(f"{parent}/")]
62
+ if not candidates:
63
+ candidates = get_close_matches(normalized, paths, n=_HINT_LIMIT, cutoff=0.25)
64
+ if not candidates:
65
+ candidates = paths[:_HINT_LIMIT]
66
+ if not candidates:
67
+ return " The current diff contains no atoms."
68
+ return f" Nearby paths: {', '.join(repr(path) for path in candidates[:_HINT_LIMIT])}."
69
+
70
+
71
+ def _nearby_range_hints(atoms: Sequence[Atom], *, path: str, start: int, end: int) -> str:
72
+ same_file = [atom for atom in atoms if atom.path == path]
73
+ if not same_file:
74
+ return _nearby_path_hints(atoms, path)
75
+
76
+ def distance(atom: Atom) -> int:
77
+ if atom.final_len == 0:
78
+ return min(abs(atom.final_start - start), abs(atom.final_start - end))
79
+ atom_start = atom.final_start
80
+ atom_end = atom.final_start + atom.final_len - 1
81
+ return min(abs(atom_start - end), abs(start - atom_end))
82
+
83
+ nearby = sorted(same_file, key=lambda atom: (distance(atom), atom.final_start, atom.atom_id))
84
+ rendered = ", ".join(
85
+ f"{atom.atom_id} final:{_display_final_range(atom)}" for atom in nearby[:_HINT_LIMIT]
86
+ )
87
+ return f" Nearby atoms in {path!r}: {rendered}."
88
+
89
+
90
+ def _resolve_selector(atoms: Sequence[Atom], selector: str) -> _ResolvedSelector:
91
+ if not selector:
92
+ raise SelectorError("Selectors must not be empty")
93
+
94
+ by_id = {atom.atom_id: index for index, atom in enumerate(atoms)}
95
+ if selector in by_id:
96
+ return _ResolvedSelector((by_id[selector],), explicit_atom_id=True)
97
+
98
+ if _ATOM_ID_RE.fullmatch(selector):
99
+ current_ids = ", ".join(atom.atom_id for atom in atoms[:_HINT_LIMIT]) or "(none)"
100
+ raise SelectorNotFoundError(
101
+ f"Atom id {selector!r} is stale or unknown; run `git-paoding status` again. "
102
+ f"Current atom ids include: {current_ids}."
103
+ )
104
+
105
+ normalized_selector = _repository_path(selector)
106
+ exact_path = tuple(
107
+ index for index, atom in enumerate(atoms) if atom.path == normalized_selector
108
+ )
109
+ if exact_path:
110
+ return _ResolvedSelector(exact_path, explicit_atom_id=False)
111
+
112
+ range_match = _RANGE_RE.fullmatch(selector)
113
+ if range_match is not None:
114
+ path = _repository_path(range_match.group("path"))
115
+ start = int(range_match.group("start"))
116
+ end = int(range_match.group("end"))
117
+ if end < start:
118
+ raise SelectorError(
119
+ f"Invalid Final line range {selector!r}: end must be greater than or equal to start"
120
+ )
121
+ indexes = tuple(
122
+ index
123
+ for index, atom in enumerate(atoms)
124
+ if atom.path == path
125
+ and atom.final_len > 0
126
+ and atom.final_start <= end
127
+ and atom.final_start + atom.final_len - 1 >= start
128
+ )
129
+ if not indexes:
130
+ raise SelectorNotFoundError(
131
+ f"Final-coordinate range {selector!r} matched no atom."
132
+ + _nearby_range_hints(atoms, path=path, start=start, end=end)
133
+ )
134
+ return _ResolvedSelector(indexes, explicit_atom_id=False)
135
+
136
+ normalized_directory = _repository_path(selector.removesuffix("/"))
137
+ directory_matches = tuple(
138
+ index
139
+ for index, atom in enumerate(atoms)
140
+ if normalized_directory in {"", "."} or atom.path.startswith(f"{normalized_directory}/")
141
+ )
142
+ if directory_matches:
143
+ return _ResolvedSelector(directory_matches, explicit_atom_id=False)
144
+
145
+ if any(character in selector for character in _GLOB_CHARACTERS):
146
+ normalized_glob = _repository_path(selector)
147
+ glob_matches = tuple(
148
+ index
149
+ for index, atom in enumerate(atoms)
150
+ if PurePosixPath(atom.path).match(normalized_glob)
151
+ )
152
+ if glob_matches:
153
+ return _ResolvedSelector(glob_matches, explicit_atom_id=False)
154
+
155
+ raise SelectorNotFoundError(
156
+ f"Selector {selector!r} matched no atom in the current diff by id, path, directory, "
157
+ "glob, or line range." + _nearby_path_hints(atoms, selector)
158
+ )
159
+
160
+
161
+ def _resolve_selectors(
162
+ atoms: Sequence[Atom], selectors: Sequence[str]
163
+ ) -> tuple[tuple[int, bool], ...]:
164
+ if not selectors:
165
+ raise SelectorError(
166
+ "At least one atom id, path, directory, glob, or line-range selector is required"
167
+ )
168
+
169
+ selected: dict[int, bool] = {}
170
+ for selector in selectors:
171
+ resolved = _resolve_selector(atoms, selector)
172
+ for index in resolved.indexes:
173
+ selected[index] = selected.get(index, False) or resolved.explicit_atom_id
174
+ return tuple(selected.items())
175
+
176
+
177
+ def _assignment_record(atom: Atom, *, owner: str, previous_owner: str | None) -> AssignmentRecord:
178
+ return AssignmentRecord(
179
+ atom_id=atom.atom_id,
180
+ path=atom.path,
181
+ previous_owner=previous_owner,
182
+ owner=owner,
183
+ preview=atom.preview,
184
+ )
185
+
186
+
187
+ def _apply_plan(
188
+ atoms: Sequence[Atom],
189
+ plan: Sequence[tuple[int, str, bool]],
190
+ *,
191
+ force: bool,
192
+ ) -> tuple[tuple[Atom, ...], AssignResult]:
193
+ updated_atoms = list(atoms)
194
+ assigned: list[AssignmentRecord] = []
195
+ skipped: list[AssignmentRecord] = []
196
+
197
+ for index, slice_id, explicit_atom_id in plan:
198
+ atom = updated_atoms[index]
199
+ previous_owner = atom.owner
200
+ if previous_owner == slice_id:
201
+ skipped.append(_assignment_record(atom, owner=slice_id, previous_owner=previous_owner))
202
+ continue
203
+ if previous_owner is not None and not (force or explicit_atom_id):
204
+ skipped.append(
205
+ _assignment_record(atom, owner=previous_owner, previous_owner=previous_owner)
206
+ )
207
+ continue
208
+
209
+ updated_atoms[index] = atom.model_copy(
210
+ update={"owner": slice_id, "state": AtomState.ASSIGNED}
211
+ )
212
+ assigned.append(_assignment_record(atom, owner=slice_id, previous_owner=previous_owner))
213
+
214
+ return tuple(updated_atoms), AssignResult(assigned=assigned, skipped=skipped)
215
+
216
+
217
+ def assign_selectors(
218
+ atoms: Sequence[Atom],
219
+ *,
220
+ slice_id: str,
221
+ selectors: Sequence[str],
222
+ force: bool = False,
223
+ ) -> tuple[tuple[Atom, ...], AssignResult]:
224
+ """Resolve selectors and assign their atoms to one slice atomically.
225
+
226
+ Exact atom ids may take ownership without ``force``. Broader selectors
227
+ preserve already-owned atoms unless ``force`` is true. Every selector is
228
+ resolved before any updated atom tuple is produced.
229
+ """
230
+
231
+ resolved = _resolve_selectors(atoms, selectors)
232
+ plan = tuple((index, slice_id, explicit_atom_id) for index, explicit_atom_id in resolved)
233
+ return _apply_plan(atoms, plan, force=force)
234
+
235
+
236
+ def assign_batch_selectors(
237
+ atoms: Sequence[Atom],
238
+ *,
239
+ assignments: Mapping[str, Sequence[str]],
240
+ active_slice_ids: Collection[str],
241
+ force: bool = False,
242
+ ) -> tuple[tuple[Atom, ...], AssignResult]:
243
+ """Validate and resolve an entire multi-slice assignment plan before applying it.
244
+
245
+ A batch that names an unknown slice, contains an invalid selector, or assigns
246
+ one atom to different slices fails without returning any mutated atoms.
247
+ """
248
+
249
+ if not assignments:
250
+ raise SelectorError("Batch assignments must contain at least one slice")
251
+
252
+ active = set(active_slice_ids)
253
+ unknown = [slice_id for slice_id in assignments if slice_id not in active]
254
+ if unknown:
255
+ rendered = ", ".join(repr(slice_id) for slice_id in unknown)
256
+ raise UnknownBatchSliceError(f"Batch names unknown or inactive slices: {rendered}")
257
+
258
+ plan_by_index: dict[int, tuple[str, bool]] = {}
259
+ ordered_indexes: list[int] = []
260
+ for slice_id, selectors in assignments.items():
261
+ resolved = _resolve_selectors(atoms, selectors)
262
+ for index, explicit_atom_id in resolved:
263
+ prior = plan_by_index.get(index)
264
+ if prior is not None and prior[0] != slice_id:
265
+ atom = atoms[index]
266
+ raise SelectorConflictError(
267
+ f"Batch assigns atom {atom.atom_id!r} ({atom.path}) to both "
268
+ f"{prior[0]!r} and {slice_id!r}"
269
+ )
270
+ if prior is None:
271
+ ordered_indexes.append(index)
272
+ plan_by_index[index] = (slice_id, explicit_atom_id)
273
+ else:
274
+ plan_by_index[index] = (slice_id, prior[1] or explicit_atom_id)
275
+
276
+ plan = tuple(
277
+ (index, plan_by_index[index][0], plan_by_index[index][1]) for index in ordered_indexes
278
+ )
279
+ return _apply_plan(atoms, plan, force=force)
@@ -0,0 +1 @@
1
+ """GitHub integration package."""
@@ -0,0 +1,53 @@
1
+ """Backend-neutral GitHub pull-request operations.
2
+
3
+ The protocol is intentionally small: core publishing code should know about
4
+ pull requests, but never about ``gh`` arguments or JSON response shapes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol, runtime_checkable
10
+
11
+ from git_paoding.core.model import PaodingError, PRRecord
12
+
13
+
14
+ class GitHubBackendError(PaodingError):
15
+ """Base class for failures at the GitHub backend boundary."""
16
+
17
+
18
+ class DuplicatePullRequestMarkerError(GitHubBackendError):
19
+ """Raised when one stable slice marker identifies multiple open PRs."""
20
+
21
+
22
+ class PullRequestNotFoundError(GitHubBackendError):
23
+ """Raised when a requested pull-request identity no longer exists."""
24
+
25
+
26
+ @runtime_checkable
27
+ class GitHubBackend(Protocol):
28
+ """Thin interface consumed by publish and archive orchestration."""
29
+
30
+ def check_ready(self) -> None:
31
+ """Verify that the backend is installed, supported, and authenticated."""
32
+
33
+ def create_draft_pr(
34
+ self,
35
+ *,
36
+ title: str,
37
+ body: str,
38
+ base_ref: str,
39
+ head_ref: str,
40
+ ) -> PRRecord:
41
+ """Create a draft pull request and return its backend-neutral record."""
42
+
43
+ def update_pr(self, number: int, *, title: str, body: str) -> PRRecord:
44
+ """Replace the title and body of an existing pull request."""
45
+
46
+ def close_pr(self, number: int) -> PRRecord:
47
+ """Close an existing pull request without deleting its history."""
48
+
49
+ def get_pr(self, number: int) -> PRRecord:
50
+ """Return one pull request by number."""
51
+
52
+ def list_open_prs(self) -> list[PRRecord]:
53
+ """Return open pull requests with body text available for marker search."""