alnkit 0.2.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.
Files changed (49) hide show
  1. alnkit/__init__.py +11 -0
  2. alnkit/__main__.py +10 -0
  3. alnkit/_docs.py +53 -0
  4. alnkit/cli/__init__.py +53 -0
  5. alnkit/cli/__main__.py +10 -0
  6. alnkit/cli/_common.py +25 -0
  7. alnkit/cli/align.py +52 -0
  8. alnkit/cli/doctor.py +81 -0
  9. alnkit/cli/long.py +346 -0
  10. alnkit/cli/plot.py +293 -0
  11. alnkit/cli/ref.py +50 -0
  12. alnkit/cli/seg.py +131 -0
  13. alnkit/cli/selftest.py +308 -0
  14. alnkit/cli/short.py +245 -0
  15. alnkit/core/__init__.py +9 -0
  16. alnkit/core/bam_segments.py +183 -0
  17. alnkit/core/coords.py +129 -0
  18. alnkit/core/errors.py +25 -0
  19. alnkit/core/external.py +63 -0
  20. alnkit/core/fasta.py +83 -0
  21. alnkit/core/logging.py +25 -0
  22. alnkit/core/paf.py +52 -0
  23. alnkit/core/segments.py +253 -0
  24. alnkit/core/tables.py +340 -0
  25. alnkit/longread/__init__.py +5 -0
  26. alnkit/longread/assemble.py +453 -0
  27. alnkit/longread/breakpoints.py +910 -0
  28. alnkit/longread/classify.py +958 -0
  29. alnkit/longread/graph.py +174 -0
  30. alnkit/longread/locate.py +904 -0
  31. alnkit/plot/__init__.py +5 -0
  32. alnkit/plot/ribbon.py +1306 -0
  33. alnkit/plot/tracks.py +1035 -0
  34. alnkit/prepare.py +256 -0
  35. alnkit/py.typed +0 -0
  36. alnkit/shortread/__init__.py +20 -0
  37. alnkit/shortread/bam.py +527 -0
  38. alnkit/shortread/evidence.py +214 -0
  39. alnkit/shortread/interference.py +44 -0
  40. alnkit/shortread/merge.py +126 -0
  41. alnkit/shortread/model.py +301 -0
  42. alnkit/shortread/origin.py +507 -0
  43. alnkit/shortread/report.py +316 -0
  44. alnkit/shortread/scan.py +788 -0
  45. alnkit-0.2.0.dist-info/METADATA +163 -0
  46. alnkit-0.2.0.dist-info/RECORD +49 -0
  47. alnkit-0.2.0.dist-info/WHEEL +4 -0
  48. alnkit-0.2.0.dist-info/entry_points.txt +2 -0
  49. alnkit-0.2.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,183 @@
1
+ """BAM -> 规范 ``Segment``(D2):CIGAR 解析、简化视图、read 轴归正。
2
+
3
+ 来源:``upstream/scripts/alignment_table.py``(276 行)的移植。与移植前保持三件事不变:
4
+
5
+ * 参考坐标 0-based 半开,取 pysam 的 ``reference_start/reference_end``;
6
+ * read 坐标统一到**正向 read 轴**(反向记录做 ``read_length - stored`` 镜像),
7
+ 这样同一条 read 的基因组段与外源段才能直接比距离;
8
+ * ``cigar`` 是**简化视图**(两个 M 之间总长 <= 5 bp 的 I/D 并入 M),
9
+ 因此它的 M 之和可能小于 ``ref_aln_len`` 的真实跨度。
10
+
11
+ 历史表头(``ref_pos_start`` 等 14 列)不在这里写,见 ``alnkit.cli.seg`` 的 legacy 写出器。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ import pysam
19
+
20
+ from .coords import flip_read_interval
21
+ from .errors import UsageError
22
+ from .segments import Segment
23
+
24
+ __all__ = [
25
+ "ABSORBABLE_GAP_MAX_LENGTH",
26
+ "cigar_ops_from_pysam",
27
+ "format_cigar_ops",
28
+ "load_alignment_segments",
29
+ "load_fai_lengths",
30
+ "simplify_cigar_ops",
31
+ ]
32
+
33
+ #: 被吸进 M 的小 I/D 总长上限
34
+ ABSORBABLE_GAP_MAX_LENGTH = 5
35
+
36
+ _OPERATION_BY_CODE = {0: "M", 1: "I", 2: "D", 3: "N", 4: "S", 5: "H", 6: "P", 7: "=", 8: "X"}
37
+
38
+
39
+ def cigar_ops_from_pysam(read: pysam.AlignedSegment) -> list[tuple[int, str]]:
40
+ """pysam ``cigartuples`` -> ``[(长度, 操作符)]``。"""
41
+ return [(length, _OPERATION_BY_CODE[code]) for code, length in (read.cigartuples or [])]
42
+
43
+
44
+ def simplify_cigar_ops(cigar_ops: list[tuple[int, str]]) -> list[tuple[int, str]]:
45
+ """把“夹在两个 M 之间、总长 <= 5”的 I/D 并进 M。
46
+
47
+ 例:``[(39,'M'),(1,'D'),(66,'M'),(1,'D'),(92,'M'),(1,'I'),(445,'M')]`` -> ``[(645,'M')]``。
48
+ S/H/N/P 与首尾的 I/D 一律不动;相邻同类型操作合并。
49
+ """
50
+ match_length = 0
51
+ pending_ops: list[tuple[int, str]] = []
52
+ pending_bases = 0
53
+ simplified: list[tuple[int, str]] = []
54
+
55
+ def close_match_run() -> None:
56
+ nonlocal match_length, pending_ops, pending_bases
57
+ if match_length:
58
+ simplified.append((match_length, "M"))
59
+ match_length = 0
60
+ if pending_ops:
61
+ simplified.extend(pending_ops)
62
+ pending_ops = []
63
+ pending_bases = 0
64
+
65
+ for length, operation in cigar_ops:
66
+ if operation in ("M", "=", "X"):
67
+ if match_length:
68
+ match_length += pending_bases + length
69
+ else:
70
+ close_match_run()
71
+ match_length = length
72
+ pending_ops = []
73
+ pending_bases = 0
74
+ continue
75
+ if (
76
+ operation in ("I", "D")
77
+ and match_length
78
+ and pending_bases + length <= ABSORBABLE_GAP_MAX_LENGTH
79
+ ):
80
+ pending_ops.append((length, operation))
81
+ pending_bases += length
82
+ continue
83
+ close_match_run()
84
+ simplified.append((length, operation))
85
+
86
+ close_match_run()
87
+ merged: list[tuple[int, str]] = []
88
+ for length, operation in simplified:
89
+ if merged and merged[-1][1] == operation:
90
+ merged[-1] = (merged[-1][0] + length, operation)
91
+ else:
92
+ merged.append((length, operation))
93
+ return merged
94
+
95
+
96
+ def format_cigar_ops(cigar_ops: list[tuple[int, str]]) -> str:
97
+ """``[(418,'S'),(1358,'M'),(10151,'S')]`` -> ``"418S1358M10151S"``。"""
98
+ return "".join(f"{length}{operation}" for length, operation in cigar_ops)
99
+
100
+
101
+ def load_fai_lengths(fai_path: str | os.PathLike[str]) -> dict[str, int]:
102
+ """读 ``.fai``,返回 ``{序列名: 长度}``。"""
103
+ lengths: dict[str, int] = {}
104
+ try:
105
+ with open(fai_path, encoding="utf-8") as handle:
106
+ for line in handle:
107
+ if not line.strip():
108
+ continue
109
+ fields = line.rstrip("\n").split("\t")
110
+ if len(fields) >= 2:
111
+ lengths[fields[0]] = int(fields[1])
112
+ except FileNotFoundError:
113
+ raise UsageError(f"找不到 .fai:{os.fspath(fai_path)}(用 samtools faidx 生成)") from None
114
+ return lengths
115
+
116
+
117
+ def load_alignment_segments(
118
+ bam_path: str | os.PathLike[str],
119
+ chrom_lengths: dict[str, int] | None = None,
120
+ selected_read_names: set[str] | None = None,
121
+ skip_secondary: bool = False,
122
+ ) -> list[Segment]:
123
+ """读 BAM 的每条比对,转成规范 ``Segment``。
124
+
125
+ ``selected_read_names`` 给定时只解析这些 read(“先找目标 read、再只处理目标”,
126
+ 大 BAM 上省内存);``skip_secondary`` 在解析 CIGAR 之前就跳过 secondary 记录。
127
+ """
128
+ if not os.path.exists(bam_path):
129
+ raise UsageError(f"找不到 BAM:{os.fspath(bam_path)}")
130
+
131
+ segments: list[Segment] = []
132
+ with pysam.AlignmentFile(os.fspath(bam_path), "rb") as bam_file:
133
+ for read in bam_file:
134
+ if read.is_unmapped:
135
+ continue
136
+ if skip_secondary and read.is_secondary:
137
+ continue
138
+ if selected_read_names is not None and read.query_name not in selected_read_names:
139
+ continue
140
+ # 优先 infer_read_length:含硬剪切,否则反向记录的镜像换算会偏
141
+ read_length = read.infer_read_length() or read.query_length
142
+ if not read_length:
143
+ # 无 SEQ 的 BAM(samtools view --no-seq)算不出 read 轴坐标,宁可报错也不写 0
144
+ raise UsageError(
145
+ f"{read.query_name}: 无法确定 read 长度(BAM 缺 SEQ?用原始 BAM 重跑)"
146
+ )
147
+ cigar_ops = cigar_ops_from_pysam(read)
148
+ match_columns = sum(length for length, op in cigar_ops if op in ("M", "=", "X"))
149
+ inserted_bases = sum(length for length, op in cigar_ops if op == "I")
150
+ deleted_bases = sum(length for length, op in cigar_ops if op == "D")
151
+ alignment_start = read.query_alignment_start or 0
152
+ alignment_end = read.query_alignment_end or 0
153
+ if read.is_reverse:
154
+ read_start, read_end = flip_read_interval(
155
+ read_length, alignment_start, alignment_end
156
+ )
157
+ else:
158
+ read_start, read_end = alignment_start, alignment_end
159
+ nm_value = read.get_tag("NM") if read.has_tag("NM") else None
160
+ segments.append(
161
+ Segment(
162
+ read_name=read.query_name or "",
163
+ chrom=read.reference_name or "",
164
+ ref_start_0based=read.reference_start,
165
+ ref_end_0based=read.reference_end or read.reference_start,
166
+ read_start_0based=read_start,
167
+ read_end_0based=read_end,
168
+ strand="-" if read.is_reverse else "+",
169
+ mapq=read.mapping_quality,
170
+ read_length=read_length,
171
+ chrom_length=(chrom_lengths or {}).get(read.reference_name or "", None),
172
+ ref_aln_len=(read.reference_end or 0) - read.reference_start,
173
+ read_aln_len=alignment_end - alignment_start,
174
+ cigar=format_cigar_ops(simplify_cigar_ops(cigar_ops)),
175
+ is_secondary=bool(read.is_secondary),
176
+ is_supplementary=bool(read.is_supplementary),
177
+ nm=int(nm_value) if isinstance(nm_value, (int, float)) else None,
178
+ match_columns=match_columns,
179
+ inserted_bases=inserted_bases,
180
+ deleted_bases=deleted_bases,
181
+ )
182
+ )
183
+ return segments
alnkit/core/coords.py ADDED
@@ -0,0 +1,129 @@
1
+ """坐标换算与区间字符串:全包唯一的 1-based / 0-based 转换入口。
2
+
3
+ 约定:
4
+
5
+ * 内部计算一律 **0-based 半开** ``[start, end)``;
6
+ * 区间展示串一律 **1-based 闭区间** ``chrom:start-end``(IGV/samtools/VCF 习惯);
7
+ * 断点这类“单点”用 ``*_pos_1based`` 表示**边界左侧碱基**的 1-based 坐标,
8
+ 其 0-based 边界值与之数值相同(历史巧合,见 ``left_base_*``)。
9
+
10
+ 任何模块都不得手写 ``+1`` / ``-1``,只能调用本模块。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from collections.abc import Iterable
17
+
18
+ __all__ = [
19
+ "REGION_PATTERN",
20
+ "merge_intervals",
21
+ "flip_read_interval",
22
+ "format_region_1based",
23
+ "interval_0based_to_1based",
24
+ "interval_1based_to_0based",
25
+ "left_base_0based",
26
+ "left_base_1based",
27
+ "parse_region_1based",
28
+ "to_0based",
29
+ "to_1based",
30
+ ]
31
+
32
+ # 数字允许千分位逗号,但只在正确位置:1,000 合法,1,5 不合法
33
+ REGION_PATTERN = re.compile(
34
+ r"^(?P<chrom>[^:\s]+):(?P<start>\d{1,3}(?:,\d{3})*|\d+)-(?P<end>\d{1,3}(?:,\d{3})*|\d+)$"
35
+ )
36
+
37
+
38
+ def to_1based(position_0based: int) -> int:
39
+ """碱基坐标:0-based -> 1-based。"""
40
+ if position_0based < 0:
41
+ raise ValueError(f"0-based 坐标不能为负:{position_0based}")
42
+ return position_0based + 1
43
+
44
+
45
+ def to_0based(position_1based: int) -> int:
46
+ """碱基坐标:1-based -> 0-based。"""
47
+ if position_1based < 1:
48
+ raise ValueError(f"1-based 坐标必须 >= 1:{position_1based}")
49
+ return position_1based - 1
50
+
51
+
52
+ def interval_0based_to_1based(start_0based: int, end_0based: int) -> tuple[int, int]:
53
+ """半开区间 ``[s, e)`` -> 1-based 闭区间 ``(s+1, e)``。"""
54
+ if start_0based < 0:
55
+ raise ValueError(f"0-based 起点不能为负:{start_0based}")
56
+ if end_0based < start_0based:
57
+ raise ValueError(f"区间终点小于起点:[{start_0based}, {end_0based})")
58
+ if end_0based == start_0based:
59
+ # 零长区间无法表示为 1-based 闭区间(start+1 > end),直接拒绝而不是产出解析不回去的串
60
+ raise ValueError(f"零长区间无法表示:{start_0based}")
61
+ return start_0based + 1, end_0based
62
+
63
+
64
+ def interval_1based_to_0based(start_1based: int, end_1based: int) -> tuple[int, int]:
65
+ """1-based 闭区间 ``(s, e)`` -> 半开区间 ``[s-1, e)``。"""
66
+ if start_1based < 1:
67
+ raise ValueError(f"1-based 起点必须 >= 1:{start_1based}")
68
+ if end_1based < start_1based:
69
+ raise ValueError(f"区间终点小于起点:{start_1based}-{end_1based}")
70
+ return start_1based - 1, end_1based
71
+
72
+
73
+ def left_base_1based(boundary_0based: int) -> int:
74
+ """边界左侧碱基的 1-based 坐标(= legacy ``Insert_Position`` 的口径)。
75
+
76
+ 0-based 边界 B 表示“其左侧有 B 个碱基”,故左侧碱基的 1-based 编号恰为 B。
77
+ """
78
+ if boundary_0based < 0:
79
+ raise ValueError(f"边界不能为负:{boundary_0based}")
80
+ return boundary_0based
81
+
82
+
83
+ def left_base_0based(boundary_0based: int) -> int:
84
+ """边界左侧碱基的 0-based 坐标(= 1-based 值 - 1)。
85
+
86
+ 边界为 0 表示插入发生在第一个碱基之前,没有“左侧碱基”,抛 ``ValueError``。
87
+ """
88
+ if boundary_0based < 1:
89
+ raise ValueError(f"边界 {boundary_0based} 没有左侧碱基")
90
+ return left_base_1based(boundary_0based) - 1
91
+
92
+
93
+ def flip_read_interval(read_length: int, start: int, end: int) -> tuple[int, int]:
94
+ """把反向链比对段的 read 区间翻到正向 read 轴(0-based 半开)。"""
95
+ if start < 0 or end > read_length or end < start:
96
+ raise ValueError(f"read 区间越界:[{start}, {end}) / 长度 {read_length}")
97
+ return read_length - end, read_length - start
98
+
99
+
100
+ def parse_region_1based(text: str) -> tuple[str, int, int]:
101
+ """``"chr1:1,000-2,000"`` -> ``("chr1", 999, 2000)``(0-based 半开)。"""
102
+ match = REGION_PATTERN.match(text.strip())
103
+ if not match:
104
+ raise ValueError(f"区间格式应为 chrom:start-end(1-based):{text!r}")
105
+ chrom = match.group("chrom")
106
+ start_1based = int(match.group("start").replace(",", ""))
107
+ end_1based = int(match.group("end").replace(",", ""))
108
+ start_0based, end_0based = interval_1based_to_0based(start_1based, end_1based)
109
+ return chrom, start_0based, end_0based
110
+
111
+
112
+ def format_region_1based(chrom: str, start_0based: int, end_0based: int) -> str:
113
+ """``("chr1", 999, 2000)`` -> ``"chr1:1000-2000"``(0-based 半开输入)。"""
114
+ start_1based, end_1based = interval_0based_to_1based(start_0based, end_0based)
115
+ return f"{chrom}:{start_1based}-{end_1based}"
116
+
117
+
118
+ def merge_intervals(intervals: Iterable[tuple[int, int]]) -> list[tuple[int, int]]:
119
+ """合并重叠或相邻的半开区间(0-based)。
120
+
121
+ 例:``[(0, 10), (5, 20), (30, 40)]`` -> ``[(0, 20), (30, 40)]``。
122
+ """
123
+ merged: list[list[int]] = []
124
+ for start, end in sorted(intervals):
125
+ if merged and start <= merged[-1][1]:
126
+ merged[-1][1] = max(merged[-1][1], end)
127
+ else:
128
+ merged.append([start, end])
129
+ return [(start, end) for start, end in merged]
alnkit/core/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ """异常类型与退出码约定。
2
+
3
+ 退出码沿用被重构脚本的既有约定:
4
+ 0 成功(可有警告)/ 1 运行期错误 / 2 参数或前置校验失败。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class AlnkitError(Exception):
11
+ """所有可预期错误的基类;``exit_code`` 决定进程退出码。"""
12
+
13
+ exit_code = 1
14
+
15
+
16
+ class UsageError(AlnkitError):
17
+ """参数错误或前置条件不满足(退出码 2)。"""
18
+
19
+ exit_code = 2
20
+
21
+
22
+ class RunError(AlnkitError):
23
+ """运行期失败:读文件、外部命令、写盘(退出码 1)。"""
24
+
25
+ exit_code = 1
@@ -0,0 +1,63 @@
1
+ """外部命令探测(minimap2 / samtools / bwa)。
2
+
3
+ 只负责“找得到吗、什么版本”,命令拼装与执行留给各自的分析模块,
4
+ 避免核心层耦合具体流程。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+
13
+ __all__ = ["Tool", "ALIGNERS", "REFERENCE_TOOLS", "find_tool"]
14
+
15
+ #: 比对器(至少装一个)
16
+ ALIGNERS = ("minimap2", "bwa")
17
+ #: 参考/比对文件处理
18
+ REFERENCE_TOOLS = ("samtools",)
19
+
20
+ _TIMEOUT_SECONDS = 10
21
+
22
+
23
+ @dataclass(slots=True, frozen=True)
24
+ class Tool:
25
+ """一个外部命令的探测结果。"""
26
+
27
+ name: str
28
+ path: str | None
29
+ version: str | None
30
+
31
+ @property
32
+ def available(self) -> bool:
33
+ return self.path is not None
34
+
35
+ def describe(self) -> str:
36
+ if not self.available:
37
+ return "未安装"
38
+ return self.version or "已安装(版本未知)"
39
+
40
+
41
+ def _read_version(name: str, path: str) -> str | None:
42
+ try:
43
+ completed = subprocess.run(
44
+ [path, "--version"],
45
+ capture_output=True,
46
+ text=True,
47
+ timeout=_TIMEOUT_SECONDS,
48
+ check=False,
49
+ )
50
+ except (OSError, subprocess.SubprocessError):
51
+ return None
52
+ for line in (completed.stdout + completed.stderr).splitlines():
53
+ if line.strip():
54
+ return line.strip()[:120]
55
+ return None
56
+
57
+
58
+ def find_tool(name: str) -> Tool:
59
+ """探测一个命令;找不到时返回 ``available=False`` 而不是抛异常。"""
60
+ path = shutil.which(name)
61
+ if path is None:
62
+ return Tool(name=name, path=None, version=None)
63
+ return Tool(name=name, path=path, version=_read_version(name, path))
alnkit/core/fasta.py ADDED
@@ -0,0 +1,83 @@
1
+ """FASTA/FASTQ 的小工具(D1):反向互补、按 60 bp 折行写出、FAI 长度。
2
+
3
+ 只放多个步骤都要用的最小函数;参考基因组的随机访问请直接用 ``pysam.FastaFile``。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import gzip
9
+ import os
10
+ from collections.abc import Mapping
11
+
12
+ import pysam
13
+
14
+ from .errors import UsageError
15
+
16
+ __all__ = [
17
+ "read_fasta_records",
18
+ "read_fastq_sequences",
19
+ "reverse_complement",
20
+ "write_fasta_records",
21
+ ]
22
+
23
+ _COMPLEMENT = str.maketrans("ACGTNacgtn", "TGCANtgcan")
24
+ LINE_WIDTH = 60
25
+
26
+
27
+ def read_fasta_records(path: str | os.PathLike[str]) -> dict[str, str]:
28
+ """读小 FASTA(支持 ``.gz``),返回 ``{记录名: 大写序列}``。
29
+
30
+ 只用于外源序列 / 组装片段这类小文件;参考基因组请用 ``pysam.FastaFile`` 随机访问。
31
+ 记录名取 ``>`` 后的第一个字段;CRLF 与空行都已处理。
32
+ """
33
+ opener = gzip.open if str(path).endswith((".gz", ".bgz")) else open
34
+ records: dict[str, str] = {}
35
+ name: str | None = None
36
+ chunks: list[str] = []
37
+ try:
38
+ with opener(path, "rt", encoding="utf-8") as handle:
39
+ for line in handle:
40
+ line = line.rstrip("\r\n")
41
+ if line.startswith(">"):
42
+ if name is not None:
43
+ records[name] = "".join(chunks).upper()
44
+ name = line[1:].split()[0] if line[1:].split() else ""
45
+ chunks = []
46
+ elif line:
47
+ chunks.append(line)
48
+ except FileNotFoundError:
49
+ raise UsageError(f"找不到 FASTA:{os.fspath(path)}") from None
50
+ if name is not None:
51
+ records[name] = "".join(chunks).upper()
52
+ return records
53
+
54
+
55
+ def reverse_complement(sequence: str) -> str:
56
+ """反向互补(保留大小写)。"""
57
+ return sequence.translate(_COMPLEMENT)[::-1]
58
+
59
+
60
+ def write_fasta_records(
61
+ records: Mapping[str, str], path: str | os.PathLike[str], width: int = LINE_WIDTH
62
+ ) -> int:
63
+ """按输入顺序写 FASTA,每行 ``width`` bp;返回写入的记录数。"""
64
+ target = os.fspath(path)
65
+ os.makedirs(os.path.dirname(os.path.abspath(target)), exist_ok=True)
66
+ with open(target, "w", encoding="utf-8", newline="") as handle:
67
+ for name, sequence in records.items():
68
+ handle.write(f">{name}\n")
69
+ for start in range(0, len(sequence), width):
70
+ handle.write(sequence[start : start + width] + "\n")
71
+ return len(records)
72
+
73
+
74
+ def read_fastq_sequences(path: str | os.PathLike[str]) -> dict[str, str]:
75
+ """读 FASTQ,返回 ``{read_name: 大写序列}``(用 ``pysam.FastxFile``,与旧脚本一致)。"""
76
+ if not os.path.exists(path):
77
+ raise UsageError(f"找不到 FASTQ:{os.fspath(path)}")
78
+ sequences: dict[str, str] = {}
79
+ with pysam.FastxFile(os.fspath(path)) as handle:
80
+ for record in handle:
81
+ if record.name:
82
+ sequences[record.name] = (record.sequence or "").upper()
83
+ return sequences
alnkit/core/logging.py ADDED
@@ -0,0 +1,25 @@
1
+ """统一日志:单点配置,避免各模块自建 handler。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ LOGGER_NAME = "alnkit"
9
+ _FORMAT = "%(levelname)s %(name)s: %(message)s"
10
+
11
+
12
+ def configure(level: str = "INFO", quiet: bool = False) -> None:
13
+ """配置 alnkit 根 logger。重复调用只更新级别,不叠加 handler。"""
14
+ logger = logging.getLogger(LOGGER_NAME)
15
+ logger.setLevel(logging.ERROR if quiet else level.upper())
16
+ if not logger.handlers:
17
+ handler = logging.StreamHandler(sys.stderr)
18
+ handler.setFormatter(logging.Formatter(_FORMAT))
19
+ logger.addHandler(handler)
20
+ logger.propagate = False
21
+
22
+
23
+ def get_logger(name: str) -> logging.Logger:
24
+ """取 ``alnkit.<name>`` 子 logger。"""
25
+ return logging.getLogger(f"{LOGGER_NAME}.{name}")
alnkit/core/paf.py ADDED
@@ -0,0 +1,52 @@
1
+ """PAF 解析(D2):minimap2 原生格式,坐标一律 0-based 半开。
2
+
3
+ 与绘图模块里的 PAF 解析保持同一语义,但这里保留全部 12 列(含 ``matches`` / ``mapq``),
4
+ 组装与 overlap 图需要一致率与比对长度。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ __all__ = ["PAF_MIN_FIELDS", "parse_paf"]
12
+
13
+ PAF_MIN_FIELDS = 12
14
+
15
+
16
+ def parse_paf(path: str | os.PathLike[str]) -> list[dict[str, object]]:
17
+ """读 PAF(需要 ``-c`` 才有第 10/11 列的匹配数与比对长度)。
18
+
19
+ 返回每个比对块的字段:``query, query_length, query_start, query_end, strand,
20
+ target, target_length, target_start, target_end, matches, block_bp, identity, mapq``。
21
+ 空行、``#`` 注释、列数不足、比对长度为 0 的行会被跳过。
22
+ """
23
+ blocks: list[dict[str, object]] = []
24
+ with open(path, encoding="utf-8") as handle:
25
+ for line in handle:
26
+ if not line.strip() or line.startswith("#"):
27
+ continue
28
+ fields = line.rstrip("\n").split("\t")
29
+ if len(fields) < PAF_MIN_FIELDS:
30
+ continue
31
+ aligned_length = int(fields[10])
32
+ if aligned_length <= 0:
33
+ continue
34
+ matches = int(fields[9])
35
+ blocks.append(
36
+ {
37
+ "query": fields[0],
38
+ "query_length": int(fields[1]),
39
+ "query_start": int(fields[2]),
40
+ "query_end": int(fields[3]),
41
+ "strand": fields[4],
42
+ "target": fields[5],
43
+ "target_length": int(fields[6]),
44
+ "target_start": int(fields[7]),
45
+ "target_end": int(fields[8]),
46
+ "matches": matches,
47
+ "block_bp": aligned_length,
48
+ "identity": matches / aligned_length,
49
+ "mapq": int(fields[11]),
50
+ }
51
+ )
52
+ return blocks