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/constants.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ Configuration constants for Elite Dangerous CAPI access.
3
+ """
4
+
5
+ # CAPI Server URLs
6
+ CAPI_SERVER_LIVE = "https://companion.orerve.net"
7
+ CAPI_SERVER_LEGACY = "https://legacy-companion.orerve.net"
8
+ CAPI_SERVER_BETA = "https://pts-companion.orerve.net"
9
+
10
+ # CAPI Endpoints
11
+ CAPI_PATH_PROFILE = "/profile"
12
+ CAPI_PATH_MARKET = "/market"
13
+ CAPI_PATH_SHIPYARD = "/shipyard"
14
+ CAPI_PATH_FLEETCARRIER = "/fleetcarrier"
15
+ CAPI_PATH_JOURNAL = "/journal"
16
+ CAPI_PATH_COMMUNITYGOALS = "/communitygoals"
17
+
18
+ # Frontier Auth URLs
19
+ AUTH_SERVER = "https://auth.frontierstore.net"
20
+ AUTH_PATH_AUTH = "/auth"
21
+ AUTH_PATH_TOKEN = "/token"
22
+ AUTH_PATH_DECODE = "/decode"
23
+
24
+ # Default timeouts (seconds)
25
+ DEFAULT_TIMEOUT = 10
26
+ FLEETCARRIER_TIMEOUT = 60
27
+
28
+ # Rate limiting
29
+ MIN_QUERY_INTERVAL = 60 # seconds between queries
30
+ FLEETCARRIER_COOLDOWN = 900 # 15 minutes between FC queries
31
+
32
+ # OAuth2 scopes required
33
+ OAUTH_SCOPES = ["capi", "auth"]
34
+
35
+ # Token file location (in user's home directory)
36
+ TOKEN_FILE = ".ed_capi_tokens.json"
@@ -0,0 +1,451 @@
1
+ """
2
+ What a colony construction site still needs, read from the game's own journal.
3
+
4
+ Elite Dangerous writes a ``ColonisationConstructionDepot`` event carrying the
5
+ complete state of a construction site: every commodity it wants, how much has
6
+ been handed over, and what it pays per tonne. That is the whole picture, so
7
+ this module does no arithmetic of consequence -- it parses and it tells the
8
+ truth about what it parsed.
9
+
10
+ Two things are worth knowing before reading further, because both shaped the
11
+ design and neither is obvious:
12
+
13
+ **The game states what was delivered.** ``ProvidedAmount`` is the figure a
14
+ tracking spreadsheet wants in its "delivered" column. Deriving it instead --
15
+ required minus remaining -- is possible and unnecessary, and the subtraction
16
+ is one more place to be wrong.
17
+
18
+ **The site is identified by ``MarketID``, not by name.** The same id appears on
19
+ the ``Docked`` event, so a site can be matched exactly. It cannot be *declared*
20
+ that way by a person, who has no way to see it; resolving a human-typed system
21
+ and station to an id is a separate concern and deliberately not this module's
22
+ (see :mod:`APITool.journal` for the dock events that make it possible).
23
+
24
+ No CAPI, no authentication, no cooldown. This is a local file, the same class
25
+ of source as ``Market.json`` and ``Cargo.json`` -- and unlike the fleet
26
+ carrier's manifest, which a probe established cannot be tracked from the
27
+ journal at all (``tests/one-offs/thinking/carrier-delta/poc.py``).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass, field
33
+ from datetime import datetime, timezone
34
+ from typing import Iterable, Mapping, Optional
35
+
36
+ from .catalog import CommodityCatalog, normalize, strip_symbol
37
+
38
+ #: How long a site may go unmentioned before it is called "stale".
39
+ #: Not a game rule -- the game exposes no deadline and never marks a lapsed
40
+ #: build failed. 60 days is simply long enough that an active build will have
41
+ #: been visited, and short enough to separate a live effort from one abandoned
42
+ #: last year. Measured on a real journal: active builds were 0-3 days old and
43
+ #: an abandoned one was 303.
44
+ STALE_AFTER_DAYS = 60
45
+
46
+ #: Events that name a place and carry its market id. Docking is NOT required
47
+ #: to learn a name: approaching a settlement, or merely requesting docking,
48
+ #: records it. Measured across a 621-file journal: 2817 ApproachSettlement and
49
+ #: 1982 DockingRequested against 2314 Docked -- so reading only Docked throws
50
+ #: away more sightings than it keeps, and leaves a site the commander flew to
51
+ #: but chose not to land at with no name at all.
52
+ NAMING_EVENTS = (
53
+ "Docked",
54
+ "Undocked",
55
+ "Location",
56
+ "ApproachSettlement",
57
+ "DockingRequested",
58
+ "DockingGranted",
59
+ "DockingDenied",
60
+ )
61
+
62
+ #: The journal event carrying a construction site's full state.
63
+ DEPOT_EVENT = "ColonisationConstructionDepot"
64
+ #: Emitted when cargo is handed over. A delta, where the above is the state.
65
+ CONTRIBUTION_EVENT = "ColonisationContribution"
66
+
67
+ #: Station types that identify a construction site, independent of its name.
68
+ #: Both were observed live: a planetary depot and an orbital one, in the same
69
+ #: system. Filtering on this beats looking for "Construction" in the station
70
+ #: name, which is a display string and not a contract.
71
+ DEPOT_STATION_TYPES = frozenset({"PlanetaryConstructionDepot", "SpaceConstructionDepot"})
72
+
73
+ #: The game prefixes a construction site's station name. A person reading their
74
+ #: own spreadsheet writes "Badeaux Nutrition Centre"; the journal says
75
+ #: "Planetary Construction Site: Badeaux Nutrition Centre". Any match against a
76
+ #: human-typed name has to survive that.
77
+ NAME_PREFIXES = (
78
+ "Planetary Construction Site:",
79
+ "Orbital Construction Site:",
80
+ "Construction Site:",
81
+ # A colonisation ship -- the first structure of a build -- names itself
82
+ # with a game localization key rather than a display string:
83
+ # "$EXT_PANEL_ColonisationShip; Naim Territories"
84
+ # Observed live on three sites. Left unstripped, a person could never type
85
+ # a name that matched it.
86
+ "$EXT_PANEL_ColonisationShip;",
87
+ )
88
+
89
+
90
+ def _as_int(value: object, default: int = 0) -> int:
91
+ try:
92
+ return int(value) # type: ignore[arg-type]
93
+ except (TypeError, ValueError):
94
+ return default
95
+
96
+
97
+ def _parse_timestamp(raw: object) -> Optional[datetime]:
98
+ if not raw:
99
+ return None
100
+ try:
101
+ text = str(raw).replace("Z", "+00:00")
102
+ parsed = datetime.fromisoformat(text)
103
+ except ValueError:
104
+ return None
105
+ if parsed.tzinfo is None:
106
+ parsed = parsed.replace(tzinfo=timezone.utc)
107
+ return parsed
108
+
109
+
110
+ def station_key(name: object) -> str:
111
+ """
112
+ Normalize a station name so a person's version matches the game's.
113
+
114
+ Strips the construction-site prefix before normalizing, so that
115
+ "Badeaux Nutrition Centre" and
116
+ "Planetary Construction Site: Badeaux Nutrition Centre"
117
+ reduce to the same key. Returns "" for nothing usable.
118
+ """
119
+ text = str(name or "").strip()
120
+ for prefix in NAME_PREFIXES:
121
+ if text.lower().startswith(prefix.lower()):
122
+ text = text[len(prefix):].strip()
123
+ break
124
+ return normalize(text)
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class ConstructionResource:
129
+ """One commodity a site wants, and how much of it has arrived."""
130
+
131
+ symbol: str
132
+ name: str
133
+ required: int = 0
134
+ provided: int = 0
135
+ payment: int = 0
136
+
137
+ @property
138
+ def key(self) -> str:
139
+ """The normalized display name, for matching against a sheet row."""
140
+ return normalize(self.name)
141
+
142
+ @property
143
+ def remaining(self) -> int:
144
+ """Still to deliver. Never negative: over-delivery is not a debt."""
145
+ return max(0, self.required - self.provided)
146
+
147
+ @property
148
+ def satisfied(self) -> bool:
149
+ return self.provided >= self.required
150
+
151
+ @property
152
+ def coverage(self) -> float:
153
+ """Fraction delivered, clamped to [0, 1]. Zero required reads as done."""
154
+ if self.required <= 0:
155
+ return 1.0
156
+ return max(0.0, min(1.0, self.provided / self.required))
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class ConstructionSite:
161
+ """A snapshot of one construction site's requirements."""
162
+
163
+ market_id: Optional[int]
164
+ timestamp: Optional[datetime] = None
165
+ progress: float = 0.0
166
+ complete: bool = False
167
+ failed: bool = False
168
+ resources: tuple[ConstructionResource, ...] = field(default_factory=tuple)
169
+
170
+ def __len__(self) -> int:
171
+ return len(self.resources)
172
+
173
+ @property
174
+ def total_required(self) -> int:
175
+ return sum(r.required for r in self.resources)
176
+
177
+ @property
178
+ def total_provided(self) -> int:
179
+ return sum(r.provided for r in self.resources)
180
+
181
+ @property
182
+ def total_remaining(self) -> int:
183
+ return sum(r.remaining for r in self.resources)
184
+
185
+ @property
186
+ def measured_progress(self) -> float:
187
+ """
188
+ Progress computed from the resource rows, as a cross-check.
189
+
190
+ The game reports ``ConstructionProgress`` directly and it was measured
191
+ to be a plain tonnage ratio -- 5168/8517 = 0.6068, matching exactly.
192
+ This recomputes it so a caller can notice if that ever stops being
193
+ true, rather than assuming the relationship holds forever.
194
+ """
195
+ required = self.total_required
196
+ if required <= 0:
197
+ return 1.0 if self.complete else 0.0
198
+ return self.total_provided / required
199
+
200
+ @property
201
+ def outstanding(self) -> tuple[ConstructionResource, ...]:
202
+ """Only what is still owed, in descending order of what is missing."""
203
+ return tuple(
204
+ sorted(
205
+ (r for r in self.resources if r.remaining > 0),
206
+ key=lambda r: r.remaining,
207
+ reverse=True,
208
+ )
209
+ )
210
+
211
+ def age_days(self, now: Optional[datetime] = None) -> Optional[int]:
212
+ """Days since the game last reported this site's state."""
213
+ if self.timestamp is None:
214
+ return None
215
+ reference = now or datetime.now(timezone.utc)
216
+ return max(0, (reference - self.timestamp).days)
217
+
218
+ def status(
219
+ self, now: Optional[datetime] = None, stale_after_days: int = STALE_AFTER_DAYS
220
+ ) -> str:
221
+ """
222
+ One word for what this site is: complete, failed, stale, or active.
223
+
224
+ **"stale" is an inference and the others are facts**, which is why it is
225
+ worded that way rather than as "expired" or "abandoned". A colonisation
226
+ deadline that passes does NOT set ``ConstructionFailed``: measured
227
+ across a 621-file journal, a site left at 15.2% and untouched for 303
228
+ days still reports ``failed: False``. The flag exists, the game simply
229
+ never sets it, so silence cannot be read as "still going".
230
+
231
+ All the tool can honestly say is how long it has been since the game
232
+ last mentioned the site. A caller that wants to hide such sites should
233
+ do so on that basis and say so, rather than claiming a verdict the
234
+ journal never gave.
235
+ """
236
+ if self.failed:
237
+ return "failed"
238
+ if self.complete:
239
+ return "complete"
240
+ age = self.age_days(now)
241
+ if age is not None and age >= stale_after_days:
242
+ return "stale"
243
+ return "active"
244
+
245
+ def find(self, name: object) -> Optional[ConstructionResource]:
246
+ """Look a commodity up by display name, however it is capitalised."""
247
+ wanted = normalize(name)
248
+ for resource in self.resources:
249
+ if resource.key == wanted:
250
+ return resource
251
+ return None
252
+
253
+
254
+ def _resource_from_journal(
255
+ entry: Mapping, catalog: Optional[CommodityCatalog] = None
256
+ ) -> ConstructionResource:
257
+ symbol = strip_symbol(entry.get("Name"))
258
+ # Prefer the localised name the game supplies; fall back to the catalog,
259
+ # then to the bare symbol, so an unrecognised commodity still appears
260
+ # rather than vanishing from a total the sheet depends on.
261
+ name = str(entry.get("Name_Localised") or "").strip()
262
+ if not name and catalog is not None:
263
+ commodity = catalog.by_symbol(symbol) if hasattr(catalog, "by_symbol") else None
264
+ name = getattr(commodity, "display_name", "") or ""
265
+ return ConstructionResource(
266
+ symbol=symbol,
267
+ name=name or symbol,
268
+ required=_as_int(entry.get("RequiredAmount")),
269
+ provided=_as_int(entry.get("ProvidedAmount")),
270
+ payment=_as_int(entry.get("Payment")),
271
+ )
272
+
273
+
274
+ def from_journal(
275
+ event: Mapping, catalog: Optional[CommodityCatalog] = None
276
+ ) -> ConstructionSite:
277
+ """Build a site snapshot from one ``ColonisationConstructionDepot`` event."""
278
+ entries = event.get("ResourcesRequired") or []
279
+ resources = tuple(
280
+ _resource_from_journal(entry, catalog)
281
+ for entry in entries
282
+ if isinstance(entry, Mapping)
283
+ )
284
+ return ConstructionSite(
285
+ market_id=_as_int(event.get("MarketID"), default=0) or None,
286
+ timestamp=_parse_timestamp(event.get("timestamp")),
287
+ progress=float(event.get("ConstructionProgress") or 0.0),
288
+ complete=bool(event.get("ConstructionComplete")),
289
+ failed=bool(event.get("ConstructionFailed")),
290
+ resources=resources,
291
+ )
292
+
293
+
294
+ @dataclass(frozen=True)
295
+ class SiteLocation:
296
+ """Where a construction site is, and what it is and was called."""
297
+
298
+ market_id: int
299
+ station: str = ""
300
+ system: str = ""
301
+ station_type: str = ""
302
+ #: Every earlier name this market id has carried, oldest first.
303
+ #: Sites really are renamed mid-build, and really do drop the
304
+ #: "Planetary Construction Site: " prefix on completion -- measured on one
305
+ #: market id that went "Abara Drilling Enterprise" -> "Beginning" ->
306
+ #: "Beginning" (unprefixed) over two days, keeping its id throughout.
307
+ previous_names: tuple[str, ...] = ()
308
+
309
+ @property
310
+ def short_station(self) -> str:
311
+ """The station name without the game's construction-site prefix."""
312
+ text = self.station.strip()
313
+ for prefix in NAME_PREFIXES:
314
+ if text.lower().startswith(prefix.lower()):
315
+ return text[len(prefix):].strip()
316
+ return text
317
+
318
+ @property
319
+ def former_names(self) -> tuple[str, ...]:
320
+ """Earlier names, prefix-stripped, excluding the current one.
321
+
322
+ A build renamed halfway leaves a spreadsheet declared under the old
323
+ name matching nothing, with no indication why. Keeping the history is
324
+ what lets a declaration go on working across a rename.
325
+ """
326
+ current = station_key(self.station)
327
+ out, seen = [], {current}
328
+ for name in self.previous_names:
329
+ key = station_key(name)
330
+ if key and key not in seen:
331
+ seen.add(key)
332
+ stripped = name
333
+ for prefix in NAME_PREFIXES:
334
+ if stripped.lower().startswith(prefix.lower()):
335
+ stripped = stripped[len(prefix):].strip()
336
+ break
337
+ out.append(stripped)
338
+ return tuple(out)
339
+
340
+ def answers_to(self, name: object) -> bool:
341
+ """Whether this site has EVER been called `name`."""
342
+ key = station_key(name)
343
+ if not key:
344
+ return False
345
+ return key == station_key(self.station) or any(
346
+ key == station_key(n) for n in self.previous_names
347
+ )
348
+
349
+ @property
350
+ def describe(self) -> str:
351
+ if not self.station and not self.system:
352
+ return ""
353
+ return f"{self.station} / {self.system}".strip(" /")
354
+
355
+
356
+ def locations_from_events(
357
+ events: Iterable[Mapping], market_ids: Optional[Iterable[int]] = None
358
+ ) -> dict[int, SiteLocation]:
359
+ """
360
+ Map ``MarketID`` to the name and system of the place it identifies.
361
+
362
+ The depot event carries a site's contents but never its name; the ``Docked``
363
+ event carries the name but not the contents. They share the market id, so
364
+ joining them turns an opaque integer into something a person recognises --
365
+ which matters in both directions. Reporting a site as "4312376579" is
366
+ useless to a reader, and a person declaring one in a spreadsheet can only
367
+ type the name.
368
+
369
+ **This function does not decide what counts as a construction site**, and
370
+ an earlier version that did was wrong in a way worth recording. It filtered
371
+ docks by ``StationType``, accepting only the two depot types -- which
372
+ silently hid three real sites, because a colonisation ship docks as a plain
373
+ ``SurfaceStation`` and is indistinguishable by type from any surface port.
374
+ The symptom was three builds reported as "(name unknown)" at every scan
375
+ depth, which reads as missing data rather than as a filter excluding it.
376
+
377
+ So identity comes from the depot events, which is the only source that
378
+ actually knows; pass their market ids as ``market_ids`` and this resolves
379
+ names for exactly those. With no ids it resolves every dock it sees.
380
+ """
381
+ wanted = set(market_ids) if market_ids is not None else None
382
+ found: dict[int, SiteLocation] = {}
383
+ for event in events:
384
+ if not isinstance(event, Mapping) or event.get("event") not in NAMING_EVENTS:
385
+ continue
386
+ market_id = _as_int(event.get("MarketID"), default=0)
387
+ if not market_id or (wanted is not None and market_id not in wanted):
388
+ continue
389
+ # ApproachSettlement names the place in `Name`; the docking events use
390
+ # `StationName`. Same fact, two spellings.
391
+ name = str(event.get("StationName") or event.get("Name") or "")
392
+ if not name:
393
+ continue
394
+ history = found[market_id].previous_names if market_id in found else ()
395
+ earlier = found[market_id].station if market_id in found else ""
396
+ if earlier and earlier != name and earlier not in history:
397
+ history = history + (earlier,)
398
+ # MERGE, never replace. Only some naming events carry a system or a
399
+ # station type -- ApproachSettlement and DockingRequested give a name
400
+ # and nothing else -- so overwriting wholesale erases fields a richer
401
+ # earlier event had already supplied. Taking the latest event and
402
+ # letting it null out the system is how four sites lost theirs.
403
+ previous = found.get(market_id)
404
+ found[market_id] = SiteLocation(
405
+ market_id=market_id,
406
+ station=name,
407
+ system=str(event.get("StarSystem") or "") or (previous.system if previous else ""),
408
+ station_type=(
409
+ str(event.get("StationType") or "")
410
+ or (previous.station_type if previous else "")
411
+ ),
412
+ previous_names=history,
413
+ )
414
+ return found
415
+
416
+
417
+ def sites_from_events(
418
+ events: Iterable[Mapping], catalog: Optional[CommodityCatalog] = None
419
+ ) -> dict[int, ConstructionSite]:
420
+ """
421
+ The latest state of every construction site in a run of journal events.
422
+
423
+ Keyed by ``MarketID`` and last-one-wins, because the event carries the
424
+ site's whole state rather than a delta -- a commander bouncing between two
425
+ builds emits interleaved events for both, and each must end up with its own
426
+ most recent snapshot rather than a merge of the two.
427
+ """
428
+ sites: dict[int, ConstructionSite] = {}
429
+ for event in events:
430
+ if not isinstance(event, Mapping) or event.get("event") != DEPOT_EVENT:
431
+ continue
432
+ site = from_journal(event, catalog)
433
+ if site.market_id is not None:
434
+ sites[site.market_id] = site
435
+ return sites
436
+
437
+
438
+ __all__ = [
439
+ "CONTRIBUTION_EVENT",
440
+ "NAMING_EVENTS",
441
+ "STALE_AFTER_DAYS",
442
+ "SiteLocation",
443
+ "locations_from_events",
444
+ "DEPOT_EVENT",
445
+ "DEPOT_STATION_TYPES",
446
+ "ConstructionResource",
447
+ "ConstructionSite",
448
+ "from_journal",
449
+ "sites_from_events",
450
+ "station_key",
451
+ ]