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
@@ -3,6 +3,7 @@
3
3
  import subprocess # nosec B404 - used safely with shell disabled
4
4
  from dataclasses import dataclass, field
5
5
 
6
+ import click
6
7
  from loguru import logger
7
8
 
8
9
  from lintro.enums.hadolint_enums import (
@@ -216,6 +217,11 @@ class HadolintTool(BaseTool):
216
217
  Returns:
217
218
  ToolResult: ToolResult instance.
218
219
  """
220
+ # Check version requirements
221
+ version_result = self._verify_tool_version()
222
+ if version_result is not None:
223
+ return version_result
224
+
219
225
  self._validate_paths(paths=paths)
220
226
  if not paths:
221
227
  return ToolResult(
@@ -240,38 +246,101 @@ class HadolintTool(BaseTool):
240
246
  all_issues: list = []
241
247
  all_success: bool = True
242
248
  skipped_files: list[str] = []
249
+ execution_failures: int = 0
243
250
  total_issues: int = 0
244
251
 
245
- for file_path in dockerfile_files:
246
- cmd: list[str] = self._build_command() + [str(file_path)]
247
- try:
248
- success: bool
249
- output: str
250
- success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
251
- issues = parse_hadolint_output(output=output)
252
- issues_count: int = len(issues)
253
- # Tool is successful if subprocess succeeds, regardless of issues found
254
- if not success:
252
+ # Show progress bar only when processing multiple files
253
+ if len(dockerfile_files) >= 2:
254
+ with click.progressbar(
255
+ dockerfile_files,
256
+ label="Processing files",
257
+ bar_template="%(label)s %(info)s",
258
+ ) as bar:
259
+ for file_path in bar:
260
+ cmd: list[str] = self._build_command() + [str(file_path)]
261
+ try:
262
+ success: bool
263
+ output: str
264
+ success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
265
+ issues = parse_hadolint_output(output=output)
266
+ issues_count: int = len(issues)
267
+ # Tool is successful if subprocess succeeds,
268
+ # regardless of issues found
269
+ if not success:
270
+ all_success = False
271
+ total_issues += issues_count
272
+ # Prefer parsed issues for formatted output;
273
+ # keep raw for metadata
274
+ # Preserve output when subprocess fails even if parsing
275
+ # yields no issues
276
+ if not success or issues:
277
+ all_outputs.append(output)
278
+ if issues:
279
+ all_issues.extend(issues)
280
+ except subprocess.TimeoutExpired:
281
+ skipped_files.append(file_path)
282
+ all_success = False
283
+ # Count timeout as an execution failure
284
+ execution_failures += 1
285
+ except Exception as e:
286
+ all_outputs.append(f"Error processing {file_path}: {str(e)}")
287
+ all_success = False
288
+ # Count execution errors as failures
289
+ execution_failures += 1
290
+ else:
291
+ # Process without progress bar for single file or no files
292
+ for file_path in dockerfile_files:
293
+ cmd: list[str] = self._build_command() + [str(file_path)]
294
+ try:
295
+ success: bool
296
+ output: str
297
+ success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
298
+ issues = parse_hadolint_output(output=output)
299
+ issues_count: int = len(issues)
300
+ # Tool is successful if subprocess succeeds,
301
+ # regardless of issues found
302
+ if not success:
303
+ all_success = False
304
+ total_issues += issues_count
305
+ # Prefer parsed issues for formatted output;
306
+ # keep raw for metadata
307
+ # Preserve output when subprocess fails even if parsing
308
+ # yields no issues
309
+ if not success or issues:
310
+ all_outputs.append(output)
311
+ if issues:
312
+ all_issues.extend(issues)
313
+ except subprocess.TimeoutExpired:
314
+ skipped_files.append(file_path)
315
+ all_success = False
316
+ # Count timeout as an execution failure
317
+ execution_failures += 1
318
+ except Exception as e:
319
+ all_outputs.append(f"Error processing {file_path}: {str(e)}")
255
320
  all_success = False
256
- total_issues += issues_count
257
- # Prefer parsed issues for formatted output; keep raw for metadata
258
- if issues:
259
- all_outputs.append(output)
260
- all_issues.extend(issues)
261
- except subprocess.TimeoutExpired:
262
- skipped_files.append(file_path)
263
- all_success = False
264
- except Exception as e:
265
- all_outputs.append(f"Error processing {file_path}: {str(e)}")
266
- all_success = False
321
+ # Count execution errors as failures
322
+ execution_failures += 1
267
323
 
268
324
  output: str = "\n".join(all_outputs) if all_outputs else ""
269
- if skipped_files:
325
+ if execution_failures > 0:
270
326
  if output:
271
327
  output += "\n\n"
272
- output += f"Skipped {len(skipped_files)} files due to timeout:"
273
- for file in skipped_files:
274
- output += f"\n - {file}"
328
+ if skipped_files:
329
+ output += (
330
+ f"Skipped/failed {execution_failures} file(s) due to "
331
+ f"execution failures (including timeouts)"
332
+ )
333
+ if timeout:
334
+ output += f" (timeout: {timeout}s)"
335
+ output += ":"
336
+ for file in skipped_files:
337
+ output += f"\n - {file}"
338
+ else:
339
+ # Execution failures but no skipped files (all were exceptions)
340
+ output += (
341
+ f"Failed to process {execution_failures} file(s) "
342
+ "due to execution errors"
343
+ )
275
344
 
276
345
  if not output.strip():
277
346
  output = None
@@ -0,0 +1,354 @@
1
+ """Markdownlint-cli2 Markdown linter integration."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess # nosec B404 - used safely with shell disabled
6
+ import tempfile
7
+ from dataclasses import dataclass, field
8
+
9
+ from loguru import logger
10
+
11
+ from lintro.enums.tool_type import ToolType
12
+ from lintro.models.core.tool import ToolConfig, ToolResult
13
+ from lintro.parsers.markdownlint.markdownlint_parser import parse_markdownlint_output
14
+ from lintro.tools.core.tool_base import BaseTool
15
+ from lintro.utils.config import get_central_line_length
16
+ from lintro.utils.tool_utils import walk_files_with_excludes
17
+ from lintro.utils.unified_config import DEFAULT_TOOL_PRIORITIES
18
+
19
+ # Constants
20
+ MARKDOWNLINT_DEFAULT_TIMEOUT: int = 30
21
+ # Use centralized priority from unified_config.py for consistency
22
+ MARKDOWNLINT_DEFAULT_PRIORITY: int = DEFAULT_TOOL_PRIORITIES.get("markdownlint", 30)
23
+ MARKDOWNLINT_FILE_PATTERNS: list[str] = [
24
+ "*.md",
25
+ "*.markdown",
26
+ ]
27
+
28
+
29
+ @dataclass
30
+ class MarkdownlintTool(BaseTool):
31
+ """Markdownlint-cli2 Markdown linter integration.
32
+
33
+ Markdownlint-cli2 is a linter for Markdown files that checks for style
34
+ issues and best practices.
35
+
36
+ Attributes:
37
+ name: Tool name
38
+ description: Tool description
39
+ can_fix: Whether the tool can fix issues
40
+ config: Tool configuration
41
+ exclude_patterns: List of patterns to exclude
42
+ include_venv: Whether to include virtual environment files
43
+ """
44
+
45
+ name: str = "markdownlint"
46
+ description: str = "Markdown linter for style checking and best practices"
47
+ can_fix: bool = False
48
+ config: ToolConfig = field(
49
+ default_factory=lambda: ToolConfig(
50
+ priority=MARKDOWNLINT_DEFAULT_PRIORITY,
51
+ conflicts_with=[],
52
+ file_patterns=MARKDOWNLINT_FILE_PATTERNS,
53
+ tool_type=ToolType.LINTER,
54
+ options={
55
+ "timeout": MARKDOWNLINT_DEFAULT_TIMEOUT,
56
+ },
57
+ ),
58
+ )
59
+
60
+ def __post_init__(self) -> None:
61
+ """Initialize the tool."""
62
+ super().__post_init__()
63
+
64
+ def _verify_tool_version(self) -> ToolResult | None:
65
+ """Verify that markdownlint-cli2 meets minimum version requirements.
66
+
67
+ Overrides base implementation to use the correct executable name.
68
+
69
+ Returns:
70
+ Optional[ToolResult]: None if version check passes, or a skip result
71
+ if it fails
72
+ """
73
+ from lintro.tools.core.version_requirements import check_tool_version
74
+
75
+ # Use the correct command for markdownlint-cli2
76
+ command = self._get_markdownlint_command()
77
+ version_info = check_tool_version(self.name, command)
78
+
79
+ if version_info.version_check_passed:
80
+ return None # Version check passed
81
+
82
+ # Version check failed - return skip result with warning
83
+ skip_message = (
84
+ f"Skipping {self.name}: {version_info.error_message}. "
85
+ f"Minimum required: {version_info.min_version}. "
86
+ f"{version_info.install_hint}"
87
+ )
88
+
89
+ return ToolResult(
90
+ name=self.name,
91
+ success=True, # Not an error, just skipping
92
+ output=skip_message,
93
+ issues_count=0,
94
+ )
95
+
96
+ def set_options(
97
+ self,
98
+ timeout: int | None = None,
99
+ line_length: int | None = None,
100
+ **kwargs,
101
+ ) -> None:
102
+ """Set Markdownlint-specific options.
103
+
104
+ Args:
105
+ timeout: Timeout in seconds per file (default: 30)
106
+ line_length: Line length for MD013 rule. If not provided, uses
107
+ central line_length from [tool.lintro] or falls back to Ruff's
108
+ line-length setting.
109
+ **kwargs: Other tool options
110
+
111
+ Raises:
112
+ ValueError: If timeout is not an integer or is not positive, or
113
+ if line_length is not an integer or is not positive
114
+ """
115
+ if timeout is not None:
116
+ if not isinstance(timeout, int):
117
+ raise ValueError("timeout must be an integer")
118
+ if timeout <= 0:
119
+ raise ValueError("timeout must be positive")
120
+
121
+ # Use provided line_length, or get from central config
122
+ if line_length is None:
123
+ line_length = get_central_line_length()
124
+
125
+ if line_length is not None:
126
+ if not isinstance(line_length, int):
127
+ raise ValueError("line_length must be an integer")
128
+ if line_length <= 0:
129
+ raise ValueError("line_length must be positive")
130
+ # Store for use in check() method
131
+ self.options["line_length"] = line_length
132
+
133
+ super().set_options(timeout=timeout, **kwargs)
134
+
135
+ def _get_markdownlint_command(self) -> list[str]:
136
+ """Get the command to run markdownlint-cli2.
137
+
138
+ Returns:
139
+ list[str]: Command arguments for markdownlint-cli2.
140
+ """
141
+ import shutil
142
+
143
+ # Use npx to run markdownlint-cli2 (similar to prettier)
144
+ if shutil.which("npx"):
145
+ return ["npx", "--yes", "markdownlint-cli2"]
146
+ # Fallback to direct executable if npx not found
147
+ return ["markdownlint-cli2"]
148
+
149
+ def _create_temp_markdownlint_config(
150
+ self,
151
+ line_length: int,
152
+ ) -> str | None:
153
+ """Create a temporary markdownlint-cli2 config with the specified line length.
154
+
155
+ Creates a temp file with MD013 rule configured. This avoids modifying
156
+ the user's project files.
157
+
158
+ Args:
159
+ line_length: Line length to configure for MD013 rule.
160
+
161
+ Returns:
162
+ Path to the temporary config file, or None if creation failed.
163
+ """
164
+ config_wrapper: dict[str, object] = {
165
+ "config": {
166
+ "MD013": {
167
+ "line_length": line_length,
168
+ "code_blocks": False,
169
+ "tables": False,
170
+ },
171
+ },
172
+ }
173
+
174
+ try:
175
+ # Create a temp file that persists until explicitly deleted
176
+ # Using delete=False so it survives the subprocess call
177
+ # markdownlint-cli2 requires config files to follow specific naming
178
+ # conventions - the file must end with ".markdownlint-cli2.jsonc"
179
+ # or be named ".markdownlint-cli2.jsonc"
180
+ with tempfile.NamedTemporaryFile(
181
+ mode="w",
182
+ suffix=".markdownlint-cli2.jsonc",
183
+ prefix="lintro-",
184
+ delete=False,
185
+ encoding="utf-8",
186
+ ) as f:
187
+ json.dump(config_wrapper, f, indent=2)
188
+ temp_path = f.name
189
+
190
+ logger.debug(
191
+ f"[MarkdownlintTool] Created temp config at {temp_path} "
192
+ f"with line_length={line_length}",
193
+ )
194
+ return temp_path
195
+
196
+ except (PermissionError, OSError) as e:
197
+ logger.warning(
198
+ f"[MarkdownlintTool] Could not create temp config file: {e}",
199
+ )
200
+ return None
201
+
202
+ def check(
203
+ self,
204
+ paths: list[str],
205
+ ) -> ToolResult:
206
+ """Check files with Markdownlint.
207
+
208
+ Args:
209
+ paths: List of file or directory paths to check.
210
+
211
+ Returns:
212
+ ToolResult: Result of the check operation.
213
+ """
214
+ # Check version requirements
215
+ version_result = self._verify_tool_version()
216
+ if version_result is not None:
217
+ return version_result
218
+
219
+ self._validate_paths(paths=paths)
220
+ if not paths:
221
+ return ToolResult(
222
+ name=self.name,
223
+ success=True,
224
+ output="No files to check.",
225
+ issues_count=0,
226
+ )
227
+
228
+ markdown_files: list[str] = walk_files_with_excludes(
229
+ paths=paths,
230
+ file_patterns=self.config.file_patterns,
231
+ exclude_patterns=self.exclude_patterns,
232
+ include_venv=self.include_venv,
233
+ )
234
+
235
+ logger.debug(
236
+ f"[MarkdownlintTool] Discovered {len(markdown_files)} files matching "
237
+ f"patterns: {self.config.file_patterns}",
238
+ )
239
+ logger.debug(
240
+ f"[MarkdownlintTool] Exclude patterns applied: {self.exclude_patterns}",
241
+ )
242
+ if markdown_files:
243
+ logger.debug(
244
+ f"[MarkdownlintTool] Files to check (first 10): "
245
+ f"{markdown_files[:10]}",
246
+ )
247
+
248
+ if not markdown_files:
249
+ return ToolResult(
250
+ name=self.name,
251
+ success=True,
252
+ output="No Markdown files found to check.",
253
+ issues_count=0,
254
+ )
255
+
256
+ # Use relative paths and set cwd to the common parent
257
+ cwd: str | None = self.get_cwd(paths=markdown_files)
258
+ logger.debug(f"[MarkdownlintTool] Working directory: {cwd}")
259
+ rel_files: list[str] = [
260
+ os.path.relpath(f, cwd) if cwd else f for f in markdown_files
261
+ ]
262
+
263
+ # Build command
264
+ cmd: list[str] = self._get_markdownlint_command()
265
+
266
+ # Track temp config for cleanup
267
+ temp_config_path: str | None = None
268
+
269
+ # Try Lintro config injection first
270
+ config_args = self._build_config_args()
271
+ if config_args:
272
+ cmd.extend(config_args)
273
+ logger.debug("[MarkdownlintTool] Using Lintro config injection")
274
+ else:
275
+ # Fallback: Apply line_length configuration if set
276
+ line_length = self.options.get("line_length")
277
+ if line_length:
278
+ temp_config_path = self._create_temp_markdownlint_config(
279
+ line_length=line_length,
280
+ )
281
+ if temp_config_path:
282
+ cmd.extend(["--config", temp_config_path])
283
+
284
+ cmd.extend(rel_files)
285
+
286
+ logger.debug(f"[MarkdownlintTool] Running: {' '.join(cmd)} (cwd={cwd})")
287
+
288
+ timeout_val: int = self.options.get("timeout", MARKDOWNLINT_DEFAULT_TIMEOUT)
289
+ try:
290
+ success, output = self._run_subprocess(
291
+ cmd=cmd,
292
+ timeout=timeout_val,
293
+ cwd=cwd,
294
+ )
295
+ except subprocess.TimeoutExpired:
296
+ timeout_msg = (
297
+ f"Markdownlint execution timed out ({timeout_val}s limit exceeded).\n\n"
298
+ "This may indicate:\n"
299
+ " - Large codebase taking too long to process\n"
300
+ " - Need to increase timeout via --tool-options markdownlint:timeout=N"
301
+ )
302
+ return ToolResult(
303
+ name=self.name,
304
+ success=False,
305
+ output=timeout_msg,
306
+ issues_count=1,
307
+ )
308
+ finally:
309
+ # Clean up temp config file if created
310
+ if temp_config_path:
311
+ try:
312
+ os.unlink(temp_config_path)
313
+ logger.debug(
314
+ "[MarkdownlintTool] Cleaned up temp config: "
315
+ f"{temp_config_path}",
316
+ )
317
+ except OSError as e:
318
+ logger.debug(
319
+ f"[MarkdownlintTool] Failed to clean up temp config: {e}",
320
+ )
321
+
322
+ # Parse output
323
+ issues = parse_markdownlint_output(output=output)
324
+ issues_count: int = len(issues)
325
+ success_flag: bool = success and issues_count == 0
326
+
327
+ # Suppress output when no issues found
328
+ if success_flag:
329
+ output = None
330
+
331
+ return ToolResult(
332
+ name=self.name,
333
+ success=success_flag,
334
+ output=output,
335
+ issues_count=issues_count,
336
+ issues=issues,
337
+ )
338
+
339
+ def fix(
340
+ self,
341
+ paths: list[str],
342
+ ) -> ToolResult:
343
+ """Markdownlint cannot fix issues, only report them.
344
+
345
+ Args:
346
+ paths: List of file or directory paths to fix.
347
+
348
+ Raises:
349
+ NotImplementedError: Markdownlint is a linter only and cannot fix issues.
350
+ """
351
+ raise NotImplementedError(
352
+ "Markdownlint cannot fix issues; use a Markdown formatter like Prettier "
353
+ "for formatting.",
354
+ )