Skip to content

Commit 65e0ea9

Browse files
drernieclaude
andcommitted
Use outdir param to place metadata files inside output subdirectory
When the Nextflow outdir param points deeper than the Quilt package root (e.g. s3://bucket/ns/pkg/run-name/), README_NF_QUILT.md, quilt_summarize.json, and nf-quilt/*.json metadata files now land inside the run subdirectory rather than at the package root. This fixes the reported issue where these files appeared one level above the pipeline output. The observer extracts `outdir` from session params and passes it through to QuiltProduct, which computes the prefix by stripping the bucket/namespace/name components from the outdir path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 95e80d7 commit 65e0ea9

6 files changed

Lines changed: 126 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [0.9.2] 2025-05-XX
44

5+
- Use outdir param to determine package-relative paths instead of positional S3 path guessing
56
- Use "package" as default prefix
67
- Modernize main*.nf files
78

plugins/nf-quilt/src/main/nextflow/quilt/QuiltObserver.groovy

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class QuiltObserver implements TraceObserver {
3636

3737
private Session session
3838
private String workDir
39+
private String outdir
3940

4041
final private Lock lock = new ReentrantLock() // Need this because of threads
4142
// Is this overkill? Do we ever have more than one output URI per run?
@@ -68,6 +69,10 @@ class QuiltObserver implements TraceObserver {
6869
log.info("`onFlowCreate` $session")
6970
this.session = session
7071
this.workDir = session.config.workDir
72+
this.outdir = session.getParams()?.get('outdir')?.toString()
73+
if (this.outdir) {
74+
log.info("onFlowCreate.outdir: ${this.outdir}")
75+
}
7176
}
7277

7378
@Override
@@ -96,7 +101,7 @@ class QuiltObserver implements TraceObserver {
96101
// create a QuiltProduct for each unique package key
97102
publishedPaths.each { key, path ->
98103
log.debug("onFlowComplete: $key -> $path")
99-
new QuiltProduct(path, session)
104+
new QuiltProduct(path, session, outdir)
100105
}
101106
}
102107

plugins/nf-quilt/src/main/nextflow/quilt/QuiltProduct.groovy

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ ${nextflow}
147147
protected final QuiltPackage pkg
148148
protected final Session session
149149
protected final Map<String, Map<String,Object>> config
150+
protected final String outdirPrefix
150151

151152
protected final Map metadata
152153
protected final Expando flags = new Expando([
@@ -159,12 +160,13 @@ ${nextflow}
159160
workflow: false,
160161
])
161162

162-
QuiltProduct(QuiltPathify pathify, Session session) {
163+
QuiltProduct(QuiltPathify pathify, Session session, String outdir = null) {
163164
log.debug("Creating QuiltProduct: ${pathify}")
164165
this.session = session
165166
this.config = session.config ?: [:]
166167
this.path = pathify.path
167168
this.pkg = pathify.pkg
169+
this.outdirPrefix = computeOutdirPrefix(outdir)
168170
this.metadata = collectMetadata()
169171
if (session.isSuccess() || flags.getProperty(QuiltParser.P_FORCE) == true) {
170172
publish()
@@ -173,6 +175,41 @@ ${nextflow}
173175
}
174176
}
175177

178+
/**
179+
* Compute the sub-path prefix within the package that corresponds to the outdir.
180+
*
181+
* Given outdir 's3://bucket/ns/pkg/run-name/' and the package being 'ns/pkg',
182+
* the outdir prefix is 'run-name' — the portion of the outdir path that lives
183+
* inside the package. README and summarize files are written here instead of
184+
* the package root.
185+
*
186+
* Returns empty string when outdir cannot be parsed or matches the package root exactly.
187+
*/
188+
String computeOutdirPrefix(String outdir) {
189+
if (!outdir) {
190+
return ''
191+
}
192+
try {
193+
// Strip scheme (s3://) to get bare path components
194+
String barePath = outdir
195+
.replaceFirst('^s3://', '')
196+
.replaceFirst('^quilt\\+s3://', '')
197+
.replaceAll('/+$', '') // trim trailing slashes
198+
199+
String[] parts = barePath.split('/')
200+
// parts[0] = bucket, parts[1] = prefix (namespace), parts[2] = suffix (name)
201+
// parts[3+] = sub-path within the package
202+
if (parts.length > 3) {
203+
String prefix = parts[3..-1].join('/')
204+
log.debug("computeOutdirPrefix: '${prefix}' from outdir '${outdir}'")
205+
return prefix
206+
}
207+
} catch (Exception e) {
208+
log.warn("computeOutdirPrefix: failed to parse outdir '${outdir}': ${e.message}")
209+
}
210+
return ''
211+
}
212+
176213
Map collectMetadata() {
177214
if (shouldSkip(KEY_META)) {
178215
log.debug("SKIP: metadata for ${pkg}")
@@ -262,8 +299,16 @@ ${nextflow}
262299
flags.setProperty(QuiltParser.P_PKG, pkgName)
263300
}
264301

302+
/**
303+
* Prepend the outdirPrefix to a filename so that generated files
304+
* land inside the outdir sub-directory of the package (when known).
305+
*/
306+
String prefixedPath(String filename) {
307+
return outdirPrefix ? "${outdirPrefix}/${filename}" : filename
308+
}
309+
265310
String writeMapToPackage(Map map, String prefix) {
266-
String filename = "nf-quilt/${prefix}.json"
311+
String filename = prefixedPath("nf-quilt/${prefix}.json")
267312
log.debug("writeMapToPackage[$prefix]: ${filename}")
268313
try {
269314
writeString(toJson(map), pkg, filename)
@@ -353,7 +398,7 @@ ${nextflow}
353398
}
354399
if (text != null && text.length() > 0) {
355400
log.debug("writeReadme: ${text.length()} bytes")
356-
writeString(text, pkg, README_FILE)
401+
writeString(text, pkg, prefixedPath(README_FILE))
357402
}
358403
return text
359404
}
@@ -407,7 +452,7 @@ ${nextflow}
407452

408453
try {
409454
String qs_json = toJson(quilt_summarize)
410-
writeString(qs_json, pkg, SUMMARY_FILE)
455+
writeString(qs_json, pkg, prefixedPath(SUMMARY_FILE))
411456
}
412457
catch (Exception e) {
413458
log.error("writeSummarize.toJson failed: ${e.getMessage()}\n{$e}", SUMMARY_FILE)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Manifest-Version: 1.0
22
Plugin-Class: nextflow.quilt.QuiltPlugin
33
Plugin-Id: nf-quilt
4-
Plugin-Version: 0.9.1
4+
Plugin-Version: 0.9.2
55
Plugin-Provider: Quilt Data
66
Plugin-Requires: >=24.10.0
77

plugins/nf-quilt/src/test/nextflow/quilt/QuiltObserverTest.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class QuiltObserverTest extends QuiltSpecification {
3535
Session mockSession(boolean success = false) {
3636
String quilt_uri = 'quilt+s3://bucket#package=prefix%2fsuffix'
3737
return GroovyMock(Session) {
38-
getParams() >> [pubNot: 'foo', pubBad: 'foo:bar', outdir: SpecURI(), pubDir: testURI, inDir: quilt_uri]
38+
getParams() >> [pubNot: 'foo', pubBad: 'foo:bar', outdir: 's3://udp-spec/nf-quilt/source', pubDir: testURI, inDir: quilt_uri]
3939
isSuccess() >> success
4040
config >> [quilt: [metadata: [key: 'value']]]
4141
workDir >> Paths.get('./work')

plugins/nf-quilt/src/test/nextflow/quilt/QuiltProductTest.groovy

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,19 @@ import spock.lang.Unroll
4040
@CompileDynamic
4141
class QuiltProductTest extends QuiltSpecification {
4242

43-
QuiltProduct makeProductFromUrl(String url, boolean success = false) {
43+
QuiltProduct makeProductFromUrl(String url, boolean success = false, String outdir = null) {
4444
WorkflowMetadata wf_meta = GroovyMock(WorkflowMetadata) {
4545
toMap() >> [start:'2022-01-01', complete:'2022-01-02']
4646
}
4747
QuiltPath path = QuiltPathFactory.parse(url)
4848
QuiltPathify pathify = new QuiltPathify(path)
4949
Session session = GroovyMock(Session) {
5050
getWorkflowMetadata() >> wf_meta
51-
getParams() >> [outdir: url]
51+
getParams() >> [outdir: outdir ?: url]
5252
isSuccess() >> success
5353
config >> [quilt: [meta: [cf_key: 'cf_val']]]
5454
}
55-
return new QuiltProduct(pathify, session)
55+
return new QuiltProduct(pathify, session, outdir)
5656
}
5757

5858
QuiltProduct makeProduct(String query=null, boolean success = false) {
@@ -372,4 +372,69 @@ class QuiltProductTest extends QuiltSpecification {
372372
true
373373
}
374374

375+
void 'computeOutdirPrefix extracts sub-path from outdir'() {
376+
given:
377+
QuiltProduct product = makeProduct()
378+
379+
expect:
380+
product.computeOutdirPrefix(outdir) == expected
381+
382+
where:
383+
outdir | expected
384+
null | ''
385+
'' | ''
386+
's3://bucket/ns/pkg' | ''
387+
's3://bucket/ns/pkg/' | ''
388+
's3://bucket/ns/pkg/run-name' | 'run-name'
389+
's3://bucket/ns/pkg/run-name/' | 'run-name'
390+
's3://bucket/ns/pkg/run-name/sub' | 'run-name/sub'
391+
's3://bucket/ns/pkg/run-name/sub/' | 'run-name/sub'
392+
}
393+
394+
void 'prefixedPath uses outdirPrefix when set'() {
395+
when:
396+
QuiltProduct withPrefix = makeProductFromUrl(testURI, false, 's3://bkt/pre/suf/my-run')
397+
398+
then:
399+
withPrefix.outdirPrefix == 'my-run'
400+
withPrefix.prefixedPath('README.md') == 'my-run/README.md'
401+
withPrefix.prefixedPath('nf-quilt/params.json') == 'my-run/nf-quilt/params.json'
402+
403+
when:
404+
QuiltProduct noPrefix = makeProductFromUrl(testURI, false, 's3://bkt/pre/suf')
405+
406+
then:
407+
noPrefix.outdirPrefix == ''
408+
noPrefix.prefixedPath('README.md') == 'README.md'
409+
}
410+
411+
void 'writeReadme places file under outdirPrefix'() {
412+
given:
413+
QuiltProduct product = makeProductFromUrl(testURI, false, 's3://bkt/pre/suf/my-run')
414+
product.pkg.reset()
415+
416+
when:
417+
product.writeReadme('test message')
418+
419+
then:
420+
product.match("my-run/${QuiltProduct.README_FILE}").size() == 1
421+
product.match(QuiltProduct.README_FILE).size() == 0
422+
}
423+
424+
void 'writeSummarize places file under outdirPrefix'() {
425+
given:
426+
QuiltProduct product = makeProductFromUrl(testURI, false, 's3://bkt/pre/suf/my-run')
427+
product.pkg.reset()
428+
429+
when:
430+
// Write a file in the prefixed subdir so summarize has something to find
431+
String prefixedFile = "my-run/test.md"
432+
QuiltProduct.writeString('# Test', product.pkg, prefixedFile)
433+
product.writeSummarize()
434+
435+
then:
436+
product.match("my-run/${QuiltProduct.SUMMARY_FILE}").size() == 1
437+
product.match(QuiltProduct.SUMMARY_FILE).size() == 0
438+
}
439+
375440
}

0 commit comments

Comments
 (0)