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,253 @@
1
+ """比对段模型与表读写:三套历史表头的统一入口。
2
+
3
+ 历史上有三套“比对段表”(14 列 / 15 列 / 10 列,列名各不相同),语义一致、
4
+ 基准一致。本模块只做一件事:
5
+ **把任意一套读成同一个 ``Segment``**,写出时统一用规范列名。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import Iterable, Mapping, Sequence
12
+ from dataclasses import asdict, dataclass, fields
13
+ from typing import Any
14
+
15
+ from .errors import UsageError
16
+ from .tables import read_tsv
17
+
18
+ __all__ = ["ALIASES", "REQUIRED_FIELDS", "Segment", "read_segments", "segment_rows"]
19
+
20
+ #: 规范字段 -> 可接受的输入列名(第一项是规范名)
21
+ ALIASES: dict[str, tuple[str, ...]] = {
22
+ "read_name": ("read_name",),
23
+ "read_length": ("read_length",),
24
+ "segment_role": ("segment_role", "segment_type"),
25
+ "chrom": ("chrom", "target_name", "Chromosome"),
26
+ "chrom_length": ("chrom_length",),
27
+ "ref_start_0based": (
28
+ "ref_start_0based",
29
+ "ref_start",
30
+ "ref_pos_start",
31
+ "M_ref_pos_start",
32
+ "target_start",
33
+ "segment_start",
34
+ ),
35
+ "ref_end_0based": (
36
+ "ref_end_0based",
37
+ "ref_end",
38
+ "ref_pos_end",
39
+ "M_ref_pos_end",
40
+ "target_end",
41
+ "segment_end",
42
+ ),
43
+ "read_start_0based": (
44
+ "read_start_0based",
45
+ "read_start",
46
+ "read_pos_start",
47
+ "M_read_pos_start",
48
+ ),
49
+ "read_end_0based": ("read_end_0based", "read_end", "read_pos_end", "M_read_pos_end"),
50
+ "strand": ("strand", "stand"), # stand:历史拼写错误
51
+ "mapq": ("mapq", "mapping_quality"),
52
+ "ref_aln_len": ("ref_aln_len",),
53
+ "read_aln_len": ("read_aln_len",),
54
+ "cigar": ("cigar", "simplified_cigar"),
55
+ "kept": ("kept",),
56
+ "drop_reason": ("drop_reason",),
57
+ "is_secondary": ("is_secondary",),
58
+ "is_supplementary": ("is_supplementary",),
59
+ "nm": ("nm",),
60
+ "match_columns": ("match_columns",),
61
+ "inserted_bases": ("inserted_bases",),
62
+ "deleted_bases": ("deleted_bases",),
63
+ }
64
+
65
+ REQUIRED_FIELDS: tuple[str, ...] = (
66
+ "read_name",
67
+ "chrom",
68
+ "ref_start_0based",
69
+ "ref_end_0based",
70
+ "read_start_0based",
71
+ "read_end_0based",
72
+ "strand",
73
+ "mapq",
74
+ )
75
+
76
+ #: 空值标记(表里统一用 "-" 表示空)
77
+ _EMPTY = {"", "-", "NA", "N/A", "None", "null"}
78
+ #: strand 列例外:"-" 是合法值,不能当空
79
+ _EMPTY_TEXT = {"", "NA", "N/A", "None", "null"}
80
+
81
+
82
+ @dataclass(slots=True, frozen=True)
83
+ class Segment:
84
+ """一条 read 的一段比对(参考 + read 双坐标,0-based 半开)。"""
85
+
86
+ read_name: str
87
+ chrom: str
88
+ ref_start_0based: int
89
+ ref_end_0based: int
90
+ read_start_0based: int
91
+ read_end_0based: int
92
+ strand: str
93
+ mapq: int
94
+ read_length: int | None = None
95
+ segment_role: str | None = None
96
+ chrom_length: int | None = None
97
+ ref_aln_len: int | None = None
98
+ read_aln_len: int | None = None
99
+ cigar: str | None = None
100
+ kept: bool = True
101
+ drop_reason: str | None = None
102
+ is_secondary: bool = False
103
+ is_supplementary: bool = False
104
+ nm: int | None = None
105
+ match_columns: int | None = None
106
+ inserted_bases: int | None = None
107
+ deleted_bases: int | None = None
108
+
109
+ @property
110
+ def ref_span(self) -> int:
111
+ """参考跨度(由坐标算,不看 ``ref_aln_len``)。"""
112
+ return self.ref_end_0based - self.ref_start_0based
113
+
114
+ @property
115
+ def read_span(self) -> int:
116
+ """read 跨度(由坐标算)。"""
117
+ return self.read_end_0based - self.read_start_0based
118
+
119
+
120
+ def _clean(value: str | None) -> str | None:
121
+ if value is None:
122
+ return None
123
+ text = value.strip()
124
+ return None if text in _EMPTY else text
125
+
126
+
127
+ def _clean_text(value: str | None) -> str | None:
128
+ """只把空白/NA 当空,保留 "-"(strand 用)。"""
129
+ if value is None:
130
+ return None
131
+ text = value.strip()
132
+ return None if text in _EMPTY_TEXT else text
133
+
134
+
135
+ def _to_int(value: str | None, field: str, where: str) -> int:
136
+ text = _clean(value)
137
+ if text is None:
138
+ raise UsageError(f"{where}: 列 {field} 为空,但它是必需的整数列")
139
+ try:
140
+ return int(text)
141
+ except ValueError:
142
+ raise UsageError(f"{where}: 列 {field} 不是整数:{text!r}") from None
143
+
144
+
145
+ def _to_opt_int(value: str | None, field: str, where: str) -> int | None:
146
+ text = _clean(value)
147
+ if text is None:
148
+ return None
149
+ try:
150
+ return int(text)
151
+ except ValueError:
152
+ raise UsageError(f"{where}: 列 {field} 不是整数:{text!r}") from None
153
+
154
+
155
+ def _strand_or_fail(value: str | None, where: str) -> str:
156
+ """strand 是必需语义列:空/NA 直接报错,绝不默认成 "+"(会静默算错断点侧)。"""
157
+ text = _clean_text(value)
158
+ if text is None:
159
+ raise UsageError(f"{where}: strand 列为空;必须是 + 或 -")
160
+ return text
161
+
162
+
163
+ def _to_bool(value: str | None, default: bool = True) -> bool:
164
+ text = _clean(value)
165
+ if text is None:
166
+ return default
167
+ return text.lower() in {"1", "true", "yes", "y"}
168
+
169
+
170
+ def _resolve_columns(header: Sequence[str], where: str) -> dict[str, str]:
171
+ """规范字段 -> 该表实际列名(找不到就不放进结果)。"""
172
+ present = set(header)
173
+ resolved: dict[str, str] = {}
174
+ for canonical, candidates in ALIASES.items():
175
+ for candidate in candidates:
176
+ if candidate in present:
177
+ resolved[canonical] = candidate
178
+ break
179
+ missing = [name for name in REQUIRED_FIELDS if name not in resolved]
180
+ if missing:
181
+ raise UsageError(
182
+ f"{where}: 缺少必需列 {missing};表头是 {list(header)}"
183
+ "(可用 --columns legacy:* 指定历史表头)"
184
+ )
185
+ return resolved
186
+
187
+
188
+ def segments_from_rows(
189
+ header: Sequence[str], rows: Iterable[Mapping[str, str]], where: str = "<rows>"
190
+ ) -> list[Segment]:
191
+ """把 (表头, 行) 转成 ``Segment`` 列表。"""
192
+ mapping = _resolve_columns(header, where)
193
+
194
+ def value(row: Mapping[str, str], canonical: str) -> str | None:
195
+ source = mapping.get(canonical)
196
+ return None if source is None else row.get(source)
197
+
198
+ segments: list[Segment] = []
199
+ for index, row in enumerate(rows, start=2): # 2 = 首个数据行
200
+ here = f"{where}:{index}"
201
+ segment = Segment(
202
+ read_name=_clean(value(row, "read_name")) or "",
203
+ chrom=_clean(value(row, "chrom")) or "",
204
+ ref_start_0based=_to_int(value(row, "ref_start_0based"), "ref_start_0based", here),
205
+ ref_end_0based=_to_int(value(row, "ref_end_0based"), "ref_end_0based", here),
206
+ read_start_0based=_to_int(value(row, "read_start_0based"), "read_start_0based", here),
207
+ read_end_0based=_to_int(value(row, "read_end_0based"), "read_end_0based", here),
208
+ strand=_strand_or_fail(value(row, "strand"), here),
209
+ mapq=_to_int(value(row, "mapq"), "mapq", here),
210
+ read_length=_to_opt_int(value(row, "read_length"), "read_length", here),
211
+ segment_role=_clean(value(row, "segment_role")),
212
+ chrom_length=_to_opt_int(value(row, "chrom_length"), "chrom_length", here),
213
+ ref_aln_len=_to_opt_int(value(row, "ref_aln_len"), "ref_aln_len", here),
214
+ read_aln_len=_to_opt_int(value(row, "read_aln_len"), "read_aln_len", here),
215
+ cigar=_clean(value(row, "cigar")),
216
+ kept=_to_bool(value(row, "kept")),
217
+ drop_reason=_clean(value(row, "drop_reason")),
218
+ is_secondary=_to_bool(value(row, "is_secondary"), default=False),
219
+ is_supplementary=_to_bool(value(row, "is_supplementary"), default=False),
220
+ nm=_to_opt_int(value(row, "nm"), "nm", here),
221
+ match_columns=_to_opt_int(value(row, "match_columns"), "match_columns", here),
222
+ inserted_bases=_to_opt_int(value(row, "inserted_bases"), "inserted_bases", here),
223
+ deleted_bases=_to_opt_int(value(row, "deleted_bases"), "deleted_bases", here),
224
+ )
225
+ if segment.ref_end_0based < segment.ref_start_0based:
226
+ raise UsageError(
227
+ f"{here}: 参考区间倒置 [{segment.ref_start_0based}, {segment.ref_end_0based})"
228
+ )
229
+ if segment.read_end_0based < segment.read_start_0based:
230
+ raise UsageError(
231
+ f"{here}: read 区间倒置 [{segment.read_start_0based}, {segment.read_end_0based})"
232
+ )
233
+ if segment.strand not in {"+", "-"}:
234
+ raise UsageError(f"{here}: strand 只能是 + 或 -,得到 {segment.strand!r}")
235
+ segments.append(segment)
236
+ return segments
237
+
238
+
239
+ def read_segments(path: str | os.PathLike[str]) -> list[Segment]:
240
+ """读一比对段表(自动识别三套历史表头)。"""
241
+ header, rows = read_tsv(path)
242
+ if not header:
243
+ raise UsageError(f"{os.fspath(path)}: 空表或没有表头")
244
+ return segments_from_rows(header, rows, os.fspath(path))
245
+
246
+
247
+ def segment_rows(segments: Iterable[Segment]) -> list[dict[str, Any]]:
248
+ """``Segment`` 列表 -> 可直接交给 ``tables.write_tsv(..., "segments", ...)`` 的行。"""
249
+ names = {field.name for field in fields(Segment)}
250
+ return [
251
+ {key: value for key, value in asdict(segment).items() if key in names}
252
+ for segment in segments
253
+ ]
alnkit/core/tables.py ADDED
@@ -0,0 +1,340 @@
1
+ """表契约:列序、列说明、读写。
2
+
3
+ 规则:
4
+
5
+ * 列序只在这里定义,任何模块不得自己拼表头;
6
+ * 坐标列名**必须**带 ``_0based`` / ``_1based`` 后缀(长度列、``*_side`` 等除外);
7
+ * 写入原子化,行序由调用方显式给出;
8
+ * 浮点固定 4 位小数(与历史产物一致,改格式会破坏黄金对账)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import os
15
+ import shutil
16
+ from collections.abc import Callable, Iterable, Mapping, Sequence
17
+ from types import TracebackType
18
+ from typing import Any, Literal, TextIO
19
+
20
+ __all__ = [
21
+ "COLUMNS",
22
+ "COLUMN_DOC",
23
+ "columns_of",
24
+ "is_coordinate_column",
25
+ "read_legacy_tsv",
26
+ "read_tsv",
27
+ "write_legacy_tsv",
28
+ "validate_column_names",
29
+ "validate_columns",
30
+ "write_tsv",
31
+ ]
32
+
33
+ #: 判定“这是坐标列”的词根(按 ``_`` 切分后逐段比较)
34
+ COORDINATE_TOKENS = frozenset({"start", "end", "pos", "position", "coordinate", "boundary"})
35
+
36
+ #: 坐标列必须以此结尾
37
+ BASIS_SUFFIXES = ("_0based", "_1based")
38
+
39
+ #: 一定不是坐标的列后缀(方向、类别、编号)
40
+ NON_COORDINATE_SUFFIXES = ("_side", "_type", "_name", "_id")
41
+ #: 长度类后缀:与坐标词根同时出现时是可疑命名(如 ``ref_end_bp``),仍按坐标列要求基准后缀
42
+ LENGTH_SUFFIXES = ("_len", "_length", "_bp", "_count")
43
+
44
+ COLUMNS: dict[str, tuple[str, ...]] = {
45
+ # 规范化的“比对段表”(三套历史表头的统一出口)
46
+ "segments": (
47
+ "read_name",
48
+ "read_length",
49
+ "segment_role",
50
+ "chrom",
51
+ "chrom_length",
52
+ "ref_start_0based",
53
+ "ref_end_0based",
54
+ "read_start_0based",
55
+ "read_end_0based",
56
+ "strand",
57
+ "mapq",
58
+ "ref_aln_len",
59
+ "read_aln_len",
60
+ "cigar",
61
+ "kept",
62
+ "drop_reason",
63
+ "is_secondary",
64
+ "is_supplementary",
65
+ "nm",
66
+ "match_columns",
67
+ "inserted_bases",
68
+ "deleted_bases",
69
+ ),
70
+ # 二代位点规范表(机器接口):`short scan` 与三张 legacy .xls 并行输出。
71
+ "insert_sites": (
72
+ "chrom",
73
+ "insert_pos_1based",
74
+ "insert_pos_0based",
75
+ "insert_boundary_0based",
76
+ "n_sr",
77
+ "n_pesr",
78
+ "n_dup",
79
+ "sample",
80
+ "query_insert",
81
+ "nearest_other_insert",
82
+ "other_inserts",
83
+ ),
84
+ # 丝带图映射块。注意:这里登记的是**规范名**,而 `alnkit plot ribbon` 目前仍按旧表头
85
+ # (query_start / ref_start …)写出——迁移期有意保留,等 C1 的黄金口径统一后再切。
86
+ "ribbon_blocks": (
87
+ "query_fragment",
88
+ "query_start_0based",
89
+ "query_end_0based",
90
+ "strand",
91
+ "reference",
92
+ "ref_start_0based",
93
+ "ref_end_0based",
94
+ "block_bp",
95
+ "identity",
96
+ ),
97
+ }
98
+
99
+ COLUMN_DOC: dict[str, dict[str, str]] = {
100
+ "segments": {
101
+ "read_name": "read 名(与 BAM/FASTQ 一致)",
102
+ "read_length": "read 长度(bp);未知时留空",
103
+ "segment_role": "段角色:genome / exog / vector / target",
104
+ "chrom": "参考序列名",
105
+ "chrom_length": "参考序列长度(bp)",
106
+ "ref_start_0based": "参考起点,0-based 半开",
107
+ "ref_end_0based": "参考终点,0-based 半开(不含)",
108
+ "read_start_0based": "read 轴起点,已翻到正向 read,0-based 半开",
109
+ "read_end_0based": "read 轴终点,已翻到正向 read,0-based 半开(不含)",
110
+ "strand": "比对方向:+ / -",
111
+ "mapq": "MAPQ",
112
+ "ref_aln_len": "参考跨度(含首尾 D/N),与简化 CIGAR 的 M 之和可不等",
113
+ "read_aln_len": "read 跨度",
114
+ "cigar": "简化 CIGAR(两个 M 之间总长 <=5 bp 的 I/D 并入 M)",
115
+ "kept": "1/0:是否参与分类;只有 `long classify` 的产物有真实含义,`seg table` 恒为 1",
116
+ "drop_reason": "被丢弃原因;未丢弃填 -",
117
+ "is_secondary": "是否 secondary 比对(1/0)",
118
+ "is_supplementary": "是否 supplementary 比对(1/0)",
119
+ "nm": "编辑距离(NM 标签;缺失时为空)",
120
+ "match_columns": "CIGAR 里 M/=/X 的碱基总数",
121
+ "inserted_bases": "CIGAR 里 I 的碱基数",
122
+ "deleted_bases": "CIGAR 里 D 的碱基数",
123
+ },
124
+ "insert_sites": {
125
+ "chrom": "参考序列名",
126
+ "insert_pos_1based": "断点左侧碱基的 1-based 坐标(= legacy Insert_Position)",
127
+ "insert_pos_0based": "同一碱基的 0-based 坐标(= insert_pos_1based - 1)",
128
+ "insert_boundary_0based": "断点边界,0-based(数值等于 insert_pos_1based)",
129
+ "n_sr": "SR 证据数",
130
+ "n_pesr": "PESR 证据数",
131
+ "n_dup": "该位点上被 markdup 标记的 read 数",
132
+ "sample": "样本名(BAM 文件名首个 . 之前)",
133
+ "query_insert": "本次运行针对的插入片段(= --query-chr)",
134
+ "nearest_other_insert": (
135
+ "除 query 外、支持该位点 read 命中最多的插入片段"
136
+ "(并列取名字小的);没有其它插入片段命中时填 -"
137
+ ),
138
+ "other_inserts": "去掉 query 与最近者之后,其余命中过的插入片段,`,` 分隔;无则 -",
139
+ },
140
+ "ribbon_blocks": {
141
+ "query_fragment": "查询片段名",
142
+ "query_start_0based": "查询片段自身坐标起点,0-based 半开",
143
+ "query_end_0based": "查询片段自身坐标终点,0-based 半开(不含)",
144
+ "strand": "查询相对参考的方向:+ / -",
145
+ "reference": "参考序列名",
146
+ "ref_start_0based": "参考起点,0-based 半开",
147
+ "ref_end_0based": "参考终点,0-based 半开(不含)",
148
+ "block_bp": "块长度(= ref_end - ref_start)",
149
+ "identity": "一致率,0-1 浮点,固定 4 位小数",
150
+ },
151
+ }
152
+
153
+
154
+ def is_coordinate_column(name: str) -> bool:
155
+ """按词根判断是否是坐标列。
156
+
157
+ ``read_start_0based`` 是;``end_side`` / ``junction_type`` 不是(类别后缀优先);
158
+ ``ref_end_bp`` 这种“坐标词根 + 长度后缀”的混搭**仍按坐标列处理**——它几乎一定是
159
+ 写错了名字,让校验要求基准后缀,作者必须显式做决定。
160
+ """
161
+ if name.endswith(NON_COORDINATE_SUFFIXES):
162
+ return False
163
+ parts = name.split("_")
164
+ return any(part in COORDINATE_TOKENS for part in parts)
165
+
166
+
167
+ def columns_of(table: str) -> tuple[str, ...]:
168
+ """取某张表的规范列序。"""
169
+ try:
170
+ return COLUMNS[table]
171
+ except KeyError:
172
+ raise ValueError(f"未知的表:{table}(已注册:{', '.join(sorted(COLUMNS))})") from None
173
+
174
+
175
+ def validate_column_names(table: str, names: Sequence[str]) -> None:
176
+ """检查一组列名是否满足基准后缀规则;违反即抛 ``AssertionError``。"""
177
+ problems = [
178
+ name for name in names if is_coordinate_column(name) and not name.endswith(BASIS_SUFFIXES)
179
+ ]
180
+ if problems:
181
+ raise AssertionError(
182
+ f"表 {table} 的坐标列缺少基准后缀 {BASIS_SUFFIXES}:{', '.join(problems)}"
183
+ )
184
+
185
+
186
+ def validate_columns(table: str) -> None:
187
+ """检查已注册表的列名是否满足基准后缀规则。"""
188
+ validate_column_names(table, columns_of(table))
189
+
190
+
191
+ def read_tsv(path: str | os.PathLike[str]) -> tuple[list[str], list[dict[str, str]]]:
192
+ """读 TSV:跳过 ``#`` 注释行,返回 (列名, 行字典)。"""
193
+ lines = []
194
+ with open(path, encoding="utf-8", newline="") as handle:
195
+ for line in handle:
196
+ if line.startswith("#") or not line.strip():
197
+ continue
198
+ lines.append(line)
199
+ if not lines:
200
+ return [], []
201
+ reader = csv.DictReader(lines, delimiter="\t", restkey="__extra__")
202
+ return list(reader.fieldnames or ()), [dict(row) for row in reader]
203
+
204
+
205
+ def read_legacy_tsv(
206
+ path: str | os.PathLike[str],
207
+ ) -> tuple[list[str], list[list[str]]]:
208
+ """读历史格式 TSV:跳过 ``#`` 注释与空行,返回 ``(表头, 行列表)``。
209
+
210
+ 与 :func:`read_tsv` 的区别是行仍是字符串列表(旧脚本按位置取列)。
211
+ """
212
+ header: list[str] = []
213
+ rows: list[list[str]] = []
214
+ with open(path, encoding="utf-8") as handle:
215
+ for line in handle:
216
+ if not line.strip() or line.startswith("#"):
217
+ continue
218
+ fields = line.rstrip("\n").split("\t")
219
+ if not header:
220
+ header = fields
221
+ else:
222
+ rows.append(fields)
223
+ return header, rows
224
+
225
+
226
+ def _default_file_mode() -> int:
227
+ """按当前 umask 算出默认文件权限(mkstemp 固定 0600,写出后要改回来)。"""
228
+ current = os.umask(0)
229
+ os.umask(current)
230
+ return 0o666 & ~current
231
+
232
+
233
+ class _AtomicWriter:
234
+ """临时文件 + ``os.replace``;失败清理,成功保留目标原权限。"""
235
+
236
+ def __init__(self, target: str) -> None:
237
+ self.target = target
238
+ self.temporary = ""
239
+
240
+ def __enter__(self) -> TextIO:
241
+ import tempfile
242
+
243
+ directory = os.path.dirname(os.path.abspath(self.target))
244
+ os.makedirs(directory, exist_ok=True)
245
+ descriptor, self.temporary = tempfile.mkstemp(
246
+ dir=directory, prefix=".alnkit-", suffix=".tmp"
247
+ )
248
+ self._handle = os.fdopen(descriptor, "w", encoding="utf-8", newline="")
249
+ return self._handle
250
+
251
+ def __exit__(
252
+ self,
253
+ exc_type: type[BaseException] | None,
254
+ exc: BaseException | None,
255
+ traceback: TracebackType | None,
256
+ ) -> Literal[False]:
257
+ self._handle.close()
258
+ try:
259
+ if exc_type is None:
260
+ if os.path.exists(self.target):
261
+ shutil.copymode(self.target, self.temporary)
262
+ else:
263
+ os.chmod(self.temporary, _default_file_mode())
264
+ os.replace(self.temporary, self.target)
265
+ return False
266
+ finally:
267
+ if os.path.exists(self.temporary):
268
+ os.unlink(self.temporary)
269
+ return False
270
+
271
+
272
+ def _format_value(value: Any) -> str:
273
+ if value is None:
274
+ return "-"
275
+ if isinstance(value, bool):
276
+ return "1" if value else "0"
277
+ if isinstance(value, float):
278
+ return f"{value:.4f}"
279
+ return str(value)
280
+
281
+
282
+ def write_tsv(
283
+ path: str | os.PathLike[str],
284
+ table: str,
285
+ rows: Iterable[Mapping[str, Any]],
286
+ *,
287
+ comments: Sequence[str] = (),
288
+ sort_key: Callable[[Mapping[str, Any]], Any] | None = None,
289
+ ) -> int:
290
+ """按注册列序写 TSV(原子写);返回写入行数。
291
+
292
+ ``sort_key`` 必须显式给出(或确认输入已排序),禁止依赖 dict/set 迭代顺序。
293
+ 未注册的列在任何字节落盘前就会报错;失败时不破坏已存在的目标文件。
294
+ """
295
+ columns = columns_of(table)
296
+ column_set = set(columns)
297
+ ordered = [dict(row) for row in rows]
298
+ if sort_key is not None:
299
+ ordered.sort(key=sort_key)
300
+ unknown: set[str] = set()
301
+ for row in ordered:
302
+ unknown |= set(row) - column_set
303
+ if unknown:
304
+ raise ValueError(f"表 {table} 出现未注册的列:{', '.join(sorted(unknown))}")
305
+
306
+ target = os.fspath(path)
307
+ with _AtomicWriter(target) as handle:
308
+ for comment in comments:
309
+ handle.write(f"# {comment}\n")
310
+ handle.write("\t".join(columns) + "\n")
311
+ for row in ordered:
312
+ handle.write("\t".join(_format_value(row.get(name)) for name in columns) + "\n")
313
+ return len(ordered)
314
+
315
+
316
+ def write_legacy_tsv(
317
+ path: str | os.PathLike[str],
318
+ columns: Sequence[str],
319
+ rows: Iterable[Sequence[Any]],
320
+ comments: Sequence[str] = (),
321
+ ) -> int:
322
+ """写历史格式 TSV(原子写):列名由调用方给、不加基准校验、值用 ``str()``。
323
+
324
+ 这是三代旧脚本(``sequence_io.write_tsv``)的语义:注释行 + 表头 + 逐行 ``str(value)``,
325
+ 不做浮点格式化、不做列注册校验。新表请用 :func:`write_tsv`。
326
+ """
327
+ target = os.fspath(path)
328
+ written = 0
329
+ with _AtomicWriter(target) as handle:
330
+ for comment in comments:
331
+ handle.write("# " + comment + "\n")
332
+ handle.write("\t".join(columns) + "\n")
333
+ for row in rows:
334
+ handle.write("\t".join(str(value) for value in row) + "\n")
335
+ written += 1
336
+ return written
337
+
338
+
339
+ for _table in COLUMNS:
340
+ validate_columns(_table)
@@ -0,0 +1,5 @@
1
+ """长读长链路(A1–A7):断点 → 类 → 组装 → 落点。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["classify"]