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.
- esf_format-1.0.0/.github/dependabot.yml +18 -0
- esf_format-1.0.0/.github/workflows/release.yml +31 -0
- esf_format-1.0.0/.github/workflows/test.yml +51 -0
- esf_format-1.0.0/.gitignore +5 -0
- esf_format-1.0.0/LICENSE +19 -0
- esf_format-1.0.0/PKG-INFO +119 -0
- esf_format-1.0.0/README.md +99 -0
- esf_format-1.0.0/pyproject.toml +53 -0
- esf_format-1.0.0/src/esf_format/__init__.py +104 -0
- esf_format-1.0.0/src/esf_format/binary.py +185 -0
- esf_format-1.0.0/src/esf_format/canonical.py +285 -0
- esf_format-1.0.0/src/esf_format/catalog.py +94 -0
- esf_format-1.0.0/src/esf_format/errors.py +28 -0
- esf_format-1.0.0/src/esf_format/model.py +69 -0
- esf_format-1.0.0/src/esf_format/names.py +33 -0
- esf_format-1.0.0/src/esf_format/py.typed +0 -0
- esf_format-1.0.0/src/esf_format/resolve.py +293 -0
- esf_format-1.0.0/src/esf_format/source.py +126 -0
- esf_format-1.0.0/src/esf_format/text.py +354 -0
- esf_format-1.0.0/src/esf_format/wire.py +120 -0
- esf_format-1.0.0/tests/catalog.py +59 -0
- esf_format-1.0.0/tests/program.py +27 -0
- esf_format-1.0.0/tests/test_api.py +212 -0
- esf_format-1.0.0/uv.lock +509 -0
|
@@ -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"
|
esf_format-1.0.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/esf-format/)
|
|
24
|
+
[](https://pypi.org/project/esf-format/)
|
|
25
|
+
[](https://github.com/EVEShipFit/esf-format-python/actions/workflows/test.yml)
|
|
26
|
+
|
|
27
|
+
[](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
|
+
[](https://pypi.org/project/esf-format/)
|
|
4
|
+
[](https://pypi.org/project/esf-format/)
|
|
5
|
+
[](https://github.com/EVEShipFit/esf-format-python/actions/workflows/test.yml)
|
|
6
|
+
|
|
7
|
+
[](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
|