diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1fd3897..dfaec53 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,7 +14,33 @@ on: jobs: - test: + pytest: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Run + run: | + pytest -v + + cli: runs-on: ubuntu-latest strategy: @@ -42,4 +68,4 @@ jobs: - name: Run run: | - pangonet --output-prefix data/pangonet --output-all \ No newline at end of file + pangonet --output-prefix data/pangonet --output-all \ No newline at end of file diff --git a/README.md b/README.md index cfcb74b..c35ab0c 100644 --- a/README.md +++ b/README.md @@ -164,12 +164,12 @@ pango = PangoNet().build() pango = PangoNet().build(alias_key="alias_key.json", lineage_notes="lineage_notes.txt") ``` -Compress and decompress aliases. +Compress and uncompress aliases. > ❗ See [pango_aliasor](https://github.com/corneliusroemer/pango_aliasor) for a more sophisticated approach to alias compression. ```python -pango.decompress("KP.3.1") +pango.uncompress("KP.3.1") 'B.1.1.529.2.86.1.1.11.1.3.1' pango.compress('B.1.1.529.2.86.1.1.11') @@ -314,7 +314,7 @@ print(pango_filter.to_json(compact=True)) ```json { "root": { - "decompressed": "", + "uncompressed": "", "depth": 0, "parents": "", "children": "B", @@ -323,7 +323,7 @@ print(pango_filter.to_json(compact=True)) }, ... "BA.2.10.1": { - "decompressed": "B.1.1.529.2.10.1", + "uncompressed": "B.1.1.529.2.10.1", "depth": 7, "parents": "BA.2.10", "children": "BJ.1", @@ -332,7 +332,7 @@ print(pango_filter.to_json(compact=True)) }, ... "XDB": { - "decompressed": "XDB", + "uncompressed": "XDB", "depth": 15, "parents": "XBB.1.16.19, XBB", "children": "", diff --git a/src/pangonet/pangonet.py b/src/pangonet/pangonet.py index c368dd3..c02c597 100644 --- a/src/pangonet/pangonet.py +++ b/src/pangonet/pangonet.py @@ -8,14 +8,15 @@ import copy import logging -ALIAS_KEY_URL = "https://raw.githubusercontent.com/cov-lineages/pango-designation/master/pango_designation/alias_key.json" -LINEAGE_NOTES_URL = "https://raw.githubusercontent.com/cov-lineages/pango-designation/master/lineage_notes.txt" +# Will use github API later to get proper download link +ALIAS_KEY_URL = "https://api.github.com/repos/cov-lineages/pango-designation/contents/pango_designation/alias_key.json" +LINEAGE_NOTES_URL = "https://api.github.com/repos/cov-lineages/pango-designation/contents/lineage_notes.txt" logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, format='%(asctime)s %(levelname)s:%(message)s') class PangoNet: - def __init__(self, alias_key: str = None, lineage_notes: str = None, root: str = "root"): + def __init__(self, root: str = "root"): ''' root: If not None, manually create top level node with this name ''' @@ -26,7 +27,7 @@ def __init__(self, alias_key: str = None, lineage_notes: str = None, root: str = self.root = root self.lineages = list() - def build(self, alias_key: str = None, lineage_notes: str = None, root: str = "root", outdir: str = None): + def build(self, alias_key: str = None, lineage_notes: str = None, outdir: str = None): if outdir and outdir != "" and outdir != "." and not os.path.exists(outdir): os.makedirs(outdir) @@ -54,8 +55,8 @@ def compress(self, lineage): Compress lineage name ''' - # Decompress fully first - lineage_compress = self.decompress(lineage) + # uncompress fully first + lineage_compress = self.uncompress(lineage) # Split nomenclature on '.'. ex. BC = B.1.1.529.1.1.1 # [ 'B', '1', '1', '529', '1', '1', '1'] lineage_split = lineage_compress.split(".") @@ -77,74 +78,6 @@ def compress(self, lineage): alias = ".".join(lineage_split) return alias - def decompress(self, lineage): - ''' - Decompress lineage name. - ''' - - # Split nomenclature on '.'. ex. BC = B.1.1.529.1.1.1 - # [ 'B', '1', '1', '529', '1', '1', '1'] - lineage_split = lineage.split(".") - # Pango lineages have a maximum of four pieces before they need to be aliased - # Keep compressing it until it's the right size - prefix = lineage_split[0] - if prefix in self.aliases: - suffix = lineage_split[1:] if len(lineage_split) > 1 else [] - lineage_split = [self.aliases[prefix]] + suffix - lineage = ".".join(lineage_split) - return(lineage) - - def parse_aliases(self, alias_key_path: str): - ''' - Extract the aliases from the hierarchy and removing recombinants because they - are not really aliases, so much as the alias key is a specification of their - parent-child relationships. - ''' - - logging.info(f"Creating aliases.") - with open(alias_key_path) as data: - alias_key = json.load(data) - aliases = {alias:lineage for alias,lineage in alias_key.items() if lineage != '' and type(lineage) != list} - return aliases - - def parse_lineages(self, lineage_notes_path): - ''' - Returns a list of designated lineages from the lineage notes. - ''' - lineages = [] - - # The lineage notes file is a TSV table - with open(lineage_notes_path) as table: - # Locate the lineage column in the header - header = table.readline().strip().split("\t") - lineage_i = header.index("Lineage") - for line in table: - lineage = line.strip().split("\t")[lineage_i] - # Skip over Withdrawn lineages (that start with '*') - if lineage.startswith('*'): continue - lineages.append(lineage) - - return lineages - - def parse_recombinants(self, alias_key_path): - ''' - Returns recombinants and their parents. - ''' - recombinants = {} - with open(alias_key_path) as data: - alias_key = json.load(data) - for lineage, parents in alias_key.items(): - if type(parents) != list: continue - parents_unique = [] - for p in parents: - p = p.replace("*", "") - if p not in parents_unique: - parents_unique.append(p) - recombinants[lineage] = parents_unique - - return recombinants - - def create_network(self): ''' root : If not None, manually create top level node with this name @@ -154,7 +87,7 @@ def create_network(self): network = OrderedDict() # Manually add a root node according to params if self.root: - network[self.root] = {"decompressed": "", "depth": 0, "parents": [], "children": [], "ancestors": [], "descendants": [], "depth": 0} + network[self.root] = {"uncompressed": "", "depth": 0, "parents": [], "children": [], "ancestors": [], "descendants": [], "depth": 0} root = self.root # --------------------------------------------------------------------- @@ -162,8 +95,8 @@ def create_network(self): for lineage in self.lineages: - decompressed = self.decompress(lineage) - decompressed_split = decompressed.split(".") + uncompressed = self.uncompress(lineage) + uncompressed_split = uncompressed.split(".") # Option 1: Root node # If we don't have a root yet, assign to first one encountered @@ -176,15 +109,15 @@ def create_network(self): elif lineage in self.recombinants: parents = self.recombinants[lineage] else: - decompressed = self.decompress(lineage) + uncompressed = self.uncompress(lineage) # Option 3: Top level node, A or B - if "." not in decompressed: + if "." not in uncompressed: parents = [self.root] else: - decompressed_parent = decompressed_split[0: (len(decompressed_split) - 1)] - parents = [self.compress(".".join(decompressed_parent))] + uncompressed_parent = uncompressed_split[0: (len(uncompressed_split) - 1)] + parents = [self.compress(".".join(uncompressed_parent))] - network[lineage] = {"decompressed": decompressed, "depth": 0, "parents": parents, "children": [], "ancestors": [], "descendants": []} + network[lineage] = {"uncompressed": uncompressed, "depth": 0, "parents": parents, "children": [], "ancestors": [], "descendants": []} # --------------------------------------------------------------------- # Iteratation #2: Children @@ -201,13 +134,13 @@ def create_network(self): network[lineage]["descendants"] = self.get_descendants(lineage=lineage, network=network) # --------------------------------------------------------------------- - # Iteratation #5: Decompressed aliases + # Iteratation #5: uncompressed aliases lineages = list(network.keys()) for lineage in lineages: - decompressed = network[lineage]["decompressed"] - if decompressed and decompressed != '' and decompressed not in network: - network[decompressed] = network[lineage] + uncompressed = network[lineage]["uncompressed"] + if uncompressed and uncompressed != '' and uncompressed not in network: + network[uncompressed] = network[lineage] # --------------------------------------------------------------------- # Iteratation #4: Depth @@ -225,20 +158,32 @@ def create_network(self): max_parent_depth = parent_depth depth = max_parent_depth + 1 else: - depth = len(info["decompressed"].split(".")) + depth = len(info["uncompressed"].split(".")) network[lineage]["depth"] = depth return network def download_alias_key(self, url: str = ALIAS_KEY_URL, outdir: str = None): + + # Get proper download link with github api + if "api.github.com" in url: + with urllib.request.urlopen(url) as api_url: + url = str(json.load(api_url)["download_url"]) + alias_key_path = os.path.basename(url) if outdir: alias_key_path = os.path.join(outdir, alias_key_path) - logging.info(f"Downloading alias key: {alias_key_path}") + logging.info(f"Downloading alias key: {alias_key_path}") urllib.request.urlretrieve(url, alias_key_path) return alias_key_path def download_lineage_notes(self, url: str = LINEAGE_NOTES_URL, outdir: str = None): + + # Get proper download link with github api + if "api.github.com" in url: + with urllib.request.urlopen(url) as api_url: + url = str(json.load(api_url)["download_url"]) + lineage_notes_path = os.path.basename(url) if outdir: lineage_notes_path = os.path.join(outdir, lineage_notes_path) @@ -256,7 +201,7 @@ def filter(self, lineages: [str], network: OrderedDict = None): # Keep order of lineages in network lineages = [l for l in network if l in lineages] - pango_net = copy.deepcopy(self) + pango = copy.deepcopy(self) filtered_network = OrderedDict() for lineage in lineages: @@ -267,20 +212,11 @@ def filter(self, lineages: [str], network: OrderedDict = None): filtered_network[lineage]["ancestors"] = [l for l in info["ancestors"] if l in lineages] filtered_network[lineage]["descendants"] = [l for l in info["descendants"] if l in lineages] - pango_net.network = filtered_network + pango.network = filtered_network # Update attributes - pango_net.recombinants = self.get_recombinants() - return pango_net - - def get_parents(self, lineage: str, network : OrderedDict = None): - if not network: - network = self.network - return network[lineage]["parents"] + pango.recombinants = self.get_recombinants() + return pango - def get_children(self, lineage: str, network : OrderedDict = None): - if not network: - network = self.network - return network[lineage]["children"] def get_ancestors(self, lineage: str, network : OrderedDict = None): ''' @@ -300,7 +236,12 @@ def get_ancestors(self, lineage: str, network : OrderedDict = None): ancestors += [parent] + parent_ancestors # remove duplicates (python 3.7+ preserves order) ancestors = list(dict.fromkeys(ancestors)) - return ancestors + return ancestors + + def get_children(self, lineage: str, network : OrderedDict = None): + if not network: + network = self.network + return network[lineage]["children"] def get_descendants(self, lineage: str, network : OrderedDict = None): ''' @@ -319,7 +260,45 @@ def get_descendants(self, lineage: str, network : OrderedDict = None): descendants += [child] + child_descendants # remove duplicates (python 3.7+ preserves order) descendants = list(dict.fromkeys(descendants)) - return descendants + return descendants + + def get_mrca(self, lineages: [str], network: OrderedDict = None): + ''' + Get most recent common ancestors + ''' + + if not network: + network = self.network + + # Make a pile of all ancestors, include lineages themselves in list + ancestors_count = {l:1 for l in lineages} + for lineage in lineages: + lineage_ancestors = network[lineage]["ancestors"] + for a in lineage_ancestors: + if a not in ancestors_count: + ancestors_count[a] = 0 + ancestors_count[a] += 1 + + # Filter down to ancestors observed in all lineages + ancestors_shared = [a for a,count in ancestors_count.items() if count == len(lineages)] + + # If no shared ancestors? + if len(ancestors_shared) == 0: + return [] + + # Add network depth to each ancestors + ancestors_depth = {a:network[a]["depth"] for a in ancestors_shared} + + # Find the lineage with the highest depth value + max_depth = max(ancestors_depth.values()) + ancestors = [a for a,d in ancestors_depth.items() if d == max_depth] + + return ancestors + + def get_parents(self, lineage: str, network : OrderedDict = None): + if not network: + network = self.network + return network[lineage]["parents"] def get_recombinants(self, descendants=False, network: OrderedDict = None): ''' @@ -337,24 +316,57 @@ def get_recombinants(self, descendants=False, network: OrderedDict = None): return recombinants - def to_mermaid(self, network: OrderedDict = None): - if not network: - network = self.network - lines = [] + def parse_aliases(self, alias_key_path: str): + ''' + Extract the aliases from the hierarchy and removing recombinants because they + are not really aliases, so much as the alias key is a specification of their + parent-child relationships. + ''' - lines.append("graph LR;") - for lineage,info in network.items(): - for parent in info["parents"]: - # Calculate the depth difference between the parent and lineage - # Ex. B (1) --> B.1 (2) is diff=1, which means the arrow will be --> - # Ex. BJ.1 (8) --> XBB (11) is diff=3, which means the arrow will be ----> - depth_diff = (info["depth"] - network[parent]["depth"]) - 1 - arrow = "--" + ("-" * depth_diff) + ">" - lines.append(f" {parent}{arrow}{lineage};") + logging.info(f"Creating aliases.") + with open(alias_key_path) as data: + alias_key = json.load(data) + aliases = {alias:lineage for alias,lineage in alias_key.items() if lineage != '' and type(lineage) != list} + return aliases - mermaid = "\n".join(lines) - return mermaid + def parse_lineages(self, lineage_notes_path): + ''' + Returns a list of designated lineages from the lineage notes. + ''' + lineages = [] + + # The lineage notes file is a TSV table + with open(lineage_notes_path) as table: + # Locate the lineage column in the header + header = table.readline().strip().split("\t") + lineage_i = header.index("Lineage") + for line in table: + lineage = line.strip().split("\t")[lineage_i] + # Skip over Withdrawn lineages (that start with '*') + if lineage.startswith('*'): continue + lineages.append(lineage) + + return lineages + + def parse_recombinants(self, alias_key_path): + ''' + Returns recombinants and their parents. + ''' + recombinants = {} + with open(alias_key_path) as data: + alias_key = json.load(data) + for lineage, parents in alias_key.items(): + if type(parents) != list: continue + parents_unique = [] + for p in parents: + p = p.replace("*", "") + if p not in parents_unique: + parents_unique.append(p) + recombinants[lineage] = parents_unique + + return recombinants + def to_dot(self, network: OrderedDict = None): @@ -376,27 +388,6 @@ def to_dot(self, network: OrderedDict = None): dot = "\n".join(lines) return dot - def to_table(self, sep="\t"): - ''' - Create tsv table. - ''' - - header = sep.join(["lineage", "parents", "children", "recombinant", "recombinant_descendant"]) - rows = [header] - recombinant_descendants = self.get_recombinants(descendants=True) - for lineage,info in self.network.items(): - row = [ - lineage, - ", ".join(info["parents"]), - ", ".join(info["children"]), - True if lineage in self.recombinants else False, - True if lineage in recombinant_descendants else False - ] - row = [str(r)for r in row] - rows.append(sep.join(row)) - - table = "\n".join(rows) - return table def to_json(self, network: OrderedDict = None, compact=False): if not network: @@ -414,6 +405,27 @@ def to_json(self, network: OrderedDict = None, compact=False): json_data = str(json.dumps(network, indent=4)) return json_data + + def to_mermaid(self, network: OrderedDict = None): + + if not network: + network = self.network + lines = [] + + lines.append("graph LR;") + for lineage,info in network.items(): + for parent in info["parents"]: + # Calculate the depth difference between the parent and lineage + # Ex. B (1) --> B.1 (2) is diff=1, which means the arrow will be --> + # Ex. BJ.1 (8) --> XBB (11) is diff=3, which means the arrow will be ----> + depth_diff = (info["depth"] - network[parent]["depth"]) - 1 + arrow = "--" + ("-" * depth_diff) + ">" + lines.append(f" {parent}{arrow}{lineage};") + + mermaid = "\n".join(lines) + return mermaid + + def to_newick(self, node: str=None, parent: str=None, processed:set=set(), depth:int=0, extended:bool=True): ''' Convert network to newick. @@ -474,15 +486,55 @@ def to_newick(self, node: str=None, parent: str=None, processed:set=set(), depth else: return (newick, processed) + + def to_table(self, sep="\t"): + ''' + Create tsv table. + ''' + + header = sep.join(["lineage", "parents", "children", "recombinant", "recombinant_descendant"]) + rows = [header] + recombinant_descendants = self.get_recombinants(descendants=True) + for lineage,info in self.network.items(): + row = [ + lineage, + ", ".join(info["parents"]), + ", ".join(info["children"]), + True if lineage in self.recombinants else False, + True if lineage in recombinant_descendants else False + ] + row = [str(r)for r in row] + rows.append(sep.join(row)) + + table = "\n".join(rows) + return table + + def uncompress(self, lineage): + ''' + Uncompress lineage name. + ''' + + # Split nomenclature on '.'. ex. BC = B.1.1.529.1.1.1 + # [ 'B', '1', '1', '529', '1', '1', '1'] + lineage_split = lineage.split(".") + # Pango lineages have a maximum of four pieces before they need to be aliased + # Keep compressing it until it's the right size + prefix = lineage_split[0] + if prefix in self.aliases: + suffix = lineage_split[1:] if len(lineage_split) > 1 else [] + lineage_split = [self.aliases[prefix]] + suffix + lineage = ".".join(lineage_split) + return(lineage) + def get_cli_options(): import argparse - description = 'Create and manipulate SARS-CoV-2 pango lineages in a phylogenetic network.' + description = 'pangonet v0.1.0 | Create and manipulate SARS-CoV-2 pango lineages in a phylogenetic network.' parser = argparse.ArgumentParser(description=description) parser.add_argument('--lineage-notes', help='Path to the lineage_notes.txt') parser.add_argument('--alias-key', help='Path to the alias_key.json') - parser.add_argument('--output-prefix', help='Output prefix', default="pangonet") + parser.add_argument('--output-prefix', help='Output prefix', default="pango") parser.add_argument('--output-all', help='Output all formats', action="store_true") parser.add_argument('--tsv', help='Output metadata TSV', action="store_true") parser.add_argument('--json', help='Output json', action="store_true") @@ -511,7 +563,7 @@ def cli(): os.makedirs(outdir) # Create the network from the alias key and lineage notes, will download the files if not given - pango_net = PangoNet().build(alias_key=options.alias_key, lineage_notes=options.lineage_notes, outdir=outdir) + pango = PangoNet().build(alias_key=options.alias_key, lineage_notes=options.lineage_notes, outdir=outdir) # ------------------------------------------------------------------------- # Export @@ -521,7 +573,7 @@ def cli(): if options.output_all or options.tsv: table_path = options.output_prefix + ".tsv" logging.info(f"Exporting table: {table_path}") - table = pango_net.to_table() + table = pango.to_table() with open(table_path, 'w') as outfile: outfile.write(table + "\n") @@ -529,7 +581,7 @@ def cli(): if options.output_all or options.nwk: newick_path = options.output_prefix + ".nwk" logging.info(f"Exporting standard newick: {newick_path}") - newick = pango_net.to_newick(extended=False) + newick = pango.to_newick(extended=False) with open(newick_path, 'w') as outfile: outfile.write(newick + "\n") @@ -537,7 +589,7 @@ def cli(): if options.output_all or options.enwk: newick_path = options.output_prefix + ".enwk" logging.info(f"Exporting extended newick: {newick_path}") - newick = pango_net.to_newick(extended=True) + newick = pango.to_newick(extended=True) with open(newick_path, 'w') as outfile: outfile.write(newick + "\n") @@ -545,14 +597,14 @@ def cli(): if options.output_all or options.mermaid: mermaid_path = options.output_prefix + ".mermaid" logging.info(f"Exporting mermaid: {mermaid_path}") - mermaid = pango_net.to_mermaid() + mermaid = pango.to_mermaid() with open(mermaid_path, 'w') as outfile: outfile.write(mermaid + "\n") # Dot if options.output_all or options.dot: dot_path = options.output_prefix + ".dot" - dot = pango_net.to_dot() + dot = pango.to_dot() logging.info(f"Exporting dot: {dot_path}") with open(dot_path, 'w') as outfile: outfile.write(dot + "\n") @@ -561,13 +613,13 @@ def cli(): if options.output_all or options.json: json_path = options.output_prefix + ".json" logging.info(f"Exporting json: {json_path}") - json_data = pango_net.to_json() + json_data = pango.to_json() with open(json_path, 'w') as outfile: outfile.write(json_data + "\n") json_path = options.output_prefix + ".compact.json" logging.info(f"Exporting compact json: {json_path}") - json_data = pango_net.to_json(compact=True) + json_data = pango.to_json(compact=True) with open(json_path, 'w') as outfile: outfile.write(json_data + "\n") diff --git a/tests/test_pangonet.py b/tests/test_pangonet.py new file mode 100644 index 0000000..8c34c62 --- /dev/null +++ b/tests/test_pangonet.py @@ -0,0 +1,77 @@ +from pangonet import PangoNet +import os + +# Version controlled data files for testing expected values +data_dir = os.path.join(os.getcwd(), "tests", "data") +alias_key = os.path.join(data_dir , "alias_key_2024-07-19.json") +lineage_notes = os.path.join(data_dir , "lineage_notes_2024-07-19.txt") + +# New files for testing new compatibility +new_dir = os.path.join(os.getcwd(), "tests", "output") +new_alias_key = os.path.join(new_dir, "alias_key.json") +new_lineage_notes = os.path.join(new_dir, "lineage_notes.txt") + +def test_pangonet_init(): + pango = PangoNet() + +# def test_pangonet_build(): +# pango = PangoNet().build(outdir=new_dir) + +def test_pangonet_compress(): + pango = PangoNet().build(alias_key=new_alias_key, lineage_notes=new_lineage_notes) + assert pango.compress("BA.1") == "BA.1" + assert pango.compress("B.1.1.529.1.1.1.4.5") == "BC.4.5" + assert pango.compress("XBB.1.2") == "XBB.1.2" + assert pango.compress("XBC") == "XBC" + +def test_pangonet_filter(): + ... + +def test_pangonet_get_ancestors(): + ... + +def test_pangonet_get_children(): + pango = PangoNet().build(alias_key=alias_key, lineage_notes=lineage_notes) + assert pango.get_children("JN.1.1") == ['JN.1.1.1', 'JN.1.1.2', 'JN.1.1.3', 'JN.1.1.4', 'JN.1.1.5', 'JN.1.1.6', 'JN.1.1.7', 'JN.1.1.8', 'JN.1.1.9', 'JN.1.1.10', 'XDN', 'XDR'] + +def test_pangonet_get_descendants(): + ... + +def test_pangonet_get_mrca(): + pango = PangoNet().build(alias_key=alias_key, lineage_notes=lineage_notes) + assert pango.get_mrca(["BA.1", "BA.2", "BC.1"]) == ["B.1.1.529"] + assert pango.get_mrca(["BA.1.2", "XD"]) == ["BA.1"] + assert pango.get_mrca(["XE", "XG"]) == ["BA.1", "BA.2"] + assert pango.get_mrca(["BA.1", "BA.1.1"]) == ["BA.1"] + assert pango.get_mrca(["XBB.1", "XBL"]) == ["XBB.1"] + +def test_pangonet_get_parents(): + pango = PangoNet().build(alias_key=new_alias_key, lineage_notes=new_lineage_notes) + assert pango.get_parents("BA.1") == ["B.1.1.529"] + assert pango.get_parents("XBB") == ['BJ.1', 'BM.1.1.1'] + assert pango.get_parents("XBB.1.5") == ['XBB.1'] + +def test_pangonet_get_recombinants(): + ... + +def test_pangonet_to_dot(): + ... + +def test_pangonet_to_json(): + ... + +def test_pangonet_to_mermaid(): + ... + +def test_pangonet_to_newick(): + ... + +def test_pangonet_to_table(): + ... + +def test_pangonet_uncompress(): + pango = PangoNet().build(alias_key=new_alias_key, lineage_notes=new_lineage_notes) + assert pango.uncompress("BA.1") == "B.1.1.529.1" + assert pango.uncompress("BC.4.5") == "B.1.1.529.1.1.1.4.5" + assert pango.uncompress("XBB.1.2") == "XBB.1.2" + assert pango.uncompress("XBC") == "XBC"