Skip to content

Commit 31f741a

Browse files
authored
Merge pull request #10 from samsrabin/dry-run
Add --dry-run and --timing options
2 parents 52b1b81 + dd3603d commit 31f741a

2 files changed

Lines changed: 574 additions & 99 deletions

File tree

relink.py

Lines changed: 115 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@
99
import pwd
1010
import argparse
1111
import 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
1720
logger = 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

100141
def 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

Comments
 (0)