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/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)