crackerjack 0.28.0__py3-none-any.whl → 0.30.3__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.

Potentially problematic release.


This version of crackerjack might be problematic. Click here for more details.

@@ -0,0 +1,586 @@
1
+ import tempfile
2
+ import typing as t
3
+ from pathlib import Path
4
+
5
+ import jinja2
6
+
7
+
8
+ class HookMetadata(t.TypedDict):
9
+ id: str
10
+ name: str | None
11
+ repo: str
12
+ rev: str
13
+ tier: int
14
+ time_estimate: float
15
+ stages: list[str] | None
16
+ args: list[str] | None
17
+ files: str | None
18
+ exclude: str | None
19
+ additional_dependencies: list[str] | None
20
+ types_or: list[str] | None
21
+ language: str | None
22
+ entry: str | None
23
+ experimental: bool
24
+
25
+
26
+ class ConfigMode(t.TypedDict):
27
+ max_time: float
28
+ tiers: list[int]
29
+ experimental: bool
30
+ stages: list[str]
31
+
32
+
33
+ HOOKS_REGISTRY: dict[str, list[HookMetadata]] = {
34
+ "structure": [
35
+ {
36
+ "id": "trailing-whitespace",
37
+ "name": "trailing-whitespace",
38
+ "repo": "https://github.com/pre-commit/pre-commit-hooks",
39
+ "rev": "v5.0.0",
40
+ "tier": 1,
41
+ "time_estimate": 0.2,
42
+ "stages": None,
43
+ "args": None,
44
+ "files": None,
45
+ "exclude": None,
46
+ "additional_dependencies": None,
47
+ "types_or": None,
48
+ "language": None,
49
+ "entry": None,
50
+ "experimental": False,
51
+ },
52
+ {
53
+ "id": "end-of-file-fixer",
54
+ "name": "end-of-file-fixer",
55
+ "repo": "https://github.com/pre-commit/pre-commit-hooks",
56
+ "rev": "v5.0.0",
57
+ "tier": 1,
58
+ "time_estimate": 0.2,
59
+ "stages": None,
60
+ "args": None,
61
+ "files": None,
62
+ "exclude": None,
63
+ "additional_dependencies": None,
64
+ "types_or": None,
65
+ "language": None,
66
+ "entry": None,
67
+ "experimental": False,
68
+ },
69
+ {
70
+ "id": "check-yaml",
71
+ "name": "check-yaml",
72
+ "repo": "https://github.com/pre-commit/pre-commit-hooks",
73
+ "rev": "v5.0.0",
74
+ "tier": 1,
75
+ "time_estimate": 0.3,
76
+ "stages": None,
77
+ "args": None,
78
+ "files": None,
79
+ "exclude": None,
80
+ "additional_dependencies": None,
81
+ "types_or": None,
82
+ "language": None,
83
+ "entry": None,
84
+ "experimental": False,
85
+ },
86
+ {
87
+ "id": "check-toml",
88
+ "name": "check-toml",
89
+ "repo": "https://github.com/pre-commit/pre-commit-hooks",
90
+ "rev": "v5.0.0",
91
+ "tier": 1,
92
+ "time_estimate": 0.3,
93
+ "stages": None,
94
+ "args": None,
95
+ "files": None,
96
+ "exclude": None,
97
+ "additional_dependencies": None,
98
+ "types_or": None,
99
+ "language": None,
100
+ "entry": None,
101
+ "experimental": False,
102
+ },
103
+ {
104
+ "id": "check-added-large-files",
105
+ "name": "check-added-large-files",
106
+ "repo": "https://github.com/pre-commit/pre-commit-hooks",
107
+ "rev": "v5.0.0",
108
+ "tier": 1,
109
+ "time_estimate": 0.5,
110
+ "stages": None,
111
+ "args": None,
112
+ "files": None,
113
+ "exclude": None,
114
+ "additional_dependencies": None,
115
+ "types_or": None,
116
+ "language": None,
117
+ "entry": None,
118
+ "experimental": False,
119
+ },
120
+ ],
121
+ "package_management": [
122
+ {
123
+ "id": "pyproject-fmt",
124
+ "name": None,
125
+ "repo": "https://github.com/tox-dev/pyproject-fmt",
126
+ "rev": "v2.6.0",
127
+ "tier": 1,
128
+ "time_estimate": 0.5,
129
+ "stages": None,
130
+ "args": ["-n"],
131
+ "files": None,
132
+ "exclude": None,
133
+ "additional_dependencies": None,
134
+ "types_or": None,
135
+ "language": None,
136
+ "entry": None,
137
+ "experimental": False,
138
+ },
139
+ {
140
+ "id": "uv-lock",
141
+ "name": None,
142
+ "repo": "https://github.com/astral-sh/uv-pre-commit",
143
+ "rev": "0.7.21",
144
+ "tier": 1,
145
+ "time_estimate": 0.5,
146
+ "stages": None,
147
+ "args": None,
148
+ "files": "^pyproject\\.toml$",
149
+ "exclude": None,
150
+ "additional_dependencies": None,
151
+ "types_or": None,
152
+ "language": None,
153
+ "entry": None,
154
+ "experimental": False,
155
+ },
156
+ ],
157
+ "security": [
158
+ {
159
+ "id": "detect-secrets",
160
+ "name": None,
161
+ "repo": "https://github.com/Yelp/detect-secrets",
162
+ "rev": "v1.5.0",
163
+ "tier": 2,
164
+ "time_estimate": 1.0,
165
+ "stages": None,
166
+ "args": None,
167
+ "files": None,
168
+ "exclude": "uv\\.lock|pyproject\\.toml|tests/.*|docs/.*|.*\\.md",
169
+ "additional_dependencies": None,
170
+ "types_or": None,
171
+ "language": None,
172
+ "entry": None,
173
+ "experimental": False,
174
+ },
175
+ {
176
+ "id": "bandit",
177
+ "name": None,
178
+ "repo": "https://github.com/PyCQA/bandit",
179
+ "rev": "1.8.6",
180
+ "tier": 3,
181
+ "time_estimate": 3.0,
182
+ "stages": ["pre-push", "manual"],
183
+ "args": ["-c", "pyproject.toml", "-r", "-ll"],
184
+ "files": None,
185
+ "exclude": None,
186
+ "additional_dependencies": None,
187
+ "types_or": None,
188
+ "language": None,
189
+ "entry": None,
190
+ "experimental": False,
191
+ },
192
+ ],
193
+ "formatting": [
194
+ {
195
+ "id": "codespell",
196
+ "name": None,
197
+ "repo": "https://github.com/codespell-project/codespell",
198
+ "rev": "v2.4.1",
199
+ "tier": 2,
200
+ "time_estimate": 1.0,
201
+ "stages": None,
202
+ "args": None,
203
+ "files": None,
204
+ "exclude": None,
205
+ "additional_dependencies": ["tomli"],
206
+ "types_or": None,
207
+ "language": None,
208
+ "entry": None,
209
+ "experimental": False,
210
+ },
211
+ {
212
+ "id": "ruff-check",
213
+ "name": None,
214
+ "repo": "https://github.com/astral-sh/ruff-pre-commit",
215
+ "rev": "v0.12.3",
216
+ "tier": 2,
217
+ "time_estimate": 1.5,
218
+ "stages": None,
219
+ "args": None,
220
+ "files": None,
221
+ "exclude": None,
222
+ "additional_dependencies": None,
223
+ "types_or": None,
224
+ "language": None,
225
+ "entry": None,
226
+ "experimental": False,
227
+ },
228
+ {
229
+ "id": "ruff-format",
230
+ "name": None,
231
+ "repo": "https://github.com/astral-sh/ruff-pre-commit",
232
+ "rev": "v0.12.3",
233
+ "tier": 2,
234
+ "time_estimate": 1.0,
235
+ "stages": None,
236
+ "args": None,
237
+ "files": None,
238
+ "exclude": None,
239
+ "additional_dependencies": None,
240
+ "types_or": None,
241
+ "language": None,
242
+ "entry": None,
243
+ "experimental": False,
244
+ },
245
+ {
246
+ "id": "mdformat",
247
+ "name": None,
248
+ "repo": "https://github.com/executablebooks/mdformat",
249
+ "rev": "0.7.22",
250
+ "tier": 2,
251
+ "time_estimate": 0.5,
252
+ "stages": None,
253
+ "args": None,
254
+ "files": None,
255
+ "exclude": None,
256
+ "additional_dependencies": ["mdformat-ruff"],
257
+ "types_or": None,
258
+ "language": None,
259
+ "entry": None,
260
+ "experimental": False,
261
+ },
262
+ ],
263
+ "analysis": [
264
+ {
265
+ "id": "vulture",
266
+ "name": None,
267
+ "repo": "https://github.com/jendrikseipp/vulture",
268
+ "rev": "v2.14",
269
+ "tier": 3,
270
+ "time_estimate": 2.0,
271
+ "stages": ["pre-push", "manual"],
272
+ "args": None,
273
+ "files": None,
274
+ "exclude": None,
275
+ "additional_dependencies": None,
276
+ "types_or": None,
277
+ "language": None,
278
+ "entry": None,
279
+ "experimental": False,
280
+ },
281
+ {
282
+ "id": "creosote",
283
+ "name": None,
284
+ "repo": "https://github.com/fredrikaverpil/creosote",
285
+ "rev": "v4.0.3",
286
+ "tier": 3,
287
+ "time_estimate": 1.5,
288
+ "stages": ["pre-push", "manual"],
289
+ "args": None,
290
+ "files": None,
291
+ "exclude": None,
292
+ "additional_dependencies": None,
293
+ "types_or": None,
294
+ "language": None,
295
+ "entry": None,
296
+ "experimental": False,
297
+ },
298
+ {
299
+ "id": "complexipy",
300
+ "name": None,
301
+ "repo": "https://github.com/rohaquinlop/complexipy-pre-commit",
302
+ "rev": "v3.0.0",
303
+ "tier": 3,
304
+ "time_estimate": 2.0,
305
+ "stages": ["pre-push", "manual"],
306
+ "args": ["-d", "low"],
307
+ "files": None,
308
+ "exclude": None,
309
+ "additional_dependencies": None,
310
+ "types_or": None,
311
+ "language": None,
312
+ "entry": None,
313
+ "experimental": False,
314
+ },
315
+ {
316
+ "id": "refurb",
317
+ "name": None,
318
+ "repo": "https://github.com/dosisod/refurb",
319
+ "rev": "v2.1.0",
320
+ "tier": 3,
321
+ "time_estimate": 3.0,
322
+ "stages": ["pre-push", "manual"],
323
+ "args": None,
324
+ "files": None,
325
+ "exclude": None,
326
+ "additional_dependencies": None,
327
+ "types_or": None,
328
+ "language": None,
329
+ "entry": None,
330
+ "experimental": False,
331
+ },
332
+ {
333
+ "id": "autotyping",
334
+ "name": "autotyping",
335
+ "repo": "local",
336
+ "rev": "",
337
+ "tier": 3,
338
+ "time_estimate": 7.0,
339
+ "stages": ["pre-push", "manual"],
340
+ "args": [
341
+ "--aggressive",
342
+ "--only-without-imports",
343
+ "--guess-common-names",
344
+ "crackerjack",
345
+ ],
346
+ "files": "^crackerjack/.*\\.py$",
347
+ "exclude": None,
348
+ "additional_dependencies": ["autotyping>=24.3.0", "libcst>=1.1.0"],
349
+ "types_or": ["python", "pyi"],
350
+ "language": "python",
351
+ "entry": "python -m autotyping",
352
+ "experimental": False,
353
+ },
354
+ {
355
+ "id": "pyright",
356
+ "name": None,
357
+ "repo": "https://github.com/RobertCraigie/pyright-python",
358
+ "rev": "v1.1.403",
359
+ "tier": 3,
360
+ "time_estimate": 5.0,
361
+ "stages": ["pre-push", "manual"],
362
+ "args": None,
363
+ "files": None,
364
+ "exclude": None,
365
+ "additional_dependencies": None,
366
+ "types_or": None,
367
+ "language": None,
368
+ "entry": None,
369
+ "experimental": False,
370
+ },
371
+ ],
372
+ "experimental": [
373
+ {
374
+ "id": "pyrefly",
375
+ "name": "pyrefly",
376
+ "repo": "local",
377
+ "rev": "",
378
+ "tier": 3,
379
+ "time_estimate": 5.0,
380
+ "stages": ["manual"],
381
+ "args": ["--check"],
382
+ "files": "^crackerjack/.*\\.py$",
383
+ "exclude": None,
384
+ "additional_dependencies": ["pyrefly>=0.1.0"],
385
+ "types_or": ["python"],
386
+ "language": "python",
387
+ "entry": "python -m pyrefly",
388
+ "experimental": True,
389
+ },
390
+ {
391
+ "id": "ty",
392
+ "name": "ty",
393
+ "repo": "local",
394
+ "rev": "",
395
+ "tier": 3,
396
+ "time_estimate": 2.0,
397
+ "stages": ["manual"],
398
+ "args": ["--check"],
399
+ "files": "^crackerjack/.*\\.py$",
400
+ "exclude": None,
401
+ "additional_dependencies": ["ty>=0.1.0"],
402
+ "types_or": ["python"],
403
+ "language": "python",
404
+ "entry": "python -m ty",
405
+ "experimental": True,
406
+ },
407
+ ],
408
+ }
409
+
410
+ CONFIG_MODES: dict[str, ConfigMode] = {
411
+ "fast": {
412
+ "max_time": 5.0,
413
+ "tiers": [1, 2],
414
+ "experimental": False,
415
+ "stages": ["pre-commit"],
416
+ },
417
+ "comprehensive": {
418
+ "max_time": 30.0,
419
+ "tiers": [1, 2, 3],
420
+ "experimental": False,
421
+ "stages": ["pre-commit", "pre-push", "manual"],
422
+ },
423
+ "experimental": {
424
+ "max_time": 60.0,
425
+ "tiers": [1, 2, 3],
426
+ "experimental": True,
427
+ "stages": ["pre-commit", "pre-push", "manual"],
428
+ },
429
+ }
430
+
431
+ PRE_COMMIT_TEMPLATE = """repos:
432
+ {%- for repo_group in repos %}
433
+ {%- if repo_group.comment %}
434
+ {%- endif %}
435
+ - repo: {{ repo_group.repo }}
436
+ {%- if repo_group.rev %}
437
+ rev: {{ repo_group.rev }}
438
+ {%- endif %}
439
+ hooks:
440
+ {%- for hook in repo_group.hooks %}
441
+ - id: {{ hook.id }}
442
+ {%- if hook.name %}
443
+ name: {{ hook.name }}
444
+ {%- endif %}
445
+ {%- if hook.entry %}
446
+ entry: {{ hook.entry }}
447
+ {%- endif %}
448
+ {%- if hook.language %}
449
+ language: {{ hook.language }}
450
+ {%- endif %}
451
+ {%- if hook.args %}
452
+ args: {{ hook.args | tojson }}
453
+ {%- endif %}
454
+ {%- if hook.files %}
455
+ files: {{ hook.files }}
456
+ {%- endif %}
457
+ {%- if hook.exclude %}
458
+ exclude: {{ hook.exclude }}
459
+ {%- endif %}
460
+ {%- if hook.types_or %}
461
+ types_or: {{ hook.types_or | tojson }}
462
+ {%- endif %}
463
+ {%- if hook.stages %}
464
+ stages: {{ hook.stages | tojson }}
465
+ {%- endif %}
466
+ {%- if hook.additional_dependencies %}
467
+ additional_dependencies: {{ hook.additional_dependencies | tojson }}
468
+ {%- endif %}
469
+ {%- endfor %}
470
+
471
+ {%- endfor %}
472
+ """
473
+
474
+
475
+ class DynamicConfigGenerator:
476
+ def __init__(self) -> None:
477
+ self.template = jinja2.Template(PRE_COMMIT_TEMPLATE)
478
+
479
+ def _should_include_hook(
480
+ self, hook: HookMetadata, config: ConfigMode, enabled_experimental: list[str]
481
+ ) -> bool:
482
+ if hook["tier"] not in config["tiers"]:
483
+ return False
484
+ if hook["experimental"]:
485
+ if not config["experimental"]:
486
+ return False
487
+ if enabled_experimental and hook["id"] not in enabled_experimental:
488
+ return False
489
+ if hook["time_estimate"] > config["max_time"]:
490
+ return False
491
+ return True
492
+
493
+ def filter_hooks_for_mode(
494
+ self, mode: str, enabled_experimental: list[str] | None = None
495
+ ) -> list[HookMetadata]:
496
+ config = CONFIG_MODES[mode]
497
+ filtered_hooks = []
498
+ enabled_experimental = enabled_experimental or []
499
+ for category_hooks in HOOKS_REGISTRY.values():
500
+ for hook in category_hooks:
501
+ if self._should_include_hook(hook, config, enabled_experimental):
502
+ filtered_hooks.append(hook)
503
+
504
+ return filtered_hooks
505
+
506
+ def group_hooks_by_repo(
507
+ self, hooks: list[HookMetadata]
508
+ ) -> dict[tuple[str, str], list[HookMetadata]]:
509
+ repo_groups: dict[tuple[str, str], list[HookMetadata]] = {}
510
+ for hook in hooks:
511
+ key = (hook["repo"], hook["rev"])
512
+ if key not in repo_groups:
513
+ repo_groups[key] = []
514
+ repo_groups[key].append(hook)
515
+
516
+ return repo_groups
517
+
518
+ def _get_repo_comment(self, repo_url: str) -> str | None:
519
+ if repo_url == "https://github.com/pre-commit/pre-commit-hooks":
520
+ return "File structure and format validators"
521
+ elif (
522
+ "security" in repo_url
523
+ or "bandit" in repo_url
524
+ or "detect-secrets" in repo_url
525
+ ):
526
+ return "Security checks"
527
+ elif "ruff" in repo_url or "mdformat" in repo_url or "codespell" in repo_url:
528
+ return "Code formatting and quality"
529
+ elif repo_url == "local":
530
+ return "Local tools and custom hooks"
531
+ return None
532
+
533
+ def generate_config(
534
+ self, mode: str, enabled_experimental: list[str] | None = None
535
+ ) -> str:
536
+ filtered_hooks = self.filter_hooks_for_mode(mode, enabled_experimental)
537
+ repo_groups = self.group_hooks_by_repo(filtered_hooks)
538
+ repos = []
539
+ for (repo_url, rev), hooks in repo_groups.items():
540
+ repo_data = {
541
+ "repo": repo_url,
542
+ "rev": rev,
543
+ "hooks": hooks,
544
+ "comment": self._get_repo_comment(repo_url),
545
+ }
546
+ repos.append(repo_data)
547
+
548
+ return self.template.render(repos=repos)
549
+
550
+ def create_temp_config(
551
+ self, mode: str, enabled_experimental: list[str] | None = None
552
+ ) -> Path:
553
+ config_content = self.generate_config(mode, enabled_experimental)
554
+ temp_file = tempfile.NamedTemporaryFile(
555
+ mode="w",
556
+ suffix=".yaml",
557
+ prefix=f"crackerjack-{mode}-",
558
+ delete=False,
559
+ encoding="utf-8",
560
+ )
561
+ temp_file.write(config_content)
562
+ temp_file.flush()
563
+ temp_file.close()
564
+
565
+ return Path(temp_file.name)
566
+
567
+
568
+ def generate_config_for_mode(
569
+ mode: str, enabled_experimental: list[str] | None = None
570
+ ) -> Path:
571
+ return DynamicConfigGenerator().create_temp_config(mode, enabled_experimental)
572
+
573
+
574
+ def get_available_modes() -> list[str]:
575
+ return list(CONFIG_MODES.keys())
576
+
577
+
578
+ def add_experimental_hook(hook_id: str, hook_config: HookMetadata) -> None:
579
+ hook_config["experimental"] = True
580
+ HOOKS_REGISTRY["experimental"].append(hook_config)
581
+
582
+
583
+ def remove_experimental_hook(hook_id: str) -> None:
584
+ HOOKS_REGISTRY["experimental"] = [
585
+ hook for hook in HOOKS_REGISTRY["experimental"] if hook["id"] != hook_id
586
+ ]
@@ -169,10 +169,7 @@ class InteractiveCLI:
169
169
  version_text = Text(f"v{version}", style="dim cyan")
170
170
  subtitle = Text("Your Python project management toolkit", style="italic")
171
171
  panel = Panel(
172
- f"{title} {version_text}\n{subtitle}",
173
- box=ROUNDED,
174
- border_style="cyan",
175
- expand=False,
172
+ f"{title} {version_text}\n{subtitle}", border_style="cyan", expand=False
176
173
  )
177
174
  self.console.print(panel)
178
175
  self.console.print()
@@ -198,9 +195,7 @@ class InteractiveCLI:
198
195
  Layout(name="main"),
199
196
  Layout(name="footer", size=3),
200
197
  )
201
- layout["main"].split_row(
202
- Layout(name="tasks", ratio=1), Layout(name="details", ratio=2)
203
- )
198
+ layout["main"].split_row(Layout(name="tasks"), Layout(name="details", ratio=2))
204
199
  return layout
205
200
 
206
201
  def show_task_status(self, task: Task) -> Panel:
@@ -233,8 +228,6 @@ class InteractiveCLI:
233
228
  def show_task_table(self) -> Table:
234
229
  table = Table(
235
230
  title="Workflow Tasks",
236
- box=ROUNDED,
237
- show_header=True,
238
231
  header_style="bold white",
239
232
  )
240
233
  table.add_column("Task", style="white")
@@ -262,7 +255,7 @@ class InteractiveCLI:
262
255
  self.console.clear()
263
256
  layout = self._setup_interactive_layout()
264
257
  progress_tracker = self._create_progress_tracker()
265
- with Live(layout, refresh_per_second=4, screen=True) as live:
258
+ with Live(layout) as live:
266
259
  try:
267
260
  self._execute_workflow_loop(layout, progress_tracker, live)
268
261
  self._display_final_summary(layout)
@@ -274,9 +267,9 @@ class InteractiveCLI:
274
267
  def _setup_interactive_layout(self) -> Layout:
275
268
  layout = self.setup_layout()
276
269
  layout["header"].update(
277
- Panel("Crackerjack Interactive Mode", style="bold white", box=ROUNDED)
270
+ Panel("Crackerjack Interactive Mode", style="bold white")
278
271
  )
279
- layout["footer"].update(Panel("Press Ctrl+C to exit", style="dim", box=ROUNDED))
272
+ layout["footer"].update(Panel("Press Ctrl+C to exit", style="dim"))
280
273
  return layout
281
274
 
282
275
  def _create_progress_tracker(self) -> dict[str, t.Any]:
@@ -353,7 +346,7 @@ class InteractiveCLI:
353
346
  layout["tasks"].update(self.show_task_table())
354
347
  task_counts = self._count_tasks_by_status()
355
348
  summary = Panel(
356
- f"Workflow completed!\n\n"
349
+ f"🏆 Workflow completed!\n\n"
357
350
  f"[green]✅ Successful tasks: {task_counts['successful']}[/green]\n"
358
351
  f"[red]❌ Failed tasks: {task_counts['failed']}[/red]\n"
359
352
  f"[blue]⏩ Skipped tasks: {task_counts['skipped']}[/blue]",
@@ -382,9 +375,7 @@ class InteractiveCLI:
382
375
  }
383
376
 
384
377
  def _handle_user_interruption(self, layout: Layout) -> None:
385
- layout["footer"].update(
386
- Panel("Interrupted by user", style="yellow", box=ROUNDED)
387
- )
378
+ layout["footer"].update(Panel("Interrupted by user", style="yellow"))
388
379
 
389
380
  def ask_for_file(
390
381
  self, prompt: str, directory: Path, default: str | None = None
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
4
4
 
5
5
  [project]
6
6
  name = "crackerjack"
7
- version = "0.27.9"
7
+ version = "0.30.2"
8
8
  description = "Crackerjack: code quality toolkit"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -44,6 +44,7 @@ dependencies = [
44
44
  "aiofiles>=24.1",
45
45
  "autotyping>=24.9",
46
46
  "hatchling>=1.25",
47
+ "jinja2>=3.1",
47
48
  "keyring>=25.6",
48
49
  "pre-commit>=4.2",
49
50
  "pydantic>=2.11.7",
@@ -67,7 +68,9 @@ urls.repository = "https://github.com/lesleslie/crackerjack"
67
68
 
68
69
  [dependency-groups]
69
70
  dev = [
71
+ "complexipy>=3.3",
70
72
  "pyyaml>=6.0.2",
73
+ "refurb>=2.1",
71
74
  ]
72
75
 
73
76
  [tool.ruff]