esf-format 1.0.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.
esf_format/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """Read and write esf/1 (https://github.com/EVEShipFit/esf-format)."""
2
+
3
+ import base64
4
+ import re
5
+
6
+ from . import binary, canonical, resolve, source, text
7
+ from .canonical import CanonFit
8
+ from .catalog import (
9
+ AttributeRecord,
10
+ Catalog,
11
+ Kind,
12
+ ModeCatalog,
13
+ MutaplasmidCatalog,
14
+ MutaplasmidRecord,
15
+ TypeRecord,
16
+ )
17
+ from .errors import EsfError
18
+ from .model import Entry, EsfFit, Location, State
19
+ from .names import fold
20
+
21
+ __all__ = [
22
+ "AttributeRecord",
23
+ "Catalog",
24
+ "Entry",
25
+ "EsfError",
26
+ "EsfFit",
27
+ "Kind",
28
+ "Location",
29
+ "ModeCatalog",
30
+ "MutaplasmidCatalog",
31
+ "MutaplasmidRecord",
32
+ "State",
33
+ "TypeRecord",
34
+ "fold",
35
+ "from_base64url",
36
+ "is_text",
37
+ "load",
38
+ "save",
39
+ "save_binary",
40
+ "to_base64url",
41
+ ]
42
+
43
+ _BASE64URL = re.compile(r"[A-Za-z0-9_-]*")
44
+
45
+
46
+ def is_text(data: bytes | str) -> bool:
47
+ """Whether a document is text, by its first character (§10)."""
48
+ return data[:1] in (b"%", b" ", "%", " ")
49
+
50
+
51
+ def _canonical(
52
+ blocks: list[text.FitBlock], catalog: Catalog, given: list[EsfFit] | None = None
53
+ ) -> list[CanonFit]:
54
+ fits = resolve.resolve(blocks, catalog)
55
+ if given is not None:
56
+ source.verify(fits, given)
57
+ return [canonical.canonical_fit(f, catalog) for f in fits]
58
+
59
+
60
+ def _checked(fits: list[EsfFit], catalog: Catalog) -> list[CanonFit]:
61
+ data, where = source.source(fits, catalog)
62
+ try:
63
+ return _canonical(text.parse(data), catalog, fits)
64
+ except EsfError as e:
65
+ if e.line is None:
66
+ raise
67
+ fit, entry = where[e.line - 1]
68
+ raise EsfError(e.message, fit=fit, entry=entry) from None
69
+
70
+
71
+ def load(data: bytes | str, catalog: Catalog) -> list[EsfFit]:
72
+ """Read a text or binary document, in canonical form."""
73
+ if isinstance(data, str):
74
+ data = data.encode("utf-8", "surrogatepass")
75
+ if not data:
76
+ raise EsfError("the document is empty")
77
+ if is_text(data):
78
+ fits = _canonical(text.parse(data), catalog)
79
+ else:
80
+ fits = _checked(binary.decode(data), catalog)
81
+ return [canonical.to_model(f) for f in fits]
82
+
83
+
84
+ def save(fits: list[EsfFit], catalog: Catalog) -> str:
85
+ """Write fits as canonical text (§8)."""
86
+ return canonical.render(_checked(fits, catalog))
87
+
88
+
89
+ def save_binary(fits: list[EsfFit], catalog: Catalog) -> bytes:
90
+ """Write fits in the binary form (§10)."""
91
+ return binary.encode([canonical.to_model(f) for f in _checked(fits, catalog)])
92
+
93
+
94
+ def to_base64url(data: bytes) -> str:
95
+ """The binary form as base64url without padding, for URLs (§10.4)."""
96
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
97
+
98
+
99
+ def from_base64url(encoded: str) -> bytes:
100
+ """The binary form from base64url without padding (§10.4)."""
101
+ encoded = encoded.strip()
102
+ if not _BASE64URL.fullmatch(encoded) or len(encoded) % 4 == 1:
103
+ raise EsfError("not valid base64url")
104
+ return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
esf_format/binary.py ADDED
@@ -0,0 +1,185 @@
1
+ """The binary form (SPEC §10)."""
2
+
3
+ import struct
4
+ from typing import TypeVar
5
+
6
+ from . import wire
7
+ from .model import Entry, EsfFit, Location, State
8
+
9
+ T = TypeVar("T")
10
+ from .errors import EsfError
11
+ from .text import UINT32_MAX
12
+
13
+ STATES = {State.OFF: 1, State.ON: 2, State.HEAT: 3}
14
+ LOCATIONS = {
15
+ Location.HIGH: 1,
16
+ Location.MID: 2,
17
+ Location.LOW: 3,
18
+ Location.RIG: 4,
19
+ Location.SUB: 5,
20
+ Location.SVC: 6,
21
+ Location.CARGO: 7,
22
+ Location.BAY: 8,
23
+ Location.AMMO: 9,
24
+ Location.BOOSTER: 10,
25
+ Location.COMMAND: 11,
26
+ Location.CORPSE: 12,
27
+ Location.DEPOT: 13,
28
+ Location.EXPEDITION: 14,
29
+ Location.FLEET: 15,
30
+ Location.FRIGATE: 16,
31
+ Location.FUEL: 17,
32
+ Location.GAS: 18,
33
+ Location.ICE: 19,
34
+ Location.INFRASTRUCTURE: 20,
35
+ Location.MAINTENANCE: 21,
36
+ Location.MINERAL: 22,
37
+ Location.MINING: 23,
38
+ Location.MOON: 24,
39
+ Location.PLANETARY: 25,
40
+ Location.QUAFE: 26,
41
+ Location.SUBSYSTEM: 27,
42
+ }
43
+
44
+
45
+ def _encode_entry(entry: Entry) -> bytes:
46
+ out = []
47
+ if entry.type is not None:
48
+ out.append(wire.uint(1, entry.type))
49
+ if entry.count is not None:
50
+ out.append(wire.uint(2, entry.count))
51
+ if entry.fit_name:
52
+ out.append(wire.record(3, entry.fit_name.encode()))
53
+ if entry.charge is not None:
54
+ out.append(wire.uint(4, entry.charge))
55
+ if entry.charge_count is not None:
56
+ out.append(wire.uint(5, entry.charge_count))
57
+ if entry.mutaplasmid is not None:
58
+ out.append(wire.uint(6, entry.mutaplasmid))
59
+ if entry.overrides:
60
+ out.append(wire.packed_uints(7, list(entry.overrides)))
61
+ out.append(wire.packed_doubles(8, list(entry.overrides.values())))
62
+ if entry.state is not None:
63
+ out.append(wire.uint(9, STATES[entry.state]))
64
+ if entry.location is not None:
65
+ out.append(wire.uint(10, LOCATIONS[entry.location]))
66
+ return b"".join(out)
67
+
68
+
69
+ def _encode_fit(fit: EsfFit) -> bytes:
70
+ out = [wire.uint(1, 1)]
71
+ if fit.hull is not None:
72
+ out.append(wire.uint(2, fit.hull))
73
+ if fit.name:
74
+ out.append(wire.record(3, fit.name.encode()))
75
+ if fit.mode is not None:
76
+ out.append(wire.uint(4, fit.mode))
77
+ out += [wire.record(5, _encode_entry(entry)) for entry in fit.entries]
78
+ return b"".join(out)
79
+
80
+
81
+ def encode(fits: list[EsfFit]) -> bytes:
82
+ return b"".join(wire.record(1, _encode_fit(fit)) for fit in fits)
83
+
84
+
85
+ def _uint32(value: int, what: str) -> int:
86
+ if value > UINT32_MAX:
87
+ raise EsfError(f"binary: {what} is out of range")
88
+ return value
89
+
90
+
91
+ def _string(payload: bytes, what: str) -> str | None:
92
+ try:
93
+ return payload.decode("utf-8") or None
94
+ except UnicodeDecodeError:
95
+ raise EsfError(f"binary: {what} is not valid UTF-8") from None
96
+
97
+
98
+ def _enum(numbers: dict[T, int], value: int, what: str) -> T | None:
99
+ if value == 0:
100
+ return None
101
+ for member, number in numbers.items():
102
+ if number == value:
103
+ return member
104
+ raise EsfError(f"binary: unknown {what} value {value}")
105
+
106
+
107
+ def _not_in_schema(what: str) -> EsfError:
108
+ return EsfError(f"binary: {what} has a field that is not in the schema")
109
+
110
+
111
+ def _decode_entry(data: bytes) -> Entry:
112
+ e = Entry(None)
113
+ attributes: list[int] = []
114
+ values: list[float] = []
115
+ for tag, wire_type, value in wire.fields(data):
116
+ match tag, wire_type:
117
+ case 1, wire.VARINT:
118
+ e.type = _uint32(value, "Entry.type")
119
+ case 2, wire.VARINT:
120
+ e.count = _uint32(value, "Entry.count")
121
+ case 3, wire.LEN:
122
+ e.fit_name = _string(value, "Entry.fit_name")
123
+ case 4, wire.VARINT:
124
+ e.charge = _uint32(value, "Entry.charge")
125
+ case 5, wire.VARINT:
126
+ e.charge_count = _uint32(value, "Entry.charge_count")
127
+ case 6, wire.VARINT:
128
+ e.mutaplasmid = _uint32(value, "Entry.mutaplasmid")
129
+ case 7, wire.VARINT:
130
+ attributes.append(_uint32(value, "Entry.override_attributes"))
131
+ case 7, wire.LEN:
132
+ attributes += [
133
+ _uint32(v, "Entry.override_attributes") for v in wire.unpack_uints(value)
134
+ ]
135
+ case 8, wire.I64:
136
+ values.append(struct.unpack("<d", value)[0])
137
+ case 8, wire.LEN:
138
+ values += wire.unpack_doubles(value)
139
+ case 9, wire.VARINT:
140
+ e.state = _enum(STATES, value, "State")
141
+ case 10, wire.VARINT:
142
+ e.location = _enum(LOCATIONS, value, "Location")
143
+ case _:
144
+ raise _not_in_schema("Entry")
145
+ if len(attributes) != len(values):
146
+ raise EsfError("binary: override fields have different lengths")
147
+ e.overrides = dict(zip(attributes, values))
148
+ if len(e.overrides) != len(attributes):
149
+ raise EsfError("binary: an attribute is overridden twice")
150
+ return e
151
+
152
+
153
+ def _decode_fit(data: bytes) -> EsfFit:
154
+ fit = EsfFit()
155
+ version = 0
156
+ for tag, wire_type, value in wire.fields(data):
157
+ match tag, wire_type:
158
+ case 1, wire.VARINT:
159
+ version = _uint32(value, "Fit.version")
160
+ case 2, wire.VARINT:
161
+ fit.hull = _uint32(value, "Fit.hull")
162
+ case 3, wire.LEN:
163
+ fit.name = _string(value, "Fit.name")
164
+ case 4, wire.VARINT:
165
+ fit.mode = _uint32(value, "Fit.tactical_mode")
166
+ case 5, wire.LEN:
167
+ fit.entries.append(_decode_entry(value))
168
+ case _:
169
+ raise _not_in_schema("Fit")
170
+ if version != 1:
171
+ raise EsfError(f"binary: unsupported Fit.version {version}")
172
+ return fit
173
+
174
+
175
+ def decode(data: bytes) -> list[EsfFit]:
176
+ fits = []
177
+ for tag, wire_type, value in wire.fields(data):
178
+ if tag != 1:
179
+ continue
180
+ if wire_type != wire.LEN:
181
+ raise EsfError("binary: Document.fits has the wrong wire type")
182
+ fits.append(_decode_fit(value))
183
+ if not fits:
184
+ raise EsfError("binary: a document holds at least one fit")
185
+ return fits
@@ -0,0 +1,285 @@
1
+ """Canonical form (SPEC §8)."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from decimal import Decimal
5
+
6
+ from . import catalog as cat
7
+ from .catalog import AttributeRecord, Catalog, MutaplasmidRecord, TypeRecord
8
+ from .errors import EsfError
9
+ from .model import Entry, EsfFit, Location, State
10
+ from .names import fold, shortest_unique
11
+ from .resolve import (
12
+ DEFAULT_PLACES,
13
+ DRONES,
14
+ FIGHTERS,
15
+ PLUGGED,
16
+ EmptySlot,
17
+ Fit,
18
+ Item,
19
+ default_place,
20
+ mode_name,
21
+ )
22
+ from .text import HOLDS, RACKS, SIGILS, UINT32_MAX, is_count
23
+
24
+ GROUPS = (*RACKS, DRONES, FIGHTERS, Location.CARGO, *HOLDS, *PLUGGED)
25
+
26
+
27
+ @dataclass
28
+ class CanonLine:
29
+ type: TypeRecord | None
30
+ count: int | None = None
31
+ fit_name: str | None = None
32
+ charge: TypeRecord | None = None
33
+ charge_count: int | None = None
34
+ mutaplasmid: MutaplasmidRecord | None = None
35
+ mutaplasmid_name: str | None = None
36
+ overrides: list[tuple[AttributeRecord, float]] = field(default_factory=list)
37
+ state: str | None = None
38
+ location: str | None = None
39
+
40
+ def body(self) -> str:
41
+ parts = [quote_type(self.type.name) if self.type else "-"]
42
+ if self.fit_name is not None:
43
+ parts.append(quote(self.fit_name))
44
+ if self.charge is not None:
45
+ count = "" if self.charge_count is None else f"{self.charge_count}x "
46
+ parts.append(f":{count}{quote_type(self.charge.name)}")
47
+ if self.mutaplasmid_name is not None:
48
+ parts.append(f"+{quote_type(self.mutaplasmid_name)}")
49
+ if self.overrides:
50
+ pairs = ", ".join(f"{a.name} {number(v)}" for a, v in self.overrides)
51
+ parts.append(f"{{{pairs}}}")
52
+ if self.state is not None:
53
+ parts.append(f"!{self.state}")
54
+ if self.location is not None:
55
+ parts.append(f"@{self.location}")
56
+ return " ".join(parts)
57
+
58
+ def text(self) -> str:
59
+ if self.count is None:
60
+ return self.body()
61
+ return f"{self.count}x {self.body()}"
62
+
63
+
64
+ @dataclass
65
+ class CanonFit:
66
+ hull: TypeRecord | None
67
+ name: str | None
68
+ mode: TypeRecord | None
69
+ mode_text: str | None
70
+ groups: list[list[CanonLine]]
71
+
72
+ @property
73
+ def lines(self) -> list[CanonLine]:
74
+ return [line for group in self.groups for line in group]
75
+
76
+ def text(self) -> str:
77
+ hull = [quote_type(self.hull.name) if self.hull else "-"]
78
+ if self.name is not None:
79
+ hull.append(quote(self.name))
80
+ if self.mode_text is not None:
81
+ hull.append(f"/{quote_type(self.mode_text)}")
82
+ out = f"%esf/1\n{' '.join(hull)}\n"
83
+ for group in self.groups:
84
+ out += "\n" + "".join(line.text() + "\n" for line in group)
85
+ return out
86
+
87
+
88
+ def quote(s: str) -> str:
89
+ return '"' + s.replace('"', '""') + '"'
90
+
91
+
92
+ def needs_quotes(name: str) -> bool:
93
+ words = name.split(" ")
94
+ return (
95
+ words[0][:1] in ("-", "%", "")
96
+ or is_count(words[0])
97
+ or any(w[:1] in SIGILS or w == "" for w in words)
98
+ )
99
+
100
+
101
+ def quote_type(name: str) -> str:
102
+ return quote(name) if needs_quotes(name) else name
103
+
104
+
105
+ def number(value: float) -> str:
106
+ """The shortest decimal, without exponent, that reads back as the same float."""
107
+ if value == 0:
108
+ value = 0.0
109
+ text = repr(value)
110
+ if "e" in text:
111
+ text = format(Decimal(text), "f")
112
+ return text.removesuffix(".0")
113
+
114
+
115
+ def _default_state(item: Item) -> str | None:
116
+ """The state an item has when its line names none; None is active."""
117
+ if item.place in PLUGGED or not item.type.active:
118
+ return "on"
119
+ return None
120
+
121
+
122
+ def _line(item: Item, fit: Fit, catalog: Catalog) -> CanonLine:
123
+ line = item.line
124
+ out = CanonLine(item.type)
125
+
126
+ if item.reference is not None:
127
+ out.fit_name = item.reference.name
128
+
129
+ if item.charge is not None:
130
+ out.charge = item.charge
131
+ full = cat.full_load(item.type, item.charge)
132
+ if line.charge_count is not None and line.charge_count != full:
133
+ out.charge_count = line.charge_count
134
+
135
+ overrides = {a.id: (a, v or 0.0) for a, v in item.overrides}
136
+ if item.mutaplasmid is not None:
137
+ out.mutaplasmid = item.mutaplasmid
138
+ own = item.mutaplasmid.type.name
139
+ others = [m.type.name for m in cat.mutaplasmids_of(catalog, item.type)]
140
+ others.remove(own)
141
+ out.mutaplasmid_name = shortest_unique(own, others)
142
+ for attr_id, base in item.mutaplasmid.rolls:
143
+ if attr_id not in overrides:
144
+ overrides[attr_id] = (cat.attribute_of(catalog, attr_id), base)
145
+ out.overrides = sorted(overrides.values(), key=lambda o: (fold(o[0].name), o[0].name))
146
+
147
+ if line.state is not None and line.state != _default_state(item):
148
+ out.state = line.state
149
+
150
+ if item.place != default_place(item.type.kind, fit.cargo_only) and item.place not in RACKS:
151
+ out.location = item.place
152
+ return out
153
+
154
+
155
+ def _sort_key(type_: TypeRecord, text: str) -> tuple[str, bytes]:
156
+ return (fold(type_.name), text.encode())
157
+
158
+
159
+ def _collapse(lines: list[tuple[bool, CanonLine]]) -> list[CanonLine]:
160
+ """Merge adjacent identical repeatable lines into one count."""
161
+ out: list[tuple[bool, CanonLine]] = []
162
+ for repeatable, line in lines:
163
+ if out and repeatable and out[-1][0] and out[-1][1].body() == line.body():
164
+ prev = out[-1][1]
165
+ prev.count = (prev.count or 1) + (line.count or 1)
166
+ if prev.count > UINT32_MAX:
167
+ raise EsfError(f"lines collapse into a count of {prev.count}, which is too large")
168
+ continue
169
+ out.append((repeatable, line))
170
+ for _, line in out:
171
+ if line.count == 1:
172
+ line.count = None
173
+ return [line for _, line in out]
174
+
175
+
176
+ def _rack(fit: Fit, rack: str, catalog: Catalog) -> list[CanonLine]:
177
+ runs = list(fit.racks.get(rack, []))
178
+ while runs and runs[-1].item is None:
179
+ runs.pop()
180
+ lines = []
181
+ for run in runs:
182
+ if run.item is None:
183
+ line = CanonLine(None, location=rack)
184
+ else:
185
+ line = _line(run.item, fit, catalog)
186
+ line.count = run.length
187
+ lines.append((True, line))
188
+ return _collapse(lines)
189
+
190
+
191
+ def _sorted(items: list[Item], fit: Fit, catalog: Catalog) -> list[CanonLine]:
192
+ lines = []
193
+ for item in items:
194
+ line = _line(item, fit, catalog)
195
+ repeatable = not item.stored
196
+ count = item.line.count or 1
197
+ if repeatable:
198
+ line.count = count
199
+ key = _sort_key(item.type, line.body())
200
+ else:
201
+ line.count = None if count == 1 else count
202
+ key = _sort_key(item.type, line.text())
203
+ lines.append((key, repeatable, line))
204
+ lines.sort(key=lambda entry: entry[0])
205
+ return _collapse([(repeatable, line) for _, repeatable, line in lines])
206
+
207
+
208
+ def _fighters(items: list[Item], fit: Fit, catalog: Catalog) -> list[CanonLine]:
209
+ tubes = [i for i in items if i.place == FIGHTERS]
210
+ lines = []
211
+ for item in tubes:
212
+ line = _line(item, fit, catalog)
213
+ count = item.line.count
214
+ if count is not None and count != item.type.squadron_size:
215
+ line.count = count
216
+ lines.append(line)
217
+ return lines + _sorted([i for i in items if i.place == Location.BAY], fit, catalog)
218
+
219
+
220
+ def _drones(items: list[Item], fit: Fit, catalog: Catalog) -> list[CanonLine]:
221
+ space = _sorted([i for i in items if i.place == DRONES], fit, catalog)
222
+ return space + _sorted([i for i in items if i.place == Location.BAY], fit, catalog)
223
+
224
+
225
+ def canonical_fit(fit: Fit, catalog: Catalog) -> CanonFit:
226
+ groups: dict[str, list[Item]] = {}
227
+ for item in fit.items:
228
+ if isinstance(item, EmptySlot) or item.place in RACKS:
229
+ continue
230
+ group = item.place
231
+ if group == Location.BAY:
232
+ group = DEFAULT_PLACES[item.type.kind]
233
+ groups.setdefault(group, []).append(item)
234
+
235
+ chunks = []
236
+ for group in GROUPS:
237
+ if group in RACKS:
238
+ chunk = _rack(fit, group, catalog)
239
+ elif group == DRONES:
240
+ chunk = _drones(groups.get(group, []), fit, catalog)
241
+ elif group == FIGHTERS:
242
+ chunk = _fighters(groups.get(group, []), fit, catalog)
243
+ else:
244
+ chunk = _sorted(groups.get(group, []), fit, catalog)
245
+ if chunk:
246
+ chunks.append(chunk)
247
+
248
+ mode_text = None
249
+ if fit.hull is not None and fit.mode is not None:
250
+ others = [mode_name(fit.hull, m) for m in cat.modes_of(catalog, fit.hull)]
251
+ own = mode_name(fit.hull, fit.mode)
252
+ others.remove(own)
253
+ mode_text = shortest_unique(own, others)
254
+
255
+ return CanonFit(fit.hull, fit.name, fit.mode, mode_text, chunks)
256
+
257
+
258
+ def render(fits: list[CanonFit]) -> str:
259
+ return "\n".join(fit.text() for fit in fits)
260
+
261
+
262
+ def _id(record: TypeRecord | None) -> int | None:
263
+ return None if record is None else record.id
264
+
265
+
266
+ def to_model(fit: CanonFit) -> EsfFit:
267
+ return EsfFit(
268
+ _id(fit.hull),
269
+ fit.name,
270
+ _id(fit.mode),
271
+ [
272
+ Entry(
273
+ _id(line.type),
274
+ line.count,
275
+ line.fit_name,
276
+ _id(line.charge),
277
+ line.charge_count,
278
+ _id(line.mutaplasmid.type) if line.mutaplasmid else None,
279
+ {a.id: v for a, v in line.overrides},
280
+ State(line.state) if line.state else None,
281
+ Location(line.location) if line.location else None,
282
+ )
283
+ for line in fit.lines
284
+ ],
285
+ )
esf_format/catalog.py ADDED
@@ -0,0 +1,94 @@
1
+ """What esf/1 needs to know about types and attributes (SPEC §5, §6)."""
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+ from fractions import Fraction
7
+ from typing import Protocol
8
+
9
+
10
+ class Kind(StrEnum):
11
+ """What a type is (§5)."""
12
+
13
+ HULL = "hull"
14
+ CONTAINER = "container"
15
+ MODE = "mode"
16
+ SUB = "sub"
17
+ HIGH = "high"
18
+ MID = "mid"
19
+ LOW = "low"
20
+ RIG = "rig"
21
+ SVC = "svc"
22
+ DRONE = "drone"
23
+ FIGHTER = "fighter"
24
+ CHARGE = "charge"
25
+ IMPLANT = "implant"
26
+ BOOSTER = "booster"
27
+ OTHER = "other"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TypeRecord:
32
+ id: int
33
+ name: str
34
+ kind: Kind
35
+ active: bool
36
+ capacity: float
37
+ volume: float
38
+ squadron_size: int | None = None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AttributeRecord:
43
+ id: int
44
+ name: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class MutaplasmidRecord:
49
+ type: TypeRecord
50
+ rolls: tuple[tuple[int, float], ...]
51
+
52
+
53
+ class Catalog(Protocol):
54
+ """Types and attributes, by folded name (§6.10) or by ID."""
55
+
56
+ def type(self, folded: str) -> TypeRecord | None: ...
57
+
58
+ def type_by_id(self, type_id: int) -> TypeRecord | None: ...
59
+
60
+ def attribute(self, folded: str) -> AttributeRecord | None: ...
61
+
62
+ def attribute_by_id(self, attribute_id: int) -> AttributeRecord | None: ...
63
+
64
+
65
+ class ModeCatalog(Catalog, Protocol):
66
+ def modes(self, hull_id: int) -> Sequence[TypeRecord]: ...
67
+
68
+
69
+ class MutaplasmidCatalog(Catalog, Protocol):
70
+ def mutaplasmids(self, type_id: int) -> Sequence[MutaplasmidRecord]: ...
71
+
72
+
73
+ def modes_of(catalog: Catalog, hull: TypeRecord) -> Sequence[TypeRecord]:
74
+ modes = getattr(catalog, "modes", None)
75
+ return () if modes is None else modes(hull.id)
76
+
77
+
78
+ def mutaplasmids_of(catalog: Catalog, base: TypeRecord) -> Sequence[MutaplasmidRecord]:
79
+ mutaplasmids = getattr(catalog, "mutaplasmids", None)
80
+ return () if mutaplasmids is None else mutaplasmids(base.id)
81
+
82
+
83
+ def attribute_of(catalog: Catalog, attribute_id: int) -> AttributeRecord:
84
+ attribute = catalog.attribute_by_id(attribute_id)
85
+ if attribute is None:
86
+ raise LookupError(f"the catalog has no attribute {attribute_id}")
87
+ return attribute
88
+
89
+
90
+ def full_load(item: TypeRecord, charge: TypeRecord) -> int | None:
91
+ """How many charges fit in an item (§6.5)."""
92
+ if item.capacity <= 0 or charge.volume <= 0:
93
+ return None
94
+ return int(Fraction(str(item.capacity)) / Fraction(str(charge.volume)))
esf_format/errors.py ADDED
@@ -0,0 +1,28 @@
1
+ """The error for a document or fit that breaks a rule of esf/1."""
2
+
3
+
4
+ class EsfError(Exception):
5
+ """A broken rule, and where: a line of text, or a fit and entry, counting from 1."""
6
+
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ line: int | None = None,
11
+ fit: int | None = None,
12
+ entry: int | None = None,
13
+ ):
14
+ super().__init__(message)
15
+ self.message = message
16
+ self.line = line
17
+ self.fit = fit
18
+ self.entry = entry
19
+
20
+ def __str__(self) -> str:
21
+ if self.fit is not None:
22
+ where = (
23
+ f"fit {self.fit}" if self.entry is None else f"fit {self.fit}, entry {self.entry}"
24
+ )
25
+ return f"{where}: {self.message}"
26
+ if self.line is not None:
27
+ return f"line {self.line}: {self.message}"
28
+ return self.message