fishertools 0.2.1__py3-none-any.whl → 0.4.0__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 (69) hide show
  1. fishertools/__init__.py +16 -5
  2. fishertools/errors/__init__.py +11 -3
  3. fishertools/errors/exception_types.py +282 -0
  4. fishertools/errors/explainer.py +87 -1
  5. fishertools/errors/models.py +73 -1
  6. fishertools/errors/patterns.py +40 -0
  7. fishertools/examples/cli_example.py +156 -0
  8. fishertools/examples/learn_example.py +65 -0
  9. fishertools/examples/logger_example.py +176 -0
  10. fishertools/examples/menu_example.py +101 -0
  11. fishertools/examples/storage_example.py +175 -0
  12. fishertools/input_utils.py +185 -0
  13. fishertools/learn/__init__.py +19 -2
  14. fishertools/learn/examples.py +88 -1
  15. fishertools/learn/knowledge_engine.py +321 -0
  16. fishertools/learn/repl/__init__.py +19 -0
  17. fishertools/learn/repl/cli.py +31 -0
  18. fishertools/learn/repl/code_sandbox.py +229 -0
  19. fishertools/learn/repl/command_handler.py +544 -0
  20. fishertools/learn/repl/command_parser.py +165 -0
  21. fishertools/learn/repl/engine.py +479 -0
  22. fishertools/learn/repl/models.py +121 -0
  23. fishertools/learn/repl/session_manager.py +284 -0
  24. fishertools/learn/repl/test_code_sandbox.py +261 -0
  25. fishertools/learn/repl/test_code_sandbox_pbt.py +148 -0
  26. fishertools/learn/repl/test_command_handler.py +224 -0
  27. fishertools/learn/repl/test_command_handler_pbt.py +189 -0
  28. fishertools/learn/repl/test_command_parser.py +160 -0
  29. fishertools/learn/repl/test_command_parser_pbt.py +100 -0
  30. fishertools/learn/repl/test_engine.py +190 -0
  31. fishertools/learn/repl/test_session_manager.py +310 -0
  32. fishertools/learn/repl/test_session_manager_pbt.py +182 -0
  33. fishertools/learn/test_knowledge_engine.py +241 -0
  34. fishertools/learn/test_knowledge_engine_pbt.py +180 -0
  35. fishertools/patterns/__init__.py +46 -0
  36. fishertools/patterns/cli.py +175 -0
  37. fishertools/patterns/logger.py +140 -0
  38. fishertools/patterns/menu.py +99 -0
  39. fishertools/patterns/storage.py +127 -0
  40. fishertools/readme_transformer.py +631 -0
  41. fishertools/safe/__init__.py +6 -1
  42. fishertools/safe/files.py +329 -1
  43. fishertools/transform_readme.py +105 -0
  44. fishertools-0.4.0.dist-info/METADATA +104 -0
  45. fishertools-0.4.0.dist-info/RECORD +131 -0
  46. {fishertools-0.2.1.dist-info → fishertools-0.4.0.dist-info}/WHEEL +1 -1
  47. tests/test_documentation_properties.py +329 -0
  48. tests/test_documentation_structure.py +349 -0
  49. tests/test_errors/test_exception_types.py +446 -0
  50. tests/test_errors/test_exception_types_pbt.py +333 -0
  51. tests/test_errors/test_patterns.py +52 -0
  52. tests/test_input_utils/__init__.py +1 -0
  53. tests/test_input_utils/test_input_utils.py +65 -0
  54. tests/test_learn/test_examples.py +179 -1
  55. tests/test_learn/test_explain_properties.py +307 -0
  56. tests/test_patterns_cli.py +611 -0
  57. tests/test_patterns_docstrings.py +473 -0
  58. tests/test_patterns_logger.py +465 -0
  59. tests/test_patterns_menu.py +440 -0
  60. tests/test_patterns_storage.py +447 -0
  61. tests/test_readme_enhancements_v0_3_1.py +2036 -0
  62. tests/test_readme_transformer/__init__.py +1 -0
  63. tests/test_readme_transformer/test_readme_infrastructure.py +1023 -0
  64. tests/test_readme_transformer/test_transform_readme_integration.py +431 -0
  65. tests/test_safe/test_files.py +726 -1
  66. fishertools-0.2.1.dist-info/METADATA +0 -256
  67. fishertools-0.2.1.dist-info/RECORD +0 -81
  68. {fishertools-0.2.1.dist-info → fishertools-0.4.0.dist-info}/licenses/LICENSE +0 -0
  69. {fishertools-0.2.1.dist-info → fishertools-0.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,631 @@
1
+ """
2
+ README transformation infrastructure for fishertools.
3
+
4
+ This module provides utilities for parsing, transforming, and validating
5
+ the README file structure while preserving existing content.
6
+ """
7
+
8
+ import shutil
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import List, Optional, Tuple
13
+
14
+
15
+ @dataclass
16
+ class ReadmeSection:
17
+ """Represents a section of the README document."""
18
+
19
+ title: Optional[str]
20
+ content: str
21
+ level: int = 0 # Heading level (0 for no heading)
22
+
23
+
24
+ @dataclass
25
+ class FeatureEntry:
26
+ """Represents a single feature entry in the feature table."""
27
+
28
+ task: str # Russian task description
29
+ function: str # Function to call
30
+
31
+
32
+ class ReadmeParser:
33
+ """Parses and extracts sections from README markdown files."""
34
+
35
+ def __init__(self, readme_path: str) -> None:
36
+ """
37
+ Initialize the parser with a README file path.
38
+
39
+ Args:
40
+ readme_path: Path to the README.md file
41
+ """
42
+ self.readme_path = Path(readme_path)
43
+ self.content: str = ""
44
+ self.sections: List[ReadmeSection] = []
45
+
46
+ def read_file(self) -> str:
47
+ """
48
+ Read the README file content.
49
+
50
+ Returns:
51
+ The content of the README file
52
+
53
+ Raises:
54
+ FileNotFoundError: If the README file does not exist
55
+ IOError: If the file cannot be read
56
+ """
57
+ if not self.readme_path.exists():
58
+ raise FileNotFoundError(f"README file not found: {self.readme_path}")
59
+
60
+ try:
61
+ self.content = self.readme_path.read_text(encoding="utf-8")
62
+ return self.content
63
+ except IOError as e:
64
+ raise IOError(f"Failed to read README file: {e}")
65
+
66
+ def extract_first_sentence(self) -> str:
67
+ """
68
+ Extract the first introductory sentence from the README.
69
+
70
+ Returns:
71
+ The first sentence of the README content
72
+ """
73
+ lines = self.content.strip().split("\n")
74
+ for line in lines:
75
+ line = line.strip()
76
+ if line and not line.startswith("#"):
77
+ # Extract just the first sentence (up to period, exclamation, or question mark)
78
+ sentence = line.split(".")[0].split("!")[0].split("?")[0]
79
+ if sentence.strip():
80
+ return sentence.strip()
81
+ return ""
82
+
83
+ def parse_sections(self) -> List[ReadmeSection]:
84
+ """
85
+ Parse README content into sections.
86
+
87
+ Returns:
88
+ List of ReadmeSection objects representing document structure
89
+ """
90
+ self.sections = []
91
+ lines = self.content.split("\n")
92
+ current_section = ReadmeSection(title=None, content="", level=0)
93
+
94
+ for line in lines:
95
+ if line.startswith("#"):
96
+ # Save previous section if it has content
97
+ if current_section.content.strip():
98
+ self.sections.append(current_section)
99
+
100
+ # Create new section
101
+ level = len(line) - len(line.lstrip("#"))
102
+ title = line.lstrip("#").strip()
103
+ current_section = ReadmeSection(
104
+ title=title, content="", level=level
105
+ )
106
+ else:
107
+ current_section.content += line + "\n"
108
+
109
+ # Add final section
110
+ if current_section.content.strip():
111
+ self.sections.append(current_section)
112
+
113
+ return self.sections
114
+
115
+ def extract_detailed_content(self) -> str:
116
+ """
117
+ Extract all content after the first introductory sentence.
118
+
119
+ Returns:
120
+ The detailed content sections of the README
121
+ """
122
+ lines = self.content.strip().split("\n")
123
+ first_sentence_found = False
124
+ detailed_lines = []
125
+
126
+ for line in lines:
127
+ if not first_sentence_found:
128
+ line_stripped = line.strip()
129
+ if line_stripped and not line_stripped.startswith("#"):
130
+ # Skip the first sentence line
131
+ first_sentence_found = True
132
+ continue
133
+ detailed_lines.append(line)
134
+
135
+ return "\n".join(detailed_lines).strip()
136
+
137
+ def identify_feature_descriptions(self) -> List[Tuple[str, str]]:
138
+ """
139
+ Identify and extract feature descriptions from the README.
140
+
141
+ Returns:
142
+ List of tuples containing (feature_name, feature_description)
143
+ """
144
+ features = []
145
+ lines = self.content.split("\n")
146
+
147
+ for i, line in enumerate(lines):
148
+ # Look for lines that might be feature descriptions
149
+ if "explain_error" in line.lower() or "safe_read_file" in line.lower():
150
+ # Extract the feature and its description
151
+ feature_name = line.strip()
152
+ description = ""
153
+
154
+ # Collect following lines as description
155
+ j = i + 1
156
+ while j < len(lines) and lines[j].strip() and not lines[j].startswith("#"):
157
+ description += lines[j] + " "
158
+ j += 1
159
+
160
+ if feature_name:
161
+ features.append((feature_name, description.strip()))
162
+
163
+ return features
164
+
165
+ def identify_introduction_boundary(self) -> int:
166
+ """
167
+ Identify the line number where the introduction ends.
168
+
169
+ Returns:
170
+ Line number where detailed content begins
171
+ """
172
+ lines = self.content.strip().split("\n")
173
+ first_sentence_found = False
174
+
175
+ for i, line in enumerate(lines):
176
+ line_stripped = line.strip()
177
+
178
+ # Skip empty lines at the beginning
179
+ if not line_stripped:
180
+ continue
181
+
182
+ # Skip headings
183
+ if line_stripped.startswith("#"):
184
+ continue
185
+
186
+ # First non-heading, non-empty line is the introduction
187
+ if not first_sentence_found:
188
+ first_sentence_found = True
189
+ # Return the index after the introduction line
190
+ return i + 1
191
+
192
+ return 0
193
+
194
+ def identify_feature_list_section(self) -> Optional[Tuple[int, int]]:
195
+ """
196
+ Identify the boundaries of any existing feature list section.
197
+
198
+ Returns:
199
+ Tuple of (start_line, end_line) for feature list, or None if not found
200
+ """
201
+ lines = self.content.split("\n")
202
+ feature_section_start = None
203
+ feature_section_end = None
204
+
205
+ for i, line in enumerate(lines):
206
+ # Look for feature-related keywords
207
+ if any(
208
+ keyword in line.lower()
209
+ for keyword in [
210
+ "возможности",
211
+ "features",
212
+ "функции",
213
+ "capabilities",
214
+ ]
215
+ ):
216
+ if line.startswith("#"):
217
+ feature_section_start = i
218
+ # Find the end of this section (next heading or end of file)
219
+ for j in range(i + 1, len(lines)):
220
+ if lines[j].startswith("#"):
221
+ feature_section_end = j
222
+ break
223
+ if feature_section_end is None:
224
+ feature_section_end = len(lines)
225
+ return (feature_section_start, feature_section_end)
226
+
227
+ return None
228
+
229
+
230
+ class BackupManager:
231
+ """Manages backup and recovery of README files."""
232
+
233
+ def __init__(self, readme_path: str, backup_dir: str = ".readme_backups") -> None:
234
+ """
235
+ Initialize the backup manager.
236
+
237
+ Args:
238
+ readme_path: Path to the README.md file
239
+ backup_dir: Directory to store backups
240
+ """
241
+ self.readme_path = Path(readme_path)
242
+ self.backup_dir = Path(backup_dir)
243
+
244
+ def create_backup(self) -> Path:
245
+ """
246
+ Create a timestamped backup of the README file.
247
+
248
+ Returns:
249
+ Path to the created backup file
250
+
251
+ Raises:
252
+ FileNotFoundError: If the README file does not exist
253
+ IOError: If backup cannot be created
254
+ """
255
+ if not self.readme_path.exists():
256
+ raise FileNotFoundError(f"README file not found: {self.readme_path}")
257
+
258
+ # Create backup directory if it doesn't exist
259
+ self.backup_dir.mkdir(exist_ok=True)
260
+
261
+ # Generate timestamped backup filename
262
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
263
+ backup_path = self.backup_dir / f"README_backup_{timestamp}.md"
264
+
265
+ try:
266
+ shutil.copy2(self.readme_path, backup_path)
267
+ return backup_path
268
+ except IOError as e:
269
+ raise IOError(f"Failed to create backup: {e}")
270
+
271
+ def list_backups(self) -> List[Path]:
272
+ """
273
+ List all available backup files.
274
+
275
+ Returns:
276
+ List of backup file paths, sorted by creation time (newest first)
277
+ """
278
+ if not self.backup_dir.exists():
279
+ return []
280
+
281
+ backups = sorted(
282
+ self.backup_dir.glob("README_backup_*.md"),
283
+ key=lambda p: p.stat().st_mtime,
284
+ reverse=True,
285
+ )
286
+ return backups
287
+
288
+ def restore_backup(self, backup_path: Path) -> None:
289
+ """
290
+ Restore README from a backup file.
291
+
292
+ Args:
293
+ backup_path: Path to the backup file to restore
294
+
295
+ Raises:
296
+ FileNotFoundError: If the backup file does not exist
297
+ IOError: If restoration fails
298
+ """
299
+ if not backup_path.exists():
300
+ raise FileNotFoundError(f"Backup file not found: {backup_path}")
301
+
302
+ try:
303
+ shutil.copy2(backup_path, self.readme_path)
304
+ except IOError as e:
305
+ raise IOError(f"Failed to restore backup: {e}")
306
+
307
+
308
+ class ReadmeStructure:
309
+ """Defines and manages the structure of the transformed README."""
310
+
311
+ def __init__(self) -> None:
312
+ """Initialize the README structure."""
313
+ self.introduction: str = ""
314
+ self.installation_block: str = ""
315
+ self.feature_table: str = ""
316
+ self.target_audience: str = ""
317
+ self.existing_content: str = ""
318
+
319
+ def set_introduction(self, text: str) -> None:
320
+ """Set the introduction section."""
321
+ self.introduction = text
322
+
323
+ def set_installation_block(self, command: str = "pip install fishertools") -> None:
324
+ """
325
+ Set the installation block.
326
+
327
+ Args:
328
+ command: The installation command to display
329
+ """
330
+ self.installation_block = f"```bash\n{command}\n```"
331
+
332
+ def set_feature_table(self, features: Optional[List[FeatureEntry]] = None) -> None:
333
+ """
334
+ Set the feature table from a list of feature entries.
335
+
336
+ Args:
337
+ features: List of FeatureEntry objects. If None, uses default features.
338
+ """
339
+ if features is None:
340
+ features = self._get_default_features()
341
+
342
+ table_lines = ["| Задача | Что вызвать |", "|--------|-------------|"]
343
+
344
+ for feature in features:
345
+ table_lines.append(f"| {feature.task} | {feature.function} |")
346
+
347
+ self.feature_table = "\n".join(table_lines)
348
+
349
+ @staticmethod
350
+ def _get_default_features() -> List[FeatureEntry]:
351
+ """
352
+ Get the default feature entries for the feature table.
353
+
354
+ Returns:
355
+ List of default FeatureEntry objects
356
+ """
357
+ return [
358
+ FeatureEntry("Объяснить ошибку", "explain_error(e)"),
359
+ FeatureEntry("Красиво показать traceback", "explain_error(e)"),
360
+ FeatureEntry("Безопасно читать файл", "safe_read_file(path)"),
361
+ ]
362
+
363
+ def set_target_audience(
364
+ self,
365
+ title: str = "Для кого эта библиотека",
366
+ bullets: Optional[List[str]] = None,
367
+ ) -> None:
368
+ """
369
+ Set the target audience section.
370
+
371
+ Args:
372
+ title: Section title
373
+ bullets: List of bullet points
374
+ """
375
+ if bullets is None:
376
+ bullets = [
377
+ "Ты только начал изучать Python",
378
+ "Сообщения об ошибках кажутся страшными и непонятными",
379
+ "Хочешь, чтобы ошибки объяснялись на нормальном русском с примерами",
380
+ ]
381
+
382
+ lines = [f"## {title}", ""]
383
+ for bullet in bullets:
384
+ lines.append(f"- {bullet}")
385
+
386
+ self.target_audience = "\n".join(lines)
387
+
388
+ def set_existing_content(self, content: str) -> None:
389
+ """Set the existing detailed content."""
390
+ self.existing_content = content
391
+
392
+ def assemble(self) -> str:
393
+ """
394
+ Assemble all sections into the final README content.
395
+
396
+ Returns:
397
+ The complete transformed README content
398
+ """
399
+ sections = [
400
+ self.introduction,
401
+ "",
402
+ self.installation_block,
403
+ "",
404
+ self.feature_table,
405
+ "",
406
+ self.target_audience,
407
+ "",
408
+ self.existing_content,
409
+ ]
410
+
411
+ # Filter out empty sections and join
412
+ content = "\n".join(s for s in sections if s)
413
+ return content
414
+
415
+
416
+ class MarkdownValidator:
417
+ """Validates markdown syntax and structure."""
418
+
419
+ @staticmethod
420
+ def validate_markdown_syntax(content: str) -> Tuple[bool, Optional[str]]:
421
+ """
422
+ Validate basic markdown syntax.
423
+
424
+ Args:
425
+ content: The markdown content to validate
426
+
427
+ Returns:
428
+ Tuple of (is_valid, error_message)
429
+ """
430
+ lines = content.split("\n")
431
+ code_block_open = False
432
+ code_block_marker = None
433
+
434
+ for i, line in enumerate(lines, 1):
435
+ # Check for code block markers
436
+ if line.strip().startswith("```"):
437
+ if not code_block_open:
438
+ code_block_open = True
439
+ code_block_marker = line.strip()
440
+ else:
441
+ code_block_open = False
442
+
443
+ # Check for unmatched brackets in links
444
+ if "[" in line and "]" not in line:
445
+ # This is a simple check; more complex validation could be added
446
+ pass
447
+
448
+ if code_block_open:
449
+ return False, "Unclosed code block detected"
450
+
451
+ return True, None
452
+
453
+ @staticmethod
454
+ def validate_document_structure(content: str) -> Tuple[bool, Optional[str]]:
455
+ """
456
+ Validate document structure ordering.
457
+
458
+ Args:
459
+ content: The markdown content to validate
460
+
461
+ Returns:
462
+ Tuple of (is_valid, error_message)
463
+ """
464
+ lines = content.split("\n")
465
+
466
+ # Find key sections
467
+ intro_idx = None
468
+ install_idx = None
469
+ feature_table_idx = None
470
+ target_audience_idx = None
471
+
472
+ for i, line in enumerate(lines):
473
+ if intro_idx is None and line.strip() and not line.startswith("#"):
474
+ intro_idx = i
475
+ if "```bash" in line and install_idx is None:
476
+ install_idx = i
477
+ if "Задача" in line and "Что вызвать" in line and feature_table_idx is None:
478
+ feature_table_idx = i
479
+ if "Для кого эта библиотека" in line and target_audience_idx is None:
480
+ target_audience_idx = i
481
+
482
+ # Validate ordering
483
+ indices = [
484
+ (intro_idx, "introduction"),
485
+ (install_idx, "installation block"),
486
+ (feature_table_idx, "feature table"),
487
+ (target_audience_idx, "target audience"),
488
+ ]
489
+
490
+ valid_indices = [(idx, name) for idx, name in indices if idx is not None]
491
+
492
+ for i in range(len(valid_indices) - 1):
493
+ if valid_indices[i][0] > valid_indices[i + 1][0]:
494
+ return (
495
+ False,
496
+ f"Section ordering error: {valid_indices[i][1]} appears after {valid_indices[i + 1][1]}",
497
+ )
498
+
499
+ return True, None
500
+
501
+
502
+ class ReadmeTransformer:
503
+ """Main transformer class that orchestrates README transformation."""
504
+
505
+ def __init__(self, readme_path: str) -> None:
506
+ """
507
+ Initialize the transformer.
508
+
509
+ Args:
510
+ readme_path: Path to the README.md file
511
+ """
512
+ self.readme_path = readme_path
513
+ self.parser = ReadmeParser(readme_path)
514
+ self.backup_manager = BackupManager(readme_path)
515
+ self.structure = ReadmeStructure()
516
+ self.validator = MarkdownValidator()
517
+
518
+ def validate_readme_exists(self) -> bool:
519
+ """
520
+ Validate that the README file exists.
521
+
522
+ Returns:
523
+ True if README exists, False otherwise
524
+ """
525
+ return Path(self.readme_path).exists()
526
+
527
+ def parse_readme(self) -> None:
528
+ """Parse the README file and extract sections."""
529
+ self.parser.read_file()
530
+ self.parser.parse_sections()
531
+
532
+ def extract_content(self) -> Tuple[str, str]:
533
+ """
534
+ Extract introduction and existing content from README.
535
+
536
+ Returns:
537
+ Tuple of (introduction, existing_content)
538
+ """
539
+ # Ensure parser has read the file
540
+ if not self.parser.content:
541
+ self.parser.read_file()
542
+
543
+ introduction = self.parser.extract_first_sentence()
544
+ existing_content = self.parser.content
545
+
546
+ return introduction, existing_content
547
+
548
+ def create_backup(self) -> Path:
549
+ """
550
+ Create a backup of the original README.
551
+
552
+ Returns:
553
+ Path to the created backup file
554
+ """
555
+ return self.backup_manager.create_backup()
556
+
557
+ def transform(
558
+ self,
559
+ features: Optional[List[FeatureEntry]] = None,
560
+ target_audience_bullets: Optional[List[str]] = None,
561
+ ) -> str:
562
+ """
563
+ Transform the README with new structure.
564
+
565
+ Args:
566
+ features: List of features for the feature table. If None, uses default features.
567
+ target_audience_bullets: Custom target audience bullet points
568
+
569
+ Returns:
570
+ The transformed README content
571
+ """
572
+ # Parse the original README
573
+ self.parse_readme()
574
+
575
+ # Extract content
576
+ introduction, existing_content = self.extract_content()
577
+
578
+ # Set up structure
579
+ self.structure.set_introduction(introduction)
580
+ self.structure.set_installation_block()
581
+
582
+ # Set feature table (uses default if not provided)
583
+ self.structure.set_feature_table(features)
584
+
585
+ # Set target audience
586
+ self.structure.set_target_audience(bullets=target_audience_bullets)
587
+
588
+ # Set existing content
589
+ self.structure.set_existing_content(existing_content)
590
+
591
+ # Assemble and return
592
+ return self.structure.assemble()
593
+
594
+ def write_transformed_readme(self, content: str) -> None:
595
+ """
596
+ Write the transformed content to the README file.
597
+
598
+ Args:
599
+ content: The transformed README content
600
+
601
+ Raises:
602
+ IOError: If writing fails
603
+ """
604
+ try:
605
+ Path(self.readme_path).write_text(content, encoding="utf-8")
606
+ except IOError as e:
607
+ raise IOError(f"Failed to write transformed README: {e}")
608
+
609
+ def validate_transformed_content(self, content: str) -> Tuple[bool, Optional[str]]:
610
+ """
611
+ Validate the transformed README content.
612
+
613
+ Args:
614
+ content: The transformed README content to validate
615
+
616
+ Returns:
617
+ Tuple of (is_valid, error_message)
618
+ """
619
+ # Validate markdown syntax
620
+ syntax_valid, syntax_error = self.validator.validate_markdown_syntax(content)
621
+ if not syntax_valid:
622
+ return False, syntax_error
623
+
624
+ # Validate document structure
625
+ structure_valid, structure_error = self.validator.validate_document_structure(
626
+ content
627
+ )
628
+ if not structure_valid:
629
+ return False, structure_error
630
+
631
+ return True, None
@@ -6,11 +6,16 @@ that prevent typical mistakes and provide helpful error messages.
6
6
  """
7
7
 
8
8
  from .collections import safe_get, safe_divide, safe_max, safe_min, safe_sum
9
- from .files import safe_read_file, safe_write_file, safe_file_exists, safe_get_file_size, safe_list_files
9
+ from .files import (
10
+ safe_read_file, safe_write_file, safe_file_exists, safe_get_file_size, safe_list_files,
11
+ safe_open, find_file, project_root, ensure_dir, get_file_hash, read_last_lines
12
+ )
10
13
  from .strings import safe_string_operations
11
14
 
12
15
  __all__ = [
13
16
  "safe_get", "safe_divide", "safe_max", "safe_min", "safe_sum",
14
17
  "safe_read_file", "safe_write_file", "safe_file_exists", "safe_get_file_size", "safe_list_files",
18
+ "safe_open", "find_file", "project_root",
19
+ "ensure_dir", "get_file_hash", "read_last_lines",
15
20
  "safe_string_operations"
16
21
  ]