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
@@ -101,4 +101,729 @@ class TestSafeFileOperations:
101
101
  safe_read_file("test.txt", encoding=123)
102
102
 
103
103
  with pytest.raises(SafeUtilityError, match="должно быть строкой"):
104
- safe_write_file("test.txt", 123)
104
+ safe_write_file("test.txt", 123)
105
+
106
+
107
+
108
+ class TestProjectRoot:
109
+ """Tests for project_root function."""
110
+
111
+ def test_project_root_from_current_directory(self):
112
+ """Test finding project root from current directory."""
113
+ from fishertools.safe.files import project_root
114
+
115
+ root = project_root()
116
+ assert root is not None
117
+ assert Path(root).exists()
118
+ # Should find one of the markers
119
+ markers = ['setup.py', 'pyproject.toml', '.git', '.gitignore']
120
+ assert any((Path(root) / marker).exists() for marker in markers)
121
+
122
+ def test_project_root_from_subdirectory(self):
123
+ """Test finding project root from a subdirectory."""
124
+ from fishertools.safe.files import project_root
125
+
126
+ # Get root from current directory
127
+ root1 = project_root()
128
+
129
+ # Get root from a subdirectory
130
+ subdir = Path(root1) / "fishertools"
131
+ if subdir.exists():
132
+ root2 = project_root(subdir)
133
+ assert root1 == root2
134
+
135
+ def test_project_root_not_found(self):
136
+ """Test that RuntimeError is raised when project root cannot be found."""
137
+ from fishertools.safe.files import project_root
138
+
139
+ with tempfile.TemporaryDirectory() as temp_dir:
140
+ # Create a temporary directory with no markers
141
+ with pytest.raises(RuntimeError, match="Could not determine project root"):
142
+ project_root(temp_dir)
143
+
144
+
145
+ class TestFindFile:
146
+ """Tests for find_file function."""
147
+
148
+ def test_find_file_existing_file(self):
149
+ """Test finding an existing file."""
150
+ from fishertools.safe.files import find_file
151
+
152
+ # Find setup.py which should exist in project root
153
+ path = find_file("setup.py")
154
+ assert path is not None
155
+ assert Path(path).exists()
156
+ assert Path(path).name == "setup.py"
157
+
158
+ def test_find_file_nonexistent_file(self):
159
+ """Test finding a non-existent file returns None."""
160
+ from fishertools.safe.files import find_file
161
+
162
+ path = find_file("nonexistent_file_12345.txt")
163
+ assert path is None
164
+
165
+ def test_find_file_from_subdirectory(self):
166
+ """Test finding a file starting from a subdirectory."""
167
+ from fishertools.safe.files import find_file
168
+
169
+ # Create a test file in a temporary subdirectory
170
+ with tempfile.TemporaryDirectory() as temp_dir:
171
+ temp_path = Path(temp_dir)
172
+ subdir = temp_path / "subdir"
173
+ subdir.mkdir()
174
+
175
+ # Create a test file in the subdirectory
176
+ test_file = subdir / "test.txt"
177
+ test_file.write_text("test content")
178
+
179
+ # Find the file from the subdirectory
180
+ path = find_file("test.txt", subdir)
181
+ assert path is not None
182
+ assert Path(path).exists()
183
+ assert Path(path).name == "test.txt"
184
+
185
+
186
+ class TestSafeOpen:
187
+ """Tests for safe_open function."""
188
+
189
+ def test_safe_open_existing_file(self):
190
+ """Test opening an existing file."""
191
+ from fishertools.safe.files import safe_open
192
+
193
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
194
+ f.write("Test content")
195
+ temp_path = f.name
196
+
197
+ try:
198
+ with safe_open(temp_path) as f:
199
+ content = f.read()
200
+ assert content == "Test content"
201
+ finally:
202
+ os.unlink(temp_path)
203
+
204
+ def test_safe_open_nonexistent_file(self):
205
+ """Test opening a non-existent file raises FileNotFoundError."""
206
+ from fishertools.safe.files import safe_open
207
+
208
+ with pytest.raises(FileNotFoundError):
209
+ safe_open("nonexistent_file_12345.txt")
210
+
211
+ def test_safe_open_write_mode(self):
212
+ """Test opening a file in write mode."""
213
+ from fishertools.safe.files import safe_open
214
+
215
+ with tempfile.TemporaryDirectory() as temp_dir:
216
+ file_path = Path(temp_dir) / "test_file.txt"
217
+
218
+ with safe_open(file_path, mode='w') as f:
219
+ f.write("Hello World")
220
+
221
+ assert file_path.exists()
222
+ assert file_path.read_text(encoding='utf-8') == "Hello World"
223
+
224
+
225
+
226
+ # ============================================================================
227
+ # Tests for fishertools-file-utils spec functions
228
+ # ============================================================================
229
+
230
+ from hypothesis import given, strategies as st, settings
231
+ import hashlib
232
+
233
+
234
+ class TestEnsureDir:
235
+ """Unit tests for ensure_dir function."""
236
+
237
+ def test_ensure_dir_creates_new_directory(self):
238
+ """Test creating a new directory."""
239
+ from fishertools.safe.files import ensure_dir
240
+
241
+ with tempfile.TemporaryDirectory() as temp_dir:
242
+ new_dir = Path(temp_dir) / "new_directory"
243
+ result = ensure_dir(new_dir)
244
+
245
+ assert isinstance(result, Path)
246
+ assert new_dir.exists()
247
+ assert new_dir.is_dir()
248
+
249
+ def test_ensure_dir_creates_nested_directories(self):
250
+ """Test creating nested directories."""
251
+ from fishertools.safe.files import ensure_dir
252
+
253
+ with tempfile.TemporaryDirectory() as temp_dir:
254
+ nested_dir = Path(temp_dir) / "level1" / "level2" / "level3"
255
+ result = ensure_dir(nested_dir)
256
+
257
+ assert isinstance(result, Path)
258
+ assert nested_dir.exists()
259
+ assert nested_dir.is_dir()
260
+
261
+ def test_ensure_dir_idempotent(self):
262
+ """Test that ensure_dir is idempotent."""
263
+ from fishertools.safe.files import ensure_dir
264
+
265
+ with tempfile.TemporaryDirectory() as temp_dir:
266
+ test_dir = Path(temp_dir) / "test_dir"
267
+
268
+ # First call
269
+ result1 = ensure_dir(test_dir)
270
+ assert test_dir.exists()
271
+
272
+ # Second call should not raise error
273
+ result2 = ensure_dir(test_dir)
274
+ assert test_dir.exists()
275
+ assert result1 == result2
276
+
277
+ def test_ensure_dir_accepts_string_path(self):
278
+ """Test that ensure_dir accepts string paths."""
279
+ from fishertools.safe.files import ensure_dir
280
+
281
+ with tempfile.TemporaryDirectory() as temp_dir:
282
+ test_dir = str(Path(temp_dir) / "test_dir")
283
+ result = ensure_dir(test_dir)
284
+
285
+ assert isinstance(result, Path)
286
+ assert Path(test_dir).exists()
287
+
288
+ def test_ensure_dir_accepts_path_object(self):
289
+ """Test that ensure_dir accepts Path objects."""
290
+ from fishertools.safe.files import ensure_dir
291
+
292
+ with tempfile.TemporaryDirectory() as temp_dir:
293
+ test_dir = Path(temp_dir) / "test_dir"
294
+ result = ensure_dir(test_dir)
295
+
296
+ assert isinstance(result, Path)
297
+ assert test_dir.exists()
298
+
299
+ def test_ensure_dir_returns_path_object(self):
300
+ """Test that ensure_dir returns a Path object."""
301
+ from fishertools.safe.files import ensure_dir
302
+
303
+ with tempfile.TemporaryDirectory() as temp_dir:
304
+ test_dir = Path(temp_dir) / "test_dir"
305
+ result = ensure_dir(test_dir)
306
+
307
+ assert isinstance(result, Path)
308
+ assert result == test_dir
309
+
310
+
311
+ class TestGetFileHash:
312
+ """Unit tests for get_file_hash function."""
313
+
314
+ def test_get_file_hash_sha256_default(self):
315
+ """Test computing SHA256 hash (default algorithm)."""
316
+ from fishertools.safe.files import get_file_hash
317
+
318
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
319
+ f.write("test content")
320
+ temp_path = f.name
321
+
322
+ try:
323
+ result = get_file_hash(temp_path)
324
+ # Verify it's a valid hex string
325
+ assert isinstance(result, str)
326
+ assert len(result) == 64 # SHA256 produces 64 hex characters
327
+ assert all(c in '0123456789abcdef' for c in result)
328
+ finally:
329
+ os.unlink(temp_path)
330
+
331
+ def test_get_file_hash_md5(self):
332
+ """Test computing MD5 hash."""
333
+ from fishertools.safe.files import get_file_hash
334
+
335
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
336
+ f.write("test content")
337
+ temp_path = f.name
338
+
339
+ try:
340
+ result = get_file_hash(temp_path, algorithm='md5')
341
+ assert isinstance(result, str)
342
+ assert len(result) == 32 # MD5 produces 32 hex characters
343
+ finally:
344
+ os.unlink(temp_path)
345
+
346
+ def test_get_file_hash_sha1(self):
347
+ """Test computing SHA1 hash."""
348
+ from fishertools.safe.files import get_file_hash
349
+
350
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
351
+ f.write("test content")
352
+ temp_path = f.name
353
+
354
+ try:
355
+ result = get_file_hash(temp_path, algorithm='sha1')
356
+ assert isinstance(result, str)
357
+ assert len(result) == 40 # SHA1 produces 40 hex characters
358
+ finally:
359
+ os.unlink(temp_path)
360
+
361
+ def test_get_file_hash_sha512(self):
362
+ """Test computing SHA512 hash."""
363
+ from fishertools.safe.files import get_file_hash
364
+
365
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
366
+ f.write("test content")
367
+ temp_path = f.name
368
+
369
+ try:
370
+ result = get_file_hash(temp_path, algorithm='sha512')
371
+ assert isinstance(result, str)
372
+ assert len(result) == 128 # SHA512 produces 128 hex characters
373
+ finally:
374
+ os.unlink(temp_path)
375
+
376
+ def test_get_file_hash_blake2b(self):
377
+ """Test computing BLAKE2b hash."""
378
+ from fishertools.safe.files import get_file_hash
379
+
380
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
381
+ f.write("test content")
382
+ temp_path = f.name
383
+
384
+ try:
385
+ result = get_file_hash(temp_path, algorithm='blake2b')
386
+ assert isinstance(result, str)
387
+ assert len(result) == 128 # BLAKE2b produces 128 hex characters
388
+ finally:
389
+ os.unlink(temp_path)
390
+
391
+ def test_get_file_hash_nonexistent_file(self):
392
+ """Test that FileNotFoundError is raised for non-existent file."""
393
+ from fishertools.safe.files import get_file_hash
394
+
395
+ with pytest.raises(FileNotFoundError, match="File not found"):
396
+ get_file_hash("nonexistent_file_12345.txt")
397
+
398
+ def test_get_file_hash_unsupported_algorithm(self):
399
+ """Test that ValueError is raised for unsupported algorithm."""
400
+ from fishertools.safe.files import get_file_hash
401
+
402
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
403
+ f.write("test content")
404
+ temp_path = f.name
405
+
406
+ try:
407
+ with pytest.raises(ValueError, match="Unsupported algorithm"):
408
+ get_file_hash(temp_path, algorithm='unsupported')
409
+ finally:
410
+ os.unlink(temp_path)
411
+
412
+ def test_get_file_hash_deterministic(self):
413
+ """Test that hash is deterministic (same file produces same hash)."""
414
+ from fishertools.safe.files import get_file_hash
415
+
416
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
417
+ f.write("test content")
418
+ temp_path = f.name
419
+
420
+ try:
421
+ hash1 = get_file_hash(temp_path)
422
+ hash2 = get_file_hash(temp_path)
423
+ assert hash1 == hash2
424
+ finally:
425
+ os.unlink(temp_path)
426
+
427
+ def test_get_file_hash_accepts_string_path(self):
428
+ """Test that get_file_hash accepts string paths."""
429
+ from fishertools.safe.files import get_file_hash
430
+
431
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
432
+ f.write("test content")
433
+ temp_path = f.name
434
+
435
+ try:
436
+ result = get_file_hash(temp_path)
437
+ assert isinstance(result, str)
438
+ finally:
439
+ os.unlink(temp_path)
440
+
441
+ def test_get_file_hash_accepts_path_object(self):
442
+ """Test that get_file_hash accepts Path objects."""
443
+ from fishertools.safe.files import get_file_hash
444
+
445
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
446
+ f.write("test content")
447
+ temp_path = Path(f.name)
448
+
449
+ try:
450
+ result = get_file_hash(temp_path)
451
+ assert isinstance(result, str)
452
+ finally:
453
+ os.unlink(temp_path)
454
+
455
+
456
+ class TestReadLastLines:
457
+ """Unit tests for read_last_lines function."""
458
+
459
+ def test_read_last_lines_basic(self):
460
+ """Test reading last 10 lines from a file."""
461
+ from fishertools.safe.files import read_last_lines
462
+
463
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
464
+ for i in range(20):
465
+ f.write(f"line {i}\n")
466
+ temp_path = f.name
467
+
468
+ try:
469
+ result = read_last_lines(temp_path, n=10)
470
+ assert len(result) == 10
471
+ assert result[0] == "line 10"
472
+ assert result[-1] == "line 19"
473
+ finally:
474
+ os.unlink(temp_path)
475
+
476
+ def test_read_last_lines_single_line(self):
477
+ """Test reading last 1 line."""
478
+ from fishertools.safe.files import read_last_lines
479
+
480
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
481
+ for i in range(10):
482
+ f.write(f"line {i}\n")
483
+ temp_path = f.name
484
+
485
+ try:
486
+ result = read_last_lines(temp_path, n=1)
487
+ assert len(result) == 1
488
+ assert result[0] == "line 9"
489
+ finally:
490
+ os.unlink(temp_path)
491
+
492
+ def test_read_last_lines_n_greater_than_file_lines(self):
493
+ """Test reading when n is greater than number of lines."""
494
+ from fishertools.safe.files import read_last_lines
495
+
496
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
497
+ for i in range(5):
498
+ f.write(f"line {i}\n")
499
+ temp_path = f.name
500
+
501
+ try:
502
+ result = read_last_lines(temp_path, n=10)
503
+ assert len(result) == 5
504
+ assert result[0] == "line 0"
505
+ assert result[-1] == "line 4"
506
+ finally:
507
+ os.unlink(temp_path)
508
+
509
+ def test_read_last_lines_empty_file(self):
510
+ """Test reading from an empty file."""
511
+ from fishertools.safe.files import read_last_lines
512
+
513
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
514
+ temp_path = f.name
515
+
516
+ try:
517
+ result = read_last_lines(temp_path, n=10)
518
+ assert result == []
519
+ finally:
520
+ os.unlink(temp_path)
521
+
522
+ def test_read_last_lines_single_line_file(self):
523
+ """Test reading from a file with single line."""
524
+ from fishertools.safe.files import read_last_lines
525
+
526
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
527
+ f.write("only line\n")
528
+ temp_path = f.name
529
+
530
+ try:
531
+ result = read_last_lines(temp_path, n=10)
532
+ assert len(result) == 1
533
+ assert result[0] == "only line"
534
+ finally:
535
+ os.unlink(temp_path)
536
+
537
+ def test_read_last_lines_strips_newlines(self):
538
+ """Test that newlines are stripped from lines."""
539
+ from fishertools.safe.files import read_last_lines
540
+
541
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
542
+ f.write("line 1\nline 2\nline 3\n")
543
+ temp_path = f.name
544
+
545
+ try:
546
+ result = read_last_lines(temp_path, n=3)
547
+ assert all('\n' not in line for line in result)
548
+ assert all('\r' not in line for line in result)
549
+ finally:
550
+ os.unlink(temp_path)
551
+
552
+ def test_read_last_lines_nonexistent_file(self):
553
+ """Test that FileNotFoundError is raised for non-existent file."""
554
+ from fishertools.safe.files import read_last_lines
555
+
556
+ with pytest.raises(FileNotFoundError, match="File not found"):
557
+ read_last_lines("nonexistent_file_12345.txt")
558
+
559
+ def test_read_last_lines_accepts_string_path(self):
560
+ """Test that read_last_lines accepts string paths."""
561
+ from fishertools.safe.files import read_last_lines
562
+
563
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
564
+ f.write("line 1\nline 2\n")
565
+ temp_path = f.name
566
+
567
+ try:
568
+ result = read_last_lines(temp_path, n=2)
569
+ assert len(result) == 2
570
+ finally:
571
+ os.unlink(temp_path)
572
+
573
+ def test_read_last_lines_accepts_path_object(self):
574
+ """Test that read_last_lines accepts Path objects."""
575
+ from fishertools.safe.files import read_last_lines
576
+
577
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
578
+ f.write("line 1\nline 2\n")
579
+ temp_path = Path(f.name)
580
+
581
+ try:
582
+ result = read_last_lines(temp_path, n=2)
583
+ assert len(result) == 2
584
+ finally:
585
+ os.unlink(temp_path)
586
+
587
+
588
+ # ============================================================================
589
+ # Property-Based Tests using Hypothesis
590
+ # ============================================================================
591
+
592
+ class TestEnsureDirProperties:
593
+ """Property-based tests for ensure_dir function."""
594
+
595
+ @given(st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_characters='/\x00:<>"|?*\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f')))
596
+ @settings(max_examples=100)
597
+ def test_ensure_dir_returns_path_object(self, dirname):
598
+ """Property 1: ensure_dir returns Path object
599
+
600
+ **Validates: Requirements 1.1, 1.2**
601
+ """
602
+ from fishertools.safe.files import ensure_dir
603
+
604
+ with tempfile.TemporaryDirectory() as temp_dir:
605
+ test_path = Path(temp_dir) / dirname
606
+ result = ensure_dir(test_path)
607
+ assert isinstance(result, Path)
608
+
609
+ @given(st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_characters='/\x00:<>"|?*\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f')))
610
+ @settings(max_examples=100)
611
+ def test_ensure_dir_creates_directory(self, dirname):
612
+ """Property 2: ensure_dir creates directory
613
+
614
+ **Validates: Requirements 1.3**
615
+ """
616
+ from fishertools.safe.files import ensure_dir
617
+
618
+ with tempfile.TemporaryDirectory() as temp_dir:
619
+ test_path = Path(temp_dir) / dirname
620
+ ensure_dir(test_path)
621
+ assert test_path.exists()
622
+ assert test_path.is_dir()
623
+
624
+ @given(st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_characters='/\x00:<>"|?*\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f')))
625
+ @settings(max_examples=100)
626
+ def test_ensure_dir_idempotent(self, dirname):
627
+ """Property 3: ensure_dir is idempotent
628
+
629
+ **Validates: Requirements 1.4**
630
+ """
631
+ from fishertools.safe.files import ensure_dir
632
+
633
+ with tempfile.TemporaryDirectory() as temp_dir:
634
+ test_path = Path(temp_dir) / dirname
635
+ result1 = ensure_dir(test_path)
636
+ result2 = ensure_dir(test_path)
637
+ assert result1 == result2
638
+ assert test_path.exists()
639
+
640
+
641
+ class TestGetFileHashProperties:
642
+ """Property-based tests for get_file_hash function."""
643
+
644
+ @given(
645
+ st.binary(min_size=0, max_size=1000),
646
+ st.sampled_from(['md5', 'sha1', 'sha256', 'sha512', 'blake2b'])
647
+ )
648
+ @settings(max_examples=100)
649
+ def test_get_file_hash_supports_all_algorithms(self, content, algorithm):
650
+ """Property 4: get_file_hash supports all algorithms
651
+
652
+ **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
653
+ """
654
+ from fishertools.safe.files import get_file_hash
655
+
656
+ with tempfile.NamedTemporaryFile(delete=False) as f:
657
+ f.write(content)
658
+ temp_path = f.name
659
+
660
+ try:
661
+ result = get_file_hash(temp_path, algorithm=algorithm)
662
+ assert isinstance(result, str)
663
+ assert len(result) > 0
664
+ assert all(c in '0123456789abcdef' for c in result)
665
+ finally:
666
+ os.unlink(temp_path)
667
+
668
+ @given(st.binary(min_size=0, max_size=1000))
669
+ @settings(max_examples=100)
670
+ def test_get_file_hash_accepts_str_and_path(self, content):
671
+ """Property 5: get_file_hash accepts str and Path
672
+
673
+ **Validates: Requirements 2.10, 2.11**
674
+ """
675
+ from fishertools.safe.files import get_file_hash
676
+
677
+ with tempfile.NamedTemporaryFile(delete=False) as f:
678
+ f.write(content)
679
+ temp_path = f.name
680
+
681
+ try:
682
+ hash_from_str = get_file_hash(temp_path)
683
+ hash_from_path = get_file_hash(Path(temp_path))
684
+ assert hash_from_str == hash_from_path
685
+ finally:
686
+ os.unlink(temp_path)
687
+
688
+ @given(st.binary(min_size=0, max_size=1000))
689
+ @settings(max_examples=100)
690
+ def test_get_file_hash_deterministic(self, content):
691
+ """Property 6: get_file_hash is deterministic
692
+
693
+ **Validates: Requirements 2.1**
694
+ """
695
+ from fishertools.safe.files import get_file_hash
696
+
697
+ with tempfile.NamedTemporaryFile(delete=False) as f:
698
+ f.write(content)
699
+ temp_path = f.name
700
+
701
+ try:
702
+ hash1 = get_file_hash(temp_path)
703
+ hash2 = get_file_hash(temp_path)
704
+ assert hash1 == hash2
705
+ finally:
706
+ os.unlink(temp_path)
707
+
708
+
709
+ class TestReadLastLinesProperties:
710
+ """Property-based tests for read_last_lines function."""
711
+
712
+ @given(
713
+ st.lists(st.text(min_size=0, max_size=100, alphabet=st.characters(blacklist_characters='\n\r')), min_size=0, max_size=100),
714
+ st.integers(min_value=1, max_value=50)
715
+ )
716
+ @settings(max_examples=100)
717
+ def test_read_last_lines_correct_count(self, lines, n):
718
+ """Property 7: read_last_lines returns correct number of lines
719
+
720
+ **Validates: Requirements 3.1**
721
+ """
722
+ from fishertools.safe.files import read_last_lines
723
+
724
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
725
+ for line in lines:
726
+ f.write(line + '\n')
727
+ temp_path = f.name
728
+
729
+ try:
730
+ result = read_last_lines(temp_path, n=n)
731
+ expected_count = min(n, len(lines))
732
+ assert len(result) == expected_count
733
+ finally:
734
+ os.unlink(temp_path)
735
+
736
+ @given(
737
+ st.lists(st.text(min_size=0, max_size=100, alphabet=st.characters(blacklist_characters='\n\r', codec='utf-8')), min_size=0, max_size=50)
738
+ )
739
+ @settings(max_examples=100)
740
+ def test_read_last_lines_returns_all_when_n_greater(self, lines):
741
+ """Property 8: read_last_lines returns all lines when n > total
742
+
743
+ **Validates: Requirements 3.3**
744
+ """
745
+ from fishertools.safe.files import read_last_lines
746
+
747
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
748
+ for line in lines:
749
+ f.write(line + '\n')
750
+ temp_path = f.name
751
+
752
+ try:
753
+ result = read_last_lines(temp_path, n=1000)
754
+ assert len(result) == len(lines)
755
+ finally:
756
+ os.unlink(temp_path)
757
+
758
+ @given(
759
+ st.lists(st.text(min_size=0, max_size=100, alphabet=st.characters(blacklist_characters='\n\r', codec='utf-8')), min_size=0, max_size=100),
760
+ st.integers(min_value=1, max_value=50)
761
+ )
762
+ @settings(max_examples=100)
763
+ def test_read_last_lines_strips_newlines(self, lines, n):
764
+ """Property 9: read_last_lines strips newlines
765
+
766
+ **Validates: Requirements 3.11**
767
+ """
768
+ from fishertools.safe.files import read_last_lines
769
+
770
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
771
+ for line in lines:
772
+ f.write(line + '\n')
773
+ temp_path = f.name
774
+
775
+ try:
776
+ result = read_last_lines(temp_path, n=n)
777
+ assert all('\n' not in line for line in result)
778
+ assert all('\r' not in line for line in result)
779
+ finally:
780
+ os.unlink(temp_path)
781
+
782
+ @given(
783
+ st.lists(st.text(min_size=0, max_size=100, alphabet=st.characters(blacklist_characters='\n\r', codec='utf-8')), min_size=0, max_size=100),
784
+ st.integers(min_value=1, max_value=50)
785
+ )
786
+ @settings(max_examples=100)
787
+ def test_read_last_lines_accepts_str_and_path(self, lines, n):
788
+ """Property 10: read_last_lines accepts str and Path
789
+
790
+ **Validates: Requirements 3.9, 3.10**
791
+ """
792
+ from fishertools.safe.files import read_last_lines
793
+
794
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
795
+ for line in lines:
796
+ f.write(line + '\n')
797
+ temp_path = f.name
798
+
799
+ try:
800
+ result_from_str = read_last_lines(temp_path, n=n)
801
+ result_from_path = read_last_lines(Path(temp_path), n=n)
802
+ assert result_from_str == result_from_path
803
+ finally:
804
+ os.unlink(temp_path)
805
+
806
+
807
+ class TestModuleExports:
808
+ """Tests for module exports."""
809
+
810
+ def test_module_exports_all(self):
811
+ """Property 11: Module exports correct functions
812
+
813
+ **Validates: Requirements 4.3**
814
+ """
815
+ from fishertools.safe import files
816
+
817
+ assert hasattr(files, '__all__')
818
+ assert 'ensure_dir' in files.__all__
819
+ assert 'get_file_hash' in files.__all__
820
+ assert 'read_last_lines' in files.__all__
821
+ assert len(files.__all__) == 3
822
+
823
+ def test_module_functions_importable(self):
824
+ """Test that all functions can be imported."""
825
+ from fishertools.safe.files import ensure_dir, get_file_hash, read_last_lines
826
+
827
+ assert callable(ensure_dir)
828
+ assert callable(get_file_hash)
829
+ assert callable(read_last_lines)