99import pwd
1010import argparse
1111import logging
12+ import time
1213
13- DEFAULT_SOURCE_ROOT = '/glade/campaign/cesm/cesmdata/cseg/inputdata/'
14- DEFAULT_TARGET_ROOT = '/glade/campaign/collections/gdex/data/d651077/cesmdata/inputdata/'
14+ DEFAULT_SOURCE_ROOT = "/glade/campaign/cesm/cesmdata/cseg/inputdata/"
15+ DEFAULT_TARGET_ROOT = (
16+ "/glade/campaign/collections/gdex/data/d651077/cesmdata/inputdata/"
17+ )
1518
1619# Set up logger
1720logger = logging .getLogger (__name__ )
1821
19- def find_and_replace_owned_files (source_dir , target_dir , username ):
22+
23+ def find_and_replace_owned_files (source_dir , target_dir , username , dry_run = False ):
2024 """
2125 Finds files owned by a specific user in a source directory tree,
2226 deletes them, and replaces them with symbolic links to the same
@@ -26,6 +30,7 @@ def find_and_replace_owned_files(source_dir, target_dir, username):
2630 source_dir (str): The root of the directory tree to search for files.
2731 target_dir (str): The root of the directory tree containing the new files.
2832 username (str): The name of the user whose files will be processed.
33+ dry_run (bool): If True, only show what would be done without making changes.
2934 """
3035 source_dir = os .path .abspath (source_dir )
3136 target_dir = os .path .abspath (target_dir )
@@ -37,11 +42,14 @@ def find_and_replace_owned_files(source_dir, target_dir, username):
3742 logger .error ("Error: User '%s' not found. Exiting." , username )
3843 return
3944
45+ if dry_run :
46+ logger .info ("DRY RUN MODE - No changes will be made" )
47+
4048 logger .info (
4149 "Searching for files owned by '%s' (UID: %s) in '%s'..." ,
4250 username ,
4351 user_uid ,
44- source_dir
52+ source_dir ,
4553 )
4654
4755 for dirpath , _ , filenames in os .walk (source_dir ):
@@ -56,7 +64,7 @@ def find_and_replace_owned_files(source_dir, target_dir, username):
5664
5765 file_uid = os .stat (file_path ).st_uid
5866 except FileNotFoundError :
59- continue # Skip if file was deleted during traversal
67+ continue # Skip if file was deleted during traversal
6068
6169 if file_uid == user_uid :
6270 logger .info ("Found owned file: %s" , file_path )
@@ -71,16 +79,24 @@ def find_and_replace_owned_files(source_dir, target_dir, username):
7179 "Warning: Corresponding file not found in '%s' "
7280 "for '%s'. Skipping." ,
7381 target_dir ,
74- file_path
82+ file_path ,
7583 )
7684 continue
7785
7886 # Get the link name
7987 link_name = file_path
8088
89+ if dry_run :
90+ logger .info (
91+ "[DRY RUN] Would create symbolic link: %s -> %s" ,
92+ link_name ,
93+ link_target ,
94+ )
95+ continue
96+
8197 # Remove the original file
8298 try :
83- os .rename (link_name , link_name + ".tmp" )
99+ os .rename (link_name , link_name + ".tmp" )
84100 logger .info ("Deleted original file: %s" , link_name )
85101 except OSError as e :
86102 logger .error ("Error deleting file %s: %s. Skipping." , link_name , e )
@@ -91,11 +107,36 @@ def find_and_replace_owned_files(source_dir, target_dir, username):
91107 # Create parent directories for the link if they don't exist
92108 os .makedirs (os .path .dirname (link_name ), exist_ok = True )
93109 os .symlink (link_target , link_name )
94- os .remove (link_name + ".tmp" )
95- logger .info ("Created symbolic link: %s -> %s" , link_name , link_target )
110+ os .remove (link_name + ".tmp" )
111+ logger .info (
112+ "Created symbolic link: %s -> %s" , link_name , link_target
113+ )
96114 except OSError as e :
97- os .rename (link_name + ".tmp" , link_name )
98- logger .error ("Error creating symlink for %s: %s. Skipping." , link_name , e )
115+ os .rename (link_name + ".tmp" , link_name )
116+ logger .error (
117+ "Error creating symlink for %s: %s. Skipping." , link_name , e
118+ )
119+
120+
121+ def validate_directory (path ):
122+ """
123+ Validate that the path exists and is a directory.
124+
125+ Args:
126+ path (str): The path to validate.
127+
128+ Returns:
129+ str: The absolute path if valid.
130+
131+ Raises:
132+ argparse.ArgumentTypeError: If path doesn't exist or is not a directory.
133+ """
134+ if not os .path .exists (path ):
135+ raise argparse .ArgumentTypeError (f"Directory '{ path } ' does not exist" )
136+ if not os .path .isdir (path ):
137+ raise argparse .ArgumentTypeError (f"'{ path } ' is not a directory" )
138+ return os .path .abspath (path )
139+
99140
100141def parse_arguments ():
101142 """
@@ -107,59 +148,94 @@ def parse_arguments():
107148 """
108149 parser = argparse .ArgumentParser (
109150 description = (
110- ' Find files owned by a user and replace them with symbolic links to a target directory.'
151+ " Find files owned by a user and replace them with symbolic links to a target directory."
111152 )
112153 )
113154 parser .add_argument (
114- '--source-root' ,
155+ "--source-root" ,
156+ type = validate_directory ,
115157 default = DEFAULT_SOURCE_ROOT ,
116158 help = (
117- f' The root of the directory tree to search for files (default: { DEFAULT_SOURCE_ROOT } )'
118- )
159+ f" The root of the directory tree to search for files (default: { DEFAULT_SOURCE_ROOT } )"
160+ ),
119161 )
120162 parser .add_argument (
121- '--target-root' ,
163+ "--target-root" ,
164+ type = validate_directory ,
122165 default = DEFAULT_TARGET_ROOT ,
123166 help = (
124- f' The root of the directory tree where files should be moved to '
125- f' (default: { DEFAULT_TARGET_ROOT } )'
126- )
167+ f" The root of the directory tree where files should be moved to "
168+ f" (default: { DEFAULT_TARGET_ROOT } )"
169+ ),
127170 )
128171
129172 # Verbosity options (mutually exclusive)
130173 verbosity_group = parser .add_mutually_exclusive_group ()
131174 verbosity_group .add_argument (
132- '-v' , '--verbose' ,
133- action = 'store_true' ,
134- help = 'Enable verbose output'
175+ "-v" , "--verbose" , action = "store_true" , help = "Enable verbose output"
135176 )
136177 verbosity_group .add_argument (
137- '-q' , '--quiet' ,
138- action = 'store_true' ,
139- help = 'Quiet mode (show only warnings and errors)'
178+ "-q" ,
179+ "--quiet" ,
180+ action = "store_true" ,
181+ help = "Quiet mode (show only warnings and errors)" ,
182+ )
183+
184+ parser .add_argument (
185+ "--dry-run" ,
186+ action = "store_true" ,
187+ help = "Show what would be done without making any changes" ,
188+ )
189+ parser .add_argument (
190+ "--timing" ,
191+ action = "store_true" ,
192+ help = "Measure and display the execution time" ,
140193 )
141194
142- return parser .parse_args ()
195+ args = parser .parse_args ()
143196
144- if __name__ == '__main__' :
197+ process_args (args )
198+
199+ return args
145200
146- args = parse_arguments ()
147201
202+ def process_args (args ):
203+ """
204+ Process parsed arguments and set derived attributes.
205+
206+ Sets the log_level attribute on args based on verbosity flags.
207+
208+ Args:
209+ args (argparse.Namespace): Parsed command-line arguments.
210+ """
148211 # Configure logging based on verbosity flags
149212 if args .quiet :
150- LOG_LEVEL = logging .WARNING
213+ args . log_level = logging .WARNING
151214 elif args .verbose :
152- LOG_LEVEL = logging .DEBUG
215+ args . log_level = logging .DEBUG
153216 else :
154- LOG_LEVEL = logging .INFO
217+ args . log_level = logging .INFO
155218
156- logging .basicConfig (
157- level = LOG_LEVEL ,
158- format = '%(message)s' ,
159- stream = sys .stdout
160- )
161-
162- my_username = os .environ ['USER' ]
219+
220+ def main ():
221+
222+ args = parse_arguments ()
223+
224+ logging .basicConfig (level = args .log_level , format = "%(message)s" , stream = sys .stdout )
225+
226+ my_username = os .environ ["USER" ]
227+
228+ start_time = time .time ()
163229
164230 # --- Execution ---
165- find_and_replace_owned_files (args .source_root , args .target_root , my_username )
231+ find_and_replace_owned_files (
232+ args .source_root , args .target_root , my_username , dry_run = args .dry_run
233+ )
234+
235+ if args .timing :
236+ elapsed_time = time .time () - start_time
237+ logger .info ("Execution time: %.2f seconds" , elapsed_time )
238+
239+
240+ if __name__ == "__main__" :
241+ main ()
0 commit comments