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
@@ -0,0 +1,112 @@
1
+ """Shared timeout handling utilities for tool implementations.
2
+
3
+ This module provides standardized timeout handling across different tools,
4
+ ensuring consistent behavior and error messages for subprocess timeouts.
5
+ """
6
+
7
+ import subprocess # nosec B404 - used safely with shell disabled
8
+ from typing import Any
9
+
10
+ from loguru import logger
11
+
12
+
13
+ def run_subprocess_with_timeout(
14
+ tool,
15
+ cmd: list[str],
16
+ timeout: int | None = None,
17
+ cwd: str | None = None,
18
+ tool_name: str | None = None,
19
+ ) -> tuple[bool, str]:
20
+ """Run a subprocess command with timeout handling.
21
+
22
+ This is a wrapper around tool._run_subprocess that provides consistent
23
+ timeout error handling and messaging across different tools.
24
+
25
+ Args:
26
+ tool: Tool instance with _run_subprocess method.
27
+ cmd: Command to run.
28
+ timeout: Timeout in seconds. If None, uses tool's default timeout.
29
+ cwd: Working directory for command execution.
30
+ tool_name: Name of the tool for error messages. If None, uses tool.name.
31
+
32
+ Returns:
33
+ tuple[bool, str]: (success, output) where success is True if command
34
+ succeeded without timeout, and output contains command output or
35
+ timeout error message.
36
+
37
+ Raises:
38
+ TimeoutExpired: If command times out (re-raised from subprocess).
39
+ """
40
+ tool_name = tool_name or tool.name
41
+
42
+ try:
43
+ return tool._run_subprocess(cmd=cmd, timeout=timeout, cwd=cwd)
44
+ except subprocess.TimeoutExpired as e:
45
+ # Re-raise with more context for the calling tool
46
+ actual_timeout = timeout or tool.options.get("timeout", tool._default_timeout)
47
+ timeout_msg = (
48
+ f"{tool_name} execution timed out ({actual_timeout}s limit exceeded).\n\n"
49
+ "This may indicate:\n"
50
+ " - Large codebase taking too long to process\n"
51
+ " - Need to increase timeout via --tool-options timeout=N\n"
52
+ " - Command hanging due to external dependencies\n"
53
+ )
54
+ logger.warning(timeout_msg)
55
+
56
+ # Create a new TimeoutExpired with enhanced message
57
+ raise subprocess.TimeoutExpired(
58
+ cmd=cmd,
59
+ timeout=actual_timeout,
60
+ output=timeout_msg,
61
+ ) from e
62
+
63
+
64
+ def get_timeout_value(tool, default_timeout: int | None = None) -> int:
65
+ """Get timeout value from tool options with fallback to default.
66
+
67
+ Args:
68
+ tool: Tool instance with options.
69
+ default_timeout: Default timeout if not specified in options.
70
+
71
+ Returns:
72
+ int: Timeout value in seconds.
73
+ """
74
+ if default_timeout is None:
75
+ default_timeout = getattr(tool, "_default_timeout", 300)
76
+
77
+ return tool.options.get("timeout", default_timeout)
78
+
79
+
80
+ def create_timeout_result(
81
+ tool,
82
+ timeout: int,
83
+ cmd: list[str] | None = None,
84
+ tool_name: str | None = None,
85
+ ) -> dict[str, Any]:
86
+ """Create a standardized timeout result dictionary.
87
+
88
+ Args:
89
+ tool: Tool instance.
90
+ timeout: Timeout value that was exceeded.
91
+ cmd: Optional command that timed out.
92
+ tool_name: Optional tool name override.
93
+
94
+ Returns:
95
+ dict: Result dictionary with timeout information.
96
+ """
97
+ tool_name = tool_name or tool.name
98
+
99
+ return {
100
+ "success": False,
101
+ "output": (
102
+ f"{tool_name} execution timed out ({timeout}s limit exceeded).\n\n"
103
+ "This may indicate:\n"
104
+ " - Large codebase taking too long to process\n"
105
+ " - Need to increase timeout via --tool-options timeout=N\n"
106
+ " - Command hanging due to external dependencies\n"
107
+ ),
108
+ "issues_count": 1, # Count timeout as execution failure
109
+ "issues": [],
110
+ "timed_out": True,
111
+ "timeout_seconds": timeout,
112
+ }
@@ -1,15 +1,20 @@
1
1
  """Base core implementation for Lintro."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import os
4
6
  import shutil
5
7
  import subprocess # nosec B404 - subprocess used safely with shell=False
6
8
  from abc import ABC, abstractmethod
7
9
  from dataclasses import dataclass, field
10
+ from pathlib import Path
8
11
 
9
12
  from loguru import logger
10
13
 
14
+ from lintro.config import LintroConfig
11
15
  from lintro.enums.tool_type import ToolType
12
16
  from lintro.models.core.tool import ToolConfig, ToolResult
17
+ from lintro.utils.path_utils import find_lintro_ignore
13
18
 
14
19
  # Constants for default values
15
20
  DEFAULT_TIMEOUT: int = 30
@@ -91,6 +96,17 @@ class BaseTool(ABC):
91
96
  if not isinstance(self.config.tool_type, ToolType):
92
97
  raise ValueError("Tool tool_type must be a ToolType instance")
93
98
 
99
+ def _find_lintro_ignore(self) -> str | None:
100
+ """Find .lintro-ignore file by searching upward from current directory.
101
+
102
+ Uses the shared utility function to ensure consistent behavior.
103
+
104
+ Returns:
105
+ str | None: Path to .lintro-ignore file if found, None otherwise.
106
+ """
107
+ lintro_ignore_path = find_lintro_ignore()
108
+ return str(lintro_ignore_path) if lintro_ignore_path else None
109
+
94
110
  def _setup_defaults(self) -> None:
95
111
  """Set up default core options and patterns."""
96
112
  # Add default exclude patterns if not already present
@@ -99,9 +115,10 @@ class BaseTool(ABC):
99
115
  self.exclude_patterns.append(pattern)
100
116
 
101
117
  # Add .lintro-ignore patterns (project-wide) if present
118
+ # Search upward from current directory to find project root
102
119
  try:
103
- lintro_ignore_path = os.path.abspath(".lintro-ignore")
104
- if os.path.exists(lintro_ignore_path):
120
+ lintro_ignore_path = self._find_lintro_ignore()
121
+ if lintro_ignore_path and os.path.exists(lintro_ignore_path):
105
122
  with open(lintro_ignore_path, encoding="utf-8") as f:
106
123
  for line in f:
107
124
  line_stripped = line.strip()
@@ -282,64 +299,257 @@ class BaseTool(ABC):
282
299
  ) -> list[str]:
283
300
  """Get the command prefix to execute a tool.
284
301
 
285
- Prefer running via ``uv run`` when available to ensure the tool executes
286
- within the active Python environment, avoiding PATH collisions with
287
- user-level shims. Fall back to a direct executable when ``uv`` is not
288
- present, and finally to the bare tool name.
302
+ Uses a unified approach based on tool category:
303
+ - Python bundled tools: Use python -m (guaranteed to use lintro's environment)
304
+ - Node.js tools: Use npx (respects project's package.json)
305
+ - Binary tools: Use system executable
289
306
 
290
307
  Args:
291
308
  tool_name: str: Name of the tool executable to find.
292
309
 
293
310
  Returns:
294
311
  list[str]: Command prefix to execute the tool.
295
-
296
- Examples:
297
- >>> self._get_executable_command("ruff")
298
- ["uv", "run", "ruff"] # preferred when uv is available
299
-
300
- >>> self._get_executable_command("ruff")
301
- ["ruff"] # if uv is not available but the tool is on PATH
302
312
  """
303
- # Tool-specific preferences to balance reliability vs. historical expectations
304
- python_tools_prefer_uv = {"black", "bandit", "yamllint", "darglint"}
305
-
306
- # Ruff: keep historical expectation for tests (direct invocation first)
307
- if tool_name == "ruff":
308
- if shutil.which(tool_name):
309
- return [tool_name]
310
- if shutil.which("uv"):
311
- return ["uv", "run", tool_name]
313
+ import sys
314
+
315
+ # Python tools bundled with lintro (guaranteed in our environment)
316
+ # Note: darglint cannot be run as a module (python -m darglint),
317
+ # so it's excluded
318
+ python_bundled_tools = {"ruff", "black", "bandit", "yamllint"}
319
+ if tool_name in python_bundled_tools:
320
+ # Use python -m to ensure we use the tool from lintro's environment
321
+ python_exe = sys.executable
322
+ if python_exe:
323
+ return [python_exe, "-m", tool_name]
324
+ # Fallback to direct executable if python path not found
312
325
  return [tool_name]
313
326
 
314
- # Black: prefer system binary first, then project env via uv run,
315
- # and finally uvx as a last resort.
316
- if tool_name == "black":
317
- if shutil.which(tool_name):
318
- return [tool_name]
319
- if shutil.which("uv"):
320
- return ["uv", "run", tool_name]
321
- if shutil.which("uvx"):
322
- return ["uvx", tool_name]
327
+ # Pytest: user environment tool (not bundled)
328
+ if tool_name == "pytest":
329
+ # Use python -m pytest for cross-platform compatibility
330
+ python_exe = sys.executable
331
+ if python_exe:
332
+ return [python_exe, "-m", "pytest"]
333
+ # Fall back to direct executable
323
334
  return [tool_name]
324
335
 
325
- # Python-based tools where running inside env avoids PATH shim issues
326
- if tool_name in python_tools_prefer_uv:
327
- if shutil.which(tool_name):
328
- return [tool_name]
329
- if shutil.which("uvx"):
330
- return ["uvx", tool_name]
331
- if shutil.which("uv"):
332
- return ["uv", "run", tool_name]
336
+ # Node.js tools: use npx to respect project's package.json
337
+ nodejs_tools = {"eslint", "prettier"}
338
+ if tool_name in nodejs_tools:
339
+ if shutil.which("npx"):
340
+ return ["npx", "--yes", tool_name]
341
+ # Fall back to direct executable
333
342
  return [tool_name]
334
343
 
335
- # Default: prefer direct system executable (node/binary tools like
336
- # prettier, hadolint, actionlint)
337
- if shutil.which(tool_name):
338
- return [tool_name]
339
- if shutil.which("uv"):
340
- return ["uv", "run", tool_name]
344
+ # Binary tools: use system executable
341
345
  return [tool_name]
342
346
 
347
+ def _verify_tool_version(self) -> ToolResult | None:
348
+ """Verify that the tool meets minimum version requirements.
349
+
350
+ Returns:
351
+ Optional[ToolResult]: None if version check passes, or a skip result
352
+ if it fails
353
+ """
354
+ from lintro.tools.core.version_requirements import check_tool_version
355
+
356
+ command = self._get_executable_command(self.name)
357
+ version_info = check_tool_version(self.name, command)
358
+
359
+ if version_info.version_check_passed:
360
+ return None # Version check passed
361
+
362
+ # Version check failed - return skip result with warning
363
+ skip_message = (
364
+ f"Skipping {self.name}: {version_info.error_message}. "
365
+ f"Minimum required: {version_info.min_version}. "
366
+ f"{version_info.install_hint}"
367
+ )
368
+
369
+ return ToolResult(
370
+ name=self.name,
371
+ success=True, # Not an error, just skipping
372
+ output=skip_message,
373
+ issues_count=0,
374
+ )
375
+
376
+ # -------------------------------------------------------------------------
377
+ # Lintro Config Support - Tiered Model
378
+ # -------------------------------------------------------------------------
379
+
380
+ def _get_lintro_config(self) -> LintroConfig:
381
+ """Get the current Lintro configuration.
382
+
383
+ Returns:
384
+ LintroConfig: The loaded Lintro configuration.
385
+ """
386
+ from lintro.config import get_config
387
+
388
+ return get_config()
389
+
390
+ def _should_use_lintro_config(self) -> bool:
391
+ """Check if Lintro config should be used.
392
+
393
+ Returns True if:
394
+ 1. LINTRO_SKIP_CONFIG_INJECTION env var is NOT set, AND
395
+ 2. A .lintro-config.yaml exists, OR [tool.lintro] is in pyproject.toml
396
+
397
+ Returns:
398
+ bool: True if Lintro config should be used.
399
+ """
400
+ # Allow tests to disable config injection
401
+ if os.environ.get("LINTRO_SKIP_CONFIG_INJECTION"):
402
+ return False
403
+
404
+ lintro_config = self._get_lintro_config()
405
+ return lintro_config.config_path is not None
406
+
407
+ def _get_enforced_settings(self) -> set[str]:
408
+ """Get the set of settings that are enforced by Lintro config.
409
+
410
+ This allows tools to check whether a setting is already being
411
+ injected via CLI args from the enforce tier, so they can avoid
412
+ adding duplicate arguments from their own options.
413
+
414
+ Returns:
415
+ set[str]: Set of setting names like 'line_length', 'target_python'.
416
+ """
417
+ if not self._should_use_lintro_config():
418
+ return set()
419
+
420
+ lintro_config = self._get_lintro_config()
421
+ enforced: set[str] = set()
422
+
423
+ if lintro_config.enforce.line_length is not None:
424
+ enforced.add("line_length")
425
+ if lintro_config.enforce.target_python is not None:
426
+ enforced.add("target_python")
427
+
428
+ return enforced
429
+
430
+ def _get_enforce_cli_args(self) -> list[str]:
431
+ """Get CLI arguments for enforced settings.
432
+
433
+ Returns CLI args that inject enforce settings (like line_length)
434
+ directly into the tool command line.
435
+
436
+ Returns:
437
+ list[str]: CLI arguments for enforced settings.
438
+ """
439
+ from lintro.config import get_enforce_cli_args
440
+
441
+ if not self._should_use_lintro_config():
442
+ return []
443
+
444
+ lintro_config = self._get_lintro_config()
445
+ return get_enforce_cli_args(
446
+ tool_name=self.name,
447
+ lintro_config=lintro_config,
448
+ )
449
+
450
+ def _get_defaults_config_args(self) -> list[str]:
451
+ """Get CLI arguments for defaults config injection.
452
+
453
+ If the tool has no native config and defaults are defined in
454
+ the Lintro config, generates a temp config file and returns
455
+ the CLI args to pass it to the tool.
456
+
457
+ Returns:
458
+ list[str]: CLI arguments for defaults config injection.
459
+ """
460
+ from lintro.config import (
461
+ generate_defaults_config,
462
+ get_defaults_injection_args,
463
+ )
464
+
465
+ if not self._should_use_lintro_config():
466
+ return []
467
+
468
+ lintro_config = self._get_lintro_config()
469
+ config_path = generate_defaults_config(
470
+ tool_name=self.name,
471
+ lintro_config=lintro_config,
472
+ )
473
+
474
+ if config_path is None:
475
+ return []
476
+
477
+ return get_defaults_injection_args(
478
+ tool_name=self.name,
479
+ config_path=config_path,
480
+ )
481
+
482
+ def _build_config_args(self) -> list[str]:
483
+ """Build complete config-related CLI arguments for this tool.
484
+
485
+ Uses the tiered model:
486
+ 1. Enforced settings are injected via CLI flags
487
+ 2. Defaults config is used only if no native config exists
488
+
489
+ Returns:
490
+ list[str]: Combined CLI arguments for configuration.
491
+ """
492
+ args: list[str] = []
493
+
494
+ # Add enforce CLI args (e.g., --line-length 88)
495
+ args.extend(self._get_enforce_cli_args())
496
+
497
+ # Add defaults config args if applicable
498
+ args.extend(self._get_defaults_config_args())
499
+
500
+ return args
501
+
502
+ # -------------------------------------------------------------------------
503
+ # Deprecated methods for backward compatibility
504
+ # -------------------------------------------------------------------------
505
+
506
+ def _generate_tool_config(self) -> Path | None:
507
+ """Generate a temporary config file for this tool.
508
+
509
+ DEPRECATED: Use _get_enforce_cli_args() and _get_defaults_config_args()
510
+ instead.
511
+
512
+ Returns:
513
+ Path | None: Path to generated config file, or None if not needed.
514
+ """
515
+ from lintro.config import generate_defaults_config
516
+
517
+ logger.debug(
518
+ f"_generate_tool_config() is deprecated for {self.name}. "
519
+ "Use _build_config_args() or call _get_enforce_cli_args() and "
520
+ "_get_defaults_config_args() directly.",
521
+ )
522
+ lintro_config = self._get_lintro_config()
523
+ return generate_defaults_config(
524
+ tool_name=self.name,
525
+ lintro_config=lintro_config,
526
+ )
527
+
528
+ def _get_config_injection_args(self) -> list[str]:
529
+ """Get CLI arguments to inject Lintro config into this tool.
530
+
531
+ DEPRECATED: Use _get_enforce_cli_args() and _get_defaults_config_args()
532
+ instead, or use _build_config_args() which combines both.
533
+
534
+ Returns:
535
+ list[str]: CLI arguments for config injection (enforce + defaults).
536
+ """
537
+ args: list[str] = []
538
+ args.extend(self._get_enforce_cli_args())
539
+ args.extend(self._get_defaults_config_args())
540
+ return args
541
+
542
+ def _get_no_auto_config_args(self) -> list[str]:
543
+ """Get CLI arguments to disable native config auto-discovery.
544
+
545
+ DEPRECATED: No longer needed with the tiered model.
546
+ Tools use their native configs by default.
547
+
548
+ Returns:
549
+ list[str]: Empty list (no longer used).
550
+ """
551
+ return []
552
+
343
553
  @abstractmethod
344
554
  def check(
345
555
  self,
@@ -3,8 +3,11 @@
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Any
5
5
 
6
+ from loguru import logger
7
+
6
8
  from lintro.models.core.tool import Tool
7
9
  from lintro.tools.tool_enum import ToolEnum
10
+ from lintro.utils.unified_config import get_ordered_tools
8
11
 
9
12
 
10
13
  @dataclass
@@ -14,9 +17,14 @@ class ToolManager:
14
17
  This class is responsible for:
15
18
  - Tool registration
16
19
  - Tool conflict resolution
17
- - Tool execution order
20
+ - Tool execution order (priority-based, alphabetical, or custom)
18
21
  - Tool configuration management
19
22
 
23
+ Tool ordering is controlled by [tool.lintro].tool_order in pyproject.toml:
24
+ - "priority" (default): Formatters run before linters based on priority values
25
+ - "alphabetical": Tools run in alphabetical order by name
26
+ - "custom": Tools run in order specified by [tool.lintro].tool_order_custom
27
+
20
28
  Attributes:
21
29
  _tools: Dictionary mapping core names to core classes
22
30
  _check_tools: Dictionary mapping core names to core classes that can check
@@ -77,17 +85,23 @@ class ToolManager:
77
85
  ) -> list[ToolEnum]:
78
86
  """Get the order in which tools should be executed.
79
87
 
80
- This method takes into account:
81
- - Tool conflicts
82
- - Alphabetical ordering
83
- - Tool dependencies
88
+ Tool ordering is controlled by [tool.lintro].tool_order in pyproject.toml:
89
+ - "priority" (default): Formatters run before linters based on priority
90
+ - "alphabetical": Tools run in alphabetical order by name
91
+ - "custom": Tools run in order specified by [tool.lintro].tool_order_custom
92
+
93
+ This method also handles:
94
+ - Tool conflicts (unless ignore_conflicts is True)
84
95
 
85
96
  Args:
86
- tool_list: List of core names to execute
87
- ignore_conflicts: Whether to ignore core conflicts
97
+ tool_list: List of tools to order.
98
+ ignore_conflicts: If True, skip conflict checking.
88
99
 
89
100
  Returns:
90
- List of core names in alphabetical execution order
101
+ List of ToolEnum members in execution order based on configured strategy.
102
+
103
+ Raises:
104
+ ValueError: If duplicate tools are found in tool_list.
91
105
  """
92
106
  if not tool_list:
93
107
  return []
@@ -95,30 +109,69 @@ class ToolManager:
95
109
  # Get core instances
96
110
  tools = {name: self.get_tool(name) for name in tool_list}
97
111
 
98
- # Sort tools alphabetically by name
99
- if ignore_conflicts:
100
- return sorted(
101
- tool_list,
102
- key=lambda name: name.name,
112
+ # Validate for duplicate tools
113
+ seen_names: set[str] = set()
114
+ duplicates: list[str] = []
115
+ for tool in tool_list:
116
+ tool_name_lower = tool.name.lower()
117
+ if tool_name_lower in seen_names:
118
+ duplicates.append(tool.name)
119
+ else:
120
+ seen_names.add(tool_name_lower)
121
+ if duplicates:
122
+ raise ValueError(
123
+ f"Duplicate tools found in tool_list: {', '.join(duplicates)}",
103
124
  )
104
125
 
126
+ # Convert ToolEnum to tool names for unified config ordering
127
+ tool_names = [t.name.lower() for t in tool_list]
128
+ ordered_names = get_ordered_tools(tool_names)
129
+
130
+ # Map back to ToolEnum in the ordered sequence
131
+ name_to_enum = {t.name.lower(): t for t in tool_list}
132
+ sorted_tools = [
133
+ name_to_enum[name] for name in ordered_names if name in name_to_enum
134
+ ]
135
+
136
+ # Validate that all requested tools are preserved
137
+ original_names = {t.name.lower() for t in tool_list}
138
+ sorted_names = {t.name.lower() for t in sorted_tools}
139
+ missing_tools = original_names - sorted_names
140
+ if missing_tools:
141
+ # Append missing tools in their original order
142
+ missing_enums = [t for t in tool_list if t.name.lower() in missing_tools]
143
+ sorted_tools.extend(missing_enums)
144
+ logger.warning(
145
+ f"Some tools were not found in ordered list and appended: "
146
+ f"{[t.name for t in missing_enums]}",
147
+ )
148
+
149
+ if ignore_conflicts:
150
+ return sorted_tools
151
+
105
152
  # Build conflict graph
106
153
  conflict_graph: dict[ToolEnum, set[ToolEnum]] = {
107
154
  name: set() for name in tool_list
108
155
  }
156
+ # Create mapping from tool name strings to ToolEnum members
157
+ # Handle both lowercase strings and ToolEnum members in conflicts_with
158
+ name_to_enum_map = {t.name.lower(): t for t in ToolEnum}
109
159
  for name, tool in tools.items():
110
160
  for conflict in tool.config.conflicts_with:
111
- if conflict in tool_list:
112
- conflict_graph[name].add(conflict)
113
- conflict_graph[conflict].add(name)
114
-
115
- # Sort tools alphabetically by name
116
- sorted_tools = sorted(
117
- tool_list,
118
- key=lambda name: name.name,
119
- )
120
-
121
- # Resolve conflicts by keeping the first alphabetical tool
161
+ # Convert conflict string to ToolEnum if needed
162
+ conflict_enum: ToolEnum | None = None
163
+ if isinstance(conflict, str):
164
+ # Look up by lowercase name
165
+ conflict_enum = name_to_enum_map.get(conflict.lower())
166
+ elif isinstance(conflict, ToolEnum):
167
+ # Already a ToolEnum member
168
+ conflict_enum = conflict
169
+ # Only add to conflict graph if conflict_enum is in tool_list
170
+ if conflict_enum is not None and conflict_enum in tool_list:
171
+ conflict_graph[name].add(conflict_enum)
172
+ conflict_graph[conflict_enum].add(name)
173
+
174
+ # Resolve conflicts by keeping the first tool in ordered sequence
122
175
  result = []
123
176
  for tool_name in sorted_tools:
124
177
  # Check if this core conflicts with any already selected tools