crackerjack 0.20.19__tar.gz → 0.21.0__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.
Files changed (88) hide show
  1. {crackerjack-0.20.19 → crackerjack-0.21.0}/PKG-INFO +6 -6
  2. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.pre-commit-config.yaml +10 -4
  3. crackerjack-0.21.0/crackerjack/.ruff_cache/0.11.13/1867267426380906393 +0 -0
  4. crackerjack-0.21.0/crackerjack/.ruff_cache/0.12.0/5056746222905752453 +0 -0
  5. crackerjack-0.21.0/crackerjack/.ruff_cache/0.12.1/5056746222905752453 +0 -0
  6. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/__main__.py +5 -8
  7. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/crackerjack.py +241 -121
  8. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/interactive.py +119 -66
  9. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/pyproject.toml +9 -6
  10. {crackerjack-0.20.19 → crackerjack-0.21.0}/pyproject.toml +9 -6
  11. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_crackerjack.py +62 -104
  12. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_main.py +19 -12
  13. crackerjack-0.20.19/crackerjack/.ruff_cache/0.11.13/1867267426380906393 +0 -0
  14. {crackerjack-0.20.19 → crackerjack-0.21.0}/LICENSE +0 -0
  15. {crackerjack-0.20.19 → crackerjack-0.21.0}/README.md +0 -0
  16. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.gitignore +0 -0
  17. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.libcst.codemod.yaml +0 -0
  18. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.pdm.toml +0 -0
  19. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.pytest_cache/.gitignore +0 -0
  20. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.pytest_cache/CACHEDIR.TAG +0 -0
  21. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.pytest_cache/README.md +0 -0
  22. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.pytest_cache/v/cache/nodeids +0 -0
  23. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.pytest_cache/v/cache/stepwise +0 -0
  24. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/.gitignore +0 -0
  25. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.1.11/3256171999636029978 +0 -0
  26. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.1.14/602324811142551221 +0 -0
  27. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.1.4/10355199064880463147 +0 -0
  28. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.1.6/15140459877605758699 +0 -0
  29. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.1.7/1790508110482614856 +0 -0
  30. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.1.9/17041001205004563469 +0 -0
  31. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.11/18187162184424859798 +0 -0
  32. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.12/16869036553936192448 +0 -0
  33. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.12/1867267426380906393 +0 -0
  34. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.12/4240757255861806333 +0 -0
  35. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.12/4441409093023629623 +0 -0
  36. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.13/4240757255861806333 +0 -0
  37. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.2/4070660268492669020 +0 -0
  38. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.3/9818742842212983150 +0 -0
  39. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.4/9818742842212983150 +0 -0
  40. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.6/3557596832929915217 +0 -0
  41. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.7/10386934055395314831 +0 -0
  42. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.7/3557596832929915217 +0 -0
  43. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.11.8/530407680854991027 +0 -0
  44. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.2.0/10047773857155985907 +0 -0
  45. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.2.1/8522267973936635051 +0 -0
  46. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.2.2/18053836298936336950 +0 -0
  47. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.3.0/12548816621480535786 +0 -0
  48. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.3.3/11081883392474770722 +0 -0
  49. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.3.4/676973378459347183 +0 -0
  50. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.3.5/16311176246009842383 +0 -0
  51. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.5.7/1493622539551733492 +0 -0
  52. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.5.7/6231957614044513175 +0 -0
  53. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.5.7/9932762556785938009 +0 -0
  54. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.6.0/11982804814124138945 +0 -0
  55. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.6.0/12055761203849489982 +0 -0
  56. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.6.2/1206147804896221174 +0 -0
  57. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.6.4/1206147804896221174 +0 -0
  58. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.6.5/1206147804896221174 +0 -0
  59. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.6.7/3657366982708166874 +0 -0
  60. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.6.9/285614542852677309 +0 -0
  61. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.7.1/1024065805990144819 +0 -0
  62. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.7.1/285614542852677309 +0 -0
  63. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.7.3/16061516852537040135 +0 -0
  64. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.8.4/16354268377385700367 +0 -0
  65. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.9.10/12813592349865671909 +0 -0
  66. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.9.10/923908772239632759 +0 -0
  67. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.9.3/13948373885254993391 +0 -0
  68. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.9.9/12813592349865671909 +0 -0
  69. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/0.9.9/8843823720003377982 +0 -0
  70. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/.ruff_cache/CACHEDIR.TAG +0 -0
  71. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/__init__.py +0 -0
  72. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/errors.py +0 -0
  73. {crackerjack-0.20.19 → crackerjack-0.21.0}/crackerjack/py313.py +0 -0
  74. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/TESTING.md +0 -0
  75. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/__init__.py +0 -0
  76. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/conftest.py +0 -0
  77. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/data/comments_sample.txt +0 -0
  78. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/data/docstrings_sample.txt +0 -0
  79. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/data/expected_comments_sample.txt +0 -0
  80. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/data/init.py +0 -0
  81. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_crackerjack_runner.py +0 -0
  82. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_errors.py +0 -0
  83. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_interactive.py +0 -0
  84. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_interactive_run.py +0 -0
  85. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_py313_advanced.py +0 -0
  86. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_py313_features.py +0 -0
  87. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_pytest_features.py +0 -0
  88. {crackerjack-0.20.19 → crackerjack-0.21.0}/tests/test_structured_errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: crackerjack
3
- Version: 0.20.19
3
+ Version: 0.21.0
4
4
  Summary: Crackerjack: code quality toolkit
5
5
  Keywords: bandit,black,creosote,mypy,pyright,pytest,refurb,ruff
6
6
  Author-Email: lesleslie <les@wedgwoodwebworks.com>
@@ -24,14 +24,14 @@ Project-URL: repository, https://github.com/lesleslie/crackerjack
24
24
  Requires-Python: >=3.13
25
25
  Requires-Dist: autotyping>=24.9
26
26
  Requires-Dist: keyring>=25.6
27
- Requires-Dist: pdm>=2.24.2
27
+ Requires-Dist: pdm>=2.25.3
28
28
  Requires-Dist: pdm-bump>=0.9.12
29
29
  Requires-Dist: pre-commit>=4.2
30
- Requires-Dist: pydantic>=2.11.5
31
- Requires-Dist: pytest>=8.4
30
+ Requires-Dist: pydantic>=2.11.7
31
+ Requires-Dist: pytest>=8.4.1
32
32
  Requires-Dist: pytest-asyncio>=1
33
33
  Requires-Dist: pytest-benchmark>=5.1
34
- Requires-Dist: pytest-cov>=6.2
34
+ Requires-Dist: pytest-cov>=6.2.1
35
35
  Requires-Dist: pytest-mock>=3.14.1
36
36
  Requires-Dist: pytest-timeout>=2.4
37
37
  Requires-Dist: pytest-xdist>=3.7
@@ -39,7 +39,7 @@ Requires-Dist: pyyaml>=6.0.2
39
39
  Requires-Dist: rich>=14
40
40
  Requires-Dist: tomli-w>=1.2
41
41
  Requires-Dist: typer>=0.16
42
- Requires-Dist: uv>=0.7.12
42
+ Requires-Dist: uv>=0.7.15
43
43
  Description-Content-Type: text/markdown
44
44
 
45
45
  # Crackerjack: Elevate Your Python Development
@@ -26,7 +26,7 @@ repos:
26
26
 
27
27
  # Package management - once structure is valid
28
28
  - repo: https://github.com/pdm-project/pdm
29
- rev: 2.24.2
29
+ rev: 2.25.3
30
30
  hooks:
31
31
  - id: pdm-lock-check
32
32
  - id: pdm-sync
@@ -34,7 +34,7 @@ repos:
34
34
  - keyring
35
35
 
36
36
  - repo: https://github.com/astral-sh/uv-pre-commit
37
- rev: 0.7.12
37
+ rev: 0.7.15
38
38
  hooks:
39
39
  - id: uv-lock
40
40
  files: ^pyproject\.toml$
@@ -55,7 +55,7 @@ repos:
55
55
  - tomli
56
56
 
57
57
  - repo: https://github.com/astral-sh/ruff-pre-commit
58
- rev: v0.11.13
58
+ rev: v0.12.1
59
59
  hooks:
60
60
  - id: ruff-check
61
61
  - id: ruff-format
@@ -71,6 +71,12 @@ repos:
71
71
  hooks:
72
72
  - id: creosote
73
73
 
74
+ - repo: https://github.com/rohaquinlop/complexipy-pre-commit
75
+ rev: v3.0.0
76
+ hooks:
77
+ - id: complexipy
78
+ args: ["-d", "low"]
79
+
74
80
  - repo: https://github.com/dosisod/refurb
75
81
  rev: v2.1.0
76
82
  hooks:
@@ -95,7 +101,7 @@ repos:
95
101
  - libcst>=1.1.0
96
102
 
97
103
  - repo: https://github.com/PyCQA/bandit
98
- rev: '1.8.3'
104
+ rev: '1.8.5'
99
105
  hooks:
100
106
  - id: bandit
101
107
  args: ["-c", "pyproject.toml"]
@@ -40,7 +40,6 @@ class Options(BaseModel):
40
40
  ai_agent: bool = False
41
41
  create_pr: bool = False
42
42
  skip_hooks: bool = False
43
- rich_ui: bool = False
44
43
 
45
44
  @classmethod
46
45
  @field_validator("publish", "bump", mode="before")
@@ -59,7 +58,10 @@ class Options(BaseModel):
59
58
  cli_options = {
60
59
  "commit": typer.Option(False, "-c", "--commit", help="Commit changes to Git."),
61
60
  "interactive": typer.Option(
62
- False, "-i", "--interactive", help="Run pre-commit hooks interactively."
61
+ False,
62
+ "-i",
63
+ "--interactive",
64
+ help="Use the interactive Rich UI for a better experience.",
63
65
  ),
64
66
  "doc": typer.Option(False, "-d", "--doc", help="Generate documentation."),
65
67
  "no_config_updates": typer.Option(
@@ -131,9 +133,6 @@ cli_options = {
131
133
  "create_pr": typer.Option(
132
134
  False, "-r", "--pr", help="Create a pull request to the upstream repository."
133
135
  ),
134
- "rich_ui": typer.Option(
135
- False, "--rich-ui", help="Use the interactive Rich UI for a better experience."
136
- ),
137
136
  "ai_agent": typer.Option(
138
137
  False,
139
138
  "--ai-agent",
@@ -165,7 +164,6 @@ def main(
165
164
  test_timeout: int = cli_options["test_timeout"],
166
165
  skip_hooks: bool = cli_options["skip_hooks"],
167
166
  create_pr: bool = cli_options["create_pr"],
168
- rich_ui: bool = cli_options["rich_ui"],
169
167
  ai_agent: bool = cli_options["ai_agent"],
170
168
  ) -> None:
171
169
  options = Options(
@@ -188,13 +186,12 @@ def main(
188
186
  all=all,
189
187
  ai_agent=ai_agent,
190
188
  create_pr=create_pr,
191
- rich_ui=rich_ui,
192
189
  )
193
190
  if ai_agent:
194
191
  import os
195
192
 
196
193
  os.environ["AI_AGENT"] = "1"
197
- if rich_ui:
194
+ if interactive:
198
195
  from crackerjack.interactive import launch_interactive_cli
199
196
 
200
197
  try:
@@ -13,7 +13,6 @@ from tomli_w import dumps
13
13
  from crackerjack.errors import ErrorCode, ExecutionError
14
14
 
15
15
  config_files = (".gitignore", ".pre-commit-config.yaml", ".libcst.codemod.yaml")
16
- interactive_hooks = ("refurb", "bandit", "pyright")
17
16
  default_python_version = "3.13"
18
17
 
19
18
 
@@ -85,36 +84,20 @@ class CodeCleaner(BaseModel, arbitrary_types_allowed=True):
85
84
  def remove_docstrings(self, code: str) -> str:
86
85
  lines = code.split("\n")
87
86
  cleaned_lines = []
88
- in_docstring = False
89
- docstring_delimiter = None
90
- waiting_for_docstring = False
87
+ docstring_state = {"in_docstring": False, "delimiter": None, "waiting": False}
91
88
  for line in lines:
92
89
  stripped = line.strip()
93
- if stripped.startswith(("def ", "class ", "async def ")):
94
- waiting_for_docstring = True
90
+ if self._is_function_or_class_definition(stripped):
91
+ docstring_state["waiting"] = True
95
92
  cleaned_lines.append(line)
96
93
  continue
97
- if waiting_for_docstring and stripped:
98
- if stripped.startswith(('"""', "'''", '"', "'")):
99
- if stripped.startswith(('"""', "'''")):
100
- docstring_delimiter = stripped[:3]
101
- else:
102
- docstring_delimiter = stripped[0]
103
- if stripped.endswith(docstring_delimiter) and len(stripped) > len(
104
- docstring_delimiter
105
- ):
106
- waiting_for_docstring = False
107
- continue
108
- else:
109
- in_docstring = True
110
- waiting_for_docstring = False
111
- continue
94
+ if docstring_state["waiting"] and stripped:
95
+ if self._handle_docstring_start(stripped, docstring_state):
96
+ continue
112
97
  else:
113
- waiting_for_docstring = False
114
- if in_docstring:
115
- if docstring_delimiter and stripped.endswith(docstring_delimiter):
116
- in_docstring = False
117
- docstring_delimiter = None
98
+ docstring_state["waiting"] = False
99
+ if docstring_state["in_docstring"]:
100
+ if self._handle_docstring_end(stripped, docstring_state):
118
101
  continue
119
102
  else:
120
103
  continue
@@ -122,6 +105,35 @@ class CodeCleaner(BaseModel, arbitrary_types_allowed=True):
122
105
 
123
106
  return "\n".join(cleaned_lines)
124
107
 
108
+ def _is_function_or_class_definition(self, stripped_line: str) -> bool:
109
+ return stripped_line.startswith(("def ", "class ", "async def "))
110
+
111
+ def _handle_docstring_start(self, stripped: str, state: dict[str, t.Any]) -> bool:
112
+ if not stripped.startswith(('"""', "'''", '"', "'")):
113
+ return False
114
+ if stripped.startswith(('"""', "'''")):
115
+ delimiter = stripped[:3]
116
+ else:
117
+ delimiter = stripped[0]
118
+ state["delimiter"] = delimiter
119
+ if self._is_single_line_docstring(stripped, delimiter):
120
+ state["waiting"] = False
121
+ return True
122
+ else:
123
+ state["in_docstring"] = True
124
+ state["waiting"] = False
125
+ return True
126
+
127
+ def _is_single_line_docstring(self, stripped: str, delimiter: str) -> bool:
128
+ return stripped.endswith(delimiter) and len(stripped) > len(delimiter)
129
+
130
+ def _handle_docstring_end(self, stripped: str, state: dict[str, t.Any]) -> bool:
131
+ if state["delimiter"] and stripped.endswith(state["delimiter"]):
132
+ state["in_docstring"] = False
133
+ state["delimiter"] = None
134
+ return True
135
+ return True
136
+
125
137
  def remove_line_comments(self, code: str) -> str:
126
138
  lines = code.split("\n")
127
139
  cleaned_lines = []
@@ -129,87 +141,153 @@ class CodeCleaner(BaseModel, arbitrary_types_allowed=True):
129
141
  if not line.strip():
130
142
  cleaned_lines.append(line)
131
143
  continue
132
- in_string = None
133
- result = []
134
- i = 0
135
- n = len(line)
136
- while i < n:
137
- char = line[i]
138
- if char in ("'", '"') and (i == 0 or line[i - 1] != "\\"):
139
- if in_string is None:
140
- in_string = char
141
- elif in_string == char:
142
- in_string = None
143
- result.append(char)
144
- i += 1
145
- elif char == "#" and in_string is None:
146
- comment = line[i:].strip()
147
- if re.match(
148
- r"^#\s*(?:type:\s*ignore(?:\[.*?\])?|noqa|nosec|pragma:\s*no\s*cover|pylint:\s*disable|mypy:\s*ignore)",
149
- comment,
150
- ):
151
- result.append(line[i:])
152
- break
153
- break
154
- else:
155
- result.append(char)
156
- i += 1
157
- cleaned_line = "".join(result).rstrip()
144
+ cleaned_line = self._process_line_for_comments(line)
158
145
  if cleaned_line or not line.strip():
159
146
  cleaned_lines.append(cleaned_line or line)
147
+
160
148
  return "\n".join(cleaned_lines)
161
149
 
150
+ def _process_line_for_comments(self, line: str) -> str:
151
+ result = []
152
+ string_state = {"in_string": None}
153
+ for i, char in enumerate(line):
154
+ if self._handle_string_character(char, i, line, string_state, result):
155
+ continue
156
+ elif self._handle_comment_character(char, i, line, string_state, result):
157
+ break
158
+ else:
159
+ result.append(char)
160
+
161
+ return "".join(result).rstrip()
162
+
163
+ def _handle_string_character(
164
+ self,
165
+ char: str,
166
+ index: int,
167
+ line: str,
168
+ string_state: dict[str, t.Any],
169
+ result: list[str],
170
+ ) -> bool:
171
+ """Handle string quote characters. Returns True if character was handled."""
172
+ if char not in ("'", '"'):
173
+ return False
174
+
175
+ if index > 0 and line[index - 1] == "\\":
176
+ return False
177
+
178
+ if string_state["in_string"] is None:
179
+ string_state["in_string"] = char
180
+ elif string_state["in_string"] == char:
181
+ string_state["in_string"] = None
182
+
183
+ result.append(char)
184
+ return True
185
+
186
+ def _handle_comment_character(
187
+ self,
188
+ char: str,
189
+ index: int,
190
+ line: str,
191
+ string_state: dict[str, t.Any],
192
+ result: list[str],
193
+ ) -> bool:
194
+ """Handle comment character. Returns True if comment was found."""
195
+ if char != "#" or string_state["in_string"] is not None:
196
+ return False
197
+
198
+ comment = line[index:].strip()
199
+ if self._is_special_comment_line(comment):
200
+ result.append(line[index:])
201
+
202
+ return True
203
+
204
+ def _is_special_comment_line(self, comment: str) -> bool:
205
+ special_comment_pattern = (
206
+ r"^#\s*(?:type:\s*ignore(?:\[.*?\])?|noqa|nosec|pragma:\s*no\s*cover"
207
+ r"|pylint:\s*disable|mypy:\s*ignore)"
208
+ )
209
+ return bool(re.match(special_comment_pattern, comment))
210
+
162
211
  def remove_extra_whitespace(self, code: str) -> str:
163
212
  lines = code.split("\n")
164
213
  cleaned_lines = []
165
- in_function = False
166
- function_indent = 0
214
+ function_tracker = {"in_function": False, "function_indent": 0}
167
215
  for i, line in enumerate(lines):
168
216
  line = line.rstrip()
169
217
  stripped_line = line.lstrip()
170
- if stripped_line.startswith(("def ", "async def ")):
171
- in_function = True
172
- function_indent = len(line) - len(stripped_line)
173
- elif (
174
- in_function
175
- and line
176
- and (len(line) - len(stripped_line) <= function_indent)
177
- and (not stripped_line.startswith(("@", "#")))
178
- ):
179
- in_function = False
180
- function_indent = 0
218
+ self._update_function_state(line, stripped_line, function_tracker)
181
219
  if not line:
182
- if i > 0 and cleaned_lines and (not cleaned_lines[-1]):
220
+ if self._should_skip_empty_line(
221
+ i, lines, cleaned_lines, function_tracker
222
+ ):
183
223
  continue
184
- if in_function:
185
- next_line_idx = i + 1
186
- if next_line_idx < len(lines):
187
- next_line = lines[next_line_idx].strip()
188
- if not (
189
- next_line.startswith(
190
- ("return", "class ", "def ", "async def ", "@")
191
- )
192
- or next_line in ("pass", "break", "continue", "raise")
193
- or (
194
- next_line.startswith("#")
195
- and any(
196
- pattern in next_line
197
- for pattern in (
198
- "type:",
199
- "noqa",
200
- "nosec",
201
- "pragma:",
202
- "pylint:",
203
- "mypy:",
204
- )
205
- )
206
- )
207
- ):
208
- continue
209
224
  cleaned_lines.append(line)
210
- while cleaned_lines and (not cleaned_lines[-1]):
211
- cleaned_lines.pop()
212
- return "\n".join(cleaned_lines)
225
+
226
+ return "\n".join(self._remove_trailing_empty_lines(cleaned_lines))
227
+
228
+ def _update_function_state(
229
+ self, line: str, stripped_line: str, function_tracker: dict[str, t.Any]
230
+ ) -> None:
231
+ """Update function tracking state based on current line."""
232
+ if stripped_line.startswith(("def ", "async def ")):
233
+ function_tracker["in_function"] = True
234
+ function_tracker["function_indent"] = len(line) - len(stripped_line)
235
+ elif self._is_function_end(line, stripped_line, function_tracker):
236
+ function_tracker["in_function"] = False
237
+ function_tracker["function_indent"] = 0
238
+
239
+ def _is_function_end(
240
+ self, line: str, stripped_line: str, function_tracker: dict[str, t.Any]
241
+ ) -> bool:
242
+ """Check if current line marks the end of a function."""
243
+ return (
244
+ function_tracker["in_function"]
245
+ and bool(line)
246
+ and (len(line) - len(stripped_line) <= function_tracker["function_indent"])
247
+ and (not stripped_line.startswith(("@", "#")))
248
+ )
249
+
250
+ def _should_skip_empty_line(
251
+ self,
252
+ line_idx: int,
253
+ lines: list[str],
254
+ cleaned_lines: list[str],
255
+ function_tracker: dict[str, t.Any],
256
+ ) -> bool:
257
+ """Determine if an empty line should be skipped."""
258
+ if line_idx > 0 and cleaned_lines and (not cleaned_lines[-1]):
259
+ return True
260
+
261
+ if function_tracker["in_function"]:
262
+ return self._should_skip_function_empty_line(line_idx, lines)
263
+
264
+ return False
265
+
266
+ def _should_skip_function_empty_line(self, line_idx: int, lines: list[str]) -> bool:
267
+ next_line_idx = line_idx + 1
268
+ if next_line_idx >= len(lines):
269
+ return False
270
+ next_line = lines[next_line_idx].strip()
271
+ return not self._is_significant_next_line(next_line)
272
+
273
+ def _is_significant_next_line(self, next_line: str) -> bool:
274
+ if next_line.startswith(("return", "class ", "def ", "async def ", "@")):
275
+ return True
276
+ if next_line in ("pass", "break", "continue", "raise"):
277
+ return True
278
+
279
+ return self._is_special_comment(next_line)
280
+
281
+ def _is_special_comment(self, line: str) -> bool:
282
+ if not line.startswith("#"):
283
+ return False
284
+ special_patterns = ("type:", "noqa", "nosec", "pragma:", "pylint:", "mypy:")
285
+ return any(pattern in line for pattern in special_patterns)
286
+
287
+ def _remove_trailing_empty_lines(self, lines: list[str]) -> list[str]:
288
+ while lines and (not lines[-1]):
289
+ lines.pop()
290
+ return lines
213
291
 
214
292
  def reformat_code(self, code: str) -> str:
215
293
  from crackerjack.errors import handle_error
@@ -335,27 +413,75 @@ class ConfigManager(BaseModel, arbitrary_types_allowed=True):
335
413
  self, our_toml_config: dict[str, t.Any], pkg_toml_config: dict[str, t.Any]
336
414
  ) -> None:
337
415
  for tool, settings in our_toml_config.get("tool", {}).items():
338
- for setting, value in settings.items():
339
- if isinstance(value, dict):
340
- for k, v in {
341
- x: self.swap_package_name(y)
342
- for x, y in value.items()
343
- if isinstance(y, str | list) and "crackerjack" in str(y)
344
- }.items():
345
- settings[setting][k] = v
346
- elif isinstance(value, str | list) and "crackerjack" in str(value):
347
- value = self.swap_package_name(value)
348
- settings[setting] = value
349
- if setting in (
350
- "exclude-deps",
351
- "exclude",
352
- "excluded",
353
- "skips",
354
- "ignore",
355
- ) and isinstance(value, list):
356
- conf = pkg_toml_config["tool"].get(tool, {}).get(setting, [])
357
- settings[setting] = list(set(conf + value))
358
- pkg_toml_config["tool"][tool] = settings
416
+ if tool not in pkg_toml_config["tool"]:
417
+ pkg_toml_config["tool"][tool] = {}
418
+
419
+ pkg_tool_config = pkg_toml_config["tool"][tool]
420
+
421
+ self._merge_tool_config(settings, pkg_tool_config, tool)
422
+
423
+ def _merge_tool_config(
424
+ self, our_config: dict[str, t.Any], pkg_config: dict[str, t.Any], tool: str
425
+ ) -> None:
426
+ """Recursively merge tool configuration, preserving existing project settings."""
427
+ for setting, value in our_config.items():
428
+ if isinstance(value, dict):
429
+ self._merge_nested_config(setting, value, pkg_config)
430
+ else:
431
+ self._merge_direct_config(setting, value, pkg_config)
432
+
433
+ def _merge_nested_config(
434
+ self, setting: str, value: dict[str, t.Any], pkg_config: dict[str, t.Any]
435
+ ) -> None:
436
+ """Handle nested configuration merging."""
437
+ if setting not in pkg_config:
438
+ pkg_config[setting] = {}
439
+ elif not isinstance(pkg_config[setting], dict):
440
+ pkg_config[setting] = {}
441
+
442
+ self._merge_tool_config(value, pkg_config[setting], "")
443
+
444
+ for k, v in value.items():
445
+ self._merge_nested_value(k, v, pkg_config[setting])
446
+
447
+ def _merge_nested_value(
448
+ self, key: str, value: t.Any, nested_config: dict[str, t.Any]
449
+ ) -> None:
450
+ """Merge individual nested values."""
451
+ if isinstance(value, str | list) and "crackerjack" in str(value):
452
+ nested_config[key] = self.swap_package_name(value)
453
+ elif self._is_mergeable_list(key, value):
454
+ existing = nested_config.get(key, [])
455
+ if isinstance(existing, list) and isinstance(value, list):
456
+ nested_config[key] = list(set(existing + value))
457
+ else:
458
+ nested_config[key] = value
459
+ elif key not in nested_config:
460
+ nested_config[key] = value
461
+
462
+ def _merge_direct_config(
463
+ self, setting: str, value: t.Any, pkg_config: dict[str, t.Any]
464
+ ) -> None:
465
+ """Handle direct configuration merging."""
466
+ if isinstance(value, str | list) and "crackerjack" in str(value):
467
+ pkg_config[setting] = self.swap_package_name(value)
468
+ elif self._is_mergeable_list(setting, value):
469
+ existing = pkg_config.get(setting, [])
470
+ if isinstance(existing, list) and isinstance(value, list):
471
+ pkg_config[setting] = list(set(existing + value))
472
+ else:
473
+ pkg_config[setting] = value
474
+ elif setting not in pkg_config:
475
+ pkg_config[setting] = value
476
+
477
+ def _is_mergeable_list(self, key: str, value: t.Any) -> bool:
478
+ return key in (
479
+ "exclude-deps",
480
+ "exclude",
481
+ "excluded",
482
+ "skips",
483
+ "ignore",
484
+ ) and isinstance(value, list)
359
485
 
360
486
  def _update_python_version(
361
487
  self, our_toml_config: dict[str, t.Any], pkg_toml_config: dict[str, t.Any]
@@ -520,11 +646,6 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
520
646
  if self.pkg_path.stem == "crackerjack" and options.update_precommit:
521
647
  self.execute_command(["pre-commit", "autoupdate"])
522
648
 
523
- def _run_interactive_hooks(self, options: t.Any) -> None:
524
- if options.interactive:
525
- for hook in interactive_hooks:
526
- self.project_manager.run_interactive(hook)
527
-
528
649
  def _clean_project(self, options: t.Any) -> None:
529
650
  if options.clean:
530
651
  if self.pkg_dir:
@@ -655,7 +776,6 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
655
776
  self._setup_package()
656
777
  self._update_project(options)
657
778
  self._update_precommit(options)
658
- self._run_interactive_hooks(options)
659
779
  self._clean_project(options)
660
780
  if not options.skip_hooks:
661
781
  self.project_manager.run_pre_commit()