-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdataset_converter.py
More file actions
2563 lines (2010 loc) · 147 KB
/
Copy pathdataset_converter.py
File metadata and controls
2563 lines (2010 loc) · 147 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
================================================================================
Multi-Format Dataset Converter (dataset_converter.py)
================================================================================
Author : Breno Farias da Silva
Created : 2025-05-31
Short Description:
Command-line utility for discovering, cleaning, and converting datasets in multiple formats (ARFF, CSV, Parquet, TXT, PCAP, stats).
Recursively scans input directories, applies lightweight structural cleaning to text-based formats, loads them into pandas DataFrames,
and writes converted outputs (ARFF, CSV, Parquet, TXT) to a mirrored output directory structure. Handles binary PCAP and statistics files.
Defaults & Behavior:
- Default input directory: ./Datasets
- Default output directory: ./Converted
- Supported input formats: .arff, .csv, .parquet, .txt, .pcap, .stats
- Supported output formats: .arff, .csv, .parquet, .txt
- Cleaning: minimal whitespace/domain-list normalization for ARFF/CSV/TXT
- Parquet files are rewritten via `fastparquet` for consistency
- PCAP files are loaded with Scapy and converted to DataFrames
- PCAP stats files parsed as key-value or line-based tables
- Conversion preserves directory hierarchy relative to input
- Disk-space verifies are performed before writing outputs
- Optional Telegram notifications for progress and errors
- Optional completion sound (platform-dependent)
Usage:
- Run interactively:
python3 dataset_converter.py
- Or pass CLI args: `-i/--input`, `-o/--output`, `-f/--formats`, `-v/--verbose`, `--input-file-formats`, `--output-file-formats`, `--low-memory`, `--no-low-memory`
Dependencies (non-exhaustive):
- Python 3.8+
- pandas, fastparquet, scipy, liac-arff (arff), colorama, tqdm, scapy, pyyaml
Notes and Caveats:
- The converter performs pragmatic cleaning only; do not rely on it to fully sanitize malformed CSVs.
- The script uses both `scipy` and `liac-arff` as fallbacks for ARFF.
- Disk-space verifies are performed before writing outputs.
- The module expects UTF-8 encoded text files.
- Telegram integration and sound notification are optional and platform-dependent.
TODOs (short):
- Add unit tests and more robust CSV parsing
- Add optional parallel conversion mode for large workloads
- Provide more granular CLI control for cleaning rules
"""
if __name__ in {"__main__", "__mp_main__"}:
try:
from setproctitle import setproctitle
setproctitle(f"DDoS-{__file__.rsplit('/', 1)[-1].rsplit('.', 1)[0]}")
except ImportError:
pass
import arff # Liac-arff, used to save ARFF files
import argparse # For parsing command-line arguments
import atexit # For playing a sound when the program finishes
import datetime # For timestamping
import io # For in-memory file operations
import numpy as np # For NaN representation and numeric coercion
import os # For running commands in the terminal
import pandas as pd # For handling CSV and TXT file formats
import platform # For getting the operating system name
import shutil # For analyzing disk usage
import sys # For system-specific parameters and functions
import telegram_bot as telegram_module # For setting Telegram prefix and device info
import traceback # For printing tracebacks on exceptions
import yaml # For loading configuration from YAML file
from colorama import Style # For coloring the terminal output
from fastparquet import ParquetFile # For handling Parquet file format
from Logger import Logger # For logging output to both terminal and file
from pathlib import Path # For handling file paths
from scapy.all import PcapReader # For memory-efficient PCAP reading using Scapy
from scipy.io import arff as scipy_arff # Used to read ARFF files
from telegram_bot import TelegramBot, send_exception_via_telegram, send_telegram_message, setup_global_exception_hook # For Telegram utilities and global exception hook
from tqdm import tqdm # For showing a progress bar
from typing import Any, Optional, cast # For optional typing hints
from utils.process_name import set_runtime_process_name # Apply optional htop-visible process identities.
# Macros:
class BackgroundColors: # Colors for the terminal
CYAN = "\033[96m" # Cyan
GREEN = "\033[92m" # Green
YELLOW = "\033[93m" # Yellow
RED = "\033[91m" # Red
BOLD = "\033[1m" # Bold
UNDERLINE = "\033[4m" # Underline
CLEAR_TERMINAL = "\033[H\033[J" # Clear the terminal
# Execution Constants:
DEFAULTS = None # Will hold the default configuration loaded from YAML or hardcoded defaults
# Telegram Bot Setup:
TELEGRAM_BOT = None # Global Telegram bot instance (initialized in setup_telegram_bot)
# Logger Setup:
logger = Logger(f"./Logs/{Path(__file__).stem}.log", clean=True) # Create a Logger instance
sys.stdout = logger # Redirect stdout to the logger
sys.stderr = logger # Redirect stderr to the logger
# Sound Constants:
SOUND_COMMANDS = {"Darwin": "afplay", "Linux": "aplay", "Windows": "start"} # Sound play command
SOUND_FILE = "./.assets/Sounds/NotificationSound.wav" # Notification sound path
# RUN_FUNCTIONS:
RUN_FUNCTIONS = {
"Play Sound": True, # Set to True to play a sound when the program finishes
}
# Functions Definitions:
setup_global_exception_hook() # Set global exception hook to shared Telegram handler
def get_default_config() -> dict: # Return default configuration for dataset_converter
"""
Default dataset_converter configuration.
:return: Dictionary with default configuration values.
"""
return {
"dataset_converter": {
"verbose": False, # Whether to enable verbose messages
"low_memory": True, # Whether to enable low memory mode for large datasets
"input_file_formats": ["arff", "csv", "parquet", "pcap", "stats", "txt"], # Input formats to discover during dataset scanning
"output_file_formats": ["arff", "csv", "parquet", "txt"], # Output formats to generate during conversion
"input_directory": "./Datasets/", # Default input directory
"output_directory": "./Converted", # Default output directory
"ignore_dirs": [
"Classifiers",
"Classifiers_Hyperparameters",
"Converted",
"Data_Separability",
"Dataset_Description",
"Feature_Analysis",
"Results",
], # Directories to ignore during discovery
"ignore_files": ["results", "summary"], # Filename substrings to ignore
}
}
def load_config_file(path: str = "config.yaml") -> dict: # Load YAML config if exists
"""
Load configuration from YAML file if present.
:param path: Path to YAML file.
:return: Loaded configuration dictionary or empty dict.
"""
try: # Wrap file loading to report errors and fallback gracefully
if verify_filepath_exists(path): # Verify path existence
with open(path, "r", encoding="utf-8") as fh: # Open file for reading
data = yaml.safe_load(fh) or {} # Parse YAML safely
return data # Return parsed config
except Exception as e: # On error, log and notify via Telegram then return empty dict
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
return {} # Return empty dict on error
return {} # Default empty dict when file not found
def initialize_defaults() -> None:
"""
Initialize DEFAULTS by loading defaults and merging with config.yaml.
:return: None
"""
try: # Wrap initialization logic to ensure production-safe monitoring
global DEFAULTS # Declare that we will assign to the module-global DEFAULTS
defaults = get_default_config() # Load hard-coded default configuration
cfg = load_config_file() # Load configuration from disk (config.yaml)
if cfg and isinstance(cfg, dict) and "dataset_converter" in cfg: # Verify presence of dataset_converter section
try: # Attempt to merge nested dataset_converter values
defaults_dataset = defaults.get("dataset_converter", {}) # Extract defaults subsection
file_dataset = cfg.get("dataset_converter", {}) # Extract file subsection
defaults_dataset.update(file_dataset) # Merge file subsection into defaults subsection
defaults["dataset_converter"] = defaults_dataset # Assign merged subsection back into defaults
except Exception: # If nested merge fails, fall back to shallow overlay
defaults.update(cfg) # Overlay top-level keys with file config
DEFAULTS = defaults # Set the module-global DEFAULTS to the merged configuration
try:
global VERBOSE # Declare that we will assign to the module-global VERBOSE
VERBOSE = bool(DEFAULTS.get("dataset_converter", {}).get("verbose", False)) # Set VERBOSE based on DEFAULTS, defaulting to False if not specified
except Exception: # Catch any issues with accessing DEFAULTS and ensure VERBOSE is set to a boolean
VERBOSE = False # Default to False if there was an issue accessing the verbose setting in DEFAULTS
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def verbose_output(true_string="", false_string=""):
"""
Outputs a message if the VERBOSE constant is set to True.
:param true_string: The string to be outputted if the VERBOSE constant is set to True.
:param false_string: The string to be outputted if the VERBOSE constant is set to False.
:return: None
"""
try: # Wrap full function logic to ensure production-safe monitoring
if VERBOSE and true_string != "": # If VERBOSE is True and a true_string was provided
print(true_string) # Output the true statement string
elif false_string != "": # If a false_string was provided
print(false_string) # Output the false statement string
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def verify_dot_env_file():
"""
Verifies if the .env file exists in the current directory.
:return: True if the .env file exists, False otherwise
"""
try: # Wrap full function logic to ensure production-safe monitoring
env_path = Path(__file__).parent / ".env" # Path to the .env file
if not env_path.exists(): # If the .env file does not exist
print(f"{BackgroundColors.CYAN}.env{BackgroundColors.YELLOW} file not found at {BackgroundColors.CYAN}{env_path}{BackgroundColors.YELLOW}. Telegram messages may not be sent.{Style.RESET_ALL}")
return False # Return False
return True # Return True if the .env file exists
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def setup_telegram_bot():
"""
Sets up the Telegram bot for progress messages.
:return: None
"""
try: # Wrap full function logic to ensure production-safe monitoring
verbose_output(
f"{BackgroundColors.GREEN}Setting up Telegram bot for messages...{Style.RESET_ALL}"
) # Output the verbose message
verify_dot_env_file() # Verify if the .env file exists
global TELEGRAM_BOT # Declare the module-global telegram_bot variable
try: # Try to initialize the Telegram bot
TELEGRAM_BOT = TelegramBot() # Initialize Telegram bot for progress messages
telegram_module.TELEGRAM_DEVICE_INFO = f"{telegram_module.get_local_ip()} - {platform.system()}" # Set device info for Telegram notifications
telegram_module.RUNNING_CODE = os.path.basename(__file__) # Set the running code name for Telegram context
except Exception as e:
print(f"{BackgroundColors.RED}Failed to initialize Telegram bot: {e}{Style.RESET_ALL}") # Report initialization failure to terminal
TELEGRAM_BOT = None # Set to None if initialization fails
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def parse_cli_arguments():
"""
Parse command-line arguments for the dataset converter.
:return: Parsed ArgumentParser namespace.
"""
try: # Wrap full function logic to ensure production-safe monitoring
verbose_output(
f"{BackgroundColors.GREEN}Parsing command-line arguments...{Style.RESET_ALL}"
) # Output the verbose message
parser = argparse.ArgumentParser(
description="Multi-Format Dataset Converter: convert ARFF/CSV/Parquet/TXT datasets"
) # Create the argument parser
parser.add_argument("--process-name", type=str, default=None, help="Process title displayed by htop and similar tools") # Allow concurrent runs to have distinct operating-system identities.
parser.add_argument(
"-i", "--input", type=str, help="Input path (file or directory). If not provided, uses ./Input"
) # Input path argument
parser.add_argument(
"-o", "--output", type=str, help="Output directory. If not provided, uses ./Converted"
) # Output directory argument
parser.add_argument(
"-f",
"--formats",
type=str,
help="Comma-separated output formats to produce (arff,csv,parquet,txt). If not provided, all formats are produced",
) # Output formats argument
parser.add_argument(
"--input-file-formats",
type=str,
help="Comma-separated input formats to discover (arff,csv,parquet,txt). If not provided, uses config",
) # Input file formats argument
parser.add_argument(
"--output-file-formats",
type=str,
help="Comma-separated output formats to produce (arff,csv,parquet,txt). If not provided, uses config",
) # Output file formats argument
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") # Verbose mode flag
parser.add_argument("--low-memory", dest="low_memory", action="store_true", help="Enable low memory mode") # Low memory mode flag
parser.add_argument("--no-low-memory", dest="no_low_memory", action="store_true", help="Disable low memory mode") # No low memory mode flag
cli_args = parser.parse_args() # Parse the dataset-converter arguments.
set_runtime_process_name(cli_args.process_name, script_path=__file__) # Apply the generated htop identity before conversion initialization.
return cli_args # Return parsed CLI arguments.
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def resolve_low_memory(cli_args: "argparse.Namespace", config: Optional[dict]) -> bool:
"""
Resolve final low_memory flag using CLI arguments and configuration.
:param cli_args: Parsed CLI arguments.
:param config: Loaded configuration dictionary.
:return: Final low_memory boolean value.
"""
try: # Wrap full function logic to ensure production-safe monitoring
cli_low = bool(getattr(cli_args, "low_memory", False)) # Verify if --low-memory flag was provided in CLI arguments
cli_no_low = bool(getattr(cli_args, "no_low_memory", False)) # Verify if --no-low-memory flag was provided in CLI arguments
if cli_low and cli_no_low: # If both flags are provided, this is a conflict
raise ValueError("Conflicting CLI options: --low-memory and --no-low-memory were both provided") # Report the conflict as an error
if cli_low: # --low-memory takes precedence
return True # Enable low memory mode
if cli_no_low: # --no-low-memory takes precedence
return False # Disable low memory mode
try: # Attempt to access low_memory setting from configuration if CLI flags were not provided
cfg_section = config.get("dataset_converter", {}) if isinstance(config, dict) else {} # Safely access config section
cfg_value = cfg_section.get("low_memory") # Retrieve low_memory from config
if isinstance(cfg_value, bool): # Only accept boolean
return cfg_value # Use config value if valid
except Exception: # Ignore config access issues
pass # Fallback to default
return False # Default: disable low memory mode if not specified
except Exception as e: # Catch any exception to ensure logging
print(str(e)) # Print error to terminal for server logs
raise # Re-raise to preserve original failure semantics
def resolve_entry_with_trailing_space(current_path: str, entry: str, stripped_part: str) -> str:
"""
Resolve and optionally rename a directory entry with trailing spaces.
:param current_path: Current directory path.
:param entry: Directory entry name.
:param stripped_part: Normalized target name without surrounding spaces.
:return: Resolved path after optional rename.
"""
try: # Wrap full function logic to ensure safe execution
resolved = os.path.join(current_path, entry) # Build resolved path
if entry != stripped_part: # Verify trailing spaces exist
corrected = os.path.join(current_path, stripped_part) # Build corrected path
try: # Attempt to rename entry
os.rename(resolved, corrected) # Rename entry to stripped version
verbose_output(true_string=f"{BackgroundColors.GREEN}Renamed: {BackgroundColors.CYAN}{resolved}{BackgroundColors.GREEN} -> {BackgroundColors.CYAN}{corrected}{Style.RESET_ALL}") # Log rename
resolved = corrected # Update resolved path after rename
except Exception: # Handle rename failure
verbose_output(true_string=f"{BackgroundColors.RED}Failed to rename: {BackgroundColors.CYAN}{resolved}{Style.RESET_ALL}") # Log failure
return resolved # Return resolved path
except Exception: # Catch unexpected errors
return os.path.join(current_path, entry) # Return fallback resolved path
def resolve_full_trailing_space_path(filepath: str) -> str:
"""
Resolve trailing space issues across all path components.
:param filepath: Path to resolve potential trailing space mismatches.
:return: Corrected full path if matches are found, otherwise original filepath.
"""
try: # Wrap full function logic to ensure safe execution
verbose_output(true_string=f"{BackgroundColors.GREEN}Resolving full trailing space path for: {BackgroundColors.CYAN}{filepath}{Style.RESET_ALL}") # Log start
if not isinstance(filepath, str) or not filepath: # Verify filepath validity
verbose_output(true_string=f"{BackgroundColors.YELLOW}Invalid filepath provided, skipping resolution.{Style.RESET_ALL}") # Log invalid input
return filepath # Return original
filepath = os.path.expanduser(filepath) # Expand ~ to user directory
parts = filepath.split(os.sep) # Split path into components
if not parts: # Verify path parts exist
return filepath # Return original
if filepath.startswith(os.sep): # Handle absolute paths
current_path = os.sep # Start from root
parts = parts[1:] # Remove empty root part
else:
current_path = parts[0] if parts[0] else os.getcwd() # Initialize base
parts = parts[1:] if parts[0] else parts # Adjust parts
for part in parts: # Iterate over each path component
if part == "": # Skip empty parts
continue # Continue iteration
try: # Attempt to list current directory
entries = os.listdir(current_path) if os.path.isdir(current_path) else [] # List current directory entries
except Exception: # Handle failure to list directory contents
verbose_output(true_string=f"{BackgroundColors.RED}Failed to list directory: {BackgroundColors.CYAN}{current_path}{Style.RESET_ALL}") # Log failure
return filepath # Return original
stripped_part = part.strip() # Normalize current part
match_found = False # Initialize match flag
for entry in entries: # Iterate directory entries
try: # Attempt safe comparison for each entry
if entry.strip() == stripped_part: # Compare stripped names
current_path = resolve_entry_with_trailing_space(current_path, entry, stripped_part) # Resolve entry and update current path
match_found = True # Mark match
break # Stop searching
except Exception: # Handle any unexpected error during comparison
continue # Continue on error
if not match_found: # If no match found for this segment
verbose_output(true_string=f"{BackgroundColors.YELLOW}No match for segment: {BackgroundColors.CYAN}{part}{Style.RESET_ALL}") # Log miss
return filepath # Return original
return current_path # Return fully resolved path
except Exception: # Catch unexpected errors to maintain stability
verbose_output(true_string=f"{BackgroundColors.RED}Error resolving full path: {BackgroundColors.CYAN}{filepath}{Style.RESET_ALL}") # Log error
return filepath # Return original
def verify_filepath_exists(filepath):
"""
Verify if a file or folder exists at the specified path.
:param filepath: Path to the file or folder
:return: True if the file or folder exists, False otherwise
"""
try: # Wrap full function logic to ensure production-safe monitoring
verbose_output(
f"{BackgroundColors.GREEN}Verifying if the file or folder exists at the path: {BackgroundColors.CYAN}{filepath}{Style.RESET_ALL}"
) # Output the verbose message
if not isinstance(filepath, str) or not filepath.strip(): # Verify for non-string or empty/whitespace-only input
verbose_output(true_string=f"{BackgroundColors.YELLOW}Invalid filepath provided, skipping existence verification.{Style.RESET_ALL}") # Log invalid input
return False # Return False for invalid input
if os.path.exists(filepath): # Fast path: original input exists
return True # Return True immediately
candidate = str(filepath).strip() # Normalize input to string and strip surrounding whitespace
if (candidate.startswith("'") and candidate.endswith("'")) or (
candidate.startswith('"') and candidate.endswith('"')
): # Handle quoted paths from config files
candidate = candidate[1:-1].strip() # Remove wrapping quotes and trim again
candidate = os.path.expanduser(candidate) # Expand ~ to user home directory
candidate = os.path.normpath(candidate) # Normalize path separators and structure
if os.path.exists(candidate): # Verify normalized candidate directly
return True # Return True if normalized path exists
repo_dir = os.path.dirname(os.path.abspath(__file__)) # Resolve repository directory
cwd = os.getcwd() # Capture current working directory
alt = candidate.lstrip(os.sep) if candidate.startswith(os.sep) else candidate # Prepare relative-safe path
repo_candidate = os.path.join(repo_dir, alt) # Build repo-relative candidate
cwd_candidate = os.path.join(cwd, alt) # Build cwd-relative candidate
for path_variant in (repo_candidate, cwd_candidate): # Iterate alternative base paths
try:
normalized_variant = os.path.normpath(path_variant) # Normalize variant
if os.path.exists(normalized_variant): # Verify existence
return True # Return True if found
except Exception:
continue # Continue safely on error
try: # Attempt absolute path resolution as fallback
abs_candidate = os.path.abspath(candidate) # Build absolute path
if os.path.exists(abs_candidate): # Verify existence
return True # Return True if found
except Exception:
pass # Ignore resolution errors
for path_variant in (candidate, repo_candidate, cwd_candidate): # Attempt trailing-space resolution on all variants
try: # Attempt to resolve trailing space issues across path components for this variant
resolved = resolve_full_trailing_space_path(path_variant) # Resolve trailing space issues across path components
if resolved != path_variant and os.path.exists(resolved): # Verify resolved path exists
verbose_output(
f"{BackgroundColors.YELLOW}Resolved trailing space mismatch: {BackgroundColors.CYAN}{path_variant}{BackgroundColors.YELLOW} -> {BackgroundColors.CYAN}{resolved}{Style.RESET_ALL}"
) # Log successful resolution
return True # Return True if corrected path exists
except Exception: # Catch any exception during trailing space resolution
continue # Continue safely on error
return False # Not found after all resolution strategies
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def extract_input_paths_from_datasets(dmap: dict) -> list: # Define a nested function to extract candidate paths
"""
Extract input path candidates from datasets mapping.
:param dmap: Datasets mapping from configuration.
:return: List of candidate input path strings.
"""
try: # Wrap function logic to ensure production-safe monitoring
if not dmap or not isinstance(dmap, dict): # Verify mapping is a dict
return [] # Return empty list when mapping is missing or invalid
candidates = [] # Initialize list of candidate paths
for key in sorted(dmap.keys()): # Iterate deterministically over mapping keys
val = dmap.get(key) # Retrieve the mapping value for the current key
if isinstance(val, str): # If the mapping value is a string path
cleaned = val.strip() if isinstance(val, str) else val # Strip surrounding whitespace from the path
if cleaned: # Only add non-empty cleaned paths
candidates.append(cleaned) # Add the cleaned string path to candidates
elif isinstance(val, (list, tuple)): # If the mapping value is a list/tuple of paths
for p in val: # Iterate each candidate path in the sequence
cleaned = p.strip() if isinstance(p, str) else p # Strip surrounding whitespace from each candidate
if cleaned: # Only add non-empty cleaned candidates
candidates.append(cleaned) # Add the cleaned candidate to list
elif isinstance(val, dict): # If the mapping value is a nested dict
single = val.get("path") or val.get("input") # Extract a single path candidate from known keys
if isinstance(single, str): # If the single candidate is a string
cleaned = single.strip() # Strip surrounding whitespace from the single candidate
if cleaned: # Only add non-empty cleaned single candidate
candidates.append(cleaned) # Add the single candidate to the list
multi = val.get("paths") or val.get("inputs") # Extract multi-paths from known keys
if isinstance(multi, (list, tuple)): # If multi-paths is a sequence
for candidate in multi: # Iterate provided multi-path entries
cleaned = candidate.strip() if isinstance(candidate, str) else candidate # Strip whitespace from each multi candidate
if cleaned: # Only add non-empty cleaned entries
candidates.append(cleaned) # Append each cleaned candidate to the list
return candidates # Return collected candidate paths
except Exception as e: # Catch exceptions inside function
print(str(e)) # Print function exception to terminal for logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send function exception via Telegram
raise # Re-raise to preserve failure semantics
def validate_and_prepare_input_paths(paths: list) -> list: # Define a nested function to validate and create inputs
"""
Validate candidate input paths and ensure directories exist.
:param paths: Candidate input path list.
:return: List of validated input paths.
"""
try: # Wrap function logic to ensure production-safe monitoring
valid = [] # Initialize list for validated existing paths
for p in paths: # Iterate provided candidate paths
p_str = str(p).strip() if p is not None else "" # Strip surrounding whitespace and coerce to string
if not p_str: # Skip empty or None entries after cleaning
continue # Continue to next candidate when value is falsy
if verify_filepath_exists(p_str): # Verify candidate exists on filesystem
valid.append(p_str) # Add existing cleaned path to validated list
else: # If candidate does not exist, do NOT create input directories automatically
verbose_output(f"{BackgroundColors.YELLOW}Configured input path does not exist, skipping: {BackgroundColors.CYAN}{p_str}{Style.RESET_ALL}") # Informative verbose message when configured input is missing
continue # Skip non-existing configured input paths without creating them
return valid # Return the list of validated paths
except Exception as e: # Catch exceptions inside function
print(str(e)) # Print function exception to terminal for logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send function exception via Telegram
raise # Re-raise to preserve failure semantics
def resolve_output_path(arg_output: Optional[str], cfg_section: dict) -> str:
"""
Resolve the output directory path from CLI argument or configuration.
:param arg_output: Output path provided via CLI.
:param cfg_section: The dataset_converter configuration section.
:return: Resolved output path string.
"""
try: # Wrap function logic to ensure production-safe monitoring
output_default = cfg_section.get("output_directory", "./Output") or "./Output" # Determine configured default
out = arg_output if arg_output else output_default # Choose CLI-provided output or fallback default; do not create directories here to keep creation lazy
return out # Return the resolved output path without creating it (creation is performed lazily per-dataset)
except Exception as e: # Catch exceptions inside function
print(str(e)) # Print function exception to terminal for logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send function exception via Telegram
raise # Re-raise to preserve failure semantics
def resolve_io_paths(args):
"""
Resolve and validate input/output paths from CLI arguments.
:param args: Parsed CLI arguments.
:return: Tuple (input_path, output_path).
"""
try: # Wrap full function logic to ensure production-safe monitoring
verbose_output(
f"{BackgroundColors.GREEN}Resolving input/output paths...{Style.RESET_ALL}"
) # Output the verbose message
cfg = DEFAULTS.get("dataset_converter", {}) if DEFAULTS else {} # Get dataset_converter config safely
datasets_cfg = cfg.get("datasets", {}) # Resolve datasets mapping from config
input_candidates = [args.input] if args.input else extract_input_paths_from_datasets(datasets_cfg) # Build initial candidate list from CLI or config
resolved_inputs = validate_and_prepare_input_paths(input_candidates) # Validate and prepare candidate input paths
out_path = resolve_output_path(args.output if hasattr(args, "output") else None, cfg) # Resolve output path using function
if not resolved_inputs: # If no validated input paths were found
print(f"{BackgroundColors.RED}No input path available from CLI or configuration datasets{Style.RESET_ALL}") # Report missing input paths
return None, None # Return failure when no inputs are available
return resolved_inputs, out_path # Return validated input list and resolved output path
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def configure_verbose_mode(args):
"""
Enable verbose output mode when requested via CLI.
:param args: Parsed CLI arguments.
:return: None
"""
try: # Wrap full function logic to ensure production-safe monitoring
if args.verbose: # If verbose mode requested
global VERBOSE # Use global variable
VERBOSE = True # Enable verbose mode
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def configure_input_output_formats(args):
"""
Update DEFAULTS with input and output file formats from CLI arguments.
:param args: Parsed CLI arguments.
:return: None
"""
try: # Wrap full function logic to ensure production-safe monitoring
global DEFAULTS # Declare that we will assign to the module-global DEFAULTS
if DEFAULTS is None: # Verify DEFAULTS is initialized before mutation
DEFAULTS = get_default_config() # Initialize DEFAULTS if not yet initialized
cfg_section = DEFAULTS.setdefault("dataset_converter", {}) # Retrieve or create the dataset_converter section in DEFAULTS
if hasattr(args, "input_file_formats") and args.input_file_formats: # Verify if input formats were provided via CLI
parsed = [file.strip().lower().lstrip(".") for file in args.input_file_formats.split(",") if file.strip()] # Parse and normalize comma-separated input formats from CLI
if parsed: # Only update when parsed list is non-empty
cfg_section["input_file_formats"] = parsed # Override input_file_formats in DEFAULTS with CLI-provided value
if hasattr(args, "output_file_formats") and args.output_file_formats: # Verify if output formats were provided via CLI
parsed = [file.strip().lower().lstrip(".") for file in args.output_file_formats.split(",") if file.strip()] # Parse and normalize comma-separated output formats from CLI
if parsed: # Only update when parsed list is non-empty
cfg_section["output_file_formats"] = parsed # Override output_file_formats in DEFAULTS with CLI-provided value
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def create_directories(directory_name):
"""
Creates a directory if it does not exist.
:param directory_name: Name of the directory to be created.
:return: None
"""
try: # Wrap full function logic to ensure production-safe monitoring
if not directory_name: # Empty string or None
print(f"{BackgroundColors.YELLOW}Warning: create_directories called with empty path; skipping{Style.RESET_ALL}")
return # Skip when no valid directory name provided
verbose_output(
f"{BackgroundColors.GREEN}Creating directory: {BackgroundColors.CYAN}{directory_name}{Style.RESET_ALL}"
) # Output the verbose message
if not verify_filepath_exists(directory_name): # If the directory does not exist
os.makedirs(directory_name, exist_ok=True) # Create the directory using exist_ok to avoid race conditions
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def get_dataset_files(directory=None):
"""
Get all dataset files in the specified directory and its subdirectories.
:param directory: Path to the directory to search for dataset files.
:return: List of paths to dataset files.
"""
try: # Wrap full function logic to ensure production-safe monitoring
verbose_output(
f"{BackgroundColors.GREEN}Searching for dataset files in: {BackgroundColors.CYAN}{directory}{Style.RESET_ALL}"
) # Output the verbose message
dataset_files = [] # List to store discovered dataset file paths
cfg = DEFAULTS.get("dataset_converter", {}) if DEFAULTS else {} # Get dataset_converter settings from DEFAULTS
ignore_list = cfg.get(
"ignore_dirs",
[
"Classifiers",
"Classifiers_Hyperparameters",
"Converted",
"Data_Separability",
"Dataset_Description",
"Feature_Analysis",
"Results",
],
) # Get ignore directories list from configuration (default expanded)
ignore_files = cfg.get("ignore_files", ["results", "summary"]) or ["results", "summary"] # Get ignore filename substrings from configuration (default to ["results", "summary"])
input_fmts = resolve_input_file_formats(None) # Resolve allowed input formats from configuration for discovery
allowed_exts = {"." + str(f).lower().lstrip(".") for f in input_fmts} # Build allowed extensions set from input formats
if directory: # If a specific directory argument provided
roots = [directory] # Use the provided directory as single root to scan
else: # If no directory argument provided
datasets_map = cfg.get("datasets", {}) # Retrieve datasets mapping from configuration
roots = [] # Initialize roots list for scanning
for v in datasets_map.values(): # Iterate over dataset groups in mapping
if isinstance(v, (list, tuple)): # If mapping value is a list of paths
for candidate in v: # Iterate candidate paths inside list
roots.append(candidate) # Add candidate path to roots list
elif isinstance(v, str): # If mapping value is a single path string
roots.append(v) # Add single path to roots list
for root in roots: # Iterate roots to walk through filesystem
if not root: # If root is empty string or None
continue # Skip empty root entries safely
for dirpath, dirs, files in os.walk(root): # Walk the directory tree starting at root
if any(ignore_word.lower() in dirpath.lower() for ignore_word in ignore_list): # If the current path contains ignored directory names
continue # Skip ignored directories
for file in files: # Iterate files in the current directory
lower_filename = file.lower() # Lowercase filename for case-insensitive comparison
if any(ignore_sub.lower() in lower_filename for ignore_sub in ignore_files): # If the filename contains any of the ignored substrings
continue # Skip files that match ignore patterns
if os.path.splitext(file)[1].lower() in allowed_exts: # Verify if the file has an allowed input format extension
dataset_files.append(os.path.join(dirpath, file)) # Append full file path to results list
try: # Sort the discovered dataset files alphabetically in a case-insensitive manner for deterministic order
dataset_files = sorted(dataset_files, key=lambda p: str(p).lower()) # Sort paths case-insensitively
except Exception: # If case-insensitive sorting fails for any reason, fall back to regular sorting
dataset_files = sorted(dataset_files, key=lambda p: str(p)) # Sort paths with default string comparison
return dataset_files # Return collected dataset file paths
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def scan_top_level_for_supported_files(input_directory: str) -> list:
"""
Scan the directory itself for supported extensions.
:param input_directory: Directory path to scan.
:return: List of supported files found directly under the directory.
"""
try: # Wrap full function logic to ensure production-safe monitoring
input_fmts = resolve_input_file_formats(None) # Resolve allowed input formats from configuration for discovery
supported_exts = {"." + str(file).lower().lstrip(".") for file in input_fmts} # Build supported extensions set from input formats
direct_files = [] # Container for files found directly under the directory
if os.path.isdir(input_directory): # Verify the path is a directory before listing
for entry in os.listdir(input_directory): # Iterate entries directly under the directory
candidate = os.path.join(input_directory, entry) # Build candidate full path
if os.path.isfile(candidate) and os.path.splitext(entry)[1].lower() in supported_exts: # Verify file and extension
direct_files.append(candidate) # Add matching file to direct_files
try: # Sort the directly found files alphabetically in a case-insensitive manner for deterministic order
direct_files = sorted(direct_files, key=lambda p: str(p).lower()) # Sort paths case-insensitively
except Exception: # If case-insensitive sorting fails for any reason, fall back to regular sorting
direct_files = sorted(direct_files, key=lambda p: str(p)) # Sort paths with default string comparison
return direct_files # Return the directly found files (may be empty)
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def scan_immediate_subdirs_for_files(input_directory: str) -> list:
"""
Scan each immediate subdirectory for dataset files and return first non-empty result.
:param input_directory: Directory path whose immediate children will be scanned.
:return: List of dataset files found in the first child directory containing supported files.
"""
try: # Wrap full function logic to ensure production-safe monitoring
if not os.path.isdir(input_directory): # Verify the input path is a directory before exploring children
return [] # Return empty list when input is not a directory
for entry in os.listdir(input_directory): # Iterate child entries to explore subdirectories
child = os.path.join(input_directory, entry) # Build child path
if os.path.isdir(child): # Only consider child directories
child_files = get_dataset_files(child) # Attempt recursive discovery in the child directory
if child_files: # If any files were discovered in the child
return child_files # Return the first non-empty child discovery
return [] # Return empty list when no child directories contain dataset files
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def resolve_dataset_files(input_directory):
"""
Resolve dataset files from a directory or a single file path.
:param input_directory: Input directory or single file path.
:return: List of dataset file paths.
"""
try: # Wrap full function logic to ensure production-safe monitoring
if os.path.isfile(input_directory): # If the input_directory is actually a file
return [input_directory] # Return a single-item list containing the file path
files = get_dataset_files(input_directory) # Attempt to recursively discover dataset files under the directory
if files: # If recursive discovery returned any files
try: # Sort the discovered files alphabetically in a case-insensitive manner for deterministic order
files = sorted(files, key=lambda p: str(p).lower()) # Sort paths case-insensitively
except Exception: # If case-insensitive sorting fails for any reason, fall back to regular sorting
files = sorted(files, key=lambda p: str(p)) # Sort paths with default string comparison
return files # Return discovered files immediately
direct_files = scan_top_level_for_supported_files(input_directory) # Scan the directory itself for supported extensions
if direct_files: # If direct files were found in the top-level directory
try: # Sort the directly found files alphabetically in a case-insensitive manner for deterministic order
direct_files = sorted(direct_files, key=lambda p: str(p).lower()) # Sort paths case-insensitively
except Exception: # If case-insensitive sorting fails for any reason, fall back to regular sorting
direct_files = sorted(direct_files, key=lambda p: str(p)) # Sort paths with default string comparison
return direct_files # Return the directly found files
child_files = scan_immediate_subdirs_for_files(input_directory) # Scan each immediate subdirectory separately to handle unusual mounts
if child_files: # If any files were discovered in an immediate child directory
try: # Sort the child-discovered files alphabetically in a case-insensitive manner for deterministic order
child_files = sorted(child_files, key=lambda p: str(p).lower()) # Sort paths case-insensitively
except Exception: # If case-insensitive sorting fails for any reason, fall back to regular sorting
child_files = sorted(child_files, key=lambda p: str(p)) # Sort paths with default string comparison
return child_files # Return the first non-empty child discovery
return [] # Return empty list when no dataset files could be located
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def resolve_formats(formats):
"""
Normalize and validate the list of output formats.
:param formats: List or string of formats.
:return: Cleaned list of formats.
"""
try: # Wrap full function logic to ensure production-safe monitoring
if formats is None: # If no specific formats were provided
return ["arff", "csv", "parquet", "txt"] # Default to all supported formats
if isinstance(formats, str): # If provided as CSV string
return [f.strip().lower().lstrip(".") for f in formats.split(",") if f.strip()] # Split and clean
return [f.strip().lower().lstrip(".") for f in formats if isinstance(f, str)] # Clean list
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def resolve_input_file_formats(formats_list: Optional[list]) -> list:
"""
Resolve input_file_formats from configuration and return final discovery formats.
:param formats_list: The formats requested via CLI or per-call.
:return: The list of input formats to allow during file discovery.
"""
try: # Wrap resolution to avoid raising from malformed DEFAULTS
cfg_section = DEFAULTS.get("dataset_converter", {}) if DEFAULTS else {} # Retrieve dataset_converter section from DEFAULTS
in_formats = cfg_section.get("input_file_formats", None) # Retrieve configured input_file_formats from config
if in_formats is None: # If configuration does not provide input_file_formats
return formats_list or ["arff", "csv", "parquet", "txt"] # Return provided formats_list or all formats as default
norm = [str(file).lower() for file in (in_formats or [])] # Normalize configured entries to lowercase strings
allowed = ["arff", "csv", "parquet", "pcap", "stats", "txt"] # Allowed input formats list
final = [file for file in norm if file in allowed] # Filter configured formats to allowed set
if not final: # If no valid configured formats remain after filtering
return formats_list or ["arff", "csv", "parquet", "txt"] # Fallback to all formats when config invalid or empty
return final # Return the configured list of input formats
except Exception: # Fallback on any unexpected error during resolution
return formats_list or ["arff", "csv", "parquet", "txt"] # Return provided formats_list or all formats on error
def resolve_output_file_formats(formats_list: Optional[list]) -> list:
"""
Resolve output_file_formats from configuration and return final target formats.
:param formats_list: The formats requested via CLI or per-call.
:return: The list of formats to actually generate.
"""
try: # Wrap resolution to avoid raising from malformed DEFAULTS
cfg_section = DEFAULTS.get("dataset_converter", {}) if DEFAULTS else {} # Retrieve dataset_converter section from DEFAULTS
out_formats = cfg_section.get("output_file_formats", None) # Retrieve configured output_file_formats from config
if out_formats is None: # If configuration does not provide output_file_formats
return formats_list or [] # Return provided formats_list or empty list when not configured
norm = [str(file).lower() for file in (out_formats or [])] # Normalize configured entries to lowercase strings
allowed = ["arff", "csv", "parquet", "txt"] # Allowed target formats list
final = [file for file in norm if file in allowed] # Filter configured formats to allowed set
if not final: # If no valid configured formats remain after filtering
return formats_list or [] # Fallback to provided formats_list when config invalid or empty
return final # Return the configured list of output formats
except Exception: # Fallback on any unexpected error during resolution
return formats_list or [] # Return provided formats_list or empty list on error
def resolve_destination_directory(input_directory, input_path, output_directory):
"""
Determine where converted files should be saved.
:param input_directory: Source directory.
:param input_path: Path of the current file.
:param output_directory: Base output directory.
:return: Destination directory path.
"""
try: # Wrap full function logic to ensure production-safe monitoring
if str(output_directory).strip().lower() == "in-place": # Verify if in-place output mode is requested via CLI (--output in-place) or config (output_directory: "in-place") to save converted files alongside input files
in_place_dir = os.path.dirname(os.path.abspath(str(input_path))) # Resolve absolute parent directory of input file for in-place output
return in_place_dir if in_place_dir else "." # Return parent directory of input file or fallback to current directory when parent is empty
input_dir_str = str(input_directory) if input_directory is not None else "" # Normalize input_directory to string
out_dir_str = str(output_directory) if output_directory is not None else "" # Normalize output_directory to string
if not out_dir_str: # Verify whether an output_directory was provided
out_dir_str = "Converted" # Use 'Converted' as default when not provided
if os.path.isfile(input_dir_str): # Verify when input_directory is actually a file path
input_dir_str = os.path.dirname(input_dir_str) or "." # Normalize to parent directory when a file was passed
if os.path.isabs(out_dir_str): # Verify if provided output_directory is absolute
base_output = out_dir_str # Use absolute output_directory directly as base
else: # When output_directory is relative, resolve it under the dataset input directory
if input_dir_str and os.path.isdir(input_dir_str): # Verify the input directory exists before joining
base_output = os.path.join(input_dir_str, out_dir_str) # Place relative output_directory inside the input dataset directory
else: # Fallback when input directory is not available or does not exist
base_output = os.path.join(os.getcwd(), out_dir_str) # Resolve relative output_directory under current working directory as last resort
rel_dir = os.path.relpath(os.path.dirname(input_path), input_dir_str) # Compute subdirectory path relative to input directory
return os.path.join(base_output, rel_dir) if rel_dir != "." else base_output # Preserve directory structure under resolved base
except Exception as e: # Catch any exception to ensure logging and Telegram alert
print(str(e)) # Print error to terminal for server logs
send_exception_via_telegram(type(e), e, e.__traceback__) # Send full traceback via Telegram
raise # Re-raise to preserve original failure semantics
def get_free_space_bytes(path):
"""
Return the number of free bytes available on the filesystem
containing the specified path.