From 4594848601802f304279e626adb5efcddbd703cc Mon Sep 17 00:00:00 2001 From: Johannes Debler Date: Mon, 20 Jul 2026 13:05:48 +0000 Subject: [PATCH] Support InterProScan 5.74 output and non-spec GFF attributes Updates to keep ipr_to_gff and prot_to_genome working with recent InterProScan releases and common third-party GFF3 files. ipr_to_gff: - Handle the new XML schema namespace introduced in recent InterProScan versions (https://ftp.ebi.ac.uk/pub/software/unix/iprscan/5/schemas, previously http://www.ebi.ac.uk/interpro/resources/schemas/interproscan5). get_tag now strips any leading "{...}" namespace rather than asserting on a fixed URI, so it is robust to future namespace changes, and --namespace defaults to auto-detect. - Support the funfamhmmer3-match type (CATH-Gene3D FunFam), which newer InterProScan versions emit. process_hmmer3 is parameterised by the match and location tag names and reused for funfam matches. gff: - Tolerate unescaped ';' inside GFF3 attribute values (invalid per spec but emitted by tools such as funannotate, e.g. gene names like "SULTR3;4"). A ';'-separated piece without '=' is treated as a continuation of the previous value instead of raising ValueError. data: - Replace the deprecated pkg_resources.resource_filename with an importlib.resources-based shim. Co-Authored-By: Claude Opus 4.8 --- src/predectorutils/data/__init__.py | 11 +++++- src/predectorutils/gff.py | 18 ++++++--- src/predectorutils/subcommands/ipr_to_gff.py | 41 +++++++++++++++----- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/predectorutils/data/__init__.py b/src/predectorutils/data/__init__.py index b84e032..083abb8 100644 --- a/src/predectorutils/data/__init__.py +++ b/src/predectorutils/data/__init__.py @@ -4,12 +4,21 @@ for each database release. The latest version will always we the default one. """ -from pkg_resources import resource_filename +from importlib.resources import files import json import xgboost as xgb +def resource_filename(package: str, resource: str) -> str: + """Return the filesystem path to a data file bundled in the package. + + Drop-in replacement for the deprecated + ``pkg_resources.resource_filename``. + """ + return str(files(package).joinpath(resource)) + + def get_interesting_pfam_ids(version: str = "latest") -> list[str]: fname = resource_filename(__name__, "hmms_of_interest.json") with open(fname, "r") as handle: diff --git a/src/predectorutils/gff.py b/src/predectorutils/gff.py index 26aab6f..1f6559b 100644 --- a/src/predectorutils/gff.py +++ b/src/predectorutils/gff.py @@ -478,11 +478,19 @@ def parse( if string.strip() in (".", ""): return cls() - fields = ( - f.split("=", maxsplit=1) - for f - in string.strip(" ;").split(";") - ) + # Split on ';', then on '='. Some tools (e.g. funannotate) emit values + # containing unescaped ';' characters, which is invalid GFF3 (';' in a + # value should be percent-encoded as %3B) but common in practice -- + # e.g. gene names like "SULTR3;4". A piece without '=' is treated as a + # continuation of the previous value with the split ';' restored, + # rather than crashing. + fields: list[list[str]] = [] + for piece in string.strip(" ;").split(";"): + if "=" in piece: + fields.append(piece.split("=", maxsplit=1)) + elif len(fields) > 0: + fields[-1][1] = f"{fields[-1][1]};{piece}" + # A leading piece with no '=' has nothing to attach to; skip it. if strip_quote: kvpairs: dict[str, str] = { diff --git a/src/predectorutils/subcommands/ipr_to_gff.py b/src/predectorutils/subcommands/ipr_to_gff.py index 2d800a5..87a5773 100644 --- a/src/predectorutils/subcommands/ipr_to_gff.py +++ b/src/predectorutils/subcommands/ipr_to_gff.py @@ -44,8 +44,12 @@ def cli(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--namespace", type=str, - default=NAMESPACE, - help="The XML dom namespace. This is mostly included for developers." + default=None, + help=( + "The XML dom namespace. By default this is auto-detected from " + "the root element of the XML file. This is mostly included for " + "developers." + ) ) return @@ -63,10 +67,17 @@ def get_source( def get_tag( element: ET.Element, - namespace: str + namespace: Optional[str] = None ) -> str: - assert element.tag.startswith(namespace), element - return element.tag[len(namespace):] + # Strip any leading "{namespace-uri}" prefix to get the local tag name. + # We intentionally ignore the specific namespace URI so that the parser + # keeps working when InterProScan changes it between versions (e.g. + # http://www.ebi.ac.uk/interpro/resources/schemas/interproscan5 -> + # https://ftp.ebi.ac.uk/pub/software/unix/iprscan/5/schemas). + tag = element.tag + if tag.startswith("{"): + return tag[tag.index("}") + 1:] + return tag class Signature(NamedTuple): @@ -333,7 +344,9 @@ def process_hmmer3( itree: Iterator[tuple[str, ET.Element]], ips_version: Optional[str], query_id: Optional[str], - namespace: str = NAMESPACE + namespace: str = NAMESPACE, + match_tag: str = "hmmer3-match", + location_tag: str = "hmmer3-location", ) -> Iterator[GFFRecord]: assert ips_version is not None assert query_id is not None @@ -348,7 +361,7 @@ def process_hmmer3( event, element = next(itree) tag = get_tag(element, namespace) - if (event == "end") and (tag == "hmmer3-match"): + if (event == "end") and (tag == match_tag): break if (event == "start") and (tag == "signature"): signature = process_signature(element, itree) @@ -361,7 +374,7 @@ def process_hmmer3( goterms = signature.goterms kind = signature.kind - elif (event == "start") and (tag == "hmmer3-location"): + elif (event == "start") and (tag == location_tag): start = element.attrib["start"] end = element.attrib["end"] env_start = element.attrib["env-start"] @@ -1357,7 +1370,7 @@ def inner(records: Iterable[GFFRecord]): def inner( # noqa: C901 infile: TextIO, outfile: TextIO, - namespace: str, + namespace: Optional[str] = None, ): itree = ET.iterparse( infile, @@ -1399,6 +1412,16 @@ def inner( # noqa: C901 elif (event == "start") and (tag == "hmmer3-match"): hmm = process_hmmer3(element, itree, ips_version, query_id) printer(hmm) + elif (event == "start") and (tag == "funfamhmmer3-match"): + hmm = process_hmmer3( + element, + itree, + ips_version, + query_id, + match_tag="funfamhmmer3-match", + location_tag="funfamhmmer3-location", + ) + printer(hmm) elif (event == "start") and (tag == "panther-match"): hmm = process_panther(element, itree, ips_version, query_id) printer(hmm)