edapitool 0.6.3__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.
- APITool/__init__.py +20 -0
- APITool/auth.py +490 -0
- APITool/capi.py +292 -0
- APITool/cargo.py +148 -0
- APITool/catalog.py +274 -0
- APITool/cli.py +1419 -0
- APITool/constants.py +36 -0
- APITool/construction.py +451 -0
- APITool/daemon.py +668 -0
- APITool/data/fdev_commodities.csv +413 -0
- APITool/data/name_aliases.json +10 -0
- APITool/export.py +987 -0
- APITool/google/__init__.py +10 -0
- APITool/google/exporter.py +517 -0
- APITool/journal.py +429 -0
- APITool/market.py +463 -0
- APITool/matcher.py +279 -0
- APITool/models.py +491 -0
- APITool/service.py +398 -0
- APITool/settings.py +149 -0
- APITool/sheets/__init__.py +41 -0
- APITool/sheets/a1.py +107 -0
- APITool/sheets/destination.py +185 -0
- APITool/sheets/guard.py +77 -0
- APITool/sheets/layout.py +122 -0
- APITool/ship.py +315 -0
- APITool/version.py +130 -0
- APITool/workbook/__init__.py +12 -0
- APITool/workbook/markers.py +440 -0
- APITool/workbook/totals.py +338 -0
- edapitool-0.6.3.dist-info/METADATA +230 -0
- edapitool-0.6.3.dist-info/RECORD +36 -0
- edapitool-0.6.3.dist-info/WHEEL +5 -0
- edapitool-0.6.3.dist-info/entry_points.txt +3 -0
- edapitool-0.6.3.dist-info/licenses/LICENSE +232 -0
- edapitool-0.6.3.dist-info/top_level.txt +1 -0
APITool/catalog.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canonical commodity identity: FDev id <-> symbol <-> display name.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
Elite Dangerous names the same commodity three different ways, and the three
|
|
7
|
+
do not agree. Measured against a live station market, 97 of 366 symbols do not
|
|
8
|
+
normalize to their display name:
|
|
9
|
+
|
|
10
|
+
id 128049232 symbol TerrainEnrichmentSystems display "Land Enrichment Systems"
|
|
11
|
+
id 128049208 symbol AgriculturalMedicines display "Agri-Medicines"
|
|
12
|
+
id 128049220 symbol HeliostaticFurnaces display "Microbial Furnaces"
|
|
13
|
+
|
|
14
|
+
The spreadsheet speaks display names. The journal (Market.json) emits all three.
|
|
15
|
+
The Frontier CAPI emits id + symbol + locName. So matching a spreadsheet row to
|
|
16
|
+
a market entry needs a table, not string munging.
|
|
17
|
+
|
|
18
|
+
The second reason matters more than the first: without a catalog, "this name is
|
|
19
|
+
not in the market response" is ambiguous between *the station does not sell it*
|
|
20
|
+
and *we do not recognize the name*. A station listing 366 items hides that
|
|
21
|
+
ambiguity; a small outpost listing 30 does not.
|
|
22
|
+
|
|
23
|
+
The table is bundled (see APITool/data/fdev_commodities.csv) and regenerated by
|
|
24
|
+
scripts/update-commodity-catalog.py. Runtime never touches the network -- a
|
|
25
|
+
GitHub outage must not break a docking.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import csv
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from functools import lru_cache
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Iterable, Mapping, Optional
|
|
37
|
+
|
|
38
|
+
DEFAULT_CATALOG_PATH = Path(__file__).parent / "data" / "fdev_commodities.csv"
|
|
39
|
+
DEFAULT_ALIASES_PATH = Path(__file__).parent / "data" / "name_aliases.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize(text: object) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Fold a commodity name to a comparison key.
|
|
45
|
+
|
|
46
|
+
Lowercases and drops everything that is not a letter or digit, so
|
|
47
|
+
"Micro-weave Cooling Hoses", "microweave cooling hoses" and
|
|
48
|
+
"MicroWeaveCoolingHoses" all collapse together. Deliberately aggressive:
|
|
49
|
+
it is only ever used to look up a canonical entry, never to decide that
|
|
50
|
+
two things are different.
|
|
51
|
+
"""
|
|
52
|
+
return re.sub(r"[^a-z0-9]", "", str(text).lower())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def strip_symbol(raw: object) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Reduce a journal-style symbol token to its bare symbol.
|
|
58
|
+
|
|
59
|
+
The journal writes symbols as game localization keys:
|
|
60
|
+
"$terrainenrichmentsystems_name;" -> "terrainenrichmentsystems"
|
|
61
|
+
CAPI writes them bare already:
|
|
62
|
+
"TerrainEnrichmentSystems" -> "TerrainEnrichmentSystems"
|
|
63
|
+
"""
|
|
64
|
+
text = str(raw).strip()
|
|
65
|
+
if text.startswith("$"):
|
|
66
|
+
text = text[1:]
|
|
67
|
+
text = text.rstrip(";")
|
|
68
|
+
if text.lower().endswith("_name"):
|
|
69
|
+
text = text[: -len("_name")]
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Commodity:
|
|
75
|
+
"""One canonical commodity."""
|
|
76
|
+
|
|
77
|
+
id: int
|
|
78
|
+
symbol: str
|
|
79
|
+
category: str
|
|
80
|
+
name: str
|
|
81
|
+
rare: bool = False
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def key(self) -> str:
|
|
85
|
+
"""Stable comparison key for this commodity's display name."""
|
|
86
|
+
return normalize(self.name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CommodityCatalog:
|
|
90
|
+
"""
|
|
91
|
+
Three-way index over the bundled FDev commodity table.
|
|
92
|
+
|
|
93
|
+
Lookup precedence in :meth:`resolve` is id -> symbol -> display name.
|
|
94
|
+
That order is not arbitrary: ids never collide, symbols are stable across
|
|
95
|
+
game updates, and display names are the only one of the three that
|
|
96
|
+
Frontier has historically rewritten.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, commodities: Iterable[Commodity]):
|
|
100
|
+
self._by_id: dict[int, Commodity] = {}
|
|
101
|
+
self._by_symbol: dict[str, Commodity] = {}
|
|
102
|
+
self._by_name: dict[str, Commodity] = {}
|
|
103
|
+
|
|
104
|
+
for item in commodities:
|
|
105
|
+
self._by_id[item.id] = item
|
|
106
|
+
self._by_symbol.setdefault(normalize(item.symbol), item)
|
|
107
|
+
self._by_name.setdefault(item.key, item)
|
|
108
|
+
|
|
109
|
+
def __len__(self) -> int:
|
|
110
|
+
return len(self._by_id)
|
|
111
|
+
|
|
112
|
+
def __iter__(self):
|
|
113
|
+
return iter(self._by_id.values())
|
|
114
|
+
|
|
115
|
+
def add_alias(self, alias: object, commodity: Commodity) -> bool:
|
|
116
|
+
"""
|
|
117
|
+
Register an extra display name for a commodity.
|
|
118
|
+
|
|
119
|
+
Returns True if the alias was newly registered. An alias never
|
|
120
|
+
displaces an existing canonical name -- first registration wins, so a
|
|
121
|
+
user file cannot accidentally shadow a real commodity.
|
|
122
|
+
|
|
123
|
+
This exists because EDCD's table and the game's own localised output
|
|
124
|
+
do not always agree. Measured against a live market: EDCD says
|
|
125
|
+
"Low Temperature Diamonds", the game says "Low Temp. Diamonds". A
|
|
126
|
+
commander copying the game's spelling into their spreadsheet would
|
|
127
|
+
otherwise be unmatchable.
|
|
128
|
+
"""
|
|
129
|
+
key = normalize(alias)
|
|
130
|
+
if not key or key in self._by_name:
|
|
131
|
+
return False
|
|
132
|
+
self._by_name[key] = commodity
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def learn_from_market(self, items: Iterable[Mapping]) -> int:
|
|
136
|
+
"""
|
|
137
|
+
Register localised names observed in a real market response.
|
|
138
|
+
|
|
139
|
+
Lets display-name drift self-heal within a session: whatever the game
|
|
140
|
+
called a commodity becomes a lookup key for it. Accepts both journal
|
|
141
|
+
(``id`` / ``Name_Localised``) and CAPI (``id`` / ``locName``) shapes.
|
|
142
|
+
Returns the number of new aliases learned.
|
|
143
|
+
"""
|
|
144
|
+
learned = 0
|
|
145
|
+
for item in items:
|
|
146
|
+
found = self.by_id(item.get("id"))
|
|
147
|
+
if found is None:
|
|
148
|
+
continue
|
|
149
|
+
for field in ("Name_Localised", "locName"):
|
|
150
|
+
value = item.get(field)
|
|
151
|
+
if value and self.add_alias(value, found):
|
|
152
|
+
learned += 1
|
|
153
|
+
return learned
|
|
154
|
+
|
|
155
|
+
def load_aliases(self, path: Path) -> int:
|
|
156
|
+
"""
|
|
157
|
+
Load a user-editable alias map: ``{"Some Name": <id | symbol | name>}``.
|
|
158
|
+
|
|
159
|
+
Unresolvable entries are skipped rather than raising -- a typo in a
|
|
160
|
+
hand-edited file must not stop the tool from docking.
|
|
161
|
+
"""
|
|
162
|
+
if not path.exists():
|
|
163
|
+
return 0
|
|
164
|
+
try:
|
|
165
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
166
|
+
except (json.JSONDecodeError, OSError):
|
|
167
|
+
return 0
|
|
168
|
+
if not isinstance(raw, dict):
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
learned = 0
|
|
172
|
+
for alias, target in raw.items():
|
|
173
|
+
# Underscore-prefixed keys are documentation, not aliases.
|
|
174
|
+
if isinstance(alias, str) and alias.startswith("_"):
|
|
175
|
+
continue
|
|
176
|
+
if isinstance(target, bool):
|
|
177
|
+
continue
|
|
178
|
+
if isinstance(target, int):
|
|
179
|
+
found = self.by_id(target)
|
|
180
|
+
else:
|
|
181
|
+
found = self.resolve(symbol=target, name=target)
|
|
182
|
+
if found is not None and self.add_alias(alias, found):
|
|
183
|
+
learned += 1
|
|
184
|
+
return learned
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def load(
|
|
188
|
+
cls,
|
|
189
|
+
path: Optional[Path] = None,
|
|
190
|
+
aliases_path: Optional[Path] = None,
|
|
191
|
+
) -> "CommodityCatalog":
|
|
192
|
+
"""Load the catalog from a CSV. Defaults to the bundled table."""
|
|
193
|
+
source = Path(path) if path else DEFAULT_CATALOG_PATH
|
|
194
|
+
if not source.exists():
|
|
195
|
+
raise FileNotFoundError(
|
|
196
|
+
f"Commodity catalog not found at {source}. "
|
|
197
|
+
"Regenerate it with: python scripts/update-commodity-catalog.py"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
rows = []
|
|
201
|
+
with open(source, encoding="utf-8", newline="") as handle:
|
|
202
|
+
for row in csv.DictReader(handle):
|
|
203
|
+
try:
|
|
204
|
+
rows.append(
|
|
205
|
+
Commodity(
|
|
206
|
+
id=int(row["id"]),
|
|
207
|
+
symbol=(row.get("symbol") or "").strip(),
|
|
208
|
+
category=(row.get("category") or "").strip(),
|
|
209
|
+
name=(row.get("name") or "").strip(),
|
|
210
|
+
rare=str(row.get("rare", "0")).strip() in ("1", "true", "True"),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
except (KeyError, ValueError, TypeError):
|
|
214
|
+
# A malformed row must not take down a docking. Skip it.
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
if not rows:
|
|
218
|
+
raise ValueError(f"Commodity catalog at {source} contained no usable rows")
|
|
219
|
+
|
|
220
|
+
catalog = cls(rows)
|
|
221
|
+
alias_source = Path(aliases_path) if aliases_path else DEFAULT_ALIASES_PATH
|
|
222
|
+
catalog.load_aliases(alias_source)
|
|
223
|
+
return catalog
|
|
224
|
+
|
|
225
|
+
def by_id(self, commodity_id: object) -> Optional[Commodity]:
|
|
226
|
+
try:
|
|
227
|
+
return self._by_id.get(int(commodity_id))
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def by_symbol(self, symbol: object) -> Optional[Commodity]:
|
|
232
|
+
if symbol in (None, ""):
|
|
233
|
+
return None
|
|
234
|
+
return self._by_symbol.get(normalize(strip_symbol(symbol)))
|
|
235
|
+
|
|
236
|
+
def by_name(self, name: object) -> Optional[Commodity]:
|
|
237
|
+
if name in (None, ""):
|
|
238
|
+
return None
|
|
239
|
+
return self._by_name.get(normalize(name))
|
|
240
|
+
|
|
241
|
+
def resolve(
|
|
242
|
+
self,
|
|
243
|
+
commodity_id: object = None,
|
|
244
|
+
symbol: object = None,
|
|
245
|
+
name: object = None,
|
|
246
|
+
) -> Optional[Commodity]:
|
|
247
|
+
"""
|
|
248
|
+
Resolve a commodity from whichever identifiers are available.
|
|
249
|
+
|
|
250
|
+
Tries id, then symbol, then display name, and returns the first hit.
|
|
251
|
+
Returns None when nothing matches -- callers must treat that as
|
|
252
|
+
"unknown name", never as "the station does not sell it".
|
|
253
|
+
"""
|
|
254
|
+
for lookup, value in (
|
|
255
|
+
(self.by_id, commodity_id),
|
|
256
|
+
(self.by_symbol, symbol),
|
|
257
|
+
(self.by_name, name),
|
|
258
|
+
):
|
|
259
|
+
if value in (None, ""):
|
|
260
|
+
continue
|
|
261
|
+
found = lookup(value)
|
|
262
|
+
if found is not None:
|
|
263
|
+
return found
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@lru_cache(maxsize=4)
|
|
268
|
+
def _cached_catalog(path: Optional[str]) -> CommodityCatalog:
|
|
269
|
+
return CommodityCatalog.load(Path(path) if path else None)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def load_catalog(path: Optional[Path] = None) -> CommodityCatalog:
|
|
273
|
+
"""Load (and memoize) the commodity catalog."""
|
|
274
|
+
return _cached_catalog(str(path) if path else None)
|