unctools 0.1.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.
unctools/operations.py ADDED
@@ -0,0 +1,562 @@
1
+ r"""
2
+ High-level file operations for working with UNC paths and network drives.
3
+
4
+ This module provides functions for higher-level operations like safely opening files,
5
+ batch converting paths, and copying files with automatic path conversion.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import io
11
+ import logging
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional, Union, Callable, TextIO, BinaryIO, Any, Tuple
15
+
16
+ # Import from our own modules
17
+ from .converter import convert_to_local, convert_to_unc, normalize_path
18
+ from .detector import is_unc_path, get_path_type, detect_path_issues, PATH_TYPE_UNC
19
+
20
+ # Set up module-level logger
21
+ logger = logging.getLogger(__name__)
22
+
23
+ def safe_open(file_path: Union[str, Path], mode: str = 'r',
24
+ encoding: Optional[str] = None, convert_paths: bool = True,
25
+ **kwargs) -> Union[TextIO, BinaryIO]:
26
+ """
27
+ Safely open a file, handling UNC paths and network drives automatically.
28
+
29
+ This function attempts to open a file, automatically converting UNC paths to
30
+ local drive paths if necessary to avoid permission issues.
31
+
32
+ Args:
33
+ file_path: The path to the file to open.
34
+ mode: The mode to open the file in (same as built-in open).
35
+ encoding: The encoding to use (same as built-in open).
36
+ convert_paths: Whether to automatically convert between UNC and local paths.
37
+ **kwargs: Additional keyword arguments to pass to open.
38
+
39
+ Returns:
40
+ A file object, as returned by the built-in open function.
41
+
42
+ Raises:
43
+ FileNotFoundError: If the file does not exist.
44
+ PermissionError: If permission is denied (even after path conversion).
45
+ OSError: If another OS-level error occurs.
46
+ """
47
+ original_path = Path(file_path)
48
+
49
+ try:
50
+ # First try to open the file as-is
51
+ return open(original_path, mode=mode, encoding=encoding, **kwargs)
52
+ except PermissionError:
53
+ # If we get a permission error and conversion is enabled, try converting the path
54
+ if convert_paths:
55
+ try:
56
+ if is_unc_path(original_path):
57
+ # Convert UNC to local
58
+ local_path = convert_to_local(original_path)
59
+ if local_path != original_path:
60
+ logger.debug(f"Converting UNC path {original_path} to local path {local_path}")
61
+ return open(local_path, mode=mode, encoding=encoding, **kwargs)
62
+ else:
63
+ # Convert local to UNC
64
+ unc_path = convert_to_unc(original_path)
65
+ if unc_path != original_path:
66
+ logger.debug(f"Converting local path {original_path} to UNC path {unc_path}")
67
+ return open(unc_path, mode=mode, encoding=encoding, **kwargs)
68
+ except Exception as e:
69
+ logger.warning(f"Path conversion failed: {e}")
70
+
71
+ # If conversion failed or is disabled, re-raise the original error
72
+ raise
73
+
74
+ def file_exists(file_path: Union[str, Path], check_both_paths: bool = True) -> bool:
75
+ """
76
+ Check if a file exists, handling UNC paths and network drives.
77
+
78
+ Args:
79
+ file_path: The path to check.
80
+ check_both_paths: Whether to check both UNC and local path variants.
81
+
82
+ Returns:
83
+ True if the file exists, False otherwise.
84
+ """
85
+ original_path = Path(file_path)
86
+
87
+ # First check the original path
88
+ if os.path.exists(original_path):
89
+ return True
90
+
91
+ # If requested, check the converted path too
92
+ if check_both_paths:
93
+ try:
94
+ if is_unc_path(original_path):
95
+ # Check local path
96
+ local_path = convert_to_local(original_path)
97
+ if local_path != original_path and os.path.exists(local_path):
98
+ return True
99
+ else:
100
+ # Check UNC path
101
+ unc_path = convert_to_unc(original_path)
102
+ if unc_path != original_path and os.path.exists(unc_path):
103
+ return True
104
+ except Exception as e:
105
+ logger.debug(f"Path conversion during file_exists check failed: {e}")
106
+
107
+ return False
108
+
109
+ def batch_convert(paths: List[Union[str, Path]], to_unc: bool = False) -> Dict[str, str]:
110
+ """
111
+ Convert a batch of paths between UNC and local formats.
112
+
113
+ Args:
114
+ paths: List of paths to convert.
115
+ to_unc: If True, convert to UNC paths; if False, convert to local paths.
116
+
117
+ Returns:
118
+ A dictionary mapping original paths to converted paths.
119
+ """
120
+ result = {}
121
+
122
+ for path in paths:
123
+ original_path = str(path)
124
+
125
+ try:
126
+ if to_unc:
127
+ converted_path = str(convert_to_unc(path))
128
+ else:
129
+ converted_path = str(convert_to_local(path))
130
+
131
+ result[original_path] = converted_path
132
+ except Exception as e:
133
+ logger.warning(f"Failed to convert path {original_path}: {e}")
134
+ result[original_path] = original_path # Keep original on failure
135
+
136
+ return result
137
+
138
+ def safe_copy(src: Union[str, Path], dst: Union[str, Path],
139
+ convert_paths: bool = True, **kwargs) -> str:
140
+ """
141
+ Safely copy a file, handling UNC paths and network drives.
142
+
143
+ Args:
144
+ src: Source file path.
145
+ dst: Destination file path.
146
+ convert_paths: Whether to automatically convert between UNC and local paths.
147
+ **kwargs: Additional keyword arguments to pass to shutil.copy2.
148
+
149
+ Returns:
150
+ The path of the destination file as a string.
151
+
152
+ Raises:
153
+ FileNotFoundError: If the source file does not exist.
154
+ PermissionError: If permission is denied (even after path conversion).
155
+ OSError: If another OS-level error occurs.
156
+ """
157
+ original_src = Path(src)
158
+ original_dst = Path(dst)
159
+
160
+ try:
161
+ # First try to copy the file as-is
162
+ shutil.copy2(original_src, original_dst, **kwargs)
163
+ return str(original_dst) # Always return the destination path string
164
+ except PermissionError:
165
+ # If we get a permission error and conversion is enabled, try converting the paths
166
+ if convert_paths:
167
+ # Try different combinations of path conversions
168
+ path_variants = []
169
+
170
+ # Convert source path
171
+ try:
172
+ if is_unc_path(str(original_src)): # Convert to string before checking
173
+ local_src = convert_to_local(original_src)
174
+ if local_src != original_src:
175
+ path_variants.append((local_src, original_dst))
176
+ else:
177
+ unc_src = convert_to_unc(original_src)
178
+ if unc_src != original_src:
179
+ path_variants.append((unc_src, original_dst))
180
+ except Exception as e:
181
+ logger.debug(f"Source path conversion failed: {e}")
182
+
183
+ # Convert destination path
184
+ try:
185
+ if is_unc_path(str(original_dst)): # Convert to string before checking
186
+ local_dst = convert_to_local(original_dst)
187
+ if local_dst != original_dst:
188
+ path_variants.append((original_src, local_dst))
189
+ else:
190
+ unc_dst = convert_to_unc(original_dst)
191
+ if unc_dst != original_dst:
192
+ path_variants.append((original_src, unc_dst))
193
+ except Exception as e:
194
+ logger.debug(f"Destination path conversion failed: {e}")
195
+
196
+ # Convert both paths
197
+ try:
198
+ if is_unc_path(original_src) and not is_unc_path(original_dst):
199
+ local_src = convert_to_local(original_src)
200
+ unc_dst = convert_to_unc(original_dst)
201
+ if local_src != original_src and unc_dst != original_dst:
202
+ path_variants.append((local_src, unc_dst))
203
+ elif not is_unc_path(original_src) and is_unc_path(original_dst):
204
+ unc_src = convert_to_unc(original_src)
205
+ local_dst = convert_to_local(original_dst)
206
+ if unc_src != original_src and local_dst != original_dst:
207
+ path_variants.append((unc_src, local_dst))
208
+ except Exception as e:
209
+ logger.debug(f"Dual path conversion failed: {e}")
210
+
211
+ # Try each variant
212
+ for src_variant, dst_variant in path_variants:
213
+ try:
214
+ logger.debug(f"Trying copy with converted paths: {src_variant} -> {dst_variant}")
215
+ # Make sure to return a string, not the result from shutil.copy2 which may be a Path
216
+ shutil.copy2(src_variant, dst_variant, **kwargs)
217
+ return str(dst_variant) # Return the destination path as a string
218
+ except Exception as e:
219
+ logger.debug(f"Copy attempt failed: {e}")
220
+
221
+ # If all conversion attempts failed or conversion is disabled, re-raise the original error
222
+ raise
223
+
224
+ def batch_copy(src_paths: List[Union[str, Path]], dst_dir: Union[str, Path],
225
+ convert_paths: bool = True, max_retries: int = 1) -> Dict[str, Tuple[bool, Optional[str]]]:
226
+ """
227
+ Copy multiple files to a destination directory, handling UNC paths.
228
+
229
+ Args:
230
+ src_paths: List of source file paths to copy.
231
+ dst_dir: Destination directory.
232
+ convert_paths: Whether to automatically convert between UNC and local paths.
233
+ max_retries: Maximum number of retries for each file if copy fails.
234
+
235
+ Returns:
236
+ A dictionary mapping source paths to tuples of (success, destination_path).
237
+ If success is False, destination_path will be None.
238
+ """
239
+ dst_dir_path = Path(dst_dir)
240
+
241
+ # Make sure the destination directory exists
242
+ try:
243
+ os.makedirs(dst_dir_path, exist_ok=True)
244
+ except Exception as e:
245
+ logger.error(f"Failed to create destination directory {dst_dir_path}: {e}")
246
+
247
+ # If conversion is enabled, try with converted path
248
+ if convert_paths:
249
+ try:
250
+ if is_unc_path(str(dst_dir_path)): # Convert Path to string before checking
251
+ local_dst_dir = convert_to_local(dst_dir_path)
252
+ if local_dst_dir != dst_dir_path:
253
+ logger.debug(f"Trying to create directory with converted path: {local_dst_dir}")
254
+ os.makedirs(local_dst_dir, exist_ok=True)
255
+ dst_dir_path = local_dst_dir
256
+ else:
257
+ unc_dst_dir = convert_to_unc(dst_dir_path)
258
+ if unc_dst_dir != dst_dir_path:
259
+ logger.debug(f"Trying to create directory with converted path: {unc_dst_dir}")
260
+ os.makedirs(unc_dst_dir, exist_ok=True)
261
+ dst_dir_path = unc_dst_dir
262
+ except Exception as e2:
263
+ logger.error(f"Failed to create destination directory with converted path: {e2}")
264
+ # Return empty results since we can't proceed
265
+ return {str(src): (False, None) for src in src_paths}
266
+
267
+ # Copy each file
268
+ results = {}
269
+
270
+ for src in src_paths:
271
+ src_path = Path(src)
272
+ filename = src_path.name
273
+ dst_path = dst_dir_path / filename
274
+
275
+ # Try to copy with retries
276
+ success = False
277
+ dst_result = None
278
+ retry_count = 0
279
+ last_error = None
280
+
281
+ while not success and retry_count <= max_retries:
282
+ try:
283
+ # Attempt to copy the file
284
+ dst_result = safe_copy(src_path, dst_path, convert_paths=convert_paths)
285
+ # If we get here, the copy was successful
286
+ success = True
287
+ # Ensure the result is a string for consistency
288
+ dst_result = str(dst_result) if dst_result is not None else None
289
+ except Exception as e:
290
+ last_error = e
291
+ if retry_count < max_retries:
292
+ logger.debug(f"Copy attempt {retry_count + 1} failed for {src_path}, retrying: {e}")
293
+ retry_count += 1
294
+ else:
295
+ # Last attempt failed
296
+ logger.error(f"Failed to copy {src_path} to {dst_path} after {max_retries + 1} attempts: {e}")
297
+ break
298
+
299
+ # Record the result for this file
300
+ if success:
301
+ results[str(src)] = (True, dst_result)
302
+ else:
303
+ logger.error(f"Failed to copy {src_path} to {dst_path}: {last_error}")
304
+ results[str(src)] = (False, None)
305
+
306
+ return results
307
+
308
+ def process_files(directory: Union[str, Path], callback: Callable[[Path], Any],
309
+ pattern: str = "*", recursive: bool = True,
310
+ convert_paths: bool = True) -> Dict[str, Any]:
311
+ """
312
+ Process files in a directory, handling UNC paths and network drives.
313
+
314
+ Args:
315
+ directory: The directory to process.
316
+ callback: A function to call for each file. It should accept a Path object
317
+ and return any value, which will be included in the results.
318
+ pattern: A glob pattern to match files against.
319
+ recursive: Whether to process subdirectories recursively.
320
+ convert_paths: Whether to automatically convert between UNC and local paths.
321
+
322
+ Returns:
323
+ A dictionary mapping file paths to the results of the callback function.
324
+ """
325
+ dir_path = Path(directory)
326
+ results = {}
327
+
328
+ # Check if we need to try a path conversion
329
+ if not os.path.exists(dir_path) and convert_paths:
330
+ try:
331
+ if is_unc_path(str(dir_path)): # Convert Path to string before checking
332
+ local_dir = convert_to_local(dir_path)
333
+ if local_dir != dir_path and os.path.exists(local_dir):
334
+ logger.debug(f"Converting UNC path {dir_path} to local path {local_dir}")
335
+ dir_path = local_dir
336
+ else:
337
+ unc_dir = convert_to_unc(dir_path)
338
+ if unc_dir != dir_path and os.path.exists(unc_dir):
339
+ logger.debug(f"Converting local path {dir_path} to UNC path {unc_dir}")
340
+ dir_path = unc_dir
341
+ except Exception as e:
342
+ logger.warning(f"Path conversion failed: {e}")
343
+
344
+ # Make sure the directory exists
345
+ if not os.path.exists(dir_path):
346
+ logger.error(f"Directory not found: {dir_path}")
347
+ return results
348
+
349
+ # Process files
350
+ if recursive:
351
+ glob_pattern = f"**/{pattern}"
352
+ else:
353
+ glob_pattern = pattern
354
+
355
+ for file_path in dir_path.glob(glob_pattern):
356
+ if file_path.is_file():
357
+ try:
358
+ # Call the callback function
359
+ result = callback(file_path)
360
+ results[str(file_path)] = result
361
+ except Exception as e:
362
+ logger.error(f"Error processing file {file_path}: {e}")
363
+ results[str(file_path)] = None
364
+
365
+ return results
366
+
367
+ def replace_in_file(file_path: Union[str, Path], old_text: str, new_text: str,
368
+ encoding: str = 'utf-8', convert_paths: bool = True) -> bool:
369
+ """
370
+ Replace text in a file, handling UNC paths and network drives.
371
+
372
+ Args:
373
+ file_path: The path to the file.
374
+ old_text: The text to replace.
375
+ new_text: The new text.
376
+ encoding: The encoding to use when reading/writing the file.
377
+ convert_paths: Whether to automatically convert between UNC and local paths.
378
+
379
+ Returns:
380
+ True if the file was modified, False otherwise.
381
+ """
382
+ try:
383
+ # Read the file content
384
+ with safe_open(file_path, 'r', encoding=encoding, convert_paths=convert_paths) as f:
385
+ content = f.read()
386
+
387
+ # Check if the old text exists
388
+ if old_text not in content:
389
+ logger.warning(f"Text not found in file {file_path}")
390
+ return False
391
+
392
+ # Replace the text
393
+ new_content = content.replace(old_text, new_text)
394
+
395
+ # Write back to the file
396
+ with safe_open(file_path, 'w', encoding=encoding, convert_paths=convert_paths) as f:
397
+ f.write(new_content)
398
+
399
+ return True
400
+ except Exception as e:
401
+ logger.error(f"Error replacing text in file {file_path}: {e}")
402
+ return False
403
+
404
+ def batch_replace_in_files(directory: Union[str, Path], old_text: str, new_text: str,
405
+ pattern: str = "*.txt", recursive: bool = True,
406
+ encoding: str = 'utf-8', convert_paths: bool = True) -> Dict[str, bool]:
407
+ """
408
+ Replace text in multiple files, handling UNC paths and network drives.
409
+
410
+ Args:
411
+ directory: The directory containing files to process.
412
+ old_text: The text to replace.
413
+ new_text: The new text.
414
+ pattern: A glob pattern to match files against.
415
+ recursive: Whether to process subdirectories recursively.
416
+ encoding: The encoding to use when reading/writing the files.
417
+ convert_paths: Whether to automatically convert between UNC and local paths.
418
+
419
+ Returns:
420
+ A dictionary mapping file paths to booleans indicating success.
421
+ """
422
+ def replace_callback(file_path):
423
+ return replace_in_file(file_path, old_text, new_text, encoding, convert_paths)
424
+
425
+ return process_files(directory, replace_callback, pattern, recursive, convert_paths)
426
+
427
+ def get_unc_path_elements(path: Union[str, Path]) -> Optional[Tuple[str, str, str]]:
428
+ """
429
+ Extract server, share, and path components from a UNC path.
430
+
431
+ Args:
432
+ path: The UNC path to analyze.
433
+
434
+ Returns:
435
+ A tuple of (server, share, relative_path) if the path is a valid UNC path,
436
+ or None if the path is not a UNC path.
437
+ """
438
+ original_path_str = str(path)
439
+ path_str = original_path_str.replace('/', '\\')
440
+
441
+ # Check if it's a UNC path
442
+ if not path_str.startswith('\\\\'):
443
+ return None
444
+
445
+ # Parse the UNC path
446
+ match = re.match(r'^\\\\([^\\]+)\\([^\\]+)(?:\\(.*))?', path_str)
447
+ if match:
448
+ server = match.group(1)
449
+ share = match.group(2)
450
+ relative_path = match.group(3) or ""
451
+
452
+ # If original path used forward slashes, preserve them in the relative path
453
+ if '/' in original_path_str:
454
+ # Replace backslashes with forward slashes in the relative path
455
+ relative_path = relative_path.replace('\\', '/')
456
+
457
+ return (server, share, relative_path)
458
+ return None
459
+
460
+ def build_unc_path(server: str, share: str, relative_path: Optional[str] = None) -> str:
461
+ """
462
+ Build a UNC path from server, share, and path components.
463
+
464
+ Args:
465
+ server: The server name.
466
+ share: The share name.
467
+ relative_path: The relative path within the share (optional).
468
+
469
+ Returns:
470
+ A properly formatted UNC path.
471
+ """
472
+ unc_base = f"\\\\{server}\\{share}"
473
+
474
+ if not relative_path:
475
+ return unc_base
476
+
477
+ # Ensure relative path doesn't start with a backslash
478
+ rel_path = relative_path.lstrip('\\').lstrip('/')
479
+
480
+ if rel_path:
481
+ return f"{unc_base}\\{rel_path}"
482
+ else:
483
+ return unc_base
484
+
485
+ def is_path_accessible(path: Union[str, Path], check_both_paths: bool = True) -> bool:
486
+ """
487
+ Check if a path is accessible for reading.
488
+
489
+ Args:
490
+ path: The path to check.
491
+ check_both_paths: Whether to check both UNC and local path variants.
492
+
493
+ Returns:
494
+ True if the path is accessible, False otherwise.
495
+ """
496
+ # For files, check if they exist and are readable
497
+ if os.path.isfile(path):
498
+ try:
499
+ with open(path, 'r') as f:
500
+ f.read(1) # Try to read 1 byte
501
+ return True
502
+ except:
503
+ pass
504
+
505
+ # For directories, check if we can list their contents
506
+ elif os.path.isdir(path):
507
+ try:
508
+ os.listdir(path)
509
+ return True
510
+ except:
511
+ pass
512
+
513
+ # If the original path isn't accessible and check_both_paths is enabled
514
+ if check_both_paths:
515
+ try:
516
+ # Try the converted path
517
+ if is_unc_path(str(path)): # Convert Path to string before checking
518
+ local_path = convert_to_local(path)
519
+ if local_path != path:
520
+ return is_path_accessible(local_path, check_both_paths=False)
521
+ else:
522
+ unc_path = convert_to_unc(path)
523
+ if unc_path != path:
524
+ return is_path_accessible(unc_path, check_both_paths=False)
525
+ except Exception as e:
526
+ logger.debug(f"Path conversion during accessibility check failed: {e}")
527
+
528
+ return False
529
+
530
+ def find_accessible_path(path: Union[str, Path]) -> Optional[Path]:
531
+ """
532
+ Find an accessible variant of a path, trying both UNC and local formats.
533
+
534
+ Args:
535
+ path: The original path to find an accessible variant for.
536
+
537
+ Returns:
538
+ An accessible Path object, or None if no accessible variant is found.
539
+ """
540
+ original_path = Path(path)
541
+
542
+ # Check the original path first
543
+ if is_path_accessible(original_path, check_both_paths=False):
544
+ return original_path
545
+
546
+ # Try converted paths
547
+ try:
548
+ if is_unc_path(str(original_path)): # Convert Path to string before checking
549
+ # Try local path
550
+ local_path = convert_to_local(original_path)
551
+ if local_path != original_path and is_path_accessible(local_path, check_both_paths=False):
552
+ return local_path
553
+ else:
554
+ # Try UNC path
555
+ unc_path = convert_to_unc(original_path)
556
+ if unc_path != original_path and is_path_accessible(unc_path, check_both_paths=False):
557
+ return unc_path
558
+ except Exception as e:
559
+ logger.debug(f"Path conversion during accessibility check failed: {e}")
560
+
561
+ # If we got here, no accessible variant was found
562
+ return None
@@ -0,0 +1,40 @@
1
+ r"""
2
+ Utility functions and helpers for UNCtools.
3
+
4
+ This subpackage contains utility functions and helpers used across the UNCtools library,
5
+ including logging, cross-platform compatibility, and validation utilities.
6
+ """
7
+
8
+ import os
9
+ import logging
10
+ import platform
11
+ import sys
12
+ from typing import Dict, List, Optional, Tuple, Union, Any, Callable
13
+
14
+ # Set up module-level logger
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Import key functions for the package namespace
18
+ from .logger import configure_logging, get_logger
19
+ from .compat import is_windows, is_linux, is_macos, get_platform_info, is_module_available, safe_import
20
+ from .validation import validate_path, validate_unc_path, validate_local_path
21
+
22
+ # For convenience, re-export platform detection in the utils namespace
23
+ is_windows = is_windows()
24
+ is_linux = is_linux()
25
+ is_macos = is_macos()
26
+
27
+ # Export key functions at the package level
28
+ __all__ = [
29
+ 'configure_logging',
30
+ 'get_logger',
31
+ 'is_windows',
32
+ 'is_linux',
33
+ 'is_macos',
34
+ 'get_platform_info',
35
+ 'is_module_available',
36
+ 'safe_import',
37
+ 'validate_path',
38
+ 'validate_unc_path',
39
+ 'validate_local_path'
40
+ ]