lintro 0.13.2__py3-none-any.whl → 0.17.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.
Files changed (72) hide show
  1. lintro/__init__.py +1 -1
  2. lintro/cli.py +226 -16
  3. lintro/cli_utils/commands/__init__.py +8 -1
  4. lintro/cli_utils/commands/check.py +1 -0
  5. lintro/cli_utils/commands/config.py +325 -0
  6. lintro/cli_utils/commands/init.py +361 -0
  7. lintro/cli_utils/commands/list_tools.py +180 -42
  8. lintro/cli_utils/commands/test.py +316 -0
  9. lintro/cli_utils/commands/versions.py +81 -0
  10. lintro/config/__init__.py +62 -0
  11. lintro/config/config_loader.py +420 -0
  12. lintro/config/lintro_config.py +189 -0
  13. lintro/config/tool_config_generator.py +403 -0
  14. lintro/enums/tool_name.py +2 -0
  15. lintro/enums/tool_type.py +2 -0
  16. lintro/formatters/tools/__init__.py +12 -0
  17. lintro/formatters/tools/eslint_formatter.py +108 -0
  18. lintro/formatters/tools/markdownlint_formatter.py +88 -0
  19. lintro/formatters/tools/pytest_formatter.py +201 -0
  20. lintro/parsers/__init__.py +69 -9
  21. lintro/parsers/bandit/__init__.py +6 -0
  22. lintro/parsers/bandit/bandit_issue.py +49 -0
  23. lintro/parsers/bandit/bandit_parser.py +99 -0
  24. lintro/parsers/black/black_issue.py +4 -0
  25. lintro/parsers/eslint/__init__.py +6 -0
  26. lintro/parsers/eslint/eslint_issue.py +26 -0
  27. lintro/parsers/eslint/eslint_parser.py +63 -0
  28. lintro/parsers/markdownlint/__init__.py +6 -0
  29. lintro/parsers/markdownlint/markdownlint_issue.py +22 -0
  30. lintro/parsers/markdownlint/markdownlint_parser.py +113 -0
  31. lintro/parsers/pytest/__init__.py +21 -0
  32. lintro/parsers/pytest/pytest_issue.py +28 -0
  33. lintro/parsers/pytest/pytest_parser.py +483 -0
  34. lintro/tools/__init__.py +2 -0
  35. lintro/tools/core/timeout_utils.py +112 -0
  36. lintro/tools/core/tool_base.py +255 -45
  37. lintro/tools/core/tool_manager.py +77 -24
  38. lintro/tools/core/version_requirements.py +482 -0
  39. lintro/tools/implementations/pytest/pytest_command_builder.py +311 -0
  40. lintro/tools/implementations/pytest/pytest_config.py +200 -0
  41. lintro/tools/implementations/pytest/pytest_error_handler.py +128 -0
  42. lintro/tools/implementations/pytest/pytest_executor.py +122 -0
  43. lintro/tools/implementations/pytest/pytest_handlers.py +375 -0
  44. lintro/tools/implementations/pytest/pytest_option_validators.py +212 -0
  45. lintro/tools/implementations/pytest/pytest_output_processor.py +408 -0
  46. lintro/tools/implementations/pytest/pytest_result_processor.py +113 -0
  47. lintro/tools/implementations/pytest/pytest_utils.py +697 -0
  48. lintro/tools/implementations/tool_actionlint.py +106 -16
  49. lintro/tools/implementations/tool_bandit.py +23 -7
  50. lintro/tools/implementations/tool_black.py +236 -29
  51. lintro/tools/implementations/tool_darglint.py +180 -21
  52. lintro/tools/implementations/tool_eslint.py +374 -0
  53. lintro/tools/implementations/tool_hadolint.py +94 -25
  54. lintro/tools/implementations/tool_markdownlint.py +354 -0
  55. lintro/tools/implementations/tool_prettier.py +313 -26
  56. lintro/tools/implementations/tool_pytest.py +327 -0
  57. lintro/tools/implementations/tool_ruff.py +247 -70
  58. lintro/tools/implementations/tool_yamllint.py +448 -34
  59. lintro/tools/tool_enum.py +6 -0
  60. lintro/utils/config.py +41 -18
  61. lintro/utils/console_logger.py +211 -25
  62. lintro/utils/path_utils.py +42 -0
  63. lintro/utils/tool_executor.py +336 -39
  64. lintro/utils/tool_utils.py +38 -2
  65. lintro/utils/unified_config.py +926 -0
  66. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/METADATA +131 -29
  67. lintro-0.17.2.dist-info/RECORD +134 -0
  68. lintro-0.13.2.dist-info/RECORD +0 -96
  69. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/WHEEL +0 -0
  70. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/entry_points.txt +0 -0
  71. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/licenses/LICENSE +0 -0
  72. {lintro-0.13.2.dist-info → lintro-0.17.2.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,14 @@
1
1
  """Prettier code formatter integration."""
2
2
 
3
3
  import os
4
+ import subprocess # nosec B404 - used safely with shell disabled
4
5
  from dataclasses import dataclass, field
5
6
 
6
7
  from loguru import logger
7
8
 
8
9
  from lintro.enums.tool_type import ToolType
9
10
  from lintro.models.core.tool import Tool, ToolConfig, ToolResult
11
+ from lintro.parsers.prettier.prettier_issue import PrettierIssue
10
12
  from lintro.parsers.prettier.prettier_parser import parse_prettier_output
11
13
  from lintro.tools.core.tool_base import BaseTool
12
14
  from lintro.utils.tool_utils import walk_files_with_excludes
@@ -55,12 +57,21 @@ class PrettierTool(BaseTool):
55
57
  ),
56
58
  )
57
59
 
60
+ def __post_init__(self) -> None:
61
+ """Initialize prettier tool."""
62
+ super().__post_init__()
63
+ # Note: .prettierignore is handled by passing --ignore-path to prettier
64
+ # rather than loading into lintro's exclude patterns, to ensure prettier's
65
+ # native ignore logic is used consistently
66
+
58
67
  def set_options(
59
68
  self,
60
69
  exclude_patterns: list[str] | None = None,
61
70
  include_venv: bool = False,
62
71
  timeout: int | None = None,
63
72
  verbose_fix_output: bool | None = None,
73
+ line_length: int | None = None,
74
+ **kwargs,
64
75
  ) -> None:
65
76
  """Set options for the core.
66
77
 
@@ -69,13 +80,26 @@ class PrettierTool(BaseTool):
69
80
  include_venv: Whether to include virtual environment directories
70
81
  timeout: Timeout in seconds per file (default: 30)
71
82
  verbose_fix_output: If True, include raw Prettier output in fix()
83
+ line_length: Print width for prettier (maps to --print-width).
84
+ If provided, this will be stored and used in CLI args.
85
+ **kwargs: Additional options (ignored for compatibility)
86
+
87
+ Raises:
88
+ ValueError: If line_length is not a positive integer.
72
89
  """
73
- self.exclude_patterns = exclude_patterns or []
90
+ if exclude_patterns is not None:
91
+ self.exclude_patterns = exclude_patterns.copy()
74
92
  self.include_venv = include_venv
75
93
  if timeout is not None:
76
94
  self.timeout = timeout
77
95
  if verbose_fix_output is not None:
78
96
  self.options["verbose_fix_output"] = verbose_fix_output
97
+ if line_length is not None:
98
+ if not isinstance(line_length, int):
99
+ raise ValueError("line_length must be an integer")
100
+ if line_length <= 0:
101
+ raise ValueError("line_length must be positive")
102
+ self.options["line_length"] = line_length
79
103
 
80
104
  def _find_config(self) -> str | None:
81
105
  """Locate a Prettier config if none is found by native discovery.
@@ -90,6 +114,150 @@ class PrettierTool(BaseTool):
90
114
  """
91
115
  return None
92
116
 
117
+ def _find_prettier_config(self, search_dir: str | None = None) -> str | None:
118
+ """Locate prettier config file by walking up the directory tree.
119
+
120
+ Prettier searches upward from the file's directory to find config files,
121
+ so we do the same to match native behavior and ensure config is found
122
+ even when cwd is a subdirectory.
123
+
124
+ Args:
125
+ search_dir: Directory to start searching from. If None, searches from
126
+ current working directory.
127
+
128
+ Returns:
129
+ str | None: Path to config file if found, None otherwise.
130
+ """
131
+ config_paths = [
132
+ ".prettierrc",
133
+ ".prettierrc.json",
134
+ ".prettierrc.js",
135
+ ".prettierrc.yaml",
136
+ ".prettierrc.yml",
137
+ "prettier.config.js",
138
+ "package.json",
139
+ ]
140
+ # Search upward from search_dir (or cwd) to find config, just like prettier does
141
+ start_dir = os.path.abspath(search_dir) if search_dir else os.getcwd()
142
+ current_dir = start_dir
143
+
144
+ # Walk upward from the directory to find config
145
+ # Stop at filesystem root to avoid infinite loop
146
+ while True:
147
+ for config_name in config_paths:
148
+ config_path = os.path.join(current_dir, config_name)
149
+ if os.path.exists(config_path):
150
+ # For package.json, check if it contains prettier config
151
+ if config_name == "package.json":
152
+ try:
153
+ import json
154
+
155
+ with open(config_path, encoding="utf-8") as f:
156
+ pkg_data = json.load(f)
157
+ if "prettier" not in pkg_data:
158
+ continue
159
+ except (
160
+ json.JSONDecodeError,
161
+ FileNotFoundError,
162
+ PermissionError,
163
+ ):
164
+ # Skip invalid or unreadable package.json files
165
+ continue
166
+ logger.debug(
167
+ f"[PrettierTool] Found config file: {config_path} "
168
+ f"(searched from {start_dir})",
169
+ )
170
+ return config_path
171
+
172
+ # Move up one directory
173
+ parent_dir = os.path.dirname(current_dir)
174
+ # Stop if we've reached the filesystem root (parent == current)
175
+ if parent_dir == current_dir:
176
+ break
177
+ current_dir = parent_dir
178
+
179
+ return None
180
+
181
+ def _find_prettierignore(self, search_dir: str | None = None) -> str | None:
182
+ """Locate .prettierignore file by walking up the directory tree.
183
+
184
+ Prettier searches upward from the file's directory to find .prettierignore,
185
+ so we do the same to match native behavior and ensure ignore file is found
186
+ even when cwd is a subdirectory.
187
+
188
+ Args:
189
+ search_dir: Directory to start searching from. If None, searches from
190
+ current working directory.
191
+
192
+ Returns:
193
+ str | None: Path to .prettierignore file if found, None otherwise.
194
+ """
195
+ ignore_filename = ".prettierignore"
196
+ # Search upward from search_dir (or cwd) to find ignore file
197
+ start_dir = os.path.abspath(search_dir) if search_dir else os.getcwd()
198
+ current_dir = start_dir
199
+
200
+ # Walk upward from the directory to find ignore file
201
+ # Stop at filesystem root to avoid infinite loop
202
+ while True:
203
+ ignore_path = os.path.join(current_dir, ignore_filename)
204
+ if os.path.exists(ignore_path):
205
+ logger.debug(
206
+ f"[PrettierTool] Found .prettierignore: {ignore_path} "
207
+ f"(searched from {start_dir})",
208
+ )
209
+ return ignore_path
210
+
211
+ # Move up one directory
212
+ parent_dir = os.path.dirname(current_dir)
213
+ # Stop if we've reached the filesystem root (parent == current)
214
+ if parent_dir == current_dir:
215
+ break
216
+ current_dir = parent_dir
217
+
218
+ return None
219
+
220
+ def _create_timeout_result(
221
+ self,
222
+ timeout_val: int,
223
+ initial_issues: list | None = None,
224
+ initial_count: int = 0,
225
+ ) -> ToolResult:
226
+ """Create a ToolResult for timeout scenarios.
227
+
228
+ Args:
229
+ timeout_val: The timeout value that was exceeded.
230
+ initial_issues: Optional list of issues found before timeout.
231
+ initial_count: Optional count of initial issues.
232
+
233
+ Returns:
234
+ ToolResult: ToolResult instance representing timeout failure.
235
+ """
236
+ timeout_msg = (
237
+ f"Prettier execution timed out ({timeout_val}s limit exceeded).\n\n"
238
+ "This may indicate:\n"
239
+ " - Large codebase taking too long to process\n"
240
+ " - Need to increase timeout via --tool-options prettier:timeout=N"
241
+ )
242
+ timeout_issue = PrettierIssue(
243
+ file="execution",
244
+ line=None,
245
+ code="TIMEOUT",
246
+ message=timeout_msg,
247
+ column=None,
248
+ )
249
+ combined_issues = (initial_issues or []) + [timeout_issue]
250
+ return ToolResult(
251
+ name=self.name,
252
+ success=False,
253
+ output=timeout_msg,
254
+ issues_count=len(combined_issues),
255
+ issues=combined_issues,
256
+ initial_issues_count=initial_count,
257
+ fixed_issues_count=0,
258
+ remaining_issues_count=len(combined_issues),
259
+ )
260
+
93
261
  def check(
94
262
  self,
95
263
  paths: list[str],
@@ -102,6 +270,11 @@ class PrettierTool(BaseTool):
102
270
  Returns:
103
271
  ToolResult instance
104
272
  """
273
+ # Check version requirements
274
+ version_result = self._verify_tool_version()
275
+ if version_result is not None:
276
+ return version_result
277
+
105
278
  self._validate_paths(paths=paths)
106
279
  prettier_files: list[str] = walk_files_with_excludes(
107
280
  paths=paths,
@@ -109,6 +282,17 @@ class PrettierTool(BaseTool):
109
282
  exclude_patterns=self.exclude_patterns,
110
283
  include_venv=self.include_venv,
111
284
  )
285
+ logger.debug(
286
+ f"[PrettierTool] Discovered {len(prettier_files)} files matching patterns: "
287
+ f"{self.config.file_patterns}",
288
+ )
289
+ logger.debug(
290
+ f"[PrettierTool] Exclude patterns applied: {self.exclude_patterns}",
291
+ )
292
+ if prettier_files:
293
+ logger.debug(
294
+ f"[PrettierTool] Files to check (first 10): " f"{prettier_files[:10]}",
295
+ )
112
296
  if not prettier_files:
113
297
  return Tool.to_result(
114
298
  name=self.name,
@@ -118,6 +302,7 @@ class PrettierTool(BaseTool):
118
302
  )
119
303
  # Use relative paths and set cwd to the common parent
120
304
  cwd: str = self.get_cwd(paths=prettier_files)
305
+ logger.debug(f"[PrettierTool] Working directory: {cwd}")
121
306
  rel_files: list[str] = [
122
307
  os.path.relpath(f, cwd) if cwd else f for f in prettier_files
123
308
  ]
@@ -125,14 +310,53 @@ class PrettierTool(BaseTool):
125
310
  cmd: list[str] = self._get_executable_command(tool_name="prettier") + [
126
311
  "--check",
127
312
  ]
128
- # Do not force config; rely on native discovery via cwd
313
+
314
+ # Add Lintro config injection args (--no-config, --config)
315
+ # This takes precedence over native config auto-discovery
316
+ config_args = self._build_config_args()
317
+ if config_args:
318
+ cmd.extend(config_args)
319
+ logger.debug(
320
+ "[PrettierTool] Using Lintro config injection",
321
+ )
322
+ else:
323
+ # Fallback: Find config and ignore files by walking up from cwd
324
+ found_config = self._find_prettier_config(search_dir=cwd)
325
+ if found_config:
326
+ logger.debug(
327
+ f"[PrettierTool] Found config: {found_config} (auto-detecting)",
328
+ )
329
+ else:
330
+ logger.debug(
331
+ "[PrettierTool] No prettier config file found (using defaults)",
332
+ )
333
+ # Apply line_length as --print-width if set and no config found
334
+ line_length = self.options.get("line_length")
335
+ if line_length:
336
+ cmd.extend(["--print-width", str(line_length)])
337
+ logger.debug(
338
+ "[PrettierTool] Using --print-width=%s from options",
339
+ line_length,
340
+ )
341
+ # Find .prettierignore by walking up from cwd
342
+ prettierignore_path = self._find_prettierignore(search_dir=cwd)
343
+ if prettierignore_path:
344
+ logger.debug(
345
+ f"[PrettierTool] Found .prettierignore: {prettierignore_path} "
346
+ "(auto-detecting)",
347
+ )
348
+
129
349
  cmd.extend(rel_files)
130
350
  logger.debug(f"[PrettierTool] Running: {' '.join(cmd)} (cwd={cwd})")
131
- result = self._run_subprocess(
132
- cmd=cmd,
133
- timeout=self.options.get("timeout", self._default_timeout),
134
- cwd=cwd,
135
- )
351
+ timeout_val: int = self.options.get("timeout", self._default_timeout)
352
+ try:
353
+ result = self._run_subprocess(
354
+ cmd=cmd,
355
+ timeout=timeout_val,
356
+ cwd=cwd,
357
+ )
358
+ except subprocess.TimeoutExpired:
359
+ return self._create_timeout_result(timeout_val=timeout_val)
136
360
  output: str = result[1]
137
361
  # Do not filter lines post-hoc; rely on discovery and ignore files
138
362
  issues: list = parse_prettier_output(output=output)
@@ -164,6 +388,11 @@ class PrettierTool(BaseTool):
164
388
  Returns:
165
389
  ToolResult: Result object with counts and messages.
166
390
  """
391
+ # Check version requirements
392
+ version_result = self._verify_tool_version()
393
+ if version_result is not None:
394
+ return version_result
395
+
167
396
  self._validate_paths(paths=paths)
168
397
  prettier_files: list[str] = walk_files_with_excludes(
169
398
  paths=paths,
@@ -185,18 +414,55 @@ class PrettierTool(BaseTool):
185
414
  os.path.relpath(f, cwd) if cwd else f for f in prettier_files
186
415
  ]
187
416
 
417
+ # Get Lintro config injection args (--no-config, --config)
418
+ config_args = self._build_config_args()
419
+ fallback_args: list[str] = []
420
+ if not config_args:
421
+ # Fallback: Find config and ignore files by walking up from cwd
422
+ found_config = self._find_prettier_config(search_dir=cwd)
423
+ if found_config:
424
+ logger.debug(
425
+ f"[PrettierTool] Found config: {found_config} (auto-detecting)",
426
+ )
427
+ else:
428
+ logger.debug(
429
+ "[PrettierTool] No prettier config file found (using defaults)",
430
+ )
431
+ # Apply line_length as --print-width if set and no config found
432
+ line_length = self.options.get("line_length")
433
+ if line_length:
434
+ fallback_args.extend(["--print-width", str(line_length)])
435
+ logger.debug(
436
+ "[PrettierTool] Using --print-width=%s from options",
437
+ line_length,
438
+ )
439
+ prettierignore_path = self._find_prettierignore(search_dir=cwd)
440
+ if prettierignore_path:
441
+ logger.debug(
442
+ f"[PrettierTool] Found .prettierignore: {prettierignore_path} "
443
+ "(auto-detecting)",
444
+ )
445
+
188
446
  # Check for issues first
189
447
  check_cmd: list[str] = self._get_executable_command(tool_name="prettier") + [
190
448
  "--check",
191
449
  ]
192
- # Do not force config; rely on native discovery via cwd
450
+ # Add Lintro config injection if available, otherwise use fallback args
451
+ if config_args:
452
+ check_cmd.extend(config_args)
453
+ elif fallback_args:
454
+ check_cmd.extend(fallback_args)
193
455
  check_cmd.extend(rel_files)
194
456
  logger.debug(f"[PrettierTool] Checking: {' '.join(check_cmd)} (cwd={cwd})")
195
- check_result = self._run_subprocess(
196
- cmd=check_cmd,
197
- timeout=self.options.get("timeout", self._default_timeout),
198
- cwd=cwd,
199
- )
457
+ timeout_val: int = self.options.get("timeout", self._default_timeout)
458
+ try:
459
+ check_result = self._run_subprocess(
460
+ cmd=check_cmd,
461
+ timeout=timeout_val,
462
+ cwd=cwd,
463
+ )
464
+ except subprocess.TimeoutExpired:
465
+ return self._create_timeout_result(timeout_val=timeout_val)
200
466
  check_output: str = check_result[1]
201
467
 
202
468
  # Parse initial issues
@@ -207,21 +473,40 @@ class PrettierTool(BaseTool):
207
473
  fix_cmd: list[str] = self._get_executable_command(tool_name="prettier") + [
208
474
  "--write",
209
475
  ]
476
+ # Add Lintro config injection if available, otherwise use fallback args
477
+ if config_args:
478
+ fix_cmd.extend(config_args)
479
+ elif fallback_args:
480
+ fix_cmd.extend(fallback_args)
210
481
  fix_cmd.extend(rel_files)
211
482
  logger.debug(f"[PrettierTool] Fixing: {' '.join(fix_cmd)} (cwd={cwd})")
212
- fix_result = self._run_subprocess(
213
- cmd=fix_cmd,
214
- timeout=self.options.get("timeout", self._default_timeout),
215
- cwd=cwd,
216
- )
483
+ try:
484
+ fix_result = self._run_subprocess(
485
+ cmd=fix_cmd,
486
+ timeout=timeout_val,
487
+ cwd=cwd,
488
+ )
489
+ except subprocess.TimeoutExpired:
490
+ return self._create_timeout_result(
491
+ timeout_val=timeout_val,
492
+ initial_issues=initial_issues,
493
+ initial_count=initial_count,
494
+ )
217
495
  fix_output: str = fix_result[1]
218
496
 
219
497
  # Check for remaining issues after fixing
220
- final_check_result = self._run_subprocess(
221
- cmd=check_cmd,
222
- timeout=self.options.get("timeout", self._default_timeout),
223
- cwd=cwd,
224
- )
498
+ try:
499
+ final_check_result = self._run_subprocess(
500
+ cmd=check_cmd,
501
+ timeout=timeout_val,
502
+ cwd=cwd,
503
+ )
504
+ except subprocess.TimeoutExpired:
505
+ return self._create_timeout_result(
506
+ timeout_val=timeout_val,
507
+ initial_issues=initial_issues,
508
+ initial_count=initial_count,
509
+ )
225
510
  final_check_output: str = final_check_result[1]
226
511
  remaining_issues: list = parse_prettier_output(output=final_check_output)
227
512
  remaining_count: int = len(remaining_issues)
@@ -261,15 +546,17 @@ class PrettierTool(BaseTool):
261
546
  # Success means no remaining issues
262
547
  success: bool = remaining_count == 0
263
548
 
549
+ # Combine initial and remaining issues so formatter can split them by fixability
550
+ all_issues = (initial_issues or []) + (remaining_issues or [])
551
+
264
552
  return ToolResult(
265
553
  name=self.name,
266
554
  success=success,
267
555
  output=final_output,
268
556
  # For fix operations, issues_count represents remaining for summaries
269
557
  issues_count=remaining_count,
270
- # Provide issues so formatters can render tables. Use initial issues
271
- # (auto-fixable set) for display; fall back to remaining when none.
272
- issues=(initial_issues if initial_issues else remaining_issues),
558
+ # Provide both initial (fixed) and remaining issues for display
559
+ issues=all_issues,
273
560
  initial_issues_count=initial_count,
274
561
  fixed_issues_count=fixed_count,
275
562
  remaining_issues_count=remaining_count,