repolish 0.2.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repolish
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Maintain consistency across repositories
5
5
  Author: hotdog-werx
6
6
  License-Expression: MIT
@@ -119,6 +119,15 @@ like `python -c "open('file','w').write('x')"`.
119
119
  - list/array of argv parts (e.g. `['prettier', '--write', '.']`) — recommended
120
120
  when you want to avoid any ambiguity about argument parsing and quoting.
121
121
 
122
+ Platform note
123
+
124
+ - On Windows the tokenization rules differ; Repolish uses Python's `shlex` to
125
+ split string commands into argv lists before executing them without a shell.
126
+ Because quoting/escaping differs between POSIX shells and Windows, prefer the
127
+ argv/list form in `post_process` on Windows to avoid surprises. If you must
128
+ provide a single string entry, keep arguments simple (no shell metacharacters)
129
+ or call a committed script from the argv form.
130
+
122
131
  Security note
123
132
 
124
133
  We intentionally avoid running commands with `shell=True` to reduce shell
@@ -264,6 +273,89 @@ Notes and tips
264
273
  - Anchors are processed before cookiecutter rendering, so template substitutions
265
274
  still work around preserved sections.
266
275
 
276
+ ## Regex anchors and capture groups
277
+
278
+ Regex anchors let you specify exactly what to preserve from a local file. Two
279
+ important behaviors to know:
280
+
281
+ - Capture group preference: If your regex includes a capturing group
282
+ (parentheses), Repolish will prefer the first capture group (group 1) as the
283
+ block to insert into the template. If there are no capture groups, Repolish
284
+ falls back to the entire match (group 0).
285
+ - Safeguard trimming: As a conservative safeguard Repolish trims captured blocks
286
+ to a contiguous region based on indentation so that incidental following
287
+ sections are not accidentally pulled into the template. However, the canonical
288
+ way to express intent is an explicit capture group — authors should prefer to
289
+ capture exactly what they mean.
290
+
291
+ Implementation note
292
+
293
+ - Repolish computes the absolute span of the selected capture (group 1 when
294
+ present, otherwise the full match) inside the template and trims that slice
295
+ using an indentation-aware heuristic before replacing it with the trimmed
296
+ content extracted from the local file. Also note that the
297
+ `## repolish-regex[...]` declaration line is removed from the template prior
298
+ to matching and replacement. This avoids accidentally removing unrelated
299
+ template sections when patterns include surrounding context.
300
+
301
+ Example
302
+
303
+ Template (excerpt):
304
+
305
+ ```toml
306
+ cat1:
307
+ - line1
308
+ - line2
309
+ ## repolish-regex[cat1-filter]: (^\s*# cat1-filter-additional-paths.*\n(?:\s+.*\n)*)
310
+ # cat1-filter-additional-paths
311
+
312
+ cat2:
313
+ - from-template
314
+ ## repolish-regex[cat2-filter]: (^\s*# cat2-filter-additional-paths.*\n(?:\s+.*\n)*)
315
+ # cat2-filter-additional-paths
316
+
317
+ cat3:
318
+ - cat3-line
319
+ ## repolish-regex[cat3-filter]: (^\s*# cat3-filter-additional-paths.*\n(?:\s+.*\n)*)
320
+ # cat3-filter-additional-paths
321
+ ```
322
+
323
+ Local file (excerpt):
324
+
325
+ ```toml
326
+ cat1:
327
+ - line1
328
+ - line2
329
+ # cat1-filter-additional-paths
330
+ - extra
331
+
332
+ cat3:
333
+ - cat3-line
334
+ # cat3-filter-additional-paths
335
+ ```
336
+
337
+ Result after preprocessing:
338
+
339
+ ```toml
340
+ cat1:
341
+ - line1
342
+ - line2
343
+ # cat1-filter-additional-paths
344
+ - extra
345
+
346
+ cat2:
347
+ - from-template
348
+ # cat2-filter-additional-paths
349
+
350
+ cat3:
351
+ - cat3-line
352
+ # cat3-filter-additional-paths
353
+ ```
354
+
355
+ When your regex is too greedy, tighten it or add explicit parentheses around the
356
+ intended capture so Repolish can reliably hydrate the template without importing
357
+ unrelated content.
358
+
267
359
  ### Where anchors are declared and uniqueness
268
360
 
269
361
  Anchors can come from three places (and are merged in this order):
@@ -107,6 +107,15 @@ like `python -c "open('file','w').write('x')"`.
107
107
  - list/array of argv parts (e.g. `['prettier', '--write', '.']`) — recommended
108
108
  when you want to avoid any ambiguity about argument parsing and quoting.
109
109
 
110
+ Platform note
111
+
112
+ - On Windows the tokenization rules differ; Repolish uses Python's `shlex` to
113
+ split string commands into argv lists before executing them without a shell.
114
+ Because quoting/escaping differs between POSIX shells and Windows, prefer the
115
+ argv/list form in `post_process` on Windows to avoid surprises. If you must
116
+ provide a single string entry, keep arguments simple (no shell metacharacters)
117
+ or call a committed script from the argv form.
118
+
110
119
  Security note
111
120
 
112
121
  We intentionally avoid running commands with `shell=True` to reduce shell
@@ -252,6 +261,89 @@ Notes and tips
252
261
  - Anchors are processed before cookiecutter rendering, so template substitutions
253
262
  still work around preserved sections.
254
263
 
264
+ ## Regex anchors and capture groups
265
+
266
+ Regex anchors let you specify exactly what to preserve from a local file. Two
267
+ important behaviors to know:
268
+
269
+ - Capture group preference: If your regex includes a capturing group
270
+ (parentheses), Repolish will prefer the first capture group (group 1) as the
271
+ block to insert into the template. If there are no capture groups, Repolish
272
+ falls back to the entire match (group 0).
273
+ - Safeguard trimming: As a conservative safeguard Repolish trims captured blocks
274
+ to a contiguous region based on indentation so that incidental following
275
+ sections are not accidentally pulled into the template. However, the canonical
276
+ way to express intent is an explicit capture group — authors should prefer to
277
+ capture exactly what they mean.
278
+
279
+ Implementation note
280
+
281
+ - Repolish computes the absolute span of the selected capture (group 1 when
282
+ present, otherwise the full match) inside the template and trims that slice
283
+ using an indentation-aware heuristic before replacing it with the trimmed
284
+ content extracted from the local file. Also note that the
285
+ `## repolish-regex[...]` declaration line is removed from the template prior
286
+ to matching and replacement. This avoids accidentally removing unrelated
287
+ template sections when patterns include surrounding context.
288
+
289
+ Example
290
+
291
+ Template (excerpt):
292
+
293
+ ```toml
294
+ cat1:
295
+ - line1
296
+ - line2
297
+ ## repolish-regex[cat1-filter]: (^\s*# cat1-filter-additional-paths.*\n(?:\s+.*\n)*)
298
+ # cat1-filter-additional-paths
299
+
300
+ cat2:
301
+ - from-template
302
+ ## repolish-regex[cat2-filter]: (^\s*# cat2-filter-additional-paths.*\n(?:\s+.*\n)*)
303
+ # cat2-filter-additional-paths
304
+
305
+ cat3:
306
+ - cat3-line
307
+ ## repolish-regex[cat3-filter]: (^\s*# cat3-filter-additional-paths.*\n(?:\s+.*\n)*)
308
+ # cat3-filter-additional-paths
309
+ ```
310
+
311
+ Local file (excerpt):
312
+
313
+ ```toml
314
+ cat1:
315
+ - line1
316
+ - line2
317
+ # cat1-filter-additional-paths
318
+ - extra
319
+
320
+ cat3:
321
+ - cat3-line
322
+ # cat3-filter-additional-paths
323
+ ```
324
+
325
+ Result after preprocessing:
326
+
327
+ ```toml
328
+ cat1:
329
+ - line1
330
+ - line2
331
+ # cat1-filter-additional-paths
332
+ - extra
333
+
334
+ cat2:
335
+ - from-template
336
+ # cat2-filter-additional-paths
337
+
338
+ cat3:
339
+ - cat3-line
340
+ # cat3-filter-additional-paths
341
+ ```
342
+
343
+ When your regex is too greedy, tighten it or add explicit parentheses around the
344
+ intended capture so Repolish can reliably hydrate the template without importing
345
+ unrelated content.
346
+
255
347
  ### Where anchors are declared and uniqueness
256
348
 
257
349
  Anchors can come from three places (and are merged in this order):
@@ -1,6 +1,7 @@
1
1
  import difflib
2
2
  import filecmp
3
3
  import json
4
+ import os
4
5
  import shutil
5
6
  from pathlib import Path, PurePosixPath
6
7
 
@@ -160,6 +161,57 @@ def collect_output_files(setup_output: Path) -> list[Path]:
160
161
  return [p for p in setup_output.rglob('*') if p.is_file()]
161
162
 
162
163
 
164
+ def _preserve_line_endings() -> bool:
165
+ """Return True when REPOLISH_PRESERVE_LINE_ENDINGS is truthy in env.
166
+
167
+ Centralized to make behavior testable and reduce complexity in the main
168
+ comparison function.
169
+ """
170
+ val = os.getenv('REPOLISH_PRESERVE_LINE_ENDINGS', '')
171
+ return str(val).lower() in ('1', 'true', 'yes')
172
+
173
+
174
+ def _compare_and_prepare_diff(
175
+ out: Path,
176
+ dest: Path,
177
+ *,
178
+ preserve: bool,
179
+ ) -> tuple[bool, list[str], list[str]]:
180
+ """Compare two files and return (same, a_lines, b_lines).
181
+
182
+ - same: True when files are equal according to the chosen policy.
183
+ - a_lines, b_lines: lists of lines (with line endings) to be used in a
184
+ unified diff when same is False. When same is True these values are
185
+ empty lists.
186
+ """
187
+ if preserve:
188
+ # fast-path equality check using filecmp (may be optimized by OS)
189
+ if filecmp.cmp(out, dest, shallow=False):
190
+ return True, [], []
191
+ a_raw = out.read_bytes()
192
+ b_raw = dest.read_bytes()
193
+ a_text = a_raw.decode('utf-8', errors='replace')
194
+ b_text = b_raw.decode('utf-8', errors='replace')
195
+ return (
196
+ False,
197
+ a_text.splitlines(keepends=True),
198
+ b_text.splitlines(keepends=True),
199
+ )
200
+
201
+ # Normalized comparison (ignore CRLF vs LF)
202
+ a_raw = out.read_bytes()
203
+ b_raw = dest.read_bytes()
204
+ a_text = a_raw.decode('utf-8', errors='replace').replace('\r\n', '\n').replace('\r', '\n')
205
+ b_text = b_raw.decode('utf-8', errors='replace').replace('\r\n', '\n').replace('\r', '\n')
206
+ if a_text == b_text:
207
+ return True, [], []
208
+ return (
209
+ False,
210
+ a_text.splitlines(keepends=True),
211
+ b_text.splitlines(keepends=True),
212
+ )
213
+
214
+
163
215
  def check_generated_output(
164
216
  setup_output: Path,
165
217
  providers: Providers,
@@ -171,27 +223,34 @@ def check_generated_output(
171
223
  """
172
224
  output_files = collect_output_files(setup_output)
173
225
  diffs: list[tuple[str, str]] = []
226
+
227
+ preserve = _preserve_line_endings()
228
+
174
229
  for out in output_files:
175
230
  rel = out.relative_to(setup_output / 'repolish')
176
231
  dest = base_dir / rel
177
232
  if not dest.exists():
178
233
  diffs.append((str(rel), 'MISSING'))
179
234
  continue
180
- # compare contents
181
- if not filecmp.cmp(out, dest, shallow=False):
182
- # produce a small unified diff for logging
183
- a = out.read_text(encoding='utf-8', errors='replace').splitlines()
184
- b = dest.read_text(encoding='utf-8', errors='replace').splitlines()
185
- ud = '\n'.join(
186
- difflib.unified_diff(
187
- b,
188
- a,
189
- fromfile=str(dest),
190
- tofile=str(out),
191
- lineterm='',
192
- ),
193
- )
194
- diffs.append((str(rel), ud))
235
+
236
+ same, a_lines, b_lines = _compare_and_prepare_diff(
237
+ out,
238
+ dest,
239
+ preserve=preserve,
240
+ )
241
+ if same:
242
+ continue
243
+
244
+ ud = ''.join(
245
+ difflib.unified_diff(
246
+ b_lines,
247
+ a_lines,
248
+ fromfile=str(dest),
249
+ tofile=str(out),
250
+ lineterm='',
251
+ ),
252
+ )
253
+ diffs.append((str(rel), ud))
195
254
 
196
255
  # provider-declared deletions: if a path is expected deleted but exists in
197
256
  # the project, surface that so devs know to run repolish
@@ -90,6 +90,67 @@ def replace_tags_in_content(content: str, tags: dict[str, str]) -> str:
90
90
  return content
91
91
 
92
92
 
93
+ def _select_capture(match: re.Match) -> str:
94
+ """Return the author's intended capture from a regex match.
95
+
96
+ If the regex contains capturing groups, prefer the first group
97
+ (group 1). Otherwise fall back to the full match (group 0).
98
+ This lets regex authors precisely specify what should be
99
+ extracted and inserted.
100
+ """
101
+ if match.lastindex:
102
+ # prefer the first capture group when present
103
+ return match.group(1)
104
+ return match.group(0)
105
+
106
+
107
+ def _trim_block_by_indent(block: str) -> str:
108
+ """Trim a matched block to the contiguous, same-indentation region.
109
+
110
+ Keeps the first line and any immediately following lines that are
111
+ either blank or indented at least as far as the first line. Stops at
112
+ the first subsequent line with smaller indentation. This heuristic is
113
+ intentionally simple and works well for indentation-based formats
114
+ (YAML, Python-ish lists, etc.) but is only a safeguard — authors
115
+ should prefer explicit capture groups to precisely control what to
116
+ extract.
117
+ """
118
+ lines = block.splitlines(keepends=True)
119
+ if not lines:
120
+ return block
121
+ first = lines[0]
122
+ anchor_indent = len(first) - len(first.lstrip(' '))
123
+ kept = [first]
124
+ for ln in lines[1:]:
125
+ if ln.strip() == '':
126
+ kept.append(ln)
127
+ continue
128
+ indent = len(ln) - len(ln.lstrip(' '))
129
+ if indent >= anchor_indent:
130
+ kept.append(ln)
131
+ else:
132
+ break
133
+ return ''.join(kept)
134
+
135
+
136
+ def _extend_trimmed_region_to_include_whitespace(
137
+ content: str,
138
+ trimmed_end: int,
139
+ tpl_cap_end: int,
140
+ ) -> int:
141
+ """Preserve trailing whitespace-only content from the original capture."""
142
+ if trimmed_end < tpl_cap_end:
143
+ between = content[trimmed_end:tpl_cap_end]
144
+ # only extend if between contains only spaces/tabs (no other
145
+ # non-whitespace characters) and includes at least one newline
146
+ # so we don't accidentally swallow inline spaces.
147
+ # Consider all whitespace (including newlines) when deciding if the
148
+ # slice is empty of non-whitespace characters.
149
+ if between.strip() == '' and '\n' in between:
150
+ return tpl_cap_end
151
+ return trimmed_end
152
+
153
+
93
154
  def apply_regex_replacements(
94
155
  content: str,
95
156
  regexes: dict[str, str],
@@ -105,9 +166,59 @@ def apply_regex_replacements(
105
166
  # apply regex replacements
106
167
  for regex_pattern in regexes.values():
107
168
  pattern = re.compile(rf'{regex_pattern}', re.MULTILINE)
108
- matches = pattern.search(local_file_content)
109
- if matches:
110
- content = pattern.sub(rf'{matches.group(0)}', content)
169
+ local_match = pattern.search(local_file_content)
170
+ if not local_match:
171
+ continue
172
+
173
+ # Prefer the author's explicit capture when present (group 1); if no
174
+ # capture is present fall back to the full match (group 0). This gives
175
+ # template authors precise control while remaining backwards
176
+ # compatible for patterns that don't use groups.
177
+ local_matched_raw = _select_capture(local_match)
178
+ local_matched = _trim_block_by_indent(local_matched_raw)
179
+
180
+ # Find where the pattern would match in the template content (after
181
+ # we've removed the declaration line). Trim the template match using
182
+ # the same indentation-aware heuristic so we only replace the
183
+ # anchor's block and don't remove following unrelated sections from
184
+ # the template.
185
+ template_match = pattern.search(content)
186
+ if not template_match:
187
+ # nothing to replace in template
188
+ continue
189
+
190
+ # Determine which group index we used (1 when the author provided a
191
+ # capture group, otherwise 0 for the full match). Compute the absolute
192
+ # span of that selected region in the template so replacements are
193
+ # performed at the correct indices even when the declared pattern
194
+ # includes surrounding context.
195
+ tpl_group_idx = 1 if template_match.lastindex else 0
196
+ tpl_cap_start, tpl_cap_end = template_match.span(tpl_group_idx)
197
+
198
+ tpl_matched_raw = content[tpl_cap_start:tpl_cap_end]
199
+ tpl_matched = _trim_block_by_indent(tpl_matched_raw)
200
+
201
+ # Replace only the trimmed matched region in the template with the
202
+ # trimmed local content. The trimmed region starts at the capture
203
+ # start and extends the length of the trimmed text. However, if the
204
+ # template contained only whitespace (spaces/newlines) between the
205
+ # end of the trimmed block and the original capture end (for
206
+ # example a blank line before the next section marker), preserve
207
+ # that whitespace so surrounding structure/spacing is unchanged.
208
+ trimmed_start = tpl_cap_start
209
+ trimmed_end = tpl_cap_start + len(tpl_matched)
210
+
211
+ # Potentially extend the trimmed end to include whitespace-only
212
+ # padding that was part of the original capture. Delegate to the
213
+ # helper so the logic is tested and `apply_regex_replacements` is
214
+ # easier to read.
215
+ trimmed_end = _extend_trimmed_region_to_include_whitespace(
216
+ content,
217
+ trimmed_end,
218
+ tpl_cap_end,
219
+ )
220
+
221
+ content = content[:trimmed_start] + local_matched + content[trimmed_end:]
111
222
  return content
112
223
 
113
224
 
@@ -0,0 +1 @@
1
+ __version__ = '0.2.2'
@@ -1 +0,0 @@
1
- __version__ = '0.2.0'
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