ballpython 2.0.0__py3-none-any.whl → 2.0.2__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.
pycleaner/pipeline.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """
2
- Cleanup pipeline coordinating syntax healing, import resolution, linting, and formatting.
2
+ Cleanup pipeline coordinating syntax healing, code modernization, dead-code pruning,
3
+ import resolution, linting, and formatting.
3
4
  """
4
5
 
5
6
  from __future__ import annotations
@@ -12,9 +13,18 @@ from dataclasses import dataclass, field
12
13
  from pathlib import Path
13
14
  from typing import TYPE_CHECKING, Any
14
15
 
16
+ from pycleaner.cache import ContentAddressableCache
17
+ from pycleaner.dead_code_detector import DeadCodeFixer
15
18
  from pycleaner.import_resolver import ImportResolver
16
19
  from pycleaner.linter_formatter import LinterFormatter
20
+ from pycleaner.modernizer import Modernizer
17
21
  from pycleaner.syntax_healer import SyntaxHealer
22
+ from pycleaner.verifier import (
23
+ CounterExample,
24
+ IsolatedDifferentialVerifier,
25
+ ProofReceipt,
26
+ VerificationTier,
27
+ )
18
28
 
19
29
  if TYPE_CHECKING:
20
30
  from pycleaner.config import PyCleanerConfig
@@ -30,12 +40,17 @@ class CleanResult:
30
40
  changed: bool
31
41
  is_valid_python: bool
32
42
  syntax_repairs: list[str] = field(default_factory=list)
43
+ modernize_transforms: list[str] = field(default_factory=list)
44
+ dead_code_pruned: list[str] = field(default_factory=list)
33
45
  resolved_imports: list[str] = field(default_factory=list)
34
46
  unresolved_symbols: list[str] = field(default_factory=list)
35
47
  lint_changed: bool = False
36
48
  format_changed: bool = False
37
49
  error: str | None = None
38
50
  diagnostics: list[dict[str, str]] = field(default_factory=list)
51
+ proof_receipts: list[ProofReceipt] = field(default_factory=list)
52
+ refused_changes: list[CounterExample] = field(default_factory=list)
53
+ verification_tier: VerificationTier | None = None
39
54
 
40
55
  @property
41
56
  def diff(self) -> str:
@@ -59,10 +74,17 @@ class PipelineOptions:
59
74
  """Configurable feature flags and mappings for CleanPipeline."""
60
75
 
61
76
  enable_syntax_healing: bool = True
77
+ enable_modernizer: bool = True
78
+ enable_dead_code_pruning: bool = True
62
79
  enable_import_resolution: bool = True
63
80
  enable_lint_fixing: bool = True
64
81
  enable_formatting: bool = True
65
82
  custom_import_map: dict[str, str] | None = None
83
+ verify_proofs: bool = False
84
+ proof_iterations: int = 50
85
+ proof_seed: int | None = None
86
+ enable_cache: bool = False
87
+ cache_db_path: str = ".pycleaner/cache.db"
66
88
 
67
89
 
68
90
  class CleanPipeline:
@@ -76,16 +98,36 @@ class CleanPipeline:
76
98
  ) -> None:
77
99
  opts = options or PipelineOptions(
78
100
  enable_syntax_healing=kwargs.get("enable_syntax_healing", True),
101
+ enable_modernizer=kwargs.get("enable_modernizer", True),
102
+ enable_dead_code_pruning=kwargs.get("enable_dead_code_pruning", True),
79
103
  enable_import_resolution=kwargs.get("enable_import_resolution", True),
80
104
  enable_lint_fixing=kwargs.get("enable_lint_fixing", True),
81
105
  enable_formatting=kwargs.get("enable_formatting", True),
82
106
  custom_import_map=kwargs.get("custom_import_map"),
107
+ verify_proofs=kwargs.get("verify_proofs", False),
108
+ proof_iterations=kwargs.get("proof_iterations", 50),
109
+ proof_seed=kwargs.get("proof_seed"),
110
+ enable_cache=kwargs.get("enable_cache", False),
111
+ cache_db_path=kwargs.get("cache_db_path", ".pycleaner/cache.db"),
83
112
  )
84
113
  self.enable_syntax_healing = opts.enable_syntax_healing
114
+ self.enable_modernizer = opts.enable_modernizer
115
+ self.enable_dead_code_pruning = opts.enable_dead_code_pruning
85
116
  self.enable_import_resolution = opts.enable_import_resolution
86
117
  self.enable_lint_fixing = opts.enable_lint_fixing
87
118
  self.enable_formatting = opts.enable_formatting
119
+ self.verify_proofs = opts.verify_proofs
120
+ self.proof_iterations = opts.proof_iterations
121
+ self.proof_seed = opts.proof_seed
122
+ self.enable_cache = opts.enable_cache
123
+ self.cache = (
124
+ ContentAddressableCache(opts.cache_db_path) if opts.enable_cache else None
125
+ )
88
126
  self.config = config
127
+ self.verifier = IsolatedDifferentialVerifier(
128
+ iterations=self.proof_iterations,
129
+ base_seed=self.proof_seed,
130
+ )
89
131
 
90
132
  import_map = dict(opts.custom_import_map or {})
91
133
  custom_map = getattr(config, "custom_import_map", None)
@@ -93,6 +135,8 @@ class CleanPipeline:
93
135
  import_map.update(custom_map)
94
136
 
95
137
  self.syntax_healer = SyntaxHealer()
138
+ self.modernizer = Modernizer()
139
+ self.dead_code_fixer = DeadCodeFixer()
96
140
  self.import_resolver = ImportResolver(custom_import_map=import_map)
97
141
  self.linter_formatter = LinterFormatter()
98
142
 
@@ -118,6 +162,32 @@ class CleanPipeline:
118
162
  )
119
163
  return current_code, None
120
164
 
165
+ def _stage_modernize(
166
+ self, current_code: str, filename: str, modernize_transforms: list[str]
167
+ ) -> str:
168
+ """Execute Stage 2: Code Modernization."""
169
+ if not self.enable_modernizer:
170
+ return current_code
171
+
172
+ mod_res = self.modernizer.modernize(current_code, filename=filename)
173
+ if mod_res.changed:
174
+ modernize_transforms.extend(mod_res.transformations)
175
+ return mod_res.code
176
+ return current_code
177
+
178
+ def _stage_dead_code(
179
+ self, current_code: str, filename: str, dead_code_pruned: list[str]
180
+ ) -> str:
181
+ """Execute Stage 3: Dead Code Pruning."""
182
+ if not self.enable_dead_code_pruning:
183
+ return current_code
184
+
185
+ dc_res = self.dead_code_fixer.fix(current_code, filename=filename)
186
+ if dc_res.changed:
187
+ dead_code_pruned.extend(dc_res.pruned_items)
188
+ return dc_res.code
189
+ return current_code
190
+
121
191
  def _stage_resolve_imports(
122
192
  self,
123
193
  current_code: str,
@@ -126,7 +196,7 @@ class CleanPipeline:
126
196
  unresolved_symbols: list[str],
127
197
  diagnostics: list[dict[str, str]],
128
198
  ) -> str:
129
- """Execute Stage 2: Missing Import Resolution."""
199
+ """Execute Stage 4: Missing Import Resolution."""
130
200
  if self.enable_import_resolution:
131
201
  import_res = self.import_resolver.resolve(current_code, filename=filename)
132
202
  if import_res.resolved_imports:
@@ -149,7 +219,7 @@ class CleanPipeline:
149
219
  def _stage_lint_format(
150
220
  self, current_code: str, filename: str
151
221
  ) -> tuple[str, bool, bool]:
152
- """Execute Stage 3 & 4: Lint Auto-fixing and Formatting."""
222
+ """Execute Stage 5: Lint Auto-fixing and Formatting."""
153
223
  if not (self.enable_lint_fixing or self.enable_formatting):
154
224
  return current_code, False, False
155
225
 
@@ -176,6 +246,8 @@ class CleanPipeline:
176
246
  """Process in-memory Python source code through the pipeline."""
177
247
  current_code = source
178
248
  syntax_repairs: list[str] = []
249
+ modernize_transforms: list[str] = []
250
+ dead_code_pruned: list[str] = []
179
251
  resolved_imports: list[str] = []
180
252
  unresolved_symbols: list[str] = []
181
253
  lint_changed = False
@@ -190,6 +262,13 @@ class CleanPipeline:
190
262
  )
191
263
  if error_msg is not None:
192
264
  break
265
+
266
+ current_code = self._stage_modernize(
267
+ current_code, filename, modernize_transforms
268
+ )
269
+ current_code = self._stage_dead_code(
270
+ current_code, filename, dead_code_pruned
271
+ )
193
272
  current_code = self._stage_resolve_imports(
194
273
  current_code,
195
274
  filename,
@@ -204,39 +283,143 @@ class CleanPipeline:
204
283
  break
205
284
 
206
285
  is_valid, final_error = self._validate_syntax(current_code, filename, error_msg)
286
+
287
+ proof_receipts: list[ProofReceipt] = []
288
+ refused_changes: list[CounterExample] = []
289
+ verification_tier: VerificationTier | None = None
290
+
291
+ if self.verify_proofs and current_code != source and is_valid:
292
+ target_callables = self._find_modified_callables(source, current_code)
293
+ if not target_callables:
294
+ verification_tier = VerificationTier.TIER_B_SUGGESTED
295
+ else:
296
+ all_proven = True
297
+ for func_name in target_callables:
298
+ receipt = self.verifier.verify_transformation(
299
+ filepath=filename,
300
+ original_source=source,
301
+ transformed_source=current_code,
302
+ target_callable=func_name,
303
+ )
304
+ proof_receipts.append(receipt)
305
+ if receipt.tier == VerificationTier.TIER_C_REFUSED:
306
+ all_proven = False
307
+ if receipt.counterexample:
308
+ refused_changes.append(receipt.counterexample)
309
+ elif receipt.tier != VerificationTier.TIER_A_PROVEN:
310
+ all_proven = False
311
+
312
+ if refused_changes:
313
+ verification_tier = VerificationTier.TIER_C_REFUSED
314
+ # Absolute rollback guarantee on Tier C refusal
315
+ current_code = source
316
+ elif all_proven and proof_receipts:
317
+ verification_tier = VerificationTier.TIER_A_PROVEN
318
+ else:
319
+ verification_tier = VerificationTier.TIER_B_SUGGESTED
320
+
207
321
  return CleanResult(
208
322
  path=Path(filename),
209
323
  original_code=source,
210
324
  cleaned_code=current_code,
211
- changed=current_code != source,
325
+ changed=(current_code != source)
326
+ and (verification_tier != VerificationTier.TIER_C_REFUSED),
212
327
  is_valid_python=is_valid,
213
328
  syntax_repairs=syntax_repairs,
329
+ modernize_transforms=modernize_transforms,
330
+ dead_code_pruned=dead_code_pruned,
214
331
  resolved_imports=resolved_imports,
215
332
  unresolved_symbols=unresolved_symbols,
216
333
  lint_changed=lint_changed,
217
334
  format_changed=format_changed,
218
335
  error=final_error,
219
336
  diagnostics=diagnostics,
337
+ proof_receipts=proof_receipts,
338
+ refused_changes=refused_changes,
339
+ verification_tier=verification_tier,
220
340
  )
221
341
 
342
+ @staticmethod
343
+ def _find_modified_callables(orig_code: str, clean_code: str) -> list[str]:
344
+ """Identify functions/methods whose AST has changed."""
345
+ try:
346
+ orig_tree = ast.parse(orig_code)
347
+ clean_tree = ast.parse(clean_code)
348
+ except SyntaxError:
349
+ return []
350
+
351
+ orig_funcs: dict[str, str] = {}
352
+ for node in ast.walk(orig_tree):
353
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
354
+ orig_funcs[node.name] = ast.dump(node)
355
+
356
+ modified: list[str] = []
357
+ clean_funcs: list[str] = []
358
+ for node in ast.walk(clean_tree):
359
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
360
+ clean_funcs.append(node.name)
361
+ clean_dump = ast.dump(node)
362
+ if node.name not in orig_funcs or orig_funcs[node.name] != clean_dump:
363
+ if node.name not in modified:
364
+ modified.append(node.name)
365
+
366
+ if not modified and clean_funcs:
367
+ return clean_funcs[:3]
368
+
369
+ return modified
370
+
222
371
  def process_file(
223
372
  self,
224
373
  filepath: Path | str,
225
374
  apply_changes: bool = True,
226
375
  backup: bool = False,
227
376
  ) -> CleanResult:
228
- """Process a single file on disk and optionally write back updates.
229
-
230
- Args:
231
- filepath: Path to the Python file to process.
232
- apply_changes: If True, write cleaned code back to disk.
233
- backup: If True, create a .pycleaner.bak file before overwriting.
234
- """
377
+ """Process a single file on disk and optionally write back updates."""
235
378
  path = Path(filepath).resolve()
236
379
  content = path.read_text(encoding="utf-8", errors="replace")
380
+
381
+ if self.cache is not None:
382
+ cached_result = self.cache.get(path, content)
383
+ if cached_result is not None:
384
+ if (
385
+ apply_changes
386
+ and cached_result.changed
387
+ and cached_result.is_valid_python
388
+ and (
389
+ cached_result.verification_tier
390
+ != VerificationTier.TIER_C_REFUSED
391
+ )
392
+ ):
393
+ if backup:
394
+ bak_path = path.with_name(path.name + ".pycleaner.bak")
395
+ shutil.copy2(path, bak_path)
396
+ path.write_text(cached_result.cleaned_code, encoding="utf-8")
397
+ return cached_result
398
+
237
399
  result = self.process_source(content, filename=str(path))
238
400
 
239
- if apply_changes and result.changed and result.is_valid_python:
401
+ if self.cache is not None:
402
+ self.cache.set(path, content, result)
403
+ if result.changed and apply_changes:
404
+ tier_label = (
405
+ result.verification_tier.value
406
+ if result.verification_tier
407
+ else "UNVERIFIED"
408
+ )
409
+ self.cache.record_provenance(
410
+ file_path=path,
411
+ transformation_type="clean",
412
+ verification_tier=tier_label,
413
+ seed=self.proof_seed,
414
+ diff=result.diff,
415
+ )
416
+
417
+ if (
418
+ apply_changes
419
+ and result.changed
420
+ and result.is_valid_python
421
+ and (result.verification_tier != VerificationTier.TIER_C_REFUSED)
422
+ ):
240
423
  if backup:
241
424
  bak_path = path.with_name(path.name + ".pycleaner.bak")
242
425
  shutil.copy2(path, bak_path)
@@ -251,14 +434,7 @@ class CleanPipeline:
251
434
  backup: bool = False,
252
435
  max_workers: int | None = None,
253
436
  ) -> list[CleanResult]:
254
- """Process multiple files, optionally in parallel.
255
-
256
- Args:
257
- filepaths: List of Python file paths.
258
- apply_changes: Write cleaned code back to disk.
259
- backup: Create .pycleaner.bak before overwriting.
260
- max_workers: Max parallel workers. None = sequential. 1+ = parallel.
261
- """
437
+ """Process multiple files, optionally in parallel."""
262
438
  if max_workers is not None and max_workers > 1 and len(filepaths) > 1:
263
439
  return self._process_parallel(filepaths, apply_changes, backup, max_workers)
264
440
  return [
@@ -307,6 +483,8 @@ class CleanPipeline:
307
483
  apply_changes=apply_changes,
308
484
  backup=backup,
309
485
  enable_syntax=self.enable_syntax_healing,
486
+ enable_modernize=self.enable_modernizer,
487
+ enable_dead_code=self.enable_dead_code_pruning,
310
488
  enable_imports=self.enable_import_resolution,
311
489
  enable_lint=self.enable_lint_fixing,
312
490
  enable_format=self.enable_formatting,
@@ -329,20 +507,30 @@ class _StandaloneWorkerTask:
329
507
  apply_changes: bool
330
508
  backup: bool
331
509
  enable_syntax: bool
510
+ enable_modernize: bool
511
+ enable_dead_code: bool
332
512
  enable_imports: bool
333
513
  enable_lint: bool
334
514
  enable_format: bool
335
515
  custom_import_map: dict[str, str] | None = None
516
+ verify_proofs: bool = False
517
+ proof_iterations: int = 50
518
+ proof_seed: int | None = None
336
519
 
337
520
 
338
521
  def _process_file_standalone(task: _StandaloneWorkerTask) -> CleanResult:
339
522
  """Standalone function for ProcessPoolExecutor (must be module-level and picklable)."""
340
523
  pipeline = CleanPipeline(
341
524
  enable_syntax_healing=task.enable_syntax,
525
+ enable_modernizer=task.enable_modernize,
526
+ enable_dead_code_pruning=task.enable_dead_code,
342
527
  enable_import_resolution=task.enable_imports,
343
528
  enable_lint_fixing=task.enable_lint,
344
529
  enable_formatting=task.enable_format,
345
530
  custom_import_map=task.custom_import_map,
531
+ verify_proofs=task.verify_proofs,
532
+ proof_iterations=task.proof_iterations,
533
+ proof_seed=task.proof_seed,
346
534
  )
347
535
  return pipeline.process_file(
348
536
  task.filepath, apply_changes=task.apply_changes, backup=task.backup
pycleaner/py.typed ADDED
File without changes
@@ -9,11 +9,13 @@ and assert statements used for input validation.
9
9
  from __future__ import annotations
10
10
 
11
11
  import ast
12
- import os
13
12
  import re
13
+ from collections.abc import Sequence
14
14
  from dataclasses import dataclass, field
15
15
  from pathlib import Path
16
16
 
17
+ from pycleaner.discovery import collect_project_python_files
18
+
17
19
 
18
20
  @dataclass(slots=True)
19
21
  class SecurityFinding:
@@ -194,6 +196,35 @@ class _DangerousCallDetector(ast.NodeVisitor):
194
196
  return self.source_lines[lineno - 1].strip()
195
197
  return ""
196
198
 
199
+ def _is_suppressed(self, lineno: int, category: str) -> bool:
200
+ if 1 <= lineno <= len(self.source_lines):
201
+ line = self.source_lines[lineno - 1]
202
+ if "#" in line:
203
+ comment = line.split("#", 1)[1].strip().lower()
204
+ if (
205
+ "nosec" in comment
206
+ or "noqa" in comment
207
+ or "pycleaner: ignore" in comment
208
+ or f"ignore[{category.lower()}]" in comment
209
+ ):
210
+ return True
211
+ if lineno > 1:
212
+ prev_line = self.source_lines[lineno - 2]
213
+ if prev_line.strip().startswith("#"):
214
+ prev_comment = prev_line.strip().lstrip("#").strip().lower()
215
+ if (
216
+ "nosec" in prev_comment
217
+ or "noqa" in prev_comment
218
+ or "pycleaner: ignore" in prev_comment
219
+ or f"ignore[{category.lower()}]" in prev_comment
220
+ ):
221
+ return True
222
+ return False
223
+
224
+ def _add_finding(self, finding: SecurityFinding) -> None:
225
+ if not self._is_suppressed(finding.lineno, finding.category):
226
+ self.findings.append(finding)
227
+
197
228
  def visit_Call(self, node: ast.Call) -> None:
198
229
  func_name = self._get_call_name(node)
199
230
  if func_name:
@@ -212,7 +243,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
212
243
  def visit_Assert(self, node: ast.Assert) -> None:
213
244
  if self._is_test_path(self.filepath):
214
245
  return
215
- self.findings.append(
246
+ self._add_finding(
216
247
  SecurityFinding(
217
248
  filepath=self.filepath,
218
249
  lineno=node.lineno,
@@ -250,7 +281,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
250
281
  spec = _DANGEROUS_CALL_SPECS.get(func_name)
251
282
  if spec:
252
283
  severity, category, msg, suggestion = spec
253
- self.findings.append(
284
+ self._add_finding(
254
285
  SecurityFinding(
255
286
  filepath=self.filepath,
256
287
  lineno=node.lineno,
@@ -280,7 +311,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
280
311
  and isinstance(kw.value, ast.Constant)
281
312
  and kw.value.value is True
282
313
  ):
283
- self.findings.append(
314
+ self._add_finding(
284
315
  SecurityFinding(
285
316
  filepath=self.filepath,
286
317
  lineno=node.lineno,
@@ -319,7 +350,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
319
350
  and isinstance(kw.value, ast.Constant)
320
351
  and kw.value.value is False
321
352
  ):
322
- self.findings.append(
353
+ self._add_finding(
323
354
  SecurityFinding(
324
355
  filepath=self.filepath,
325
356
  lineno=node.lineno,
@@ -340,7 +371,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
340
371
  and isinstance(kw.value, ast.Constant)
341
372
  and kw.value.value is True
342
373
  ):
343
- self.findings.append(
374
+ self._add_finding(
344
375
  SecurityFinding(
345
376
  filepath=self.filepath,
346
377
  lineno=node.lineno,
@@ -373,7 +404,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
373
404
  ):
374
405
  has_safe_loader = True
375
406
  if not has_safe_loader and func_name == "yaml.load":
376
- self.findings.append(
407
+ self._add_finding(
377
408
  SecurityFinding(
378
409
  filepath=self.filepath,
379
410
  lineno=node.lineno,
@@ -407,7 +438,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
407
438
  return
408
439
 
409
440
  if self._is_dynamic_sql_arg(node.args[0]):
410
- self.findings.append(
441
+ self._add_finding(
411
442
  SecurityFinding(
412
443
  filepath=self.filepath,
413
444
  lineno=node.lineno,
@@ -424,23 +455,6 @@ class _DangerousCallDetector(ast.NodeVisitor):
424
455
  class SecurityScanner:
425
456
  """Scans Python source code for security vulnerabilities."""
426
457
 
427
- IGNORE_DIRS = frozenset(
428
- {
429
- ".git",
430
- ".venv",
431
- "venv",
432
- "env",
433
- "__pycache__",
434
- "build",
435
- "dist",
436
- ".tox",
437
- ".mypy_cache",
438
- ".pytest_cache",
439
- ".ruff_cache",
440
- "site-packages",
441
- }
442
- )
443
-
444
458
  def __init__(
445
459
  self,
446
460
  severity_threshold: str = "LOW",
@@ -479,21 +493,19 @@ class SecurityScanner:
479
493
 
480
494
  return SecurityReport(findings=filtered, files_scanned=1)
481
495
 
482
- def _discover_project_py_files(self, root: Path) -> list[Path]:
483
- py_files: list[Path] = []
484
- for current_root, dirs, filenames in os.walk(root):
485
- dirs[:] = [
486
- d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
487
- ]
488
- for fname in filenames:
489
- if fname.endswith(".py"):
490
- py_files.append(Path(current_root) / fname)
491
- return py_files
492
-
493
- def scan_project(self, root_dir: Path | str) -> SecurityReport:
496
+ def _discover_project_py_files(
497
+ self, root: Path, exclude_patterns: Sequence[str] = ()
498
+ ) -> list[Path]:
499
+ return collect_project_python_files(root, exclude_patterns=exclude_patterns)
500
+
501
+ def scan_project(
502
+ self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
503
+ ) -> SecurityReport:
494
504
  """Scan all Python files in a project for security issues."""
495
505
  root = Path(root_dir).resolve()
496
- py_files = self._discover_project_py_files(root)
506
+ py_files = self._discover_project_py_files(
507
+ root, exclude_patterns=exclude_patterns
508
+ )
497
509
  all_findings: list[SecurityFinding] = []
498
510
 
499
511
  for fpath in py_files:
@@ -532,6 +544,15 @@ class SecurityScanner:
532
544
  filename, stripped.lower()
533
545
  ):
534
546
  continue
547
+ if "#" in line:
548
+ comment = line.split("#", 1)[1].strip().lower()
549
+ if (
550
+ "nosec" in comment
551
+ or "noqa" in comment
552
+ or "pycleaner: ignore" in comment
553
+ or "ignore[hardcoded-secret]" in comment
554
+ ):
555
+ continue
535
556
 
536
557
  for name, pattern, suggestion in _SECRET_PATTERNS:
537
558
  if pattern.search(line):