esf-format 1.0.0__tar.gz

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.
@@ -0,0 +1,18 @@
1
+ version: 2
2
+
3
+ updates:
4
+ - package-ecosystem: github-actions
5
+ directory: /
6
+ schedule:
7
+ interval: monthly
8
+ groups:
9
+ all:
10
+ patterns: ["*"]
11
+
12
+ - package-ecosystem: uv
13
+ directory: /
14
+ schedule:
15
+ interval: monthly
16
+ groups:
17
+ all:
18
+ patterns: ["*"]
@@ -0,0 +1,31 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ test:
9
+ uses: ./.github/workflows/test.yml
10
+
11
+ publish:
12
+ needs: test
13
+ runs-on: ubuntu-latest
14
+ environment: pypi
15
+ permissions:
16
+ id-token: write
17
+ steps:
18
+ - name: Check out the repository
19
+ uses: actions/checkout@v7
20
+
21
+ - name: Install uv
22
+ uses: astral-sh/setup-uv@v10.3.3
23
+
24
+ - name: Set the version from the release tag
25
+ run: uv version --frozen "${GITHUB_REF_NAME#v}"
26
+
27
+ - name: Build
28
+ run: uv build
29
+
30
+ - name: Publish to PyPI
31
+ run: uv publish
@@ -0,0 +1,51 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ python: ["3.11", "3.12", "3.13", "3.14", "3.15"]
16
+ steps:
17
+ - name: Check out the repository
18
+ uses: actions/checkout@v7
19
+
20
+ - name: Check out esf-format
21
+ uses: actions/checkout@v7
22
+ with:
23
+ repository: EVEShipFit/esf-format
24
+ ref: v1.0
25
+ path: esf-format
26
+
27
+ - name: Install uv
28
+ uses: astral-sh/setup-uv@v10.3.3
29
+ with:
30
+ python-version: ${{ matrix.python }}
31
+
32
+ - name: Install dependencies
33
+ run: uv sync --locked
34
+
35
+ - name: Lint
36
+ run: |
37
+ uv run ruff check .
38
+ uv run ruff format --check .
39
+ uv run mypy src
40
+
41
+ - name: Run the test cases
42
+ run: uv run --project esf-format/tools esf-test -- .venv/bin/coverage run tests/program.py
43
+
44
+ - name: Run the unit tests
45
+ run: uv run coverage run -m pytest
46
+
47
+ - name: Report coverage
48
+ run: |
49
+ uv run coverage combine
50
+ uv run coverage report
51
+ uv run coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ dist/
4
+ esf-format/
5
+ .coverage*
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 EVEShipFit Team
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: esf-format
3
+ Version: 1.0.0
4
+ Summary: Read and write esf/1, the plain-text format for EVE Online ship fits
5
+ Project-URL: Homepage, https://github.com/EVEShipFit/esf-format-python
6
+ Project-URL: Specification, https://github.com/EVEShipFit/esf-format
7
+ Author: EVEShipFit Team
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Topic :: Games/Entertainment
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+
21
+ # esf-format
22
+
23
+ [![PyPI](https://img.shields.io/pypi/v/esf-format.svg)](https://pypi.org/project/esf-format/)
24
+ [![Python](https://img.shields.io/pypi/pyversions/esf-format.svg)](https://pypi.org/project/esf-format/)
25
+ [![CI](https://github.com/EVEShipFit/esf-format-python/actions/workflows/test.yml/badge.svg)](https://github.com/EVEShipFit/esf-format-python/actions/workflows/test.yml)
26
+
27
+ [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/S5V5BkvNf7)
28
+
29
+ Read and write [esf/1](https://github.com/EVEShipFit/esf-format), the
30
+ plain-text format for EVE Online ship fits. Text, canonical and binary forms.
31
+ No dependencies.
32
+
33
+ ```
34
+ pip install esf-format
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ import esf_format
41
+ from esf_format import Entry, EsfFit, Location, State
42
+
43
+ fit = EsfFit(
44
+ hull=587, # Rifter
45
+ name="Shield Buffer",
46
+ entries=[
47
+ Entry(2889, count=3, charge=21898), # 3 guns, a full load of ammo each
48
+ Entry(None, location=Location.HIGH), # an empty high slot
49
+ Entry(448, state=State.OFF), # an offline module
50
+ Entry(2048), # goes in its own rack
51
+ Entry(2456, count=5), # 5 drones
52
+ Entry(21898, count=1000, location=Location.CARGO),
53
+ ],
54
+ )
55
+
56
+ text = esf_format.save([fit], catalog) # canonical text
57
+ data = esf_format.save_binary([fit], catalog) # binary
58
+ fits = esf_format.load(data, catalog) # text or binary
59
+
60
+ url = esf_format.to_base64url(data) # the binary form in a URL
61
+ data = esf_format.from_base64url(url)
62
+ ```
63
+
64
+ `save()` and `save_binary()` take entries in any order, and check every rule
65
+ of the spec. `load()` returns fits in canonical form. All three raise
66
+ `esf_format.EsfError` when a rule is broken. It says where: `line` for text,
67
+ or `fit` and `entry` (counting from 1) for fits.
68
+
69
+ ## Fits
70
+
71
+ An `EsfFit` is one fit: `hull`, `name`, tactical `mode` and its `entries`.
72
+ `hull` is `None` for a fit without a ship. A document can hold more than one
73
+ fit.
74
+
75
+ Each `Entry` is one line of the fit. `None` means the default:
76
+
77
+ | field | what it is | `None` means |
78
+ | --- | --- | --- |
79
+ | `type` | The type ID. | An empty slot. |
80
+ | `count` | How many. | One; for fighters, a full squadron. |
81
+ | `fit_name` | The name of another fit in the list, which this ship or container carries. | Carries no fit. |
82
+ | `charge` | The charge loaded. | |
83
+ | `charge_count` | How many charges. | A full load. |
84
+ | `mutaplasmid` | The mutaplasmid applied. | |
85
+ | `overrides` | Attribute values by attribute ID. | |
86
+ | `state` | `State.OFF`, `ON` or `HEAT`. | Active if the type can be, else on. |
87
+ | `location` | `CARGO`, `BAY` or another hold; a rack only for an empty slot. | Where the type goes by default. |
88
+
89
+ ## The Catalog
90
+
91
+ The library holds no game data. You give it a `Catalog`: an object that looks
92
+ up types and attributes in your copy of the SDE.
93
+
94
+ ```python
95
+ class MyCatalog:
96
+ def type(self, folded: str) -> esf_format.TypeRecord | None: ...
97
+ def type_by_id(self, type_id: int) -> esf_format.TypeRecord | None: ...
98
+ def attribute(self, folded: str) -> esf_format.AttributeRecord | None: ...
99
+ def attribute_by_id(self, attribute_id: int) -> esf_format.AttributeRecord | None: ...
100
+
101
+ # Optional. Without them, no hull has tactical modes and no type has mutaplasmids.
102
+ def modes(self, hull_id: int) -> Sequence[esf_format.TypeRecord]: ...
103
+ def mutaplasmids(self, type_id: int) -> Sequence[esf_format.MutaplasmidRecord]: ...
104
+ ```
105
+
106
+ Names are looked up folded: index them with `esf_format.fold(name)`. Where
107
+ names are shared, published wins, then the lowest ID (§4.2).
108
+ [tests/catalog.py](https://github.com/EVEShipFit/esf-format-python/blob/main/tests/catalog.py) is a complete example.
109
+
110
+ ## Tests
111
+
112
+ The test cases live in [esf-format](https://github.com/EVEShipFit/esf-format):
113
+
114
+ ```
115
+ git clone --branch v1.0 https://github.com/EVEShipFit/esf-format
116
+ uv sync
117
+ uv run --project esf-format/tools esf-test -- .venv/bin/python tests/program.py
118
+ uv run pytest
119
+ ```
@@ -0,0 +1,99 @@
1
+ # esf-format
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/esf-format.svg)](https://pypi.org/project/esf-format/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/esf-format.svg)](https://pypi.org/project/esf-format/)
5
+ [![CI](https://github.com/EVEShipFit/esf-format-python/actions/workflows/test.yml/badge.svg)](https://github.com/EVEShipFit/esf-format-python/actions/workflows/test.yml)
6
+
7
+ [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/S5V5BkvNf7)
8
+
9
+ Read and write [esf/1](https://github.com/EVEShipFit/esf-format), the
10
+ plain-text format for EVE Online ship fits. Text, canonical and binary forms.
11
+ No dependencies.
12
+
13
+ ```
14
+ pip install esf-format
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ import esf_format
21
+ from esf_format import Entry, EsfFit, Location, State
22
+
23
+ fit = EsfFit(
24
+ hull=587, # Rifter
25
+ name="Shield Buffer",
26
+ entries=[
27
+ Entry(2889, count=3, charge=21898), # 3 guns, a full load of ammo each
28
+ Entry(None, location=Location.HIGH), # an empty high slot
29
+ Entry(448, state=State.OFF), # an offline module
30
+ Entry(2048), # goes in its own rack
31
+ Entry(2456, count=5), # 5 drones
32
+ Entry(21898, count=1000, location=Location.CARGO),
33
+ ],
34
+ )
35
+
36
+ text = esf_format.save([fit], catalog) # canonical text
37
+ data = esf_format.save_binary([fit], catalog) # binary
38
+ fits = esf_format.load(data, catalog) # text or binary
39
+
40
+ url = esf_format.to_base64url(data) # the binary form in a URL
41
+ data = esf_format.from_base64url(url)
42
+ ```
43
+
44
+ `save()` and `save_binary()` take entries in any order, and check every rule
45
+ of the spec. `load()` returns fits in canonical form. All three raise
46
+ `esf_format.EsfError` when a rule is broken. It says where: `line` for text,
47
+ or `fit` and `entry` (counting from 1) for fits.
48
+
49
+ ## Fits
50
+
51
+ An `EsfFit` is one fit: `hull`, `name`, tactical `mode` and its `entries`.
52
+ `hull` is `None` for a fit without a ship. A document can hold more than one
53
+ fit.
54
+
55
+ Each `Entry` is one line of the fit. `None` means the default:
56
+
57
+ | field | what it is | `None` means |
58
+ | --- | --- | --- |
59
+ | `type` | The type ID. | An empty slot. |
60
+ | `count` | How many. | One; for fighters, a full squadron. |
61
+ | `fit_name` | The name of another fit in the list, which this ship or container carries. | Carries no fit. |
62
+ | `charge` | The charge loaded. | |
63
+ | `charge_count` | How many charges. | A full load. |
64
+ | `mutaplasmid` | The mutaplasmid applied. | |
65
+ | `overrides` | Attribute values by attribute ID. | |
66
+ | `state` | `State.OFF`, `ON` or `HEAT`. | Active if the type can be, else on. |
67
+ | `location` | `CARGO`, `BAY` or another hold; a rack only for an empty slot. | Where the type goes by default. |
68
+
69
+ ## The Catalog
70
+
71
+ The library holds no game data. You give it a `Catalog`: an object that looks
72
+ up types and attributes in your copy of the SDE.
73
+
74
+ ```python
75
+ class MyCatalog:
76
+ def type(self, folded: str) -> esf_format.TypeRecord | None: ...
77
+ def type_by_id(self, type_id: int) -> esf_format.TypeRecord | None: ...
78
+ def attribute(self, folded: str) -> esf_format.AttributeRecord | None: ...
79
+ def attribute_by_id(self, attribute_id: int) -> esf_format.AttributeRecord | None: ...
80
+
81
+ # Optional. Without them, no hull has tactical modes and no type has mutaplasmids.
82
+ def modes(self, hull_id: int) -> Sequence[esf_format.TypeRecord]: ...
83
+ def mutaplasmids(self, type_id: int) -> Sequence[esf_format.MutaplasmidRecord]: ...
84
+ ```
85
+
86
+ Names are looked up folded: index them with `esf_format.fold(name)`. Where
87
+ names are shared, published wins, then the lowest ID (§4.2).
88
+ [tests/catalog.py](https://github.com/EVEShipFit/esf-format-python/blob/main/tests/catalog.py) is a complete example.
89
+
90
+ ## Tests
91
+
92
+ The test cases live in [esf-format](https://github.com/EVEShipFit/esf-format):
93
+
94
+ ```
95
+ git clone --branch v1.0 https://github.com/EVEShipFit/esf-format
96
+ uv sync
97
+ uv run --project esf-format/tools esf-test -- .venv/bin/python tests/program.py
98
+ uv run pytest
99
+ ```
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "esf-format"
3
+ version = "1.0.0"
4
+ description = "Read and write esf/1, the plain-text format for EVE Online ship fits"
5
+ readme = "README.md"
6
+ authors = [{ name = "EVEShipFit Team" }]
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Programming Language :: Python :: 3.15",
18
+ "Topic :: Games/Entertainment",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/EVEShipFit/esf-format-python"
24
+ Specification = "https://github.com/EVEShipFit/esf-format"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "coverage>=7.17.0",
29
+ "mypy>=2.4.1",
30
+ "pytest>=9.2.0",
31
+ "ruff",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+
41
+ [tool.coverage.run]
42
+ source = ["esf_format"]
43
+ parallel = true
44
+ branch = true
45
+
46
+ [tool.coverage.report]
47
+ show_missing = true
48
+
49
+ [tool.pytest.ini_options]
50
+ pythonpath = ["tests"]
51
+
52
+ [tool.mypy]
53
+ strict = true
@@ -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))
@@ -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