telinker 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
telinker/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ import sys
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ def log(msg):
7
+ print(f"[telinker] {msg}", file=sys.stderr)
telinker/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
telinker/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import sys
4
+ from collections import Counter
5
+
6
+ import numpy as np
7
+
8
+ from . import __version__, log
9
+ from .impute import impute
10
+ from .inputs import read_bed, read_plink, read_raw, read_vcf, sample_name, sniff
11
+ from .match import match
12
+ from .output import PRESENT, write_tsv, write_vcf
13
+ from .panel import BUILDS, PANEL_DIR, load_panel
14
+
15
+
16
+ def main(argv=None):
17
+ ap = argparse.ArgumentParser(
18
+ prog="telinker", description="Genotype polymorphic TEs from VCF, PLINK, array, BED "
19
+ "or BAM/CRAM files.")
20
+ ap.add_argument("--version", action="version", version=__version__)
21
+ sub = ap.add_subparsers(dest="cmd", required=True)
22
+
23
+ g = sub.add_parser("genotype", help="genotype catalog polyTEs")
24
+ g.add_argument("input", nargs="+",
25
+ help="VCF, PLINK prefix, 23andMe/Ancestry file, BED of TE calls or BAM/CRAM; "
26
+ "several inputs are merged by sample name")
27
+ g.add_argument("-o", "--out", help="output file (default: stdout)")
28
+ g.add_argument("--build", choices=("auto",) + BUILDS, default="auto",
29
+ help="genome build of the inputs (default: from the header)")
30
+ g.add_argument("--min-r2", type=float, default=0.5,
31
+ help="minimum model cv r2 for imputed TEs (default: 0.5)")
32
+ g.add_argument("--model", choices=("mixed", "ridge", "tag"), default="mixed",
33
+ help="imputation model: best available (default), ridge only, or single tag SNP")
34
+ g.add_argument("--vcf", action="store_true", help="write VCF (GT:DS) instead of TSV")
35
+ g.add_argument("--reference", help="reference FASTA for CRAM")
36
+ g.add_argument("--include-del-contractions", action="store_true",
37
+ help="also report DEL loci that are in-element contractions")
38
+
39
+ sub.add_parser("info", help="show the reference panel summary")
40
+ a = ap.parse_args(argv)
41
+
42
+ if a.cmd == "info":
43
+ print((PANEL_DIR / "PANEL.md").read_text(), end="")
44
+ return
45
+ genotype(a)
46
+
47
+
48
+ def genotype(a):
49
+ jobs = [inspect(path, a.reference) for path in a.input]
50
+ build = pick_build(jobs, a.build)
51
+ for j in jobs:
52
+ j["direct"] = j["kind"] in ("vcf_sv", "bed") or \
53
+ (j["kind"] == "reads" and j["long"] and build == "t2t")
54
+ panel = load_panel(build, models=not all(j["direct"] for j in jobs))
55
+
56
+ results = []
57
+ for j in jobs:
58
+ if len(jobs) > 1:
59
+ log(f"--- {j['path']}")
60
+ results.append(run(j, panel, a))
61
+ samples, calls, clashes, disagree = merge(results)
62
+ shared = [s for s, n in Counter(s for names, _ in results for s in set(names)).items() if n > 1]
63
+ if shared:
64
+ log(f"merged {len(shared):,} sample name(s) found in more than one input "
65
+ f"(e.g. {', '.join(shared[:3])}); rename files that hold different people")
66
+ if clashes:
67
+ log(f"{clashes:,} genotypes called by more than one input ({disagree:,} disagreed); "
68
+ "kept the first")
69
+
70
+ stats = Counter()
71
+ if not a.include_del_contractions:
72
+ keep = [c for c in calls if panel.loci["del_ok"][c[0]]]
73
+ skipped = len(calls) - len(keep)
74
+ calls = keep
75
+ if a.vcf:
76
+ placed = [c for c in calls if panel.te_pos()[c[0]] > 0]
77
+ if len(placed) < len(calls):
78
+ log(f"{len(calls) - len(placed):,} TEs have no {build} position; left out of the VCF")
79
+ calls = placed
80
+ stats.update(m.rstrip("0123456789") for c in calls for m in c[1].split(","))
81
+
82
+ out = open(a.out, "w") if a.out else sys.stdout
83
+ (write_vcf if a.vcf else write_tsv)(out, panel, samples, calls)
84
+ if a.out:
85
+ out.close()
86
+ log(f"{len(calls):,} TEs written for {len(samples):,} sample(s) ("
87
+ + ", ".join(f"{k} {v:,}" for k, v in stats.most_common()) + ")")
88
+ if not a.include_del_contractions and skipped:
89
+ log(f"{skipped:,} DEL contractions skipped (--include-del-contractions to keep)")
90
+
91
+
92
+ def inspect(path, reference):
93
+ kind, build = sniff(path)
94
+ job = dict(path=path, kind=kind, build=build, long=False)
95
+ if kind == "reads":
96
+ from . import reads
97
+ with reads.open_bam(path, reference) as bam:
98
+ job["samples"], job["rg"], job["build"], job["long"] = reads.describe(bam, path)
99
+ return job
100
+
101
+
102
+ def pick_build(jobs, forced):
103
+ if forced != "auto":
104
+ clash = [j for j in jobs if j["build"] not in (None, forced)]
105
+ if clash:
106
+ sys.exit(f"--build {forced} conflicts with the header of " + ", ".join(
107
+ f"{j['path']} ({j['build']})" for j in clash))
108
+ log(f"build {forced}")
109
+ return forced
110
+ for j in jobs:
111
+ if j["build"] is None and j["kind"] == "raw":
112
+ j["build"] = "hg19"
113
+ log(f"{j['path']}: no build in header, assuming hg19")
114
+ unknown = [j["path"] for j in jobs if j["build"] is None]
115
+ if unknown:
116
+ sys.exit(f"Unknown genome build for {', '.join(unknown)}; pass --build t2t|hg38|hg19")
117
+ builds = {j["build"] for j in jobs}
118
+ if len(builds) > 1:
119
+ sys.exit("Inputs are on different builds: " + ", ".join(
120
+ f"{j['path']} ({j['build']})" for j in jobs))
121
+ build = builds.pop()
122
+ log(f"build {build}")
123
+ return build
124
+
125
+
126
+ def run(j, panel, a):
127
+ log(f"input: {j['kind']}")
128
+ kind, path, calls = j["kind"], j["path"], []
129
+ if kind == "bed":
130
+ samples, svs = [sample_name(path)], read_bed(path)
131
+ calls = matched(panel, svs, np.full((len(svs), 1), PRESENT), "bed")
132
+ elif kind in ("vcf_snp", "vcf_sv"):
133
+ samples, G, svs, dose = read_vcf(path, panel)
134
+ if svs or j["direct"]:
135
+ calls = matched(panel, svs, dose, "sv")
136
+ elif kind == "reads":
137
+ from . import reads
138
+ samples = j["samples"]
139
+ with reads.open_bam(path, a.reference) as bam:
140
+ if j["direct"]:
141
+ log("long reads: genotyping from CIGAR indels")
142
+ rows = [i for i in range(len(panel))
143
+ if not reads.in_chry_par(panel.loci["chrom"][i], panel.loci["pos"][i])]
144
+ log(f"{len(panel) - len(rows):,} chrY PAR loci skipped (called on chrX)")
145
+ res = reads.genotype_long(bam, panel, rows, samples, j["rg"])
146
+ calls = [(i, "reads", None, d) for i, d in res.items()]
147
+ else:
148
+ log("pileup at panel SNPs, then imputing")
149
+ G = reads.pileup_sites(bam, panel, samples, j["rg"])
150
+ else:
151
+ samples, G = {"plink": read_plink, "raw": read_raw}[kind](path, panel)
152
+ if not j["direct"]:
153
+ log(f"{len(G):,} of {len(panel.sites):,} panel SNPs found")
154
+ done = {c[0] for c in calls}
155
+ res = impute(panel, G, len(samples), a.min_r2, a.model)
156
+ calls += [(i, *r) for i, r in res.items() if i not in done]
157
+ return samples, calls
158
+
159
+
160
+ def matched(panel, svs, dose, method):
161
+ pairs = match(panel, svs)
162
+ log(f"{len(pairs):,} of {len(svs):,} input calls matched a catalog TE")
163
+ return [(i, method, None, dose[k]) for k, i in pairs]
164
+
165
+
166
+ def merge(results):
167
+ if len(results) == 1:
168
+ samples, calls = results[0]
169
+ return samples, sorted(calls, key=lambda c: c[0]), 0, 0
170
+ samples = list(dict.fromkeys(s for names, _ in results for s in names))
171
+ col = {s: k for k, s in enumerate(samples)}
172
+ rows, clashes, disagree = {}, 0, 0
173
+ for names, calls in results:
174
+ idx = np.array([col[s] for s in names])
175
+ for i, method, r2, doses in calls:
176
+ methods, r2s, d = rows.setdefault(i, ([], [], np.full(len(samples), np.nan)))
177
+ methods += [m for m in method.split(",") if m not in methods]
178
+ if r2 is not None:
179
+ r2s.append(r2)
180
+ have, new = ~np.isnan(d[idx]), ~np.isnan(doses)
181
+ both = have & new
182
+ clashes += both.sum()
183
+ disagree += (d[idx[both]] != doses[both]).sum()
184
+ d[idx[new & ~have]] = doses[new & ~have]
185
+ calls = [(i, ",".join(m), min(r) if r else None, d) for i, (m, r, d) in sorted(rows.items())]
186
+ return samples, calls, clashes, disagree
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
telinker/impute.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+ from itertools import groupby
3
+
4
+ import numpy as np
5
+
6
+ COMPLEMENT = str.maketrans("ACGT", "TGCA")
7
+ MIN_OBSERVED = 0.8
8
+
9
+
10
+ def orient(ref, alt, panel_ref, panel_alt):
11
+ for r, a in ((ref, alt), (ref.translate(COMPLEMENT), alt.translate(COMPLEMENT))):
12
+ if (r, a) == (panel_ref, panel_alt):
13
+ return 1
14
+ if (r, a) == (panel_alt, panel_ref):
15
+ return -1
16
+ if is_palindromic(panel_ref, panel_alt):
17
+ return 0
18
+ return 0
19
+
20
+
21
+ def is_palindromic(ref, alt):
22
+ return {ref, alt} in ({"A", "T"}, {"C", "G"})
23
+
24
+
25
+ def impute(panel, G, n_samples, min_r2=0.5, kind="mixed"):
26
+ out = {}
27
+ for te, models in groupby(panel.models, key=lambda m: m.te):
28
+ dose, used = np.full(n_samples, np.nan), []
29
+ for m in models:
30
+ if m.cv_r2 < min_r2:
31
+ break
32
+ if kind != "mixed" and (m.name == "ridge") != (kind == "ridge"):
33
+ continue
34
+ ok = [s in G for s in m.sites]
35
+ need = MIN_OBSERVED if m.name == "ridge" else 1.0
36
+ if np.mean(ok) < need:
37
+ continue
38
+ X = np.full((len(m.sites), n_samples), np.nan)
39
+ for j in np.flatnonzero(ok):
40
+ X[j] = G[m.sites[j]]
41
+ fill = np.isnan(dose) & ((~np.isnan(X)).mean(0) >= need)
42
+ if not fill.any():
43
+ continue
44
+ X = np.where(np.isnan(X), m.means[:, None], X)
45
+ dose[fill] = np.clip(m.intercept + m.weights @ X[:, fill], 0, 2)
46
+ used.append(m)
47
+ if not np.isnan(dose).any():
48
+ break
49
+ if used:
50
+ out[te] = (",".join(m.name for m in used), min(m.cv_r2 for m in used), dose)
51
+ return out
telinker/inputs.py ADDED
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+ import gzip
3
+ import io
4
+ import re
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from . import log
11
+ from .impute import COMPLEMENT, is_palindromic, orient
12
+ from .match import catalog_positions, has_near
13
+ from .panel import norm_chrom
14
+
15
+ CHR1_LENGTH = {248387328: "t2t", 248956422: "hg38", 249250621: "hg19"}
16
+ SV_TYPES = re.compile(r"(?:^|;)SVTYPE=([^;]+)")
17
+ SV_LEN = re.compile(r"(?:^|;)SVLEN=(-?\d+)")
18
+ INS_TYPES = re.compile(r"INS|ALU|LINE|L1|SVA|HERV|MEI|ME:", re.I)
19
+
20
+
21
+ def sample_name(path):
22
+ return Path(path).name.split(".")[0]
23
+
24
+
25
+ def open_text(path):
26
+ path = Path(path)
27
+ with open(path, "rb") as f:
28
+ magic = f.read(4)
29
+ if magic[:2] == b"\x1f\x8b":
30
+ return gzip.open(path, "rt")
31
+ if magic == b"PK\x03\x04":
32
+ z = zipfile.ZipFile(path)
33
+ return io.TextIOWrapper(z.open(z.namelist()[0]))
34
+ return open(path)
35
+
36
+
37
+ def plink_prefix(path):
38
+ s = str(path)
39
+ return s[:-4] if s.lower().endswith((".bed", ".bim", ".fam")) else s
40
+
41
+
42
+ def sniff(path):
43
+ name = Path(path).name.lower()
44
+ if name.endswith((".bam", ".cram")):
45
+ return "reads", None
46
+ if name.endswith(".bcf"):
47
+ raise SystemExit("BCF is not supported; convert with: bcftools view -Oz -o x.vcf.gz x.bcf")
48
+ prefix = plink_prefix(path)
49
+ if Path(prefix + ".bim").exists() and Path(prefix + ".bed").exists():
50
+ return "plink", None
51
+ if not Path(path).exists():
52
+ raise SystemExit(f"No such file: {path}")
53
+ if name.endswith((".bed", ".bed.gz")):
54
+ return "bed", None
55
+ with open_text(path) as f:
56
+ first = next(f, "")
57
+ if first.startswith("##fileformat=VCF"):
58
+ return sniff_vcf(f)
59
+ head = first + "".join(next(f, "") for _ in range(30))
60
+ text = head.lower()
61
+ if "rsid" in text:
62
+ build = "hg19" if re.search(r"build 37|grch37", text) else \
63
+ "hg38" if re.search(r"build 38|grch38", text) else None
64
+ return "raw", build
65
+ raise SystemExit(f"{path}: unrecognized format (expected VCF, PLINK, BED, BAM/CRAM or 23andMe)")
66
+
67
+
68
+ def sniff_vcf(f, n=2000):
69
+ build = None
70
+ for line in f:
71
+ if line.startswith("##contig=") and re.search(r"[<,]ID=(chr)?1[,>]", line):
72
+ m = re.search(r"length=(\d+)", line)
73
+ build = CHR1_LENGTH.get(int(m.group(1))) if m else None
74
+ if line.startswith("#CHROM"):
75
+ break
76
+ snv = sv = 0
77
+ for i, line in enumerate(f):
78
+ ref, alt, _, _, info = line.split("\t", 8)[3:8]
79
+ if len(ref) == 1 and len(alt) == 1 and alt in "ACGT":
80
+ snv += 1
81
+ elif sv_kind(ref, alt, info):
82
+ sv += 1
83
+ if i >= n:
84
+ break
85
+ return ("vcf_sv" if sv > snv else "vcf_snp"), build
86
+
87
+
88
+ def vcf_records(path):
89
+ with open_text(path) as f:
90
+ for line in f:
91
+ if line.startswith("#CHROM"):
92
+ yield line.rstrip("\n").split("\t")[9:]
93
+ break
94
+ for line in f:
95
+ yield line
96
+
97
+
98
+ def gt_dosage(samples_fields):
99
+ v = np.array(samples_fields, dtype="S3").view(np.uint8).reshape(-1, 3)
100
+ a, b = v[:, 0], v[:, 2]
101
+ dose = (a == ord("1")).astype(np.float32) + (b == ord("1"))
102
+ dose[(a == ord(".")) | (b == ord(".")) | (v[:, 1] == 0)] = np.nan
103
+ return dose
104
+
105
+
106
+ def sample_dosage(fields):
107
+ fmt = fields[8].split(":")
108
+ if "DS" in fmt:
109
+ i, k, n = fmt.index("DS"), len(fmt), len(fields) - 9
110
+ values = ":".join(fields[9:]).split(":")
111
+ if len(values) == k * n:
112
+ try:
113
+ return np.array(values[i::k], dtype=np.float32)
114
+ except ValueError:
115
+ pass
116
+ return np.array([_float(s.split(":")[i]) if s.count(":") >= i else np.nan
117
+ for s in fields[9:]], dtype=np.float32)
118
+ if fmt[0] != "GT":
119
+ return np.full(len(fields) - 9, np.nan, dtype=np.float32)
120
+ return gt_dosage(fields[9:])
121
+
122
+
123
+ def _float(s):
124
+ try:
125
+ return float(s)
126
+ except ValueError:
127
+ return np.nan
128
+
129
+
130
+ def read_vcf(path, panel):
131
+ near = catalog_positions(panel)
132
+ recs = vcf_records(path)
133
+ samples = next(recs)
134
+ G, calls, dose = {}, [], []
135
+ for line in recs:
136
+ chrom, pos, _ = line.split("\t", 2)
137
+ chrom, pos = norm_chrom(chrom), int(pos)
138
+ hits = panel.site_index.get((chrom, pos))
139
+ ix = near.get(chrom)
140
+ sv_near = ix is not None and has_near(ix, pos)
141
+ if not (hits or sv_near):
142
+ continue
143
+ fields = line.rstrip("\n").split("\t")
144
+ ref, alt = fields[3], fields[4]
145
+ if "," in alt:
146
+ continue
147
+ d = None
148
+ for s in hits or ():
149
+ sign = orient(ref, alt, *panel.sites[s][2:])
150
+ if sign:
151
+ d = sample_dosage(fields) if d is None else d
152
+ G[s] = d if sign > 0 else 2 - d
153
+ kind = sv_near and sv_kind(ref, alt, fields[7])
154
+ if kind:
155
+ d = gt_dosage([s.split(":")[0] for s in fields[9:]]) if fields[8].startswith("GT") \
156
+ else np.full(len(samples), np.nan, np.float32)
157
+ calls.append((chrom, pos, kind, sv_length(ref, alt, fields[7])))
158
+ dose.append(d if kind == "INS" else 2 - d)
159
+ return samples, G, calls, np.array(dose, dtype=np.float32).reshape(len(calls), len(samples))
160
+
161
+
162
+ def sv_kind(ref, alt, info):
163
+ m = SV_TYPES.search(info)
164
+ t = m.group(1) if m else (alt.strip("<>") if alt.startswith("<") else "")
165
+ if t.upper().startswith("DEL"):
166
+ return "DEL"
167
+ if INS_TYPES.search(t):
168
+ return "INS"
169
+ if not t and abs(len(alt) - len(ref)) >= 50:
170
+ return "INS" if len(alt) > len(ref) else "DEL"
171
+ return None
172
+
173
+
174
+ def sv_length(ref, alt, info):
175
+ m = SV_LEN.search(info)
176
+ if m and m.group(1) != "0":
177
+ return abs(int(m.group(1)))
178
+ if not alt.startswith("<"):
179
+ return abs(len(alt) - len(ref))
180
+ return None
181
+
182
+
183
+ def read_bed(path):
184
+ calls = []
185
+ with open_text(path) as f:
186
+ for line in f:
187
+ if line.startswith(("#", "track", "browser")) or not line.strip():
188
+ continue
189
+ c, s, e = line.split("\t")[:3]
190
+ s, e = int(s), int(e)
191
+ calls.append((norm_chrom(c), s + 1, "INS", e - s if e - s >= 50 else None))
192
+ return calls
193
+
194
+
195
+ def read_plink(path, panel):
196
+ prefix = plink_prefix(path)
197
+ samples = [l.split()[1] for l in open(prefix + ".fam")]
198
+ n = len(samples)
199
+ bpr = (n + 3) // 4
200
+ bed = np.memmap(prefix + ".bed", dtype=np.uint8, mode="r")
201
+ if bed[:3].tolist() != [0x6C, 0x1B, 0x01]:
202
+ raise SystemExit(f"{prefix}.bed: not a SNP-major PLINK 1 file")
203
+ a1_count = np.array([2, np.nan, 1, 0], dtype=np.float32)
204
+ G, strand = {}, Strand()
205
+ with open(prefix + ".bim") as f:
206
+ for i, line in enumerate(f):
207
+ chrom, _, _, pos, a1, a2 = line.split()
208
+ hits = panel.site_index.get((norm_chrom(chrom), int(pos)))
209
+ if not hits:
210
+ continue
211
+ raw = bed[3 + i * bpr: 3 + (i + 1) * bpr]
212
+ codes = ((raw[:, None] >> np.array([0, 2, 4, 6], np.uint8)) & 3).ravel()[:n]
213
+ dose = a1_count[codes]
214
+ for s in hits:
215
+ sign = orient(a2, a1, *panel.sites[s][2:])
216
+ if sign:
217
+ G[s] = dose if sign > 0 else 2 - dose
218
+ strand.add(s, {a1, a2}, panel)
219
+ strand.drop_ambiguous(G, path)
220
+ return samples, G
221
+
222
+
223
+ def read_raw(path, panel):
224
+ G, strand = {}, Strand()
225
+ with open_text(path) as f:
226
+ for line in f:
227
+ if line.startswith("#") or line.lower().startswith("rsid"):
228
+ continue
229
+ parts = line.replace(",", "\t").split()
230
+ if len(parts) < 4:
231
+ continue
232
+ hits = panel.site_index.get((norm_chrom(parts[1]), int(parts[2])))
233
+ if not hits:
234
+ continue
235
+ gt = "".join(parts[3:5]).upper()
236
+ for s in hits:
237
+ ref, alt = panel.sites[s][2:]
238
+ d = genotype_count(gt, ref, alt)
239
+ if d == d:
240
+ G[s] = np.array([d], dtype=np.float32)
241
+ strand.add(s, set(gt), panel)
242
+ strand.drop_ambiguous(G, path)
243
+ return [sample_name(path)], G
244
+
245
+
246
+ def genotype_count(gt, ref, alt):
247
+ if len(gt) != 2 or not set(gt) <= set("ACGT"):
248
+ return np.nan
249
+ if set(gt) <= {ref, alt}:
250
+ return float(gt.count(alt))
251
+ if {ref, alt} not in ({"A", "T"}, {"C", "G"}):
252
+ flipped = gt.translate(COMPLEMENT)
253
+ if set(flipped) <= {ref, alt}:
254
+ return float(flipped.count(alt))
255
+ return np.nan
256
+
257
+
258
+ class Strand:
259
+ def __init__(self):
260
+ self.matched, self.flipped, self.palindromic = 0, 0, []
261
+
262
+ def add(self, site, alleles, panel):
263
+ ref, alt = panel.sites[site][2:]
264
+ self.matched += 1
265
+ if is_palindromic(ref, alt):
266
+ self.palindromic.append(site)
267
+ elif not alleles <= {ref, alt}:
268
+ self.flipped += 1
269
+
270
+ def drop_ambiguous(self, G, path):
271
+ if self.flipped <= 0.01 * self.matched:
272
+ return
273
+ for s in self.palindromic:
274
+ G.pop(s, None)
275
+ log(f"{path}: not strand-aligned, dropped {len(self.palindromic):,} A/T and C/G SNPs")
telinker/match.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+
4
+ POS_TOL = 100
5
+ LEN_LO, LEN_HI = 0.80, 1.25
6
+
7
+
8
+ def catalog_positions(panel):
9
+ pos = panel.te_pos()
10
+ out = {}
11
+ for i in np.flatnonzero(pos > 0):
12
+ out.setdefault(panel.loci["chrom"][i], []).append(pos[i])
13
+ return {c: np.sort(p) for c, p in out.items()}
14
+
15
+
16
+ def has_near(sorted_pos, p):
17
+ k = np.searchsorted(sorted_pos, p - POS_TOL)
18
+ return k < len(sorted_pos) and sorted_pos[k] <= p + POS_TOL
19
+
20
+
21
+ def match(panel, calls):
22
+ pos = panel.te_pos()
23
+ by_chrom = {}
24
+ for i in np.flatnonzero(pos > 0):
25
+ by_chrom.setdefault(panel.loci["chrom"][i], []).append(i)
26
+ by_chrom = {c: np.array(sorted(ix, key=lambda i: pos[i])) for c, ix in by_chrom.items()}
27
+
28
+ cands = []
29
+ same_ref = panel.build == "t2t"
30
+ for k, (chrom, p, kind, length) in enumerate(calls):
31
+ ix = by_chrom.get(chrom)
32
+ if ix is None:
33
+ continue
34
+ lo, hi = np.searchsorted(pos[ix], [p - POS_TOL, p + POS_TOL + 1])
35
+ for i in ix[lo:hi]:
36
+ if same_ref and kind and kind != panel.loci["type"][i]:
37
+ continue
38
+ ratio = length / panel.loci["len"][i] if length else 1.0
39
+ if LEN_LO <= ratio <= LEN_HI:
40
+ cands.append((abs(np.log(ratio)), abs(int(pos[i]) - p), k, i))
41
+ used_call, used_te, out = set(), set(), []
42
+ for _, _, k, i in sorted(cands):
43
+ if k not in used_call and i not in used_te:
44
+ used_call.add(k)
45
+ used_te.add(i)
46
+ out.append((k, i))
47
+ return out
telinker/output.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+ import sys
3
+
4
+ COLUMNS = ["te_id", "chrom", "pos", "type", "subfamily", "method", "r2"]
5
+ PRESENT = -1.0
6
+
7
+
8
+ def fmt(d):
9
+ if d != d:
10
+ return "."
11
+ if d == PRESENT:
12
+ return "present"
13
+ return str(int(d)) if float(d).is_integer() else f"{d:.2f}"
14
+
15
+
16
+ def write_tsv(out, panel, samples, calls):
17
+ pos = panel.te_pos()
18
+ print("\t".join(COLUMNS + list(samples)), file=out)
19
+ for i, method, r2, doses in calls:
20
+ L = panel.loci
21
+ row = [L["te_id"][i], L["chrom"][i], pos[i] if pos[i] > 0 else ".", L["type"][i],
22
+ L["subfamily"][i],
23
+ method, "" if r2 is None else f"{r2:.2f}"]
24
+ print("\t".join(map(str, row + list(map(fmt, doses.tolist())))), file=out)
25
+
26
+
27
+ def write_vcf(out, panel, samples, calls):
28
+ pos = panel.te_pos()
29
+ print("##fileformat=VCFv4.2", file=out)
30
+ print(f"##source=telinker ({' '.join(sys.argv[1:])})", file=out)
31
+ print(f"##reference={panel.build}", file=out)
32
+ print('##ALT=<ID=TE,Description="Polymorphic TE present (TE-presence allele)">', file=out)
33
+ print('##INFO=<ID=SUBFAM,Number=1,Type=String,Description="TE subfamily">', file=out)
34
+ print('##INFO=<ID=CATTYPE,Number=1,Type=String,Description="Catalog type vs T2T-CHM13 (INS/DEL)">', file=out)
35
+ print('##INFO=<ID=METHOD,Number=1,Type=String,Description="ridge, tag1-10, sv, bed or reads">', file=out)
36
+ print('##INFO=<ID=R2,Number=1,Type=Float,Description="Panel out-of-fold r2 (imputed calls)">', file=out)
37
+ print('##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">', file=out)
38
+ print('##FORMAT=<ID=DS,Number=1,Type=Float,Description="TE-presence dosage">', file=out)
39
+ print("\t".join(["#CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO", "FORMAT"]
40
+ + list(samples)), file=out)
41
+ rows = sorted(calls, key=lambda c: (chrom_key(panel.loci["chrom"][c[0]]), pos[c[0]]))
42
+ for i, method, r2, doses in rows:
43
+ L = panel.loci
44
+ info = f"SUBFAM={L['subfamily'][i]};CATTYPE={L['type'][i]};METHOD={method}"
45
+ if r2 is not None:
46
+ info += f";R2={r2:.3f}"
47
+ gts = []
48
+ for d in doses.tolist():
49
+ if d != d or d == PRESENT:
50
+ gts.append("1/.:." if d == PRESENT else "./.:.")
51
+ else:
52
+ g = int(round(d))
53
+ gts.append(f"{['0/0', '0/1', '1/1'][g]}:{d:.2f}")
54
+ print("\t".join(map(str, ["chr" + L["chrom"][i], pos[i], L["te_id"][i], "N", "<TE>",
55
+ ".", "PASS", info, "GT:DS"] + gts)), file=out)
56
+
57
+
58
+ def chrom_key(c):
59
+ return (0, int(c)) if c.isdigit() else (1, c)
@@ -0,0 +1,14 @@
1
+ # teLinker reference panel
2
+
3
+ Built 2026-10-21 by `dev/build_panel.py` from 213 HPRC individuals.
4
+
5
+ - Catalog loci: 30,257 (17,260 INS, 12,997 DEL; 7,002 DELs fail the contraction gate `del_gate_r50`)
6
+ - TEs with at least one SNP model (cv r² ≥ 0.2): 21,749
7
+ - best cv r² ≥ 0.5: 18,783; ≥ 0.8: 14,647
8
+ - Ridge vs best single tag, same TEs (n=19,291): median cv r² 0.914 vs 0.904; ridge better on 40.2%
9
+ - Median best cv r² by TE MAF: <1% 0.48, 1–5% 0.91, 5–20% 0.96, ≥20% 0.97
10
+ - Median cv r² within AFR samples (best model): 0.891 (tags and windows learned mostly on non-AFR haplotypes)
11
+ - hg38 lift of top tags agrees with the 1.6.1 tag table on 99.32%
12
+
13
+ cv r² is out-of-fold with SNP selection repeated inside each fold.
14
+ Dosages are TE-PRESENCE (0-2) for both INS and DEL loci.
Binary file
Binary file
telinker/panel.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+ import csv
3
+ import gzip
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from itertools import groupby
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+
11
+ PANEL_DIR = Path(os.environ.get("TELINKER_PANEL", Path(__file__).parent / "panel"))
12
+ BUILDS = ("t2t", "hg38", "hg19")
13
+ POS_COL = {"t2t": "pos", "hg38": "pos_hg38", "hg19": "pos_hg19"}
14
+
15
+
16
+ def norm_chrom(c: str) -> str:
17
+ c = c[3:] if c.lower().startswith("chr") else c
18
+ return "M" if c == "MT" else c
19
+
20
+
21
+ def read_tsv(path):
22
+ with gzip.open(path, "rt") as f:
23
+ return list(csv.DictReader(f, delimiter="\t"))
24
+
25
+
26
+ @dataclass
27
+ class Model:
28
+ te: int
29
+ name: str
30
+ cv_r2: float
31
+ intercept: float
32
+ sites: np.ndarray
33
+ weights: np.ndarray
34
+ means: np.ndarray
35
+
36
+
37
+ @dataclass
38
+ class Panel:
39
+ build: str
40
+ loci: dict
41
+ sites: list = field(default_factory=list)
42
+ site_index: dict = field(default_factory=dict)
43
+ models: list = field(default_factory=list)
44
+
45
+ def __len__(self):
46
+ return len(self.loci["te_id"])
47
+
48
+ def te_pos(self):
49
+ return self.loci[POS_COL[self.build]]
50
+
51
+
52
+ def load_panel(build: str, panel_dir: Path = PANEL_DIR, models: bool = True) -> Panel:
53
+ rows = read_tsv(panel_dir / "loci.tsv.gz")
54
+ loci = {k: np.array([r[k] for r in rows]) for k in rows[0]}
55
+ for k in ("pos", "len", "pos_hg38", "pos_hg19"):
56
+ loci[k] = np.array([int(v) if v else -1 for v in loci[k]])
57
+ loci["del_ok"] = loci["del_ok"] == "1"
58
+ loci["chrom"] = np.array([norm_chrom(c) for c in loci["chrom"]])
59
+ panel = Panel(build, loci)
60
+ if models:
61
+ load_models(panel, panel_dir / "models.tsv.gz")
62
+ return panel
63
+
64
+
65
+ def load_models(panel, path):
66
+ te_row = {t: i for i, t in enumerate(panel.loci["te_id"])}
67
+ site_row = {}
68
+ with gzip.open(path, "rt") as f:
69
+ r = csv.reader(f, delimiter="\t")
70
+ col = {k: i for i, k in enumerate(next(r))}
71
+ te, name, pos = col["te_id"], col["model"], col[POS_COL[panel.build]]
72
+ for (te_id, model), terms in groupby(r, key=lambda t: (t[te], t[name])):
73
+ terms = list(terms)
74
+ sites = []
75
+ for t in terms:
76
+ if not t[pos]:
77
+ sites.append(-1)
78
+ continue
79
+ snp = (norm_chrom(t[col["chrom"]]), int(t[pos]), t[col["ref"]], t[col["alt"]])
80
+ if snp not in site_row:
81
+ site_row[snp] = len(panel.sites)
82
+ panel.site_index.setdefault(snp[:2], []).append(len(panel.sites))
83
+ panel.sites.append(snp)
84
+ sites.append(site_row[snp])
85
+ panel.models.append(Model(
86
+ te_row[te_id], model, float(terms[0][col["cv_r2"]]),
87
+ float(terms[0][col["intercept"]]), np.array(sites),
88
+ np.array([float(t[col["weight"]]) for t in terms]),
89
+ np.array([float(t[col["mean"]]) for t in terms])))
90
+ panel.models.sort(key=lambda m: (m.te, -m.cv_r2))
telinker/reads.py ADDED
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from .inputs import CHR1_LENGTH, sample_name
6
+ from .panel import norm_chrom
7
+
8
+ FLANK = 200
9
+ WIN = 300
10
+ ANCHOR = 1000
11
+ FRAC = 0.5
12
+ MIN_DEPTH = 10
13
+ MIN_SNP_DEPTH = 5
14
+ MIN_MAPQ = 20
15
+ BLOCK = 100_000
16
+ BASE = {b: i for i, b in enumerate("ACGT")}
17
+ CHRY_PAR = ((0, 2_458_320), (62_122_809, 62_460_029))
18
+
19
+
20
+ def open_bam(path, reference=None):
21
+ try:
22
+ import pysam
23
+ except ImportError:
24
+ raise SystemExit("BAM/CRAM input needs pysam: pip install 'telinker[reads]'")
25
+ return pysam.AlignmentFile(str(path), reference_filename=reference)
26
+
27
+
28
+ def describe(bam, path):
29
+ header = bam.header.to_dict()
30
+ rg = {r["ID"]: r["SM"] for r in header.get("RG", []) if r.get("SM")}
31
+ names = list(dict.fromkeys(rg.values()))
32
+ samples, rg = (names, rg) if len(names) > 1 else ([sample_name(path)], {})
33
+ build = None
34
+ for sq in header.get("SQ", []):
35
+ if norm_chrom(sq["SN"]) == "1":
36
+ build = CHR1_LENGTH.get(sq["LN"])
37
+ lengths = []
38
+ for rd in bam.fetch(until_eof=True):
39
+ if not (rd.is_secondary or rd.is_supplementary or rd.is_unmapped):
40
+ lengths.append(rd.query_length)
41
+ if len(lengths) >= 2000:
42
+ break
43
+ return samples, rg, build, bool(lengths) and float(np.median(lengths)) > 2000
44
+
45
+
46
+ def contig_names(bam):
47
+ return {norm_chrom(c): c for c in bam.references}
48
+
49
+
50
+ def usable(rd):
51
+ return not (rd.is_secondary or rd.is_supplementary or rd.is_unmapped)
52
+
53
+
54
+ def sample_of(rd, rg, col):
55
+ if not rg:
56
+ return 0
57
+ return col.get(rg.get(rd.get_tag("RG"))) if rd.has_tag("RG") else None
58
+
59
+
60
+ def in_chry_par(chrom, pos):
61
+ return chrom == "Y" and any(lo <= pos < hi for lo, hi in CHRY_PAR)
62
+
63
+
64
+ def read_evidence(cigar, start, pos, length, is_ins):
65
+ end = pos if is_ins else pos + length
66
+ left = (pos - WIN - ANCHOR, pos - WIN)
67
+ right = (end + WIN, end + WIN + ANCHOR)
68
+ need = FRAC * length
69
+ r, left_bp, right_bp, alt, other = start, 0, 0, False, False
70
+ for op, n in cigar:
71
+ if op == 1 or op == 2:
72
+ if abs(r - pos) <= WIN:
73
+ if n >= need and (op == 1) == is_ins:
74
+ alt = True
75
+ elif n >= max(50, need):
76
+ other = True
77
+ r += n if op == 2 else 0
78
+ elif op == 3:
79
+ r += n
80
+ elif op in (0, 7, 8):
81
+ left_bp += max(0, min(r + n, left[1]) - max(r, left[0]))
82
+ right_bp += max(0, min(r + n, right[1]) - max(r, right[0]))
83
+ r += n
84
+ if left_bp < FLANK or right_bp < FLANK:
85
+ return None
86
+ return "alt" if alt else "other" if other else "ref"
87
+
88
+
89
+ def call(n_alt, n_ref):
90
+ n = n_alt + n_ref
91
+ if n < MIN_DEPTH:
92
+ return None
93
+ f = n_alt / n
94
+ if f >= 0.90:
95
+ return 2
96
+ if f <= 0.05:
97
+ return 0
98
+ if 0.20 <= f <= 0.80:
99
+ return 1
100
+ return None
101
+
102
+
103
+ def genotype_long(bam, panel, rows, samples, rg):
104
+ names = contig_names(bam)
105
+ col = {s: k for k, s in enumerate(samples)}
106
+ out = {}
107
+ for i in rows:
108
+ chrom = names.get(panel.loci["chrom"][i])
109
+ if chrom is None:
110
+ continue
111
+ pos, length = int(panel.loci["pos"][i]), int(panel.loci["len"][i])
112
+ is_ins = panel.loci["type"][i] == "INS"
113
+ n_alt, n_ref = [0] * len(samples), [0] * len(samples)
114
+ for rd in bam.fetch(chrom, max(0, pos - 3000), pos + (0 if is_ins else length) + 3000):
115
+ k = sample_of(rd, rg, col) if usable(rd) and rd.cigartuples else None
116
+ if k is None:
117
+ continue
118
+ ev = read_evidence(rd.cigartuples, rd.reference_start, pos, length, is_ins)
119
+ n_alt[k] += ev == "alt"
120
+ n_ref[k] += ev == "ref"
121
+ gs = [call(a, r) for a, r in zip(n_alt, n_ref)]
122
+ if any(g is not None for g in gs):
123
+ out[i] = np.array([np.nan if g is None else g if is_ins else 2 - g
124
+ for g in gs])
125
+ return out
126
+
127
+
128
+ def pileup_sites(bam, panel, samples, rg):
129
+ names = contig_names(bam)
130
+ col = {s: k for k, s in enumerate(samples)}
131
+ filters = [lambda rd, k=k: usable(rd) and not (rd.is_duplicate or rd.is_qcfail)
132
+ and rd.mapping_quality >= MIN_MAPQ and sample_of(rd, rg, col) == k
133
+ for k in range(len(samples))]
134
+ by_chrom = {}
135
+ for s, (chrom, pos, _, _) in enumerate(panel.sites):
136
+ if chrom in names:
137
+ by_chrom.setdefault(chrom, []).append((pos, s))
138
+
139
+ G = {}
140
+ for chrom, sites in by_chrom.items():
141
+ sites.sort()
142
+ i = 0
143
+ while i < len(sites):
144
+ start = sites[i][0] - 1
145
+ j = i
146
+ while j < len(sites) and sites[j][0] <= start + BLOCK:
147
+ j += 1
148
+ block, end = sites[i:j], sites[j - 1][0]
149
+ g = np.full((len(block), len(samples)), np.nan, dtype=np.float32)
150
+ for k, keep in enumerate(filters):
151
+ counts = np.array(bam.count_coverage(names[chrom], start, end, quality_threshold=20,
152
+ read_callback=keep))
153
+ for b, (pos, s) in enumerate(block):
154
+ ref, alt = panel.sites[s][2:]
155
+ n_ref, n_alt = counts[BASE[ref], pos - 1 - start], counts[BASE[alt], pos - 1 - start]
156
+ if n_ref + n_alt >= MIN_SNP_DEPTH:
157
+ f = n_alt / (n_ref + n_alt)
158
+ g[b, k] = 0 if f < 0.15 else 2 if f > 0.85 else 1
159
+ for b, (_, s) in enumerate(block):
160
+ if not np.isnan(g[b]).all():
161
+ G[s] = g[b]
162
+ i = j
163
+ return G
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: telinker
3
+ Version: 0.1.0
4
+ Summary: Genotype polymorphic transposable elements from VCF, PLINK, array, BED or BAM/CRAM files
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy>=1.20
10
+ Provides-Extra: reads
11
+ Requires-Dist: pysam>=0.19; extra == "reads"
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ # teLinker
17
+
18
+ Genotype polymorphic transposable elements in your own data.
19
+
20
+ ```
21
+ pip install telinker # numpy is the only dependency
22
+ pip install 'telinker[reads]' # + pysam, only for BAM/CRAM input
23
+
24
+ telinker genotype cohort.vcf.gz -o tes.tsv
25
+ ```
26
+
27
+ ## Report bugs
28
+
29
+ https://github.com/jaleesr/teLinker
30
+
31
+ ## Inputs
32
+
33
+ The input type and genome build are detected automatically. Pass `--build t2t|hg38|hg19`
34
+ if the build information is not present in the file header.
35
+
36
+ | Input | How TEs are called | `method` |
37
+ |---|---|---|
38
+ | SNP VCF (WGS, or imputed with `DS`) | Imputed from nearby SNPs | `ridge`, `tagN` |
39
+ | PLINK `.bed/.bim/.fam` | Imputed | `ridge`, `tagN` |
40
+ | 23andMe / AncestryDNA raw file (`.txt` or `.zip`) | Imputed (mostly single tags: arrays are sparse) | `tagN`, `ridge` |
41
+ | SV VCF (Sniffles, SVAN, MELT, PAV, …) | Matched to the catalog | `sv` |
42
+ | BED of TE insertion calls | Matched; presence only | `bed` |
43
+ | Long-read BAM/CRAM on T2T-CHM13 | Read directly from indels in the alignments | `reads` |
44
+ | Short-read BAM/CRAM | SNPs piled up, then imputed | `ridge`, `tagN` |
45
+
46
+ BCF is not read directly; convert it with `bcftools view -Oz`.
47
+
48
+ ### Several samples
49
+
50
+ **Multi-sample files.** VCF and PLINK inputs give one column per sample. A BAM/CRAM whose
51
+ read groups carry several `SM` tags is split by read group into one column per `SM`
52
+
53
+ **Several files.** These are merged into one table:
54
+
55
+ ```
56
+ telinker genotype donor1.bam donor2.bam donor3.bam -o cohort.tsv
57
+ telinker genotype cohort.chr*.vcf.gz -o cohort.tsv # per-chromosome splits
58
+ ```
59
+
60
+ - Every input must be on the same build.
61
+ - Sample names are matched across files.
62
+ - If two files genotype the same TE in the same sample, the first file wins.
63
+ - Single-sample files (a one-sample BAM, a 23andMe file, a BED) are named after the file,
64
+ up to the first dot. So `donor1.sorted.bam` becomes `donor1`.
65
+ - If the same sample name comes from more than one file, telinker merges them and warns. That
66
+ is right for a cohort split by chromosome. If the files are different people rename them.
67
+
68
+ In long-read mode, chrY pseudoautosomal loci are skipped. Their reads are shared with chrX,
69
+ so a chrY call would just repeat the chrX genotype.
70
+
71
+ ## Output
72
+
73
+ One TSV. Each row is a TE, and each sample column is a **TE-presence dosage** (0–2):
74
+
75
+ ```
76
+ te_id chrom pos type subfamily method r2 S1 S2
77
+ 10_102771350_INS_306_0 10 103646104 INS AluYa5 tag1 1.00 0 1
78
+ 10_100722374_INS_226_0 10 . INS SVA_F tag1 0.70 0.92 1.78
79
+ ```
80
+
81
+ - **Dosage cells:** dosage always counts copies of the TE, for both INS and DEL loci.
82
+ Imputed calls are continuous. `.` means the TE could not be called in that sample.
83
+ - **`r2`:** the panel's held-out accuracy for that TE's model. It is empty for direct calls.
84
+ - **`pos`:** the position in your input's build. `.` means the locus does not lift to that build.
85
+ - **`--vcf`:** writes the same calls as a VCF with `GT:DS`, ready for plink2, regenie
86
+ or tensorQTL. Its ALT allele is `<TE>` (TE present).
87
+
88
+
89
+ ## Layout
90
+
91
+ ```
92
+ telinker/ the installed package (numpy only; pysam imported lazily for BAM/CRAM)
93
+ cli.py argument parsing, input detection, dispatch, summary log
94
+ inputs.py format sniffing + readers: VCF (SNPs and SVs), PLINK, 23andMe/Ancestry, BED
95
+ impute.py allele matching and the linear-model prediction / fallback
96
+ match.py SV and BED calls -> catalog loci
97
+ reads.py BAM/CRAM: long-read CIGAR genotyper, short-read SNP pileup
98
+ output.py TSV and VCF writers
99
+ panel.py loads telinker/panel/ (loci.tsv.gz, models.tsv.gz, PANEL.md)
100
+ tests/ pytest suite on a hand-built four-TE panel
101
+ ```
102
+
103
+ ## Tests
104
+
105
+ ```
106
+ pip install '.[test,reads]'
107
+ pytest tests
108
+ ```
@@ -0,0 +1,18 @@
1
+ telinker/__init__.py,sha256=Rurx7kofeNw1aaIhfCXiO0rrrzBYy3lxoodkpL0pHDI,98
2
+ telinker/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ telinker/cli.py,sha256=csgbYBPa5TM_Ep2qP_vKUED2m0e-LHbOHF8_zTl4G_8,8104
4
+ telinker/impute.py,sha256=0HgZZMVc1jivWmRXcmI6Rv_t6xZcSfr86iSK_kjiGJ4,1725
5
+ telinker/inputs.py,sha256=wl8RCN93cISKvQZwJk_e9WRcdwauv5c-KDHpNO3_hJ4,9179
6
+ telinker/match.py,sha256=inETMnXRbe2qveVXS9dRGkjy1KDdroZb5SzUQvFw554,1542
7
+ telinker/output.py,sha256=NsP92QoKVjr66Bb6LUVcWtjjsJ21eoCksVd0WGVB3H8,2630
8
+ telinker/panel.py,sha256=0kqMCPoBziuhiW4UEuPP26Lq6Zb2YG_FW8_HqL13Pk0,2952
9
+ telinker/reads.py,sha256=PuF38tQBp5rtssO1gjZKn3fQxsMIkHzy-yTgmA02ffI,5586
10
+ telinker/panel/PANEL.md,sha256=qtC7aVkMstvUWCJ0yeN5Q7sx6BjFqLcplBp7skIjZDI,792
11
+ telinker/panel/loci.tsv.gz,sha256=mca5raf-w2sMHRfb2RgQJpfy-m48l3RyWkfi3zKj4Dk,724295
12
+ telinker/panel/models.tsv.gz,sha256=8L462PnIQphs2s13rf-VpuOAmf8jatVHlYaeEKMuuOk,23664759
13
+ telinker-0.1.0.dist-info/licenses/LICENSE,sha256=o5sgzIYQ4mOG2B74mfzd8q-qq9AgSdYT1TIGtHwKVUM,1134
14
+ telinker-0.1.0.dist-info/METADATA,sha256=e-jj0mpy05Ek40Iy86JIGfP51meW9zKfqXkdKm6gIz0,4231
15
+ telinker-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ telinker-0.1.0.dist-info/entry_points.txt,sha256=Pppwl8-u4gXhy1Z_o6yXC7CxHQX7VDi0JotDArofqsQ,47
17
+ telinker-0.1.0.dist-info/top_level.txt,sha256=qDQ3iES0tUcK7g3awDycyDkadN4PqaNV-WxmHY2lKOw,9
18
+ telinker-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ telinker = telinker.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Sanborn, Jalees Rehman, Board of Trustees of the University of Illinois.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ telinker