From 38154002cb50602236bf01976c70d30e3912f161 Mon Sep 17 00:00:00 2001 From: Eduard Kerkhoven Date: Mon, 19 Oct 2026 12:46:48 +0200 Subject: [PATCH 1/2] fix: readYAMLmodel reads notes maps into the note text cobrapy and raven-toolbox write other tools' notes on metabolites and reactions as a map under notes. readYAMLmodel stopped with 'Unknown entry' on them; it now reads each entry into metNotes or rxnNotes. --- src/io/readYAMLmodel.m | 34 ++++++++++++++++++++++++++++++---- tests/function_tests/tIO.m | 24 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/io/readYAMLmodel.m b/src/io/readYAMLmodel.m index 90a3333c..4ea753fd 100755 --- a/src/io/readYAMLmodel.m +++ b/src/io/readYAMLmodel.m @@ -364,8 +364,14 @@ model = readFieldValue(model, 'metCharges', tline_value, pos); readList=''; miriamKey=''; case 'notes' - model = readFieldValue(model, 'metNotes', tline_value, pos); - readList=''; miriamKey=''; + % A notes map (other tools' keys, as cobrapy and + % raven-toolbox write them) is read into the note text. + if isempty(tline_value) || strcmp(tline_value, '!!omap') + readList = 'notes'; + else + model = readFieldValue(model, 'metNotes', tline_value, pos); + readList=''; miriamKey=''; + end case 'inchis' model = readFieldValue(model, 'inchis', tline_value, pos); readList=''; miriamKey=''; @@ -399,6 +405,8 @@ switch readList case 'annotation' [metMiriams, miriamKey, metMirNo] = gatherAnnotation(pos,metMiriams,tline_key,tline_value,miriamKey,metMirNo); + case 'notes' + model = appendNote(model, 'metNotes', tline_key, tline_value, pos); case 'smilesInAnnotation' % A bare list item ("- OC(=O)...") has no colon, % so tline_key is empty --- still inside the list. @@ -445,8 +453,12 @@ % `notes` is the canonical reaction-side key; `rxnNotes` % is the legacy RAVEN MATLAB key, kept here for % backward-compatible reads. - model = readFieldValue(model, 'rxnNotes', tline_value, pos); - readList=''; miriamKey=''; + if isempty(tline_value) || strcmp(tline_value, '!!omap') + readList = 'notes'; + else + model = readFieldValue(model, 'rxnNotes', tline_value, pos); + readList=''; miriamKey=''; + end case 'rxnFrom' model = readFieldValue(model, 'rxnFrom', tline_value, pos); readList=''; miriamKey=''; @@ -524,6 +536,8 @@ subSysNo=subSysNo+1; case 'annotation' [rxnMiriams, miriamKey,rxnMirNo] = gatherAnnotation(pos,rxnMiriams,tline_key,tline_value,miriamKey,rxnMirNo); + case 'notes' + model = appendNote(model, 'rxnNotes', tline_key, tline_value, pos); case 'equation' coeff = sscanf(tline_value,'%f'); equations(equatiNo,1:3)={pos,tline_key,coeff}; @@ -878,6 +892,18 @@ model.(fieldName)(pos,1) = {value}; end +function model = appendNote(model, fieldName, key, value, pos) +% One entry of a notes map: its value for the key 'note' (RAVEN's own note +% text), 'key: value' otherwise; entries are separated by '; '. +if ~strcmp(key, 'note') + value = [key ': ' value]; +end +if numel(model.(fieldName)) >= pos && ~isempty(model.(fieldName){pos}) + value = [model.(fieldName){pos} '; ' value]; +end +model = readFieldValue(model, fieldName, value, pos); +end + function [miriams,miriamKey,entryNumber] = gatherAnnotation(pos,miriams,key,value,miriamKey,entryNumber) if isempty(key) key=miriamKey; diff --git a/tests/function_tests/tIO.m b/tests/function_tests/tIO.m index 86a06ae6..40990807 100644 --- a/tests/function_tests/tIO.m +++ b/tests/function_tests/tIO.m @@ -294,6 +294,30 @@ function writeReadYAMLPreservesUnknownAnnotationFields(testCase) testCase.verifyEqual(m2.annotation.geckopyVersion, '0.2.1'); end + function readYAMLmodelReadsNotesMaps(testCase) + % cobrapy and raven-toolbox write other tools' notes as a map + % under "notes"; its entries are read into the note text. + m = testCase.model; + m.metNotes = repmat({''}, numel(m.mets), 1); + m.rxnNotes = repmat({''}, numel(m.rxns), 1); + m.metNotes{1} = 'METPLACEHOLDER'; + m.rxnNotes{1} = 'RXNPLACEHOLDER'; + f = [tempname '.yml']; + testCase.addTeardown(@() delete(f)); + evalc('writeYAMLmodel(m, f);'); + txt = fileread(f); + txt = regexprep(txt, '( *)- notes: METPLACEHOLDER', ... + '$1- notes: !!omap\n$1 - enzyme_usage: Enzyme-usage pseudometabolite'); + txt = regexprep(txt, '( *)- notes: RXNPLACEHOLDER', ... + '$1- notes: !!omap\n$1 - geckopy_warning: no match for g1\n$1 - note: curated'); + fid = fopen(f, 'w'); fwrite(fid, txt); fclose(fid); + evalc('m2 = readYAMLmodel(f);'); + testCase.verifyEqual(m2.metNotes{1}, 'enzyme_usage: Enzyme-usage pseudometabolite'); + testCase.verifyEqual(m2.rxnNotes{1}, 'geckopy_warning: no match for g1; curated'); + testCase.verifyEqual(numel(m2.mets), numel(m.mets)); + testCase.verifyEqual(numel(m2.rxns), numel(m.rxns)); + end + function exportForGitWritesRepo(testCase) d = [tempname filesep]; mkdir(d); From 0d81bf125c8030489022e187a71379285986ed9a Mon Sep 17 00:00:00 2001 From: Eduard Kerkhoven Date: Fri, 23 Oct 2026 22:41:39 +0200 Subject: [PATCH 2/2] fix: read notes-map lines by indentation, not by key A key in a notes map named like an entry field (id, name, annotation) was read as that field, and list values in the map lost their items. --- src/io/readYAMLmodel.m | 62 ++++++++++++++++++++++++++++++-------- tests/function_tests/tIO.m | 23 +++++++++++--- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/src/io/readYAMLmodel.m b/src/io/readYAMLmodel.m index 4ea753fd..e8b5c351 100755 --- a/src/io/readYAMLmodel.m +++ b/src/io/readYAMLmodel.m @@ -195,6 +195,7 @@ end section = 0; +readList = ''; metMiriams=cell(100000,3); metMirNo=1; rxnMiriams=cell(100000,3); rxnMirNo=1; geneMiriams=cell(100000,3); genMirNo=1; @@ -346,6 +347,12 @@ % import metabolites: if section == 2 + % The lines of a notes map are indented below the entry's own + % fields; their keys are not entry fields, whatever their name. + if strcmp(readList, 'notes') && ~isempty(regexp(tline_raw, '^ {6,}', 'once')) + model = appendNote(model, 'metNotes', tline_raw, tline_key, tline_value, pos); + continue + end switch tline_key case 'id' pos = pos + 1; @@ -405,8 +412,6 @@ switch readList case 'annotation' [metMiriams, miriamKey, metMirNo] = gatherAnnotation(pos,metMiriams,tline_key,tline_value,miriamKey,metMirNo); - case 'notes' - model = appendNote(model, 'metNotes', tline_key, tline_value, pos); case 'smilesInAnnotation' % A bare list item ("- OC(=O)...") has no colon, % so tline_key is empty --- still inside the list. @@ -429,6 +434,12 @@ % import reactions: if section == 3 + % The lines of a notes map are indented below the entry's own + % fields; their keys are not entry fields, whatever their name. + if strcmp(readList, 'notes') && ~isempty(regexp(tline_raw, '^ {6,}', 'once')) + model = appendNote(model, 'rxnNotes', tline_raw, tline_key, tline_value, pos); + continue + end switch tline_key case 'id' pos = pos + 1; @@ -536,8 +547,6 @@ subSysNo=subSysNo+1; case 'annotation' [rxnMiriams, miriamKey,rxnMirNo] = gatherAnnotation(pos,rxnMiriams,tline_key,tline_value,miriamKey,rxnMirNo); - case 'notes' - model = appendNote(model, 'rxnNotes', tline_key, tline_value, pos); case 'equation' coeff = sscanf(tline_value,'%f'); equations(equatiNo,1:3)={pos,tline_key,coeff}; @@ -892,16 +901,45 @@ model.(fieldName)(pos,1) = {value}; end -function model = appendNote(model, fieldName, key, value, pos) -% One entry of a notes map: its value for the key 'note' (RAVEN's own note -% text), 'key: value' otherwise; entries are separated by '; '. -if ~strcmp(key, 'note') - value = [key ': ' value]; +function model = appendNote(model, fieldName, raw, key, value, pos) +% One line of a notes map: a 'note' entry adds its value (RAVEN's own note +% text), another entry adds 'key: value', a list item adds its value to the +% entry above it. Entries are separated by '; ', list items by ', '. A +% quoted list item is read whole, as the key detection would split it at +% a ': ' inside the quotes. +item = regexp(raw, '^ +- ([''"])(.*)\1$', 'tokens', 'once'); +if ~isempty(item) + key = ''; + value = item{2}; end -if numel(model.(fieldName)) >= pos && ~isempty(model.(fieldName){pos}) - value = [model.(fieldName){pos} '; ' value]; +if strcmp(value, '!!omap') + value = ''; +end +text = ''; +if numel(model.(fieldName)) >= pos + text = model.(fieldName){pos}; +end +if isempty(key) + if isempty(text) || endsWith(text, ':') + text = strtrim([text ' ' value]); + else + text = [text ', ' value]; + end +else + if isempty(value) + entry = [key ':']; + elseif strcmp(key, 'note') + entry = value; + else + entry = [key ': ' value]; + end + if isempty(text) + text = entry; + else + text = [text '; ' entry]; + end end -model = readFieldValue(model, fieldName, value, pos); +model = readFieldValue(model, fieldName, text, pos); end function [miriams,miriamKey,entryNumber] = gatherAnnotation(pos,miriams,key,value,miriamKey,entryNumber) diff --git a/tests/function_tests/tIO.m b/tests/function_tests/tIO.m index 40990807..e56709ca 100644 --- a/tests/function_tests/tIO.m +++ b/tests/function_tests/tIO.m @@ -306,14 +306,29 @@ function readYAMLmodelReadsNotesMaps(testCase) testCase.addTeardown(@() delete(f)); evalc('writeYAMLmodel(m, f);'); txt = fileread(f); + % The metabolite's map has a key named like an entry field and a + % quoted list item with ': ', and is followed by an annotation. txt = regexprep(txt, '( *)- notes: METPLACEHOLDER', ... - '$1- notes: !!omap\n$1 - enzyme_usage: Enzyme-usage pseudometabolite'); + ['$1- notes: !!omap\n$1 - enzyme_usage: Enzyme-usage pseudometabolite\n' ... + '$1 - name: other\n$1 - refs:\n$1 - "PMID: 1"\n$1 - b\n' ... + '$1- annotation: !!omap\n$1 - sbo: SBO:0000252']); + % The reaction's map comes before other fields, has keys named + % like entry fields and a list value. txt = regexprep(txt, '( *)- notes: RXNPLACEHOLDER', ... - '$1- notes: !!omap\n$1 - geckopy_warning: no match for g1\n$1 - note: curated'); + ['$1- notes: !!omap\n$1 - geckopy_warning: no match for g1\n$1 - id: R99\n' ... + '$1 - name: other\n$1 - refs:\n$1 - a\n$1 - b\n$1 - note: curated\n' ... + '$1- rxnFrom: test']); fid = fopen(f, 'w'); fwrite(fid, txt); fclose(fid); evalc('m2 = readYAMLmodel(f);'); - testCase.verifyEqual(m2.metNotes{1}, 'enzyme_usage: Enzyme-usage pseudometabolite'); - testCase.verifyEqual(m2.rxnNotes{1}, 'geckopy_warning: no match for g1; curated'); + testCase.verifyEqual(m2.metNotes{1}, ... + 'enzyme_usage: Enzyme-usage pseudometabolite; name: other; refs: PMID: 1, b'); + testCase.verifyEqual(m2.metNames, m.metNames); + testCase.verifyTrue(any(strcmp(m2.metMiriams{1}.name, 'sbo'))); + testCase.verifyEqual(m2.rxnNotes{1}, ... + 'geckopy_warning: no match for g1; id: R99; name: other; refs: a, b; curated'); + testCase.verifyEqual(m2.rxns, m.rxns); + testCase.verifyEqual(m2.rxnNames, m.rxnNames); + testCase.verifyEqual(m2.rxnFrom{1}, 'test'); testCase.verifyEqual(numel(m2.mets), numel(m.mets)); testCase.verifyEqual(numel(m2.rxns), numel(m.rxns)); end