edapitool 0.6.3__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.
Files changed (61) hide show
  1. edapitool-0.6.3/APITool/__init__.py +20 -0
  2. edapitool-0.6.3/APITool/auth.py +490 -0
  3. edapitool-0.6.3/APITool/capi.py +292 -0
  4. edapitool-0.6.3/APITool/cargo.py +148 -0
  5. edapitool-0.6.3/APITool/catalog.py +274 -0
  6. edapitool-0.6.3/APITool/cli.py +1419 -0
  7. edapitool-0.6.3/APITool/constants.py +36 -0
  8. edapitool-0.6.3/APITool/construction.py +451 -0
  9. edapitool-0.6.3/APITool/daemon.py +668 -0
  10. edapitool-0.6.3/APITool/data/fdev_commodities.csv +413 -0
  11. edapitool-0.6.3/APITool/data/name_aliases.json +10 -0
  12. edapitool-0.6.3/APITool/export.py +987 -0
  13. edapitool-0.6.3/APITool/google/__init__.py +10 -0
  14. edapitool-0.6.3/APITool/google/exporter.py +517 -0
  15. edapitool-0.6.3/APITool/journal.py +429 -0
  16. edapitool-0.6.3/APITool/market.py +463 -0
  17. edapitool-0.6.3/APITool/matcher.py +279 -0
  18. edapitool-0.6.3/APITool/models.py +491 -0
  19. edapitool-0.6.3/APITool/service.py +398 -0
  20. edapitool-0.6.3/APITool/settings.py +149 -0
  21. edapitool-0.6.3/APITool/sheets/__init__.py +41 -0
  22. edapitool-0.6.3/APITool/sheets/a1.py +107 -0
  23. edapitool-0.6.3/APITool/sheets/destination.py +185 -0
  24. edapitool-0.6.3/APITool/sheets/guard.py +77 -0
  25. edapitool-0.6.3/APITool/sheets/layout.py +122 -0
  26. edapitool-0.6.3/APITool/ship.py +315 -0
  27. edapitool-0.6.3/APITool/version.py +130 -0
  28. edapitool-0.6.3/APITool/workbook/__init__.py +12 -0
  29. edapitool-0.6.3/APITool/workbook/markers.py +440 -0
  30. edapitool-0.6.3/APITool/workbook/totals.py +338 -0
  31. edapitool-0.6.3/LICENSE +232 -0
  32. edapitool-0.6.3/PKG-INFO +230 -0
  33. edapitool-0.6.3/README.md +186 -0
  34. edapitool-0.6.3/edapitool.egg-info/PKG-INFO +230 -0
  35. edapitool-0.6.3/edapitool.egg-info/SOURCES.txt +59 -0
  36. edapitool-0.6.3/edapitool.egg-info/dependency_links.txt +1 -0
  37. edapitool-0.6.3/edapitool.egg-info/entry_points.txt +3 -0
  38. edapitool-0.6.3/edapitool.egg-info/requires.txt +19 -0
  39. edapitool-0.6.3/edapitool.egg-info/top_level.txt +1 -0
  40. edapitool-0.6.3/pyproject.toml +133 -0
  41. edapitool-0.6.3/setup.cfg +4 -0
  42. edapitool-0.6.3/tests/test_baseline_freshness.py +97 -0
  43. edapitool-0.6.3/tests/test_capi_cooldown.py +88 -0
  44. edapitool-0.6.3/tests/test_cargo_contract.py +256 -0
  45. edapitool-0.6.3/tests/test_catalog.py +429 -0
  46. edapitool-0.6.3/tests/test_ci_workflow.py +135 -0
  47. edapitool-0.6.3/tests/test_cli_ship.py +217 -0
  48. edapitool-0.6.3/tests/test_config_regions.py +253 -0
  49. edapitool-0.6.3/tests/test_construction.py +305 -0
  50. edapitool-0.6.3/tests/test_daemon.py +658 -0
  51. edapitool-0.6.3/tests/test_destination.py +188 -0
  52. edapitool-0.6.3/tests/test_gsheet_guard.py +110 -0
  53. edapitool-0.6.3/tests/test_journal.py +483 -0
  54. edapitool-0.6.3/tests/test_marker_formula.py +173 -0
  55. edapitool-0.6.3/tests/test_market_data.py +577 -0
  56. edapitool-0.6.3/tests/test_matcher.py +457 -0
  57. edapitool-0.6.3/tests/test_region_publishing.py +386 -0
  58. edapitool-0.6.3/tests/test_service.py +691 -0
  59. edapitool-0.6.3/tests/test_settings.py +183 -0
  60. edapitool-0.6.3/tests/test_sheets.py +949 -0
  61. edapitool-0.6.3/tests/test_ship.py +246 -0
@@ -0,0 +1,20 @@
1
+ """
2
+ APITool - Elite Dangerous API Tool
3
+
4
+ A Python tool for accessing the Elite Dangerous Companion API (CAPI)
5
+ to extract fleet carrier inventory and other game data.
6
+ """
7
+
8
+ from .version import get_base_version
9
+
10
+ # Derived, never hardcoded: this line said "0.1.0" through three releases
11
+ # because nothing kept it in step with version.py, which the build, the CLI
12
+ # and the release tooling all read instead.
13
+ __version__ = get_base_version()
14
+ __author__ = "djdarcy"
15
+
16
+ from .capi import CAPIClient
17
+ from .auth import FrontierAuth
18
+ from .models import FleetCarrier, CargoItem
19
+
20
+ __all__ = ["CAPIClient", "FrontierAuth", "FleetCarrier", "CargoItem", "__version__"]
@@ -0,0 +1,490 @@
1
+ """
2
+ OAuth2 authentication handler for Frontier's CAPI service.
3
+
4
+ To use the CAPI, you need to:
5
+ 1. Register your application at https://auth.frontierstore.net/client/signup
6
+ 2. Obtain a client_id
7
+ 3. Use this module to authenticate users via OAuth2
8
+
9
+ Note: Tokens expire and need refresh every ~25 days.
10
+ """
11
+
12
+ import json
13
+ import secrets
14
+ import webbrowser
15
+ import hashlib
16
+ import base64
17
+ import urllib.parse
18
+ from pathlib import Path
19
+ from datetime import datetime, timedelta
20
+ from http.server import HTTPServer, BaseHTTPRequestHandler
21
+ from typing import Optional
22
+ import threading
23
+
24
+ import requests
25
+
26
+ from .constants import (
27
+ AUTH_SERVER,
28
+ AUTH_PATH_AUTH,
29
+ AUTH_PATH_TOKEN,
30
+ TOKEN_FILE,
31
+ OAUTH_SCOPES,
32
+ )
33
+
34
+
35
+ class TokenStorage:
36
+ """Handles secure storage and retrieval of OAuth tokens."""
37
+
38
+ def __init__(self, token_file: Optional[Path] = None):
39
+ self.token_file = token_file or Path.home() / TOKEN_FILE
40
+
41
+ def save(self, tokens: dict) -> None:
42
+ """Save tokens to file."""
43
+ tokens["saved_at"] = datetime.now().isoformat()
44
+ self.token_file.write_text(json.dumps(tokens, indent=2))
45
+ # Set restrictive permissions on Unix systems
46
+ try:
47
+ self.token_file.chmod(0o600)
48
+ except (OSError, AttributeError):
49
+ pass # Windows doesn't support chmod the same way
50
+
51
+ def load(self) -> Optional[dict]:
52
+ """Load tokens from file if they exist."""
53
+ if not self.token_file.exists():
54
+ return None
55
+ try:
56
+ return json.loads(self.token_file.read_text())
57
+ except (json.JSONDecodeError, IOError):
58
+ return None
59
+
60
+ def clear(self) -> None:
61
+ """Remove stored tokens."""
62
+ if self.token_file.exists():
63
+ self.token_file.unlink()
64
+
65
+
66
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
67
+ """HTTP handler for OAuth2 callback."""
68
+
69
+ def log_message(self, format, *args):
70
+ """Suppress HTTP server logs."""
71
+ pass
72
+
73
+ def do_GET(self):
74
+ """Handle the OAuth callback."""
75
+ parsed = urllib.parse.urlparse(self.path)
76
+ query = urllib.parse.parse_qs(parsed.query)
77
+
78
+ if "code" in query:
79
+ self.server.auth_code = query["code"][0]
80
+ self.server.auth_state = query.get("state", [None])[0]
81
+ response = b"<html><body><h1>Authorization successful!</h1><p>You can close this window.</p></body></html>"
82
+ self.send_response(200)
83
+ else:
84
+ self.server.auth_code = None
85
+ self.server.auth_error = query.get("error", ["unknown"])[0]
86
+ response = b"<html><body><h1>Authorization failed</h1><p>Please try again.</p></body></html>"
87
+ self.send_response(400)
88
+
89
+ self.send_header("Content-type", "text/html")
90
+ self.end_headers()
91
+ self.wfile.write(response)
92
+
93
+
94
+ class FrontierAuth:
95
+ """
96
+ Handles OAuth2 authentication with Frontier's auth service.
97
+
98
+ Uses PKCE (Proof Key for Code Exchange) for security.
99
+
100
+ Supports two authorization modes:
101
+ - Automatic: Local HTTP server captures callback (requires localhost redirect)
102
+ - Manual: User copies authorization code from browser URL
103
+ """
104
+
105
+ # Default redirect URI for manual auth flow (doesn't need to be a real server)
106
+ DEFAULT_REDIRECT_URI = "https://localhost/callback"
107
+
108
+ def __init__(
109
+ self,
110
+ client_id: str,
111
+ redirect_uri: Optional[str] = None,
112
+ redirect_port: int = 8085,
113
+ token_storage: Optional[TokenStorage] = None,
114
+ ):
115
+ """
116
+ Initialize the auth handler.
117
+
118
+ Args:
119
+ client_id: Your registered application's client ID
120
+ redirect_uri: Custom redirect URI (for manual flow or custom domains)
121
+ redirect_port: Local port for OAuth callback (default 8085, ignored if redirect_uri set)
122
+ token_storage: Optional custom token storage handler
123
+ """
124
+ self.client_id = client_id
125
+ self.redirect_port = redirect_port
126
+ self.storage = token_storage or TokenStorage()
127
+
128
+ # Use custom redirect_uri if provided, otherwise default to localhost
129
+ if redirect_uri:
130
+ self.redirect_uri = redirect_uri
131
+ self._use_local_server = False
132
+ else:
133
+ self.redirect_uri = f"http://localhost:{redirect_port}/callback"
134
+ self._use_local_server = True
135
+
136
+ self._access_token: Optional[str] = None
137
+ self._refresh_token: Optional[str] = None
138
+ self._token_expiry: Optional[datetime] = None
139
+
140
+ # Store PKCE verifier for manual flow
141
+ self._pending_verifier: Optional[str] = None
142
+ self._pending_state: Optional[str] = None
143
+
144
+ # Try to load existing tokens
145
+ self._load_tokens()
146
+
147
+ def _load_tokens(self) -> bool:
148
+ """Load tokens from storage."""
149
+ tokens = self.storage.load()
150
+ if tokens:
151
+ self._access_token = tokens.get("access_token")
152
+ self._refresh_token = tokens.get("refresh_token")
153
+ if "expires_at" in tokens:
154
+ self._token_expiry = datetime.fromisoformat(tokens["expires_at"])
155
+ return True
156
+ return False
157
+
158
+ def _save_tokens(self) -> None:
159
+ """Save current tokens to storage."""
160
+ if self._access_token:
161
+ tokens = {
162
+ "access_token": self._access_token,
163
+ "refresh_token": self._refresh_token,
164
+ "expires_at": self._token_expiry.isoformat()
165
+ if self._token_expiry
166
+ else None,
167
+ }
168
+ self.storage.save(tokens)
169
+
170
+ def _generate_pkce(self) -> tuple[str, str]:
171
+ """Generate PKCE code verifier and challenge."""
172
+ # Generate random verifier (43-128 chars)
173
+ verifier = secrets.token_urlsafe(32)
174
+
175
+ # Create SHA256 hash, then base64url encode
176
+ digest = hashlib.sha256(verifier.encode()).digest()
177
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
178
+
179
+ return verifier, challenge
180
+
181
+ @property
182
+ def is_authenticated(self) -> bool:
183
+ """
184
+ Do we hold usable authorization?
185
+
186
+ An EXPIRED access token is not the same as no authorization. Frontier
187
+ access tokens last about four hours while the refresh token is good for
188
+ weeks, so treating expiry as "not authenticated" sends the commander
189
+ through a full browser consent flow several times a day -- and makes a
190
+ long-running daemon impossible, since nobody is at the keyboard to
191
+ click through it. Spend the refresh token first.
192
+ """
193
+ if not self._access_token:
194
+ return False
195
+ if self._token_expiry and datetime.now() >= self._token_expiry:
196
+ if self._refresh_token:
197
+ return self.refresh()
198
+ return False
199
+ return True
200
+
201
+ @property
202
+ def access_token(self) -> Optional[str]:
203
+ """Get the current access token, refreshing if needed."""
204
+ if not self._access_token:
205
+ return None
206
+
207
+ # Check if token is expired or about to expire (5 min buffer)
208
+ if self._token_expiry:
209
+ if datetime.now() >= self._token_expiry - timedelta(minutes=5):
210
+ if self._refresh_token:
211
+ self.refresh()
212
+
213
+ return self._access_token
214
+
215
+ def authorize(self, timeout: int = 120, manual: bool = False) -> bool:
216
+ """
217
+ Initiate OAuth2 authorization flow.
218
+
219
+ Args:
220
+ timeout: Seconds to wait for user authorization (auto mode only)
221
+ manual: If True, use manual code entry instead of local server
222
+
223
+ Returns:
224
+ True if authorization successful
225
+ """
226
+ # Force manual mode if not using local server
227
+ if not self._use_local_server:
228
+ manual = True
229
+
230
+ if manual:
231
+ return self.authorize_manual()
232
+ else:
233
+ return self.authorize_auto(timeout)
234
+
235
+ def authorize_auto(self, timeout: int = 120) -> bool:
236
+ """
237
+ Automatic authorization using local HTTP server.
238
+
239
+ Opens browser for user to authorize, then captures callback.
240
+
241
+ Args:
242
+ timeout: Seconds to wait for user authorization
243
+
244
+ Returns:
245
+ True if authorization successful
246
+ """
247
+ # Generate PKCE values
248
+ verifier, challenge = self._generate_pkce()
249
+ state = secrets.token_urlsafe(16)
250
+
251
+ # Build authorization URL
252
+ params = {
253
+ "response_type": "code",
254
+ "client_id": self.client_id,
255
+ "redirect_uri": self.redirect_uri,
256
+ "scope": " ".join(OAUTH_SCOPES),
257
+ "state": state,
258
+ "code_challenge": challenge,
259
+ "code_challenge_method": "S256",
260
+ }
261
+ auth_url = f"{AUTH_SERVER}{AUTH_PATH_AUTH}?{urllib.parse.urlencode(params)}"
262
+
263
+ # Start local server to receive callback
264
+ try:
265
+ server = HTTPServer(("localhost", self.redirect_port), OAuthCallbackHandler)
266
+ except OSError as e:
267
+ print(f"Could not start local server on port {self.redirect_port}: {e}")
268
+ print("Falling back to manual authorization...")
269
+ return self.authorize_manual()
270
+
271
+ server.auth_code = None
272
+ server.auth_error = None
273
+ server.auth_state = None
274
+ server.timeout = timeout
275
+
276
+ # Open browser
277
+ print(f"Opening browser for authorization...")
278
+ print(f"If browser doesn't open, visit: {auth_url}")
279
+ webbrowser.open(auth_url)
280
+
281
+ # Wait for callback (with timeout)
282
+ server.handle_request()
283
+ server.server_close()
284
+
285
+ if not server.auth_code:
286
+ print(f"Authorization failed: {server.auth_error}")
287
+ return False
288
+
289
+ if server.auth_state != state:
290
+ print("Authorization failed: state mismatch (possible CSRF attack)")
291
+ return False
292
+
293
+ # Exchange code for tokens
294
+ return self._exchange_code(server.auth_code, verifier)
295
+
296
+ def authorize_manual(self) -> bool:
297
+ """
298
+ Manual authorization flow - user copies code from browser URL.
299
+
300
+ This flow works even if the redirect URI doesn't point to a real server.
301
+ The user will see a "page not found" error but can copy the code from the URL.
302
+
303
+ Returns:
304
+ True if authorization successful
305
+ """
306
+ # Generate PKCE values
307
+ verifier, challenge = self._generate_pkce()
308
+ state = secrets.token_urlsafe(16)
309
+
310
+ # Store for later use
311
+ self._pending_verifier = verifier
312
+ self._pending_state = state
313
+
314
+ # Build authorization URL
315
+ params = {
316
+ "response_type": "code",
317
+ "client_id": self.client_id,
318
+ "redirect_uri": self.redirect_uri,
319
+ "scope": " ".join(OAUTH_SCOPES),
320
+ "state": state,
321
+ "code_challenge": challenge,
322
+ "code_challenge_method": "S256",
323
+ }
324
+ auth_url = f"{AUTH_SERVER}{AUTH_PATH_AUTH}?{urllib.parse.urlencode(params)}"
325
+
326
+ print()
327
+ print("=" * 60)
328
+ print("MANUAL AUTHORIZATION FLOW")
329
+ print("=" * 60)
330
+ print()
331
+ print("1. Open this URL in your browser:")
332
+ print()
333
+ print(f" {auth_url}")
334
+ print()
335
+ print("2. Log in to your Frontier account and authorize the app")
336
+ print()
337
+ print("3. You'll be redirected to a page that may show an error")
338
+ print(" (This is expected if the redirect URL isn't a real server)")
339
+ print()
340
+ print("4. Copy the ENTIRE URL from your browser's address bar")
341
+ print(" It will look like: https://...?code=XXXXX&state=YYYYY")
342
+ print()
343
+ print("=" * 60)
344
+ print()
345
+
346
+ # Try to open browser
347
+ try:
348
+ webbrowser.open(auth_url)
349
+ print("(Browser should have opened automatically)")
350
+ print()
351
+ except Exception:
352
+ pass
353
+
354
+ # Get the callback URL from user
355
+ callback_url = input("Paste the full callback URL here: ").strip()
356
+
357
+ if not callback_url:
358
+ print("No URL provided.")
359
+ return False
360
+
361
+ return self.complete_manual_auth(callback_url)
362
+
363
+ def complete_manual_auth(self, callback_url: str) -> bool:
364
+ """
365
+ Complete manual authorization by parsing the callback URL.
366
+
367
+ Args:
368
+ callback_url: The full URL from the browser after authorization
369
+
370
+ Returns:
371
+ True if authorization successful
372
+ """
373
+ if not self._pending_verifier:
374
+ print("No pending authorization. Call authorize_manual() first.")
375
+ return False
376
+
377
+ # Parse the callback URL
378
+ try:
379
+ parsed = urllib.parse.urlparse(callback_url)
380
+ query = urllib.parse.parse_qs(parsed.query)
381
+ except Exception as e:
382
+ print(f"Could not parse URL: {e}")
383
+ return False
384
+
385
+ # Check for error
386
+ if "error" in query:
387
+ error = query["error"][0]
388
+ error_desc = query.get("error_description", [""])[0]
389
+ print(f"Authorization failed: {error}")
390
+ if error_desc:
391
+ print(f" {error_desc}")
392
+ return False
393
+
394
+ # Get the code
395
+ if "code" not in query:
396
+ print("No authorization code found in URL.")
397
+ print("Make sure you copied the complete URL including the ?code=... part")
398
+ return False
399
+
400
+ code = query["code"][0]
401
+
402
+ # Verify state if present
403
+ if "state" in query:
404
+ if query["state"][0] != self._pending_state:
405
+ print("Warning: State mismatch. This could indicate a security issue.")
406
+ # Continue anyway for usability, but warn the user
407
+
408
+ # Exchange code for tokens
409
+ verifier = self._pending_verifier
410
+ self._pending_verifier = None
411
+ self._pending_state = None
412
+
413
+ return self._exchange_code(code, verifier)
414
+
415
+ def _exchange_code(self, code: str, verifier: str) -> bool:
416
+ """Exchange authorization code for tokens."""
417
+ data = {
418
+ "grant_type": "authorization_code",
419
+ "code": code,
420
+ "redirect_uri": self.redirect_uri,
421
+ "client_id": self.client_id,
422
+ "code_verifier": verifier,
423
+ }
424
+
425
+ try:
426
+ response = requests.post(
427
+ f"{AUTH_SERVER}{AUTH_PATH_TOKEN}",
428
+ data=data,
429
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
430
+ timeout=30,
431
+ )
432
+ response.raise_for_status()
433
+
434
+ tokens = response.json()
435
+ self._access_token = tokens["access_token"]
436
+ self._refresh_token = tokens.get("refresh_token")
437
+
438
+ # Calculate expiry time
439
+ expires_in = tokens.get("expires_in", 3600)
440
+ self._token_expiry = datetime.now() + timedelta(seconds=expires_in)
441
+
442
+ self._save_tokens()
443
+ print("Authorization successful!")
444
+ return True
445
+
446
+ except requests.RequestException as e:
447
+ print(f"Token exchange failed: {e}")
448
+ return False
449
+
450
+ def refresh(self) -> bool:
451
+ """Refresh the access token using the refresh token."""
452
+ if not self._refresh_token:
453
+ print("No refresh token available")
454
+ return False
455
+
456
+ data = {
457
+ "grant_type": "refresh_token",
458
+ "refresh_token": self._refresh_token,
459
+ "client_id": self.client_id,
460
+ }
461
+
462
+ try:
463
+ response = requests.post(
464
+ f"{AUTH_SERVER}{AUTH_PATH_TOKEN}",
465
+ data=data,
466
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
467
+ timeout=30,
468
+ )
469
+ response.raise_for_status()
470
+
471
+ tokens = response.json()
472
+ self._access_token = tokens["access_token"]
473
+ self._refresh_token = tokens.get("refresh_token", self._refresh_token)
474
+
475
+ expires_in = tokens.get("expires_in", 3600)
476
+ self._token_expiry = datetime.now() + timedelta(seconds=expires_in)
477
+
478
+ self._save_tokens()
479
+ return True
480
+
481
+ except requests.RequestException as e:
482
+ print(f"Token refresh failed: {e}")
483
+ return False
484
+
485
+ def logout(self) -> None:
486
+ """Clear stored tokens."""
487
+ self._access_token = None
488
+ self._refresh_token = None
489
+ self._token_expiry = None
490
+ self.storage.clear()