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/capi.py ADDED
@@ -0,0 +1,292 @@
1
+ """
2
+ Elite Dangerous Companion API (CAPI) client.
3
+
4
+ Provides access to game data including:
5
+ - Commander profile
6
+ - Market data
7
+ - Fleet carrier information
8
+ - Ship/module data
9
+ """
10
+
11
+ import json
12
+ import time
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Optional, Any
16
+
17
+ import requests
18
+
19
+ from .constants import (
20
+ CAPI_SERVER_LIVE,
21
+ CAPI_SERVER_LEGACY,
22
+ CAPI_SERVER_BETA,
23
+ CAPI_PATH_PROFILE,
24
+ CAPI_PATH_MARKET,
25
+ CAPI_PATH_SHIPYARD,
26
+ CAPI_PATH_FLEETCARRIER,
27
+ CAPI_PATH_COMMUNITYGOALS,
28
+ DEFAULT_TIMEOUT,
29
+ FLEETCARRIER_TIMEOUT,
30
+ MIN_QUERY_INTERVAL,
31
+ FLEETCARRIER_COOLDOWN,
32
+ )
33
+ from .auth import FrontierAuth
34
+
35
+
36
+ class CAPIError(Exception):
37
+ """Base exception for CAPI errors."""
38
+
39
+ pass
40
+
41
+
42
+ class CAPIAuthError(CAPIError):
43
+ """Authentication error with CAPI."""
44
+
45
+ pass
46
+
47
+
48
+ class CAPIRateLimitError(CAPIError):
49
+ """Rate limit exceeded."""
50
+
51
+ pass
52
+
53
+
54
+ class CAPINoDataError(CAPIError):
55
+ """No data available (e.g., no fleet carrier owned)."""
56
+
57
+ pass
58
+
59
+
60
+ class CAPIClient:
61
+ """
62
+ Client for the Elite Dangerous Companion API.
63
+
64
+ Example usage:
65
+ auth = FrontierAuth(client_id="your_client_id")
66
+ if not auth.is_authenticated:
67
+ auth.authorize()
68
+
69
+ client = CAPIClient(auth)
70
+ profile = client.get_profile()
71
+ carrier = client.get_fleet_carrier()
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ auth: FrontierAuth,
77
+ server: str = CAPI_SERVER_LIVE,
78
+ debug_dir: Optional[Path] = None,
79
+ fleet_carrier_cooldown: Optional[float] = None,
80
+ ):
81
+ """
82
+ Initialize the CAPI client.
83
+
84
+ Args:
85
+ auth: Authenticated FrontierAuth instance
86
+ server: CAPI server URL (default: live server)
87
+ debug_dir: Optional directory to save debug JSON responses
88
+ fleet_carrier_cooldown: seconds to enforce between fleet-carrier
89
+ queries, defaulting to FLEETCARRIER_COOLDOWN. This is OUR
90
+ politeness, not a limit Frontier imposes -- measured: two
91
+ calls one second apart both succeeded in about a second and
92
+ returned byte-identical cached data. A long-running publisher
93
+ may lower it deliberately; passing a number here is how that
94
+ is said out loud, rather than by building a fresh client per
95
+ call and letting the counter start from zero each time.
96
+ """
97
+ self.auth = auth
98
+ self.server = server
99
+ self.debug_dir = debug_dir
100
+ self.fleet_carrier_cooldown = (
101
+ FLEETCARRIER_COOLDOWN if fleet_carrier_cooldown is None
102
+ else fleet_carrier_cooldown
103
+ )
104
+ self._last_query_time: float = 0
105
+ self._last_fc_query_time: float = 0
106
+
107
+ def _get_headers(self) -> dict:
108
+ """Get request headers with authorization."""
109
+ token = self.auth.access_token
110
+ if not token:
111
+ raise CAPIAuthError("No access token available. Please authenticate first.")
112
+ return {
113
+ "Authorization": f"Bearer {token}",
114
+ "User-Agent": "EDAPITool/0.1.0",
115
+ }
116
+
117
+ def _check_rate_limit(self, is_fleet_carrier: bool = False) -> None:
118
+ """Check and enforce rate limiting."""
119
+ now = time.time()
120
+
121
+ if is_fleet_carrier:
122
+ elapsed = now - self._last_fc_query_time
123
+ if elapsed < self.fleet_carrier_cooldown:
124
+ wait_time = self.fleet_carrier_cooldown - elapsed
125
+ raise CAPIRateLimitError(
126
+ f"Fleet carrier query cooldown. Wait {wait_time:.0f} seconds."
127
+ )
128
+ else:
129
+ elapsed = now - self._last_query_time
130
+ if elapsed < MIN_QUERY_INTERVAL:
131
+ # For regular queries, just wait
132
+ time.sleep(MIN_QUERY_INTERVAL - elapsed)
133
+
134
+ def _save_debug(self, endpoint: str, data: dict) -> None:
135
+ """Save response to debug file if debug_dir is set."""
136
+ if not self.debug_dir:
137
+ return
138
+
139
+ self.debug_dir.mkdir(parents=True, exist_ok=True)
140
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
141
+ endpoint_name = endpoint.strip("/").replace("/", "_")
142
+ filename = f"{endpoint_name}.{timestamp}.json"
143
+ filepath = self.debug_dir / filename
144
+
145
+ with open(filepath, "w", encoding="utf-8") as f:
146
+ json.dump(data, f, indent=2, ensure_ascii=False)
147
+
148
+ def _request(
149
+ self,
150
+ endpoint: str,
151
+ timeout: int = DEFAULT_TIMEOUT,
152
+ is_fleet_carrier: bool = False,
153
+ ) -> dict:
154
+ """
155
+ Make an authenticated request to the CAPI.
156
+
157
+ Args:
158
+ endpoint: API endpoint path
159
+ timeout: Request timeout in seconds
160
+ is_fleet_carrier: Whether this is a fleet carrier query
161
+
162
+ Returns:
163
+ JSON response as dictionary
164
+
165
+ Raises:
166
+ CAPIAuthError: Authentication failed
167
+ CAPIRateLimitError: Rate limit exceeded
168
+ CAPINoDataError: No data available
169
+ CAPIError: Other API errors
170
+ """
171
+ self._check_rate_limit(is_fleet_carrier)
172
+
173
+ url = f"{self.server}{endpoint}"
174
+
175
+ try:
176
+ response = requests.get(
177
+ url,
178
+ headers=self._get_headers(),
179
+ timeout=timeout,
180
+ )
181
+
182
+ # Update query times
183
+ if is_fleet_carrier:
184
+ self._last_fc_query_time = time.time()
185
+ self._last_query_time = time.time()
186
+
187
+ # Handle response codes
188
+ if response.status_code == 200:
189
+ data = response.json()
190
+ self._save_debug(endpoint, data)
191
+ return data
192
+
193
+ elif response.status_code == 204:
194
+ raise CAPINoDataError(f"No data available for {endpoint}")
195
+
196
+ elif response.status_code == 401:
197
+ raise CAPIAuthError("Authentication failed. Token may be expired.")
198
+
199
+ elif response.status_code == 422:
200
+ # Token expired, try to refresh
201
+ if self.auth.refresh():
202
+ # Retry the request
203
+ return self._request(endpoint, timeout, is_fleet_carrier)
204
+ raise CAPIAuthError("Token expired and refresh failed.")
205
+
206
+ elif response.status_code == 429:
207
+ raise CAPIRateLimitError("Rate limit exceeded. Please wait.")
208
+
209
+ else:
210
+ raise CAPIError(
211
+ f"CAPI request failed: {response.status_code} - {response.text}"
212
+ )
213
+
214
+ except requests.Timeout:
215
+ raise CAPIError(f"Request timed out after {timeout} seconds")
216
+ except requests.RequestException as e:
217
+ raise CAPIError(f"Request failed: {e}")
218
+
219
+ def get_profile(self) -> dict:
220
+ """
221
+ Get commander profile data.
222
+
223
+ Returns:
224
+ Commander profile including name, credits, ranks, ships, etc.
225
+ """
226
+ return self._request(CAPI_PATH_PROFILE)
227
+
228
+ def get_market(self) -> dict:
229
+ """
230
+ Get current station market data.
231
+
232
+ Returns:
233
+ Market data for the current docked station.
234
+ """
235
+ return self._request(CAPI_PATH_MARKET)
236
+
237
+ def get_shipyard(self) -> dict:
238
+ """
239
+ Get current station shipyard data.
240
+
241
+ Returns:
242
+ Available ships at the current docked station.
243
+ """
244
+ return self._request(CAPI_PATH_SHIPYARD)
245
+
246
+ def get_fleet_carrier(self) -> dict:
247
+ """
248
+ Get fleet carrier data.
249
+
250
+ Returns:
251
+ Complete fleet carrier data including:
252
+ - Identity (callsign, name)
253
+ - Location and status
254
+ - Cargo and inventory
255
+ - Market orders
256
+ - Financial information
257
+ - Services and crew
258
+ - Travel history
259
+
260
+ Raises:
261
+ CAPINoDataError: Player doesn't own a fleet carrier
262
+ CAPIRateLimitError: Must wait 15 minutes between queries
263
+ """
264
+ return self._request(
265
+ CAPI_PATH_FLEETCARRIER,
266
+ timeout=FLEETCARRIER_TIMEOUT,
267
+ is_fleet_carrier=True,
268
+ )
269
+
270
+ def get_community_goals(self) -> dict:
271
+ """
272
+ Get active community goals.
273
+
274
+ Returns:
275
+ List of active community goals with progress.
276
+ """
277
+ return self._request(CAPI_PATH_COMMUNITYGOALS)
278
+
279
+ @staticmethod
280
+ def use_live_server() -> str:
281
+ """Get the live server URL."""
282
+ return CAPI_SERVER_LIVE
283
+
284
+ @staticmethod
285
+ def use_legacy_server() -> str:
286
+ """Get the legacy server URL."""
287
+ return CAPI_SERVER_LEGACY
288
+
289
+ @staticmethod
290
+ def use_beta_server() -> str:
291
+ """Get the beta/PTS server URL."""
292
+ return CAPI_SERVER_BETA
APITool/cargo.py ADDED
@@ -0,0 +1,148 @@
1
+ """
2
+ The shared column contract for every generated cargo tab.
3
+
4
+ An inventory tab -- the fleet carrier's hold, the ship's hold, and whatever
5
+ comes next -- publishes the same three columns in the same three places:
6
+
7
+ A margin, always empty
8
+ B Commodity <- the lookup key
9
+ C Quantity <- VLOOKUP index 2
10
+ D Unit Price <- VLOOKUP index 3
11
+
12
+ **Everything right of D is that tab's own business.** The carrier carries a
13
+ total value because it has real prices and someone reads that tab by eye; the
14
+ ship carries symbol and stolen flags. Neither needs the other's tail, and
15
+ forcing one would mean emitting columns a producer cannot fill.
16
+
17
+ Why the first three are fixed, and why this module exists to fix them:
18
+
19
+ A spreadsheet reads these tabs with ``VLOOKUP($B5, <tab>!$B:$D, 3, FALSE)``.
20
+ That ``3`` is a LITERAL, not a reference. Insert a column left of D and Google
21
+ Sheets widens the range to ``$B:$E`` automatically -- and leaves the literal
22
+ alone. Measured 2026-09-08 on a throwaway tab: after inserting one column,
23
+ index 2 returned the newly inserted column and index 3 returned Quantity.
24
+ Every lookup shifted one column right, silently, with no error anywhere.
25
+
26
+ There were 476 such formulas in the live workbook when this was written. So
27
+ the constraint is not "keep the columns tidy" -- it is **never insert a column
28
+ left of D**, and the test that asserts these indices is the only thing
29
+ standing between a refactor and several hundred quietly wrong numbers.
30
+
31
+ Adding a column to the RIGHT of D is always safe, which is where a future
32
+ ``Category`` belongs if it is ever wanted.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Iterable, Sequence
38
+
39
+ # Column A is left empty as a margin, matching FreighterData and MarketData.
40
+ MARGIN = ""
41
+
42
+ # The three columns every cargo tab shares, in order, starting at column B.
43
+ CONTRACT_HEADERS = ["Commodity", "Quantity", "Unit Price"]
44
+
45
+ # What a spreadsheet passes as VLOOKUP's index argument against $B:$D.
46
+ # These are the numbers that appear as literals in the sheet's formulas, so
47
+ # they are part of the published contract and cannot change without editing
48
+ # every formula that uses them.
49
+ INDEX_COMMODITY = 1
50
+ INDEX_QUANTITY = 2
51
+ INDEX_UNIT_PRICE = 3
52
+
53
+ # The A1 range a consumer should look up against.
54
+ LOOKUP_RANGE = "$B:$D"
55
+
56
+
57
+ def header_row(extra: Sequence[str] = ()) -> list:
58
+ """
59
+ The header row: margin, the three contract headers, then this tab's own.
60
+
61
+ ``extra`` lands right of Unit Price, where adding columns is safe.
62
+ """
63
+ return [MARGIN, *CONTRACT_HEADERS, *extra]
64
+
65
+
66
+ def data_row(
67
+ commodity: str,
68
+ quantity: object,
69
+ unit_price: object = "",
70
+ extra: Sequence[object] = (),
71
+ ) -> list:
72
+ """
73
+ One commodity row.
74
+
75
+ ``unit_price`` defaults to empty rather than zero. A blank cell reads as
76
+ "not known" and sums as nothing; a zero reads as "worthless", which is a
77
+ different and wrong claim about cargo whose price this tool has no source
78
+ for. Sheets coerces the empty string to 0 in arithmetic, so a SUM over the
79
+ column still works.
80
+ """
81
+ return [MARGIN, commodity, quantity, unit_price, *extra]
82
+
83
+
84
+ def lookup_formula(key_cell: str, tab: str, index: int) -> str:
85
+ """
86
+ The formula a sheet should use against a cargo tab.
87
+
88
+ Rendered here so the documentation, the README and any --show-formula
89
+ output cannot drift from the contract they describe.
90
+
91
+ IFNA rather than IFERROR: IFNA blanks a commodity that is not in the tab,
92
+ but still surfaces a #REF! if the tab is renamed or deleted. IFERROR hides
93
+ that too, and a silently blank column feeds the sheet's arithmetic a
94
+ number that looks deliberate.
95
+ """
96
+ return f'=IFNA(VLOOKUP({key_cell}, {tab}!{LOOKUP_RANGE}, {index}, FALSE), "")'
97
+
98
+
99
+ def verify_contract(grid: Iterable[Sequence], header_row_index: int) -> None:
100
+ """
101
+ Raise unless a built grid honours the contract.
102
+
103
+ Cheap enough to call from a producer before writing, and the thing a test
104
+ asserts. Checks the header text and, crucially, that the contract columns
105
+ sit at the offsets the published VLOOKUP indices name.
106
+ """
107
+ rows = list(grid)
108
+ if header_row_index >= len(rows):
109
+ raise ValueError(f"no header row at index {header_row_index}")
110
+ header = list(rows[header_row_index])
111
+
112
+ if not header or header[0] != MARGIN:
113
+ raise ValueError(f"column A must be an empty margin, found {header[:1]!r}")
114
+
115
+ # Check the INDICES, not the header list. Comparing the B-D slice against
116
+ # CONTRACT_HEADERS would be equivalent -- and was, in the first draft --
117
+ # but it reports "the headers differ" when what actually broke is "index 3
118
+ # no longer means Unit Price". The index is what the user's formulas carry
119
+ # as a literal, so it is what the error should name.
120
+ #
121
+ # VLOOKUP index N against $B:$D is header[N], because the margin is at 0.
122
+ for index, expected in (
123
+ (INDEX_COMMODITY, "Commodity"),
124
+ (INDEX_QUANTITY, "Quantity"),
125
+ (INDEX_UNIT_PRICE, "Unit Price"),
126
+ ):
127
+ found = header[index] if index < len(header) else None
128
+ if found != expected:
129
+ raise ValueError(
130
+ f"VLOOKUP index {index} must address {expected!r}, found {found!r}. "
131
+ f"Cargo tabs expose {CONTRACT_HEADERS} in columns B-D; a column "
132
+ f"was inserted or renamed left of D, which shifts every lookup "
133
+ f"in the workbook without raising anything in the sheet."
134
+ )
135
+
136
+
137
+ __all__ = [
138
+ "CONTRACT_HEADERS",
139
+ "INDEX_COMMODITY",
140
+ "INDEX_QUANTITY",
141
+ "INDEX_UNIT_PRICE",
142
+ "LOOKUP_RANGE",
143
+ "MARGIN",
144
+ "data_row",
145
+ "header_row",
146
+ "lookup_formula",
147
+ "verify_contract",
148
+ ]