alphex 0.1.0a1__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.
- alphex/__init__.py +60 -0
- alphex/_surface.py +129 -0
- alphex/alphabet.py +182 -0
- alphex/cli.py +125 -0
- alphex/convert.py +354 -0
- alphex/errors.py +88 -0
- alphex/known.py +173 -0
- alphex/mcp.py +123 -0
- alphex/py.typed +0 -0
- alphex/relation.py +76 -0
- alphex-0.1.0a1.dist-info/METADATA +161 -0
- alphex-0.1.0a1.dist-info/RECORD +15 -0
- alphex-0.1.0a1.dist-info/WHEEL +4 -0
- alphex-0.1.0a1.dist-info/entry_points.txt +4 -0
- alphex-0.1.0a1.dist-info/licenses/LICENSE +21 -0
alphex/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Amino-acid alphabet orderings and letter-preserving conversions between them.
|
|
2
|
+
|
|
3
|
+
This package exists because a silent data-corruption bug shipped: a token table built from the
|
|
4
|
+
AlphaFold residue ordering while named for the ProteinMPNN ordering, permuting every residue
|
|
5
|
+
that is not a fixed point of the permutation (only A, S and T are). Being shape-valid, it
|
|
6
|
+
raised nothing.
|
|
7
|
+
|
|
8
|
+
The unit of value here is the **declaration**, not the encoding. See
|
|
9
|
+
`.praxia/docs/specs/260814_alphabet-contract.md` for the contract and
|
|
10
|
+
`260814_alphabet-api-surface.md` for the shape.
|
|
11
|
+
|
|
12
|
+
v0.1 is Phase 0 (decision D4): the `Alphabet` value type and the shipped declarations, for
|
|
13
|
+
**dev-dependency-only conformance testing** in each consumer repo, plus the Phase 1 conversion
|
|
14
|
+
kernel (`relation`, `perm`, `convert`, `reindex`). No asset layer and no plugin registry (D5)
|
|
15
|
+
yet.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from alphex import known
|
|
19
|
+
from alphex.alphabet import Alphabet, SpecialKind
|
|
20
|
+
from alphex.convert import MaskedPerm, Policy, PolicySpec, convert, perm, reindex
|
|
21
|
+
from alphex.errors import (
|
|
22
|
+
AliasCollisionError,
|
|
23
|
+
AlphabetDeclarationError,
|
|
24
|
+
AlphabetError,
|
|
25
|
+
DegenerateSymbolError,
|
|
26
|
+
IncompatibleAlphabetError,
|
|
27
|
+
MissingSpecialError,
|
|
28
|
+
MultiCharTokenError,
|
|
29
|
+
NonStandardResidueError,
|
|
30
|
+
ReducedAlphabetError,
|
|
31
|
+
UnmappableSymbolError,
|
|
32
|
+
UnsupportedFeatureError,
|
|
33
|
+
)
|
|
34
|
+
from alphex.relation import Relation, RelationKind, relation
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AliasCollisionError",
|
|
38
|
+
"Alphabet",
|
|
39
|
+
"AlphabetDeclarationError",
|
|
40
|
+
"AlphabetError",
|
|
41
|
+
"DegenerateSymbolError",
|
|
42
|
+
"IncompatibleAlphabetError",
|
|
43
|
+
"MaskedPerm",
|
|
44
|
+
"MissingSpecialError",
|
|
45
|
+
"MultiCharTokenError",
|
|
46
|
+
"NonStandardResidueError",
|
|
47
|
+
"Policy",
|
|
48
|
+
"PolicySpec",
|
|
49
|
+
"ReducedAlphabetError",
|
|
50
|
+
"Relation",
|
|
51
|
+
"RelationKind",
|
|
52
|
+
"SpecialKind",
|
|
53
|
+
"UnmappableSymbolError",
|
|
54
|
+
"UnsupportedFeatureError",
|
|
55
|
+
"convert",
|
|
56
|
+
"known",
|
|
57
|
+
"perm",
|
|
58
|
+
"reindex",
|
|
59
|
+
"relation",
|
|
60
|
+
]
|
alphex/_surface.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Shared logic behind the CLI and the MCP server.
|
|
2
|
+
|
|
3
|
+
Both surfaces answer the same questions and must not drift, so the answers live here once and
|
|
4
|
+
each surface only handles its own I/O. Everything below is plain data in, plain data out -- no
|
|
5
|
+
printing, no MCP types, and nothing imported that is not already a hard dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from alphex import known
|
|
13
|
+
from alphex.convert import MaskedPerm, Policy, perm
|
|
14
|
+
from alphex.relation import relation
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from alphex.alphabet import Alphabet
|
|
20
|
+
|
|
21
|
+
POLICIES = tuple(p.value for p in Policy)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def names() -> list[str]:
|
|
25
|
+
"""Every shipped declaration name, in declaration order."""
|
|
26
|
+
return [a.name for a in known.ALL]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def lookup(name: str) -> Alphabet:
|
|
30
|
+
"""Resolve a declaration by name.
|
|
31
|
+
|
|
32
|
+
Raises `KeyError` listing the valid names rather than a bare miss: an agent or a shell user
|
|
33
|
+
who guessed a name needs the actual set, not a diagnosis.
|
|
34
|
+
"""
|
|
35
|
+
for alphabet in known.ALL:
|
|
36
|
+
if alphabet.name == name:
|
|
37
|
+
return alphabet
|
|
38
|
+
msg = f"unknown alphabet {name!r}; shipped declarations are: {', '.join(names())}"
|
|
39
|
+
raise KeyError(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def describe(name: str) -> dict[str, Any]:
|
|
43
|
+
"""Everything declared about one alphabet, including its provenance and any lint warnings."""
|
|
44
|
+
a = lookup(name)
|
|
45
|
+
return {
|
|
46
|
+
"name": a.name,
|
|
47
|
+
"symbols": a.symbols,
|
|
48
|
+
"n_symbols": a.n_symbols,
|
|
49
|
+
"offset": a.offset,
|
|
50
|
+
"size": a.size,
|
|
51
|
+
"padded_size": a.padded_size,
|
|
52
|
+
"specials": {kind.value: index for kind, index in sorted(a.specials.items(), key=_by_value)},
|
|
53
|
+
"unclaimed": sorted(a.unclaimed),
|
|
54
|
+
"citation": a.citation,
|
|
55
|
+
"warnings": a.lint(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _by_value(item: tuple[Any, int]) -> int:
|
|
60
|
+
return item[1]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def catalog() -> list[dict[str, Any]]:
|
|
64
|
+
"""One row per shipped declaration, for a listing."""
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
"name": a.name,
|
|
68
|
+
"symbols": a.symbols,
|
|
69
|
+
"offset": a.offset,
|
|
70
|
+
"size": a.size,
|
|
71
|
+
"specials": {kind.value: index for kind, index in sorted(a.specials.items(), key=_by_value)},
|
|
72
|
+
"warnings": len(a.lint()),
|
|
73
|
+
}
|
|
74
|
+
for a in known.ALL
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def classify(src: str, dst: str) -> dict[str, Any]:
|
|
79
|
+
"""How two declarations stand to one another. Cheap, and the right thing to ask first."""
|
|
80
|
+
rel = relation(lookup(src), lookup(dst))
|
|
81
|
+
return {
|
|
82
|
+
"src": src,
|
|
83
|
+
"dst": dst,
|
|
84
|
+
"kind": rel.kind.value,
|
|
85
|
+
"lossy": rel.lossy,
|
|
86
|
+
"moved": sorted(rel.moved),
|
|
87
|
+
"n_moved": len(rel.moved),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def table(src: str, dst: str, policy: str) -> dict[str, Any]:
|
|
92
|
+
"""Build a permutation table and report it alongside the letter-level check.
|
|
93
|
+
|
|
94
|
+
`policy` is a single uniform value here, which is a deliberate limit of these surfaces rather
|
|
95
|
+
than of the library: the per-`SpecialKind` mapping form exists so that ESM's eight specials
|
|
96
|
+
cannot be collapsed onto one index by accident, and squeezing a mapping through a CLI flag or
|
|
97
|
+
an MCP string argument would make that easy to do carelessly. Anything needing per-kind policy
|
|
98
|
+
should call `perm` directly.
|
|
99
|
+
"""
|
|
100
|
+
source, destination = lookup(src), lookup(dst)
|
|
101
|
+
chosen = Policy(policy)
|
|
102
|
+
built = perm(source, destination, policy=chosen)
|
|
103
|
+
|
|
104
|
+
masked = isinstance(built, MaskedPerm)
|
|
105
|
+
raw: np.ndarray = built.table if isinstance(built, MaskedPerm) else built
|
|
106
|
+
valid = [bool(v) for v in built.valid] if isinstance(built, MaskedPerm) else None
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"src": src,
|
|
110
|
+
"dst": dst,
|
|
111
|
+
"policy": chosen.value,
|
|
112
|
+
"masked": masked,
|
|
113
|
+
"table": [int(v) for v in raw],
|
|
114
|
+
"valid": valid,
|
|
115
|
+
"letters_preserved": _letters_preserved(source, destination, raw),
|
|
116
|
+
"relation": classify(src, dst),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _letters_preserved(src: Alphabet, dst: Alphabet, raw: np.ndarray) -> bool:
|
|
121
|
+
"""Check the invariant on the residues: same character, whatever the index."""
|
|
122
|
+
return all(dst.symbols[int(raw[src.index_of(c)]) - dst.offset] == c for c in src.symbols)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def lint() -> list[dict[str, Any]]:
|
|
126
|
+
"""Report declarations carrying warnings. Empty means every shipped declaration is clean."""
|
|
127
|
+
return [
|
|
128
|
+
{"name": a.name, "warnings": warnings} for a in known.ALL if (warnings := a.lint())
|
|
129
|
+
]
|
alphex/alphabet.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The `Alphabet` value type and its declaration-time invariants.
|
|
2
|
+
|
|
3
|
+
See `.praxia/docs/specs/260814_alphabet-api-surface.md` §3.2. The design in one line: an
|
|
4
|
+
alphabet is a bijection between a symbol set and a contiguous integer range, plus named
|
|
5
|
+
exceptions at declared indices.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import enum
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from alphex.errors import AlphabetDeclarationError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SpecialKind(enum.Enum):
|
|
23
|
+
"""A non-residue index, by meaning.
|
|
24
|
+
|
|
25
|
+
`UNKNOWN` and `STOP` are deliberately distinct: proteinsmc sets
|
|
26
|
+
`STOP_INT = UNKNOWN_AA_INT = PROTEINMPNN_X_INT = 21`, one index for two meanings, and the
|
|
27
|
+
point of typing them separately is that the conflation becomes reportable rather than
|
|
28
|
+
invisible.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
UNKNOWN = "unknown"
|
|
32
|
+
GAP = "gap"
|
|
33
|
+
STOP = "stop"
|
|
34
|
+
MASK = "mask"
|
|
35
|
+
BOS = "bos"
|
|
36
|
+
EOS = "eos"
|
|
37
|
+
PAD = "pad"
|
|
38
|
+
CHAIN_BREAK = "chain_break"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclasses.dataclass(frozen=True)
|
|
42
|
+
class Alphabet:
|
|
43
|
+
"""An ordered symbol set occupying a known integer index space.
|
|
44
|
+
|
|
45
|
+
`symbols` is the contiguous run of *residue* symbols; `offset` is where that run starts in
|
|
46
|
+
the index space. ESM's 20 canonical residues sit contiguously at indices 4-23, so they are
|
|
47
|
+
expressed as `symbols=<20 chars>, offset=4` rather than as a 33-character string with
|
|
48
|
+
placeholders -- which would reintroduce the "is this position a residue?" ambiguity the
|
|
49
|
+
sentinel half of the shipped bug lived in.
|
|
50
|
+
|
|
51
|
+
Equality and hashing ignore `name` and `citation`: two declarations of the same index space
|
|
52
|
+
ARE the same alphabet however they are labelled. That is what makes the alias problem
|
|
53
|
+
detectable instead of invisible.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
symbols: str
|
|
57
|
+
name: str
|
|
58
|
+
citation: str
|
|
59
|
+
offset: int = 0
|
|
60
|
+
specials: Mapping[SpecialKind, int] = dataclasses.field(default_factory=dict)
|
|
61
|
+
unclaimed: frozenset[int] = frozenset()
|
|
62
|
+
|
|
63
|
+
declared_size: int | None = None
|
|
64
|
+
"""Width of the index space, when the source states one. `None` infers the smallest that fits.
|
|
65
|
+
|
|
66
|
+
Read `size` instead: it is always an `int`. The two are separate names because they are
|
|
67
|
+
separate facts -- what the source wrote, and how wide the alphabet actually is. ESM is the one
|
|
68
|
+
shipped declaration that states a width (33) exceeding what its own indices imply.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
padded_size: int | None = None
|
|
72
|
+
"""Width of a model's output axis when it exceeds `size`. `None` when there is no padding."""
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def size(self) -> int:
|
|
76
|
+
"""Width of the index space. Always an `int`; never `None`, whatever was declared."""
|
|
77
|
+
if self.declared_size is not None:
|
|
78
|
+
return self.declared_size
|
|
79
|
+
return max([*self._residue_indices, *self.specials.values(), *self.unclaimed]) + 1
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def _residue_indices(self) -> range:
|
|
83
|
+
return range(self.offset, self.offset + len(self.symbols))
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
"""Validate the declaration. Every failure here is a bug caught before it can propagate."""
|
|
87
|
+
if not self.symbols:
|
|
88
|
+
raise AlphabetDeclarationError("symbols must be non-empty")
|
|
89
|
+
if any(len(c) != 1 for c in self.symbols):
|
|
90
|
+
msg = f"symbols must be single characters, got {self.symbols!r}"
|
|
91
|
+
raise AlphabetDeclarationError(msg)
|
|
92
|
+
if len(set(self.symbols)) != len(self.symbols):
|
|
93
|
+
dupes = sorted({c for c in self.symbols if self.symbols.count(c) > 1})
|
|
94
|
+
msg = f"duplicate symbols in {self.symbols!r}: {dupes}"
|
|
95
|
+
raise AlphabetDeclarationError(msg)
|
|
96
|
+
if not self.citation.strip():
|
|
97
|
+
msg = f"alphabet {self.name!r} has no citation; a declaration without provenance is "
|
|
98
|
+
msg += "how an ordering loses track of where it came from"
|
|
99
|
+
raise AlphabetDeclarationError(msg)
|
|
100
|
+
|
|
101
|
+
residue_indices = frozenset(self._residue_indices)
|
|
102
|
+
special_indices = frozenset(self.specials.values())
|
|
103
|
+
resolved = self.size
|
|
104
|
+
|
|
105
|
+
if collide := residue_indices & special_indices:
|
|
106
|
+
msg = f"special index collides with a residue index: {sorted(collide)}"
|
|
107
|
+
raise AlphabetDeclarationError(msg)
|
|
108
|
+
out_of_range = {
|
|
109
|
+
i for i in residue_indices | special_indices | self.unclaimed if not 0 <= i < resolved
|
|
110
|
+
}
|
|
111
|
+
if out_of_range:
|
|
112
|
+
msg = f"index outside [0, {resolved}): {sorted(out_of_range)}"
|
|
113
|
+
raise AlphabetDeclarationError(msg)
|
|
114
|
+
|
|
115
|
+
accounted = residue_indices | special_indices | self.unclaimed
|
|
116
|
+
if unaccounted := set(range(resolved)) - accounted:
|
|
117
|
+
msg = (
|
|
118
|
+
f"alphabet {self.name!r} leaves index(es) {sorted(unaccounted)} unaccounted for. "
|
|
119
|
+
f"Every index in [0, {resolved}) must be a residue, a special, or explicitly "
|
|
120
|
+
f"unclaimed -- an index that means nothing is how a lookup table silently clamps."
|
|
121
|
+
)
|
|
122
|
+
raise AlphabetDeclarationError(msg)
|
|
123
|
+
|
|
124
|
+
# --- identity -------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _identity(self) -> tuple[object, ...]:
|
|
127
|
+
return (self.symbols, self.offset, frozenset(self.specials.items()), self.size)
|
|
128
|
+
|
|
129
|
+
def __eq__(self, other: object) -> bool:
|
|
130
|
+
"""Compare index spaces, not labels."""
|
|
131
|
+
if not isinstance(other, Alphabet):
|
|
132
|
+
return NotImplemented
|
|
133
|
+
return self._identity() == other._identity()
|
|
134
|
+
|
|
135
|
+
def __hash__(self) -> int:
|
|
136
|
+
"""Hash the index space, so `name` cannot split one alphabet into two keys."""
|
|
137
|
+
return hash(self._identity())
|
|
138
|
+
|
|
139
|
+
# --- accessors ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def n_symbols(self) -> int:
|
|
143
|
+
"""Number of residue symbols, excluding specials."""
|
|
144
|
+
return len(self.symbols)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def conflated_specials(self) -> frozenset[frozenset[SpecialKind]]:
|
|
148
|
+
"""Groups of SpecialKinds sharing one index.
|
|
149
|
+
|
|
150
|
+
Non-empty means two meanings are indistinguishable at runtime.
|
|
151
|
+
"""
|
|
152
|
+
by_index: dict[int, set[SpecialKind]] = {}
|
|
153
|
+
for kind, idx in self.specials.items():
|
|
154
|
+
by_index.setdefault(idx, set()).add(kind)
|
|
155
|
+
return frozenset(frozenset(g) for g in by_index.values() if len(g) > 1)
|
|
156
|
+
|
|
157
|
+
def index_of(self, symbol: str) -> int:
|
|
158
|
+
"""Absolute index of a residue symbol."""
|
|
159
|
+
return self.offset + self.symbols.index(symbol)
|
|
160
|
+
|
|
161
|
+
def encode(self, text: str) -> np.ndarray:
|
|
162
|
+
"""Residue characters to absolute indices."""
|
|
163
|
+
return np.array([self.index_of(c) for c in text], dtype=np.int32)
|
|
164
|
+
|
|
165
|
+
def decode(self, codes: np.ndarray) -> str:
|
|
166
|
+
"""Absolute indices to residue characters."""
|
|
167
|
+
return "".join(self.symbols[int(i) - self.offset] for i in np.asarray(codes).ravel())
|
|
168
|
+
|
|
169
|
+
def lint(self) -> list[str]:
|
|
170
|
+
"""Return non-fatal warnings. Never raises: a dirty declaration must stay loadable.
|
|
171
|
+
|
|
172
|
+
Refusing to load a declaration that lints dirty would recreate the pressure to declare
|
|
173
|
+
something untrue, which is worse than declaring something imperfect.
|
|
174
|
+
"""
|
|
175
|
+
warnings: list[str] = []
|
|
176
|
+
for group in sorted(self.conflated_specials, key=lambda g: sorted(k.value for k in g)):
|
|
177
|
+
kinds = ", ".join(sorted(k.value for k in group))
|
|
178
|
+
idx = self.specials[next(iter(group))]
|
|
179
|
+
warnings.append(
|
|
180
|
+
f"conflated specials at index {idx}: {kinds} are indistinguishable at runtime",
|
|
181
|
+
)
|
|
182
|
+
return warnings
|
alphex/cli.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""`alphex` command line. Install with the `cli` extra: `uv add "alphex[cli]"`.
|
|
2
|
+
|
|
3
|
+
Deliberately small. This is a library first; the CLI exists so a person or an agent can answer
|
|
4
|
+
"which ordering is this?" and "what does the table look like?" without writing a script -- the
|
|
5
|
+
two questions whose absence let a mislabelled table ship.
|
|
6
|
+
|
|
7
|
+
Nothing here is imported by `alphex/__init__.py`, so the base install stays numpy-only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from alphex import _surface
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from cyclopts import App
|
|
19
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
20
|
+
msg = (
|
|
21
|
+
"the alphex CLI needs the 'cli' extra: `uv add \"alphex[cli]\"` "
|
|
22
|
+
"(or `pip install 'alphex[cli]'`). The base install is numpy-only on purpose."
|
|
23
|
+
)
|
|
24
|
+
raise SystemExit(msg) from exc
|
|
25
|
+
|
|
26
|
+
app = App(
|
|
27
|
+
name="alphex",
|
|
28
|
+
help="Biological sequence alphabet orderings and conversions between them.",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _emit(payload: object, *, as_json: bool) -> None:
|
|
33
|
+
if as_json:
|
|
34
|
+
print(json.dumps(payload, indent=2))
|
|
35
|
+
return
|
|
36
|
+
print(_render(payload))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render(payload: object) -> str:
|
|
40
|
+
"""Plain-text rendering. No `rich` dependency -- this output is meant to be piped."""
|
|
41
|
+
if isinstance(payload, list):
|
|
42
|
+
return "\n".join(_render(row) for row in payload)
|
|
43
|
+
if isinstance(payload, dict):
|
|
44
|
+
width = max((len(k) for k in payload), default=0)
|
|
45
|
+
return "\n".join(f"{k:<{width}} {_scalar(v)}" for k, v in payload.items())
|
|
46
|
+
return str(payload)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scalar(value: object) -> str:
|
|
50
|
+
if isinstance(value, dict):
|
|
51
|
+
return ", ".join(f"{k}={v}" for k, v in value.items()) or "-"
|
|
52
|
+
if isinstance(value, list):
|
|
53
|
+
return ", ".join(str(v) for v in value) if value else "-"
|
|
54
|
+
return str(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command
|
|
58
|
+
def list_(*, json_out: bool = False) -> None:
|
|
59
|
+
"""List every shipped alphabet declaration."""
|
|
60
|
+
rows = _surface.catalog()
|
|
61
|
+
if json_out:
|
|
62
|
+
_emit(rows, as_json=True)
|
|
63
|
+
return
|
|
64
|
+
width = max(len(r["name"]) for r in rows)
|
|
65
|
+
for r in rows:
|
|
66
|
+
specials = ", ".join(f"{k}@{v}" for k, v in r["specials"].items()) or "-"
|
|
67
|
+
flag = " (lints)" if r["warnings"] else ""
|
|
68
|
+
print(f"{r['name']:<{width}} size={r['size']:<3} offset={r['offset']} {specials}{flag}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command
|
|
72
|
+
def show(name: str, *, json_out: bool = False) -> None:
|
|
73
|
+
"""Show one declaration in full, including its citation."""
|
|
74
|
+
_emit(_surface.describe(name), as_json=json_out)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command
|
|
78
|
+
def relation(src: str, dst: str, *, json_out: bool = False) -> None:
|
|
79
|
+
"""Classify the mapping between two declarations before converting anything."""
|
|
80
|
+
_emit(_surface.classify(src, dst), as_json=json_out)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command
|
|
84
|
+
def perm(src: str, dst: str, *, policy: str = "raise", json_out: bool = False) -> None:
|
|
85
|
+
"""Build the permutation table from `src` to `dst`.
|
|
86
|
+
|
|
87
|
+
`policy` is one of raise, unknown, gap, mask -- applied uniformly. Conversions needing a
|
|
88
|
+
different policy per special kind must call the library directly; see `alphex._surface.table`.
|
|
89
|
+
"""
|
|
90
|
+
if policy not in _surface.POLICIES:
|
|
91
|
+
raise SystemExit(f"policy must be one of {', '.join(_surface.POLICIES)}, got {policy!r}")
|
|
92
|
+
_emit(_surface.table(src, dst, policy), as_json=json_out)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command
|
|
96
|
+
def lint(*, strict: bool = False, json_out: bool = False) -> None:
|
|
97
|
+
"""Report declarations whose conventions are worth knowing about.
|
|
98
|
+
|
|
99
|
+
Exits 0 by default even when there are findings. A warning here is not a defect to fix: two
|
|
100
|
+
shipped declarations conflate STOP and UNKNOWN because proteinsmc really does put both on
|
|
101
|
+
index 21, and declaring that is what makes it visible. Those two will warn forever, so a
|
|
102
|
+
non-zero default would be permanent noise and would train people to ignore the command.
|
|
103
|
+
|
|
104
|
+
Pass `--strict` to exit 1 on any finding -- useful in a repo that has decided its own
|
|
105
|
+
declarations must stay clean.
|
|
106
|
+
"""
|
|
107
|
+
findings = _surface.lint()
|
|
108
|
+
if not findings:
|
|
109
|
+
if json_out:
|
|
110
|
+
_emit([], as_json=True)
|
|
111
|
+
else:
|
|
112
|
+
print("no warnings")
|
|
113
|
+
return
|
|
114
|
+
_emit(findings, as_json=json_out)
|
|
115
|
+
if strict:
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
"""Console-script entry point."""
|
|
121
|
+
app()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
main()
|