Skip to content

Commit d603584

Browse files
authored
Merge pull request #1154 from fls-bioinformatics-core/update-cmd-handle-inconsistent-projects.info
'update': extend to handle inconsistencies between 'projects.info' and contents of analysis directory
2 parents a4881b7 + de9bd1d commit d603584

3 files changed

Lines changed: 287 additions & 13 deletions

File tree

auto_process_ngs/cli/auto_process.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,18 @@ def add_update_command(cmdparser):
10621062
"ANALYSIS_DIR and its projects and QC outputs "
10631063
"when directory has been moved or copied, "
10641064
"or project metadata has been updated.")
1065+
p.add_argument('--paths', action='store_true',
1066+
help="update paths stored in the metadata and parameter "
1067+
"files for ANALYSIS_DIR")
1068+
p.add_argument('--project-metadata', action='store_true',
1069+
help="propagate modified metadata in 'projects.info' to "
1070+
"project directories in ANALYSIS_DIR")
1071+
p.add_argument('--project-dirs', action='store_true',
1072+
help="synchronise project entries in 'projects.info' with "
1073+
"project directories within ANALYSIS_DIR")
1074+
p.add_argument('--qc-reports', action='store_true',
1075+
help="regenerate QC reports for projects where metadata "
1076+
"has been updated")
10651077
add_debug_option(p)
10661078
p.add_argument('analysis_dir',metavar="ANALYSIS_DIR",nargs='?',
10671079
help="existing auto_process analysis directory to "
@@ -1835,7 +1847,10 @@ def update(args):
18351847
if not analysis_dir:
18361848
analysis_dir = os.getcwd()
18371849
d = AutoProcess(analysis_dir)
1838-
d.update()
1850+
d.update(update_paths=args.paths,
1851+
update_project_metadata=args.project_metadata,
1852+
update_sync_projects=args.project_dirs,
1853+
update_qc_reports=args.qc_reports)
18391854

18401855
def readme(args):
18411856
"""

auto_process_ngs/commands/update_cmd.py

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python
22
#
33
# update_cmd.py: implement 'update' command
4-
# Copyright (C) University of Manchester 2023 Peter Briggs
4+
# Copyright (C) University of Manchester 2023-2026 Peter Briggs
55
#
66
#########################################################################
77

@@ -24,17 +24,32 @@
2424
# Command functions
2525
#######################################################################
2626

27-
def update(ap):
27+
def update(ap, update_paths=True, update_project_metadata=True,
28+
update_sync_projects=True, update_qc_reports=True):
2829
"""
2930
Update metadata and artefacts in analysis directory
3031
3132
Arguments:
3233
ap (AutoProcessor): autoprocessor pointing to the
3334
analysis directory to publish QC for
35+
update_paths (bool): whether to update analysis
36+
directory paths in metadata and parameter files
37+
(default: True)
38+
update_project_metadata (bool): whether to update
39+
metadata stored in 'projects.info' and in the
40+
project directories (default: True)
41+
update_sync_projects (bool): whether to update
42+
projects listed in 'projects.info' against
43+
project directories on the filesystem (default:
44+
True)
45+
update_qc_reports (bool): whether to update QC
46+
reports in projects where existing report is
47+
older than the project metadata file (default:
48+
True)
3449
"""
35-
update_paths = True
36-
update_projects = True
37-
update_qc_reports = True
50+
if not (update_paths or update_project_metadata or
51+
update_sync_projects or update_qc_reports):
52+
logger.warning("No updates requested")
3853

3954
if update_paths:
4055
# Update paths in the top-level parameter file
@@ -67,7 +82,57 @@ def update(ap):
6782
# Save the updated parameter data
6883
ap.save_parameters(force=True)
6984

70-
if update_projects:
85+
if update_sync_projects:
86+
# Load information from 'projects.info'
87+
project_metadata = ap.load_project_metadata(
88+
ap.params.project_metadata)
89+
# Comment out projects which don't exist on filesystem
90+
save_required = False
91+
for line in project_metadata:
92+
# Iterate through the named projects
93+
name = line['Project']
94+
if name.startswith('#'):
95+
# Commented out, ignore
96+
continue
97+
# Look for a matching project directory
98+
project_dir = os.path.join(ap.analysis_dir, name)
99+
if not os.path.exists(project_dir):
100+
print(f"Commenting out missing project '{name}'")
101+
line['Project'] = f"#{name}"
102+
save_required = True
103+
if save_required:
104+
project_metadata.save()
105+
# Add any project directories without entries
106+
save_required = False
107+
projects = [line['Project'] for line in project_metadata]
108+
for project in ap.get_analysis_projects_from_dirs():
109+
if project.name.endswith(".bak") \
110+
or project.name.endswith(".orig") \
111+
or project.name.endswith(".tmp"):
112+
# Skip directories with extensions indicating they
113+
# should be ignored
114+
print(f"Not adding entry for unlisted project '{project.name}'")
115+
continue
116+
elif project.name == "undetermined":
117+
# Skip undetermined
118+
continue
119+
elif project.name not in projects and f"#{project.name}" not in projects:
120+
# Add new entry
121+
print(f"Adding entry for unlisted project '{project.name}'")
122+
project_metadata.add_project(project.name,
123+
[s.name for s in project.samples],
124+
user=project.info.user,
125+
PI=project.info.PI,
126+
organism=project.info.organism,
127+
library_type=project.info.library_type,
128+
sc_platform=project.info.single_cell_platform,
129+
comments=project.info.comments)
130+
save_required = True
131+
# Save the updated project metadata if required
132+
if save_required:
133+
project_metadata.save()
134+
135+
if update_project_metadata:
71136
# Update project metadata
72137
project_metadata = ap.load_project_metadata(
73138
ap.params.project_metadata)

auto_process_ngs/test/commands/test_update_cmd.py

Lines changed: 200 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ def test_update_relocated_analysis_dir(self):
7878
self.assertEqual(ap.params.primary_data_dir,
7979
os.path.join(original_path,"primary_data"))
8080
# Update the metadata
81-
update(ap)
81+
update(ap,
82+
update_paths=True,
83+
update_sync_projects=False,
84+
update_project_metadata=False,
85+
update_qc_reports=False)
8286
# Reload and check metadata items post-update
8387
ap = AutoProcess(new_path)
8488
self.assertEqual(ap.analysis_dir,new_path)
@@ -138,7 +142,11 @@ def test_update_relocated_analysis_dir_with_qc(self):
138142
proj.name,
139143
"fastqs"))
140144
# Update the metadata
141-
update(ap)
145+
update(ap,
146+
update_paths=True,
147+
update_sync_projects=False,
148+
update_project_metadata=False,
149+
update_qc_reports=False)
142150
# Reload and check metadata items post-update
143151
ap = AutoProcess(new_path)
144152
self.assertEqual(ap.analysis_dir,new_path)
@@ -215,7 +223,11 @@ def test_update_project_metadata_changed(self):
215223
AB\tAB1,AB2\tAlan Bailey\tRNA-seq\t.\tMouse\tArchie Ballard\t1% PhiX spiked in
216224
CDE\tCDE3,CDE4\tCharles Edwards\tChIP-seq\t.\tMouse\tChristian Eggars\t1% PhiX spiked in
217225
""")
218-
update(ap)
226+
update(ap,
227+
update_paths=False,
228+
update_sync_projects=False,
229+
update_project_metadata=True,
230+
update_qc_reports=False)
219231
# Reload and confirm the updates in projects.info
220232
ap = AutoProcess(mockdir.dirn)
221233
ap_project_metadata = ap.load_project_metadata()
@@ -291,7 +303,11 @@ def test_update_sample_list_changed(self):
291303
"CDE": ["CDE3"]
292304
}
293305
# Update sample lists in metadata
294-
update(ap)
306+
update(ap,
307+
update_paths=False,
308+
update_sync_projects=False,
309+
update_project_metadata=True,
310+
update_qc_reports=False)
295311
# Reload and check updated sample lists in projects.info
296312
ap = AutoProcess(mockdir.dirn)
297313
ap_project_metadata = ap.load_project_metadata()
@@ -337,7 +353,11 @@ def test_update_paired_end_changed(self):
337353
os.remove(os.path.join(mockdir.dirn,fq))
338354
# Set up AutoProcess instance and update metadata
339355
ap = AutoProcess(mockdir.dirn)
340-
update(ap)
356+
update(ap,
357+
update_paths=False,
358+
update_sync_projects=False,
359+
update_project_metadata=True,
360+
update_qc_reports=False)
341361
# Reload and check update paired-end metadata in projects
342362
ap = AutoProcess(mockdir.dirn)
343363
expected_paired_end = {
@@ -350,6 +370,176 @@ def test_update_paired_end_changed(self):
350370
self.assertEqual(project_metadata.paired_end,
351371
expected_paired_end[pname])
352372

373+
def test_update_project_metadata_project_no_longer_exists(self):
374+
"""
375+
update: handle project which no longer exists in analysis dir
376+
"""
377+
# Metadata for projects
378+
project_metadata = {
379+
"AB": {
380+
"User": "Alan Bailey",
381+
"PI": "Archie Ballard",
382+
"Library": "RNA-seq",
383+
"Organism": "Human"
384+
}
385+
}
386+
# Make an auto-process directory with projects
387+
mockdir = MockAnalysisDirFactory.bcl2fastq2(
388+
'231021_A00879_0087_000000000-AGEW9',
389+
'novaseq',
390+
project_metadata=project_metadata,
391+
metadata={ "run_number": 87,
392+
"source": "local" },
393+
top_dir=self.dirn)
394+
mockdir.create()
395+
# Remove CDE project
396+
shutil.rmtree(os.path.join(mockdir.dirn, "CDE"))
397+
# Remove initial entry for CDE in projects.info
398+
projects_info_contents = []
399+
with open(os.path.join(mockdir.dirn,"projects.info"),'rt') as fp:
400+
for line in fp:
401+
if not line.startswith("CDE"):
402+
projects_info_contents.append(line)
403+
with open(os.path.join(mockdir.dirn,"projects.info"),'wt') as fp:
404+
fp.write("".join(projects_info_contents))
405+
# Set up AutoProcess instance
406+
ap = AutoProcess(mockdir.dirn)
407+
# Check metadata items in projects.info pre-update
408+
ap_project_metadata = ap.load_project_metadata()
409+
for pname in project_metadata:
410+
for item in project_metadata[pname]:
411+
self.assertEqual(ap_project_metadata.lookup(pname)[item],
412+
project_metadata[pname][item])
413+
# Check metadata items in projects pre-update
414+
for pname in project_metadata:
415+
p = ap.get_analysis_projects(pname)[0]
416+
for item in project_metadata[pname]:
417+
project_item = self.metadata_map[item]
418+
expected_value = project_metadata[pname][item]
419+
self.assertEqual(p.info[project_item],
420+
expected_value)
421+
# Append info for missing project 'CDE' to projects.info
422+
with open(os.path.join(mockdir.dirn,"projects.info"),'at') as fp:
423+
fp.write("""CDE\tCDE3,CDE4\tCharles Edwards\tChIP-seq\t.\tMouse\tChristian Eggars\t1% PhiX spiked in
424+
""")
425+
# Do the update
426+
update(ap,
427+
update_paths=False,
428+
update_sync_projects=True,
429+
update_project_metadata=False,
430+
update_qc_reports=False)
431+
# Reload and confirm the updates in projects.info
432+
ap = AutoProcess(mockdir.dirn)
433+
ap_project_metadata = ap.load_project_metadata()
434+
for pname in ["AB", "#CDE"]:
435+
self.assertTrue(pname in [p["Project"] for p in ap_project_metadata],
436+
f"'{pname}' not in project metadata")
437+
438+
def test_update_project_metadata_add_unlisted_projects(self):
439+
"""
440+
update: add unlisted project directories to 'projects.info'
441+
"""
442+
# Metadata for projects
443+
project_metadata = {
444+
"AB": {
445+
"User": "Alan Bailey",
446+
"PI": "Archie Ballard",
447+
"Library": "RNA-seq",
448+
"Organism": "Human"
449+
},
450+
"CDE": {
451+
"User": "Charles Edwards",
452+
"PI": "Christian Eggars",
453+
"Library": "ChIP-seq",
454+
"Organism": "Mouse"
455+
}
456+
}
457+
# Make an auto-process directory with projects
458+
mockdir = MockAnalysisDirFactory.bcl2fastq2(
459+
'231021_A00879_0087_000000000-AGEW9',
460+
'novaseq',
461+
project_metadata=project_metadata,
462+
metadata={ "run_number": 87,
463+
"source": "local" },
464+
top_dir=self.dirn)
465+
mockdir.create()
466+
# Remove initial entry for CDE in projects.info
467+
projects_info_contents = []
468+
with open(os.path.join(mockdir.dirn,"projects.info"),'rt') as fp:
469+
for line in fp:
470+
if not line.startswith("CDE"):
471+
projects_info_contents.append(line)
472+
with open(os.path.join(mockdir.dirn,"projects.info"),'wt') as fp:
473+
fp.write("".join(projects_info_contents))
474+
# Set up AutoProcess instance and do the update
475+
ap = AutoProcess(mockdir.dirn)
476+
update(ap,
477+
update_paths=False,
478+
update_sync_projects=True,
479+
update_project_metadata=False,
480+
update_qc_reports=False)
481+
# Check contents of projects.info
482+
with open(os.path.join(mockdir.dirn,"projects.info"),'rt') as fp:
483+
expected_lines = [
484+
"#Project\tSamples\tUser\tLibrary\tSC_Platform\tOrganism\tPI\tComments",
485+
"AB\tAB1,AB2\tAlan Bailey\tRNA-seq\t.\tHuman\tArchie Ballard\t.",
486+
"CDE\tCDE3,CDE4\tCharles Edwards\tChIP-seq\t.\tMouse\tChristian Eggars\t."
487+
]
488+
for expected, actual in zip(expected_lines, fp.read().split("\n")):
489+
self.assertEqual(expected.strip(), actual.strip())
490+
491+
def test_update_project_metadata_add_unlisted_projects_ignore_special_names(self):
492+
"""
493+
update: ignore unlisted project directories (don't add to 'projects.info')
494+
"""
495+
# Metadata for projects
496+
project_metadata = {
497+
"AB": {
498+
"User": "Alan Bailey",
499+
"PI": "Archie Ballard",
500+
"Library": "RNA-seq",
501+
"Organism": "Human"
502+
},
503+
"CDE.bak": {
504+
"User": "Charles Edwards",
505+
"PI": "Christian Eggars",
506+
"Library": "ChIP-seq",
507+
"Organism": "Mouse"
508+
}
509+
}
510+
# Make an auto-process directory with projects
511+
mockdir = MockAnalysisDirFactory.bcl2fastq2(
512+
'231021_A00879_0087_000000000-AGEW9',
513+
'novaseq',
514+
project_metadata=project_metadata,
515+
metadata={ "run_number": 87,
516+
"source": "local" },
517+
top_dir=self.dirn)
518+
mockdir.create()
519+
# Remove entry for CDE.bak in projects.info
520+
projects_info_contents = []
521+
with open(os.path.join(mockdir.dirn,"projects.info"),'rt') as fp:
522+
for line in fp:
523+
if not line.startswith("CDE.bak"):
524+
projects_info_contents.append(line)
525+
with open(os.path.join(mockdir.dirn,"projects.info"),'wt') as fp:
526+
fp.write("".join(projects_info_contents))
527+
# Set up AutoProcess instance and do the update
528+
ap = AutoProcess(mockdir.dirn)
529+
update(ap,
530+
update_paths=False,
531+
update_sync_projects=True,
532+
update_project_metadata=False,
533+
update_qc_reports=False)
534+
# Check contents of projects.info
535+
with open(os.path.join(mockdir.dirn,"projects.info"),'rt') as fp:
536+
expected_lines = [
537+
"#Project\tSamples\tUser\tLibrary\tSC_Platform\tOrganism\tPI\tComments",
538+
"AB\tAB1,AB2\tAlan Bailey\tRNA-seq\t.\tHuman\tArchie Ballard\t."
539+
]
540+
for expected, actual in zip(expected_lines, fp.read().split("\n")):
541+
self.assertEqual(expected.strip(), actual.strip())
542+
353543
def test_update_regenerate_qc_reports(self):
354544
"""
355545
update: regenerate QC reports with stale metadata
@@ -400,7 +590,11 @@ def test_update_regenerate_qc_reports(self):
400590
self.assertTrue(metadata_mtime <
401591
os.path.getmtime(metadata_file))
402592
# Re-do update and check modification times
403-
update(ap)
593+
update(ap,
594+
update_paths=False,
595+
update_sync_projects=False,
596+
update_project_metadata=False,
597+
update_qc_reports=True)
404598
for project_name in project_list:
405599
self.assertTrue(qc_report_mtimes[project_name] <
406600
os.path.getmtime(

0 commit comments

Comments
 (0)