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/cli.py
ADDED
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for Elite Dangerous API Tool.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
edapitool auth - Authenticate with Frontier
|
|
6
|
+
edapitool profile - Get commander profile
|
|
7
|
+
edapitool carrier - Get fleet carrier data
|
|
8
|
+
edapitool carrier csv - Export carrier data to CSV
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .version import __version__, get_version
|
|
18
|
+
from .constants import CAPI_SERVER_LIVE, CAPI_SERVER_LEGACY
|
|
19
|
+
from .auth import FrontierAuth
|
|
20
|
+
from .capi import CAPIClient, CAPIError, CAPINoDataError
|
|
21
|
+
from .models import FleetCarrier
|
|
22
|
+
from .export import CSVExporter, JSONExporter
|
|
23
|
+
# Imported as NAMES, not through the module, and that is load-bearing:
|
|
24
|
+
# tests monkeypatch these on this module to stub resolution out. Calling
|
|
25
|
+
# settings.get_sheet_id(...) instead would leave those patches inert.
|
|
26
|
+
from . import settings
|
|
27
|
+
# The resolvers are imported as NAMES because tests monkeypatch them on this
|
|
28
|
+
# module; CONFIG_FILE is read through `settings` instead, because it is a
|
|
29
|
+
# VALUE -- importing it by name would freeze the path at import time and a
|
|
30
|
+
# message naming it would report a different file from the one just read.
|
|
31
|
+
from .settings import (
|
|
32
|
+
get_client_id,
|
|
33
|
+
get_construction_regions,
|
|
34
|
+
get_sheet_id,
|
|
35
|
+
parse_region_spec,
|
|
36
|
+
save,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def setup_auth(
|
|
41
|
+
client_id: str,
|
|
42
|
+
redirect_uri: Optional[str] = None,
|
|
43
|
+
manual: bool = False,
|
|
44
|
+
) -> FrontierAuth:
|
|
45
|
+
"""Set up authentication."""
|
|
46
|
+
auth = FrontierAuth(client_id, redirect_uri=redirect_uri)
|
|
47
|
+
if not auth.is_authenticated:
|
|
48
|
+
print("Not authenticated. Starting authorization flow...")
|
|
49
|
+
print()
|
|
50
|
+
if not manual:
|
|
51
|
+
print("You will be redirected to Frontier's login page.")
|
|
52
|
+
print("After logging in, authorize this application.")
|
|
53
|
+
print()
|
|
54
|
+
if auth.authorize(manual=manual):
|
|
55
|
+
print("Authentication successful!")
|
|
56
|
+
else:
|
|
57
|
+
print("Authentication failed.")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
return auth
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_client_id(client_id: str) -> bool:
|
|
63
|
+
"""Save client ID to config file for future use."""
|
|
64
|
+
return save("client_id", client_id)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_auth(args: argparse.Namespace) -> int:
|
|
68
|
+
"""Handle auth command."""
|
|
69
|
+
client_id = args.client_id or get_client_id()
|
|
70
|
+
if not client_id:
|
|
71
|
+
print("Error: No client ID provided.")
|
|
72
|
+
print()
|
|
73
|
+
print("To authenticate, you need a Frontier API client ID.")
|
|
74
|
+
print("Register your application at: https://auth.frontierstore.net/client/signup")
|
|
75
|
+
print()
|
|
76
|
+
print("Then either:")
|
|
77
|
+
print(" 1. Set ED_CLIENT_ID environment variable")
|
|
78
|
+
print(" 2. Create ~/.ed_capi_config.json with: {\"client_id\": \"your_id\"}")
|
|
79
|
+
print(" 3. Use --client-id argument")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
redirect_uri = getattr(args, 'redirect_uri', None)
|
|
83
|
+
manual = getattr(args, 'manual_auth', False)
|
|
84
|
+
|
|
85
|
+
auth = setup_auth(client_id, redirect_uri=redirect_uri, manual=manual)
|
|
86
|
+
|
|
87
|
+
# Save client ID to config file for future use
|
|
88
|
+
if auth.is_authenticated and args.client_id:
|
|
89
|
+
if save_client_id(client_id):
|
|
90
|
+
print(f"Client ID saved to ~/.ed_capi_config.json")
|
|
91
|
+
|
|
92
|
+
print(f"Authenticated: {auth.is_authenticated}")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_profile(args: argparse.Namespace) -> int:
|
|
97
|
+
"""Handle profile command."""
|
|
98
|
+
client_id = args.client_id or get_client_id()
|
|
99
|
+
if not client_id:
|
|
100
|
+
print("Error: No client ID. Run 'edapitool auth' first.")
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
redirect_uri = getattr(args, 'redirect_uri', None)
|
|
104
|
+
manual = getattr(args, 'manual_auth', False)
|
|
105
|
+
|
|
106
|
+
auth = setup_auth(client_id, redirect_uri=redirect_uri, manual=manual)
|
|
107
|
+
client = CAPIClient(auth)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
profile = client.get_profile()
|
|
111
|
+
|
|
112
|
+
if args.json:
|
|
113
|
+
print(json.dumps(profile, indent=2))
|
|
114
|
+
else:
|
|
115
|
+
commander = profile.get("commander", {})
|
|
116
|
+
print(f"Commander: {commander.get('name', 'Unknown')}")
|
|
117
|
+
print(f"Credits: {commander.get('credits', 0):,}")
|
|
118
|
+
print(f"Current Ship: {profile.get('ship', {}).get('name', 'Unknown')}")
|
|
119
|
+
|
|
120
|
+
except CAPIError as e:
|
|
121
|
+
print(f"Error: {e}")
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_carrier(args: argparse.Namespace) -> int:
|
|
128
|
+
"""Handle carrier command."""
|
|
129
|
+
client_id = args.client_id or get_client_id()
|
|
130
|
+
if not client_id:
|
|
131
|
+
print("Error: No client ID. Run 'edapitool auth' first.")
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
redirect_uri = getattr(args, 'redirect_uri', None)
|
|
135
|
+
manual = getattr(args, 'manual_auth', False)
|
|
136
|
+
|
|
137
|
+
# Parse include flags
|
|
138
|
+
include_flags = [f.strip().lower() for f in args.include.split(",") if f.strip()]
|
|
139
|
+
include_stolen = "stolen" in include_flags
|
|
140
|
+
include_mission = "mission" in include_flags
|
|
141
|
+
|
|
142
|
+
# Parse export formats (comma-separated)
|
|
143
|
+
export_formats = []
|
|
144
|
+
if args.export:
|
|
145
|
+
export_formats = [f.strip().lower() for f in args.export.split(",") if f.strip()]
|
|
146
|
+
|
|
147
|
+
# Resolved the same way every other verb resolves it. This used to read
|
|
148
|
+
# args.sheet_id raw, so a sheet id sitting in the config file or in
|
|
149
|
+
# ED_SHEET_ID was ignored here and honoured everywhere else -- nobody
|
|
150
|
+
# chose that, it is what four independent call sites drift into.
|
|
151
|
+
sheet_id = get_sheet_id(args)
|
|
152
|
+
if "google" in export_formats and not sheet_id:
|
|
153
|
+
print("Error: --export google needs a spreadsheet. Pass --sheet-id,")
|
|
154
|
+
print(' set ED_SHEET_ID, or add "sheet_id" to '
|
|
155
|
+
"~/.ed_capi_config.json.")
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
auth = setup_auth(client_id, redirect_uri=redirect_uri, manual=manual)
|
|
159
|
+
server = CAPI_SERVER_LEGACY if args.legacy else CAPI_SERVER_LIVE
|
|
160
|
+
client = CAPIClient(auth, server=server)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
print("Fetching fleet carrier data...", file=sys.stderr)
|
|
164
|
+
print("(This may take up to 60 seconds for large inventories)", file=sys.stderr)
|
|
165
|
+
print(file=sys.stderr)
|
|
166
|
+
|
|
167
|
+
raw_data = client.get_fleet_carrier()
|
|
168
|
+
|
|
169
|
+
# Output raw JSON if requested (before parsing to avoid errors)
|
|
170
|
+
if args.json:
|
|
171
|
+
print(json.dumps(raw_data, indent=2))
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
# Parse into model
|
|
175
|
+
carrier = FleetCarrier.from_capi(raw_data)
|
|
176
|
+
|
|
177
|
+
output_dir = Path(args.output) if args.output else Path.cwd()
|
|
178
|
+
exported_files = {}
|
|
179
|
+
|
|
180
|
+
# Handle each export format
|
|
181
|
+
for fmt in export_formats:
|
|
182
|
+
if fmt == "csv":
|
|
183
|
+
exporter = CSVExporter(output_dir)
|
|
184
|
+
files = exporter.export_all(carrier)
|
|
185
|
+
exported_files["csv"] = files
|
|
186
|
+
|
|
187
|
+
elif fmt == "json":
|
|
188
|
+
exporter = JSONExporter(output_dir)
|
|
189
|
+
filepath = exporter.export_carrier(carrier, include_raw=args.raw)
|
|
190
|
+
exported_files["json"] = filepath
|
|
191
|
+
|
|
192
|
+
elif fmt == "gsheet":
|
|
193
|
+
exporter = CSVExporter(output_dir)
|
|
194
|
+
filepath = exporter.export_cargo_gsheet(
|
|
195
|
+
carrier,
|
|
196
|
+
include_stolen=include_stolen,
|
|
197
|
+
include_mission=include_mission,
|
|
198
|
+
)
|
|
199
|
+
exported_files["gsheet"] = filepath
|
|
200
|
+
|
|
201
|
+
elif fmt == "google":
|
|
202
|
+
# Direct Google Sheets export
|
|
203
|
+
try:
|
|
204
|
+
from .google import GoogleSheetsExporter
|
|
205
|
+
gs_exporter = GoogleSheetsExporter()
|
|
206
|
+
gs_exporter.export_cargo(
|
|
207
|
+
carrier,
|
|
208
|
+
sheet_id=sheet_id,
|
|
209
|
+
include_stolen=include_stolen,
|
|
210
|
+
include_mission=include_mission,
|
|
211
|
+
)
|
|
212
|
+
exported_files["google"] = f"Sheet ID: {sheet_id}"
|
|
213
|
+
except ImportError:
|
|
214
|
+
print("Error: Google Sheets support not installed.")
|
|
215
|
+
print("Install with: pip install edapitool[gsheets]")
|
|
216
|
+
return 1
|
|
217
|
+
|
|
218
|
+
else:
|
|
219
|
+
print(f"Warning: Unknown export format '{fmt}', skipping.")
|
|
220
|
+
|
|
221
|
+
# Print results
|
|
222
|
+
if exported_files:
|
|
223
|
+
print(f"Fleet Carrier: {carrier.identity.display_name} ({carrier.identity.callsign})")
|
|
224
|
+
print(f"Location: {carrier.location.system}")
|
|
225
|
+
print()
|
|
226
|
+
print("Exported:")
|
|
227
|
+
for fmt, result in exported_files.items():
|
|
228
|
+
if isinstance(result, dict):
|
|
229
|
+
for export_type, filepath in result.items():
|
|
230
|
+
print(f" {fmt}/{export_type}: {filepath}")
|
|
231
|
+
else:
|
|
232
|
+
print(f" {fmt}: {result}")
|
|
233
|
+
else:
|
|
234
|
+
# Default: print summary
|
|
235
|
+
print(f"Fleet Carrier: {carrier.identity.display_name}")
|
|
236
|
+
print(f"Callsign: {carrier.identity.callsign}")
|
|
237
|
+
print(f"Location: {carrier.location.system}")
|
|
238
|
+
print(f"State: {carrier.location.state}")
|
|
239
|
+
print(f"Fuel: {carrier.fuel} t")
|
|
240
|
+
print(f"Cargo Items: {len(carrier.cargo)}")
|
|
241
|
+
print()
|
|
242
|
+
print(f"Bank Balance: {carrier.finance.balance:,} CR")
|
|
243
|
+
print(f"Weekly Upkeep: {carrier.finance.weekly_upkeep:,} CR")
|
|
244
|
+
print()
|
|
245
|
+
print(f"Commodity Orders: {len(carrier.all_commodities)}")
|
|
246
|
+
print(f" Sell: {len(carrier.commodity_sales)}")
|
|
247
|
+
print(f" Buy: {len(carrier.commodity_purchases)}")
|
|
248
|
+
print()
|
|
249
|
+
print(f"Microresource Orders: {len(carrier.all_microresources)}")
|
|
250
|
+
print(f"Locker Items: {len(carrier.locker_items)}")
|
|
251
|
+
print()
|
|
252
|
+
print(f"Enabled Services: {', '.join(carrier.enabled_services)}")
|
|
253
|
+
|
|
254
|
+
except CAPINoDataError:
|
|
255
|
+
print("You don't appear to own a fleet carrier.")
|
|
256
|
+
return 1
|
|
257
|
+
except CAPIError as e:
|
|
258
|
+
print(f"Error: {e}")
|
|
259
|
+
return 1
|
|
260
|
+
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_market(args: argparse.Namespace) -> int:
|
|
267
|
+
"""
|
|
268
|
+
Compare the current station's market against the spreadsheet's
|
|
269
|
+
outstanding requirements, and optionally mark them in the sheet.
|
|
270
|
+
"""
|
|
271
|
+
if getattr(args, "show_formula", False):
|
|
272
|
+
from .workbook.markers import marker_formula_help
|
|
273
|
+
|
|
274
|
+
print(marker_formula_help())
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
from .service import MarketRefreshService, format_table
|
|
278
|
+
from .workbook.markers import (
|
|
279
|
+
MARKER_EMPTY_DOTTED,
|
|
280
|
+
MARKER_EMPTY_SMALL,
|
|
281
|
+
MARKER_ENOUGH,
|
|
282
|
+
MARKER_PARTIAL,
|
|
283
|
+
)
|
|
284
|
+
from .sheets import (
|
|
285
|
+
SIGN_NEGATIVE,
|
|
286
|
+
SIGN_POSITIVE,
|
|
287
|
+
SheetLayout,
|
|
288
|
+
WriteRefused,
|
|
289
|
+
)
|
|
290
|
+
from .matcher import MatchState
|
|
291
|
+
|
|
292
|
+
# Only override the glyph family when a non-default empty marker is asked
|
|
293
|
+
# for; the default keeps the graded quarter/half/three-quarter partial
|
|
294
|
+
# scale, which a wholesale override collapses to a single glyph.
|
|
295
|
+
markers = None
|
|
296
|
+
if args.empty_marker != "hollow":
|
|
297
|
+
empty = MARKER_EMPTY_SMALL if args.empty_marker == "small" else MARKER_EMPTY_DOTTED
|
|
298
|
+
markers = {
|
|
299
|
+
MatchState.ENOUGH: MARKER_ENOUGH,
|
|
300
|
+
MatchState.PARTIAL: MARKER_PARTIAL,
|
|
301
|
+
MatchState.EMPTY: empty,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
layout = SheetLayout(
|
|
305
|
+
totals_tab=args.totals_tab,
|
|
306
|
+
need_header=args.need_header,
|
|
307
|
+
need_sign=SIGN_NEGATIVE if args.need_sign == "negative" else SIGN_POSITIVE,
|
|
308
|
+
marker_column=args.marker_column,
|
|
309
|
+
markers=markers,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
capi_client = None
|
|
313
|
+
if args.use_capi:
|
|
314
|
+
client_id = args.client_id or get_client_id()
|
|
315
|
+
if not client_id:
|
|
316
|
+
print("Error: --use-capi needs a client ID. Run 'edapitool auth' first.")
|
|
317
|
+
return 1
|
|
318
|
+
auth = setup_auth(client_id)
|
|
319
|
+
capi_client = CAPIClient(auth)
|
|
320
|
+
|
|
321
|
+
service = MarketRefreshService(
|
|
322
|
+
journal_dir=Path(args.journal_dir) if args.journal_dir else None,
|
|
323
|
+
layout=layout,
|
|
324
|
+
capi_client=capi_client,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Parsed here rather than beside its use below, because whether a
|
|
328
|
+
# spreadsheet is required depends on which export was asked for.
|
|
329
|
+
formats = [f.strip().lower() for f in (args.export or "").split(",") if f.strip()]
|
|
330
|
+
|
|
331
|
+
worksheet = None
|
|
332
|
+
sheet_id = None
|
|
333
|
+
no_comparison = None
|
|
334
|
+
# A spreadsheet is required for the COMPARISON, not for the MARKET. Where
|
|
335
|
+
# the station is, what it sells, at what price and how fresh the data is
|
|
336
|
+
# all come from the journal or CAPI. Only "what do I still need" lives in
|
|
337
|
+
# the sheet -- so an absent spreadsheet should cost the comparison, not the
|
|
338
|
+
# whole command. Writing is different: --update-sheet and --export
|
|
339
|
+
# market-tab have nothing to do without one.
|
|
340
|
+
needs_sheet = args.update_sheet or "market-tab" in formats
|
|
341
|
+
if not args.no_sheet:
|
|
342
|
+
sheet_id = get_sheet_id(args)
|
|
343
|
+
if not sheet_id:
|
|
344
|
+
if needs_sheet:
|
|
345
|
+
print("Error: no spreadsheet id. Pass --sheet-id, set ED_SHEET_ID,")
|
|
346
|
+
print(" or add \"sheet_id\" to ~/.ed_capi_config.json.")
|
|
347
|
+
print(" (Use --no-sheet to inspect the market without a"
|
|
348
|
+
" spreadsheet.)")
|
|
349
|
+
return 1
|
|
350
|
+
# Absent configuration, not broken configuration. Carry on and say
|
|
351
|
+
# so; a sheet id that IS set and fails to open still errors below,
|
|
352
|
+
# because hiding that would hide a real misconfiguration.
|
|
353
|
+
no_comparison = "no spreadsheet configured"
|
|
354
|
+
|
|
355
|
+
if sheet_id:
|
|
356
|
+
try:
|
|
357
|
+
from .google import GoogleSheetsExporter
|
|
358
|
+
|
|
359
|
+
worksheet = GoogleSheetsExporter().worksheet(sheet_id, layout.totals_tab)
|
|
360
|
+
except ImportError:
|
|
361
|
+
print("Error: Google Sheets support not installed.")
|
|
362
|
+
print("Install with: pip install edapitool[gsheets]")
|
|
363
|
+
return 1
|
|
364
|
+
except Exception as exc:
|
|
365
|
+
# A sheet id was configured and could not be opened. That is a
|
|
366
|
+
# broken setup, not an absent one, and it stays an error --
|
|
367
|
+
# degrading here would hide a typo'd id or a revoked credential
|
|
368
|
+
# behind a silently missing comparison.
|
|
369
|
+
print(f"Error opening spreadsheet: {exc}")
|
|
370
|
+
return 1
|
|
371
|
+
else:
|
|
372
|
+
no_comparison = "--no-sheet"
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
result = service.refresh(
|
|
376
|
+
worksheet=worksheet,
|
|
377
|
+
# --no-markers shapes the PLAN, it does not veto the write. Vetoing
|
|
378
|
+
# left the location cells stale too -- everything travels in one
|
|
379
|
+
# batch -- so the one flag meant for a formula-driven sheet was the
|
|
380
|
+
# one flag that stopped it being told where you are.
|
|
381
|
+
write=args.update_sheet and not args.dry_run,
|
|
382
|
+
write_header=args.write_marker_header,
|
|
383
|
+
show_covered=not args.no_show_covered,
|
|
384
|
+
apply_colour=not args.no_colour,
|
|
385
|
+
include_markers=not args.no_markers,
|
|
386
|
+
)
|
|
387
|
+
except WriteRefused as exc:
|
|
388
|
+
print(f"Refused to write: {exc}")
|
|
389
|
+
return 1
|
|
390
|
+
except Exception as exc:
|
|
391
|
+
print(f"Error: {exc}")
|
|
392
|
+
return 1
|
|
393
|
+
|
|
394
|
+
# Emitting the market as data is deliberately independent of the marker
|
|
395
|
+
# rendering: it runs whether or not the comparison succeeded, and needs no
|
|
396
|
+
# spreadsheet for the csv/json forms.
|
|
397
|
+
exported: list[str] = []
|
|
398
|
+
if formats:
|
|
399
|
+
try:
|
|
400
|
+
exported = _export_market(args, result, formats, sheet_id)
|
|
401
|
+
except Exception as exc:
|
|
402
|
+
print(f"Error exporting market: {exc}")
|
|
403
|
+
return 1
|
|
404
|
+
|
|
405
|
+
if args.json:
|
|
406
|
+
payload = _market_result_json(result)
|
|
407
|
+
payload["exported"] = exported
|
|
408
|
+
# Stated rather than implied: a caller parsing this needs to tell
|
|
409
|
+
# "nothing was outstanding here" from "nobody asked the spreadsheet".
|
|
410
|
+
payload["comparison_skipped"] = no_comparison
|
|
411
|
+
print(json.dumps(payload, indent=2))
|
|
412
|
+
# `ok` reports whether a market reading was obtained -- not_docked,
|
|
413
|
+
# stale_market, no_journal and the rest. None of its reasons involve a
|
|
414
|
+
# spreadsheet, so a missing sheet never makes this exit 2, and a
|
|
415
|
+
# commander who is not docked still should.
|
|
416
|
+
return 0 if result.ok else 2
|
|
417
|
+
|
|
418
|
+
print(f"Commander : {result.location.commander or 'unknown'}")
|
|
419
|
+
print(f"System : {result.system or 'unknown'}")
|
|
420
|
+
print(f"Station : {result.station}")
|
|
421
|
+
if result.market is not None:
|
|
422
|
+
age = result.market.age_seconds
|
|
423
|
+
age_text = f", {age/60:.0f} min old" if age is not None else ""
|
|
424
|
+
print(
|
|
425
|
+
f"Market : {len(result.market)} commodities "
|
|
426
|
+
f"via {result.market.source}{age_text}"
|
|
427
|
+
)
|
|
428
|
+
print()
|
|
429
|
+
|
|
430
|
+
if not result.ok:
|
|
431
|
+
print(f"No comparison: {result.advice()}")
|
|
432
|
+
elif no_comparison:
|
|
433
|
+
# Without a requirements source there is no comparison to render, and
|
|
434
|
+
# an empty one must not be rendered as an answer: format_table([])
|
|
435
|
+
# prints "(nothing outstanding)" and the summary prints zeroes, which
|
|
436
|
+
# together say "you need nothing here" when the truth is that nobody
|
|
437
|
+
# was asked. Same shape as the not-ok branch above, for the same reason.
|
|
438
|
+
print(f"No comparison: {no_comparison}")
|
|
439
|
+
else:
|
|
440
|
+
print(format_table(result.matches))
|
|
441
|
+
print()
|
|
442
|
+
print(f"Summary : {result.summary.describe()}")
|
|
443
|
+
|
|
444
|
+
if result.snapshot and result.snapshot.unparsed_rows:
|
|
445
|
+
print()
|
|
446
|
+
print("Warning: rows whose quantity could not be read (formula recalculating?):")
|
|
447
|
+
for row, name, raw in result.snapshot.unparsed_rows:
|
|
448
|
+
print(f" row {row}: {name} = {raw!r}")
|
|
449
|
+
|
|
450
|
+
unknown = result.summary.unknown
|
|
451
|
+
if unknown:
|
|
452
|
+
print()
|
|
453
|
+
print("Warning: commodity names not recognized (add to name_aliases.json):")
|
|
454
|
+
for match in unknown:
|
|
455
|
+
print(f" row {match.row}: {match.name!r}")
|
|
456
|
+
|
|
457
|
+
if result.plan is not None:
|
|
458
|
+
print()
|
|
459
|
+
if result.written:
|
|
460
|
+
print(f"Wrote {len(result.plan.updates)} ranges to '{layout.totals_tab}'.")
|
|
461
|
+
print(f"Marked rows: {result.plan.marked_rows or '(none)'}")
|
|
462
|
+
elif args.update_sheet:
|
|
463
|
+
print("DRY RUN - would write:")
|
|
464
|
+
for update in result.plan.updates:
|
|
465
|
+
preview = update["values"]
|
|
466
|
+
if len(preview) > 3:
|
|
467
|
+
preview = f"{len(preview)} rows"
|
|
468
|
+
print(f" {layout.totals_tab}!{update['range']} = {preview}")
|
|
469
|
+
print(f" marked rows: {result.plan.marked_rows or '(none)'}")
|
|
470
|
+
else:
|
|
471
|
+
print("(read-only; pass --update-sheet to write markers)")
|
|
472
|
+
|
|
473
|
+
return 0 if result.ok else 2
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _site_recency(site):
|
|
477
|
+
"""Sort key for 'most recently updated site', with a floor for no stamp."""
|
|
478
|
+
from datetime import datetime, timezone
|
|
479
|
+
|
|
480
|
+
return site.timestamp or datetime.min.replace(tzinfo=timezone.utc)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def cmd_construction(args: argparse.Namespace) -> int:
|
|
484
|
+
"""
|
|
485
|
+
Report what a colony construction site still needs.
|
|
486
|
+
|
|
487
|
+
No spreadsheet, no Frontier login, no cooldown: the whole state comes from
|
|
488
|
+
a `ColonisationConstructionDepot` event in the commander's own journal.
|
|
489
|
+
Following `ship`'s rule rather than the one `market` originally had -- see
|
|
490
|
+
issue #10 -- every output here works with nothing configured at all.
|
|
491
|
+
"""
|
|
492
|
+
import json as _json
|
|
493
|
+
from pathlib import Path as _Path
|
|
494
|
+
|
|
495
|
+
from .catalog import load_catalog, normalize
|
|
496
|
+
from .construction import locations_from_events, sites_from_events, station_key
|
|
497
|
+
from .export import construction_payload
|
|
498
|
+
from .journal import JournalReader, iter_events
|
|
499
|
+
|
|
500
|
+
formats = {f.strip().lower() for f in (args.export or "").split(",") if f.strip()}
|
|
501
|
+
unknown = formats - {"csv", "json"}
|
|
502
|
+
if unknown:
|
|
503
|
+
print(f"Error: unknown export format(s): {', '.join(sorted(unknown))}")
|
|
504
|
+
print(" Valid: csv, json")
|
|
505
|
+
return 1
|
|
506
|
+
|
|
507
|
+
reader = JournalReader(_Path(args.journal_dir) if args.journal_dir else None)
|
|
508
|
+
if not reader.exists():
|
|
509
|
+
print("Error: no Elite Dangerous journal directory found.")
|
|
510
|
+
print(" Set ED_JOURNAL_DIR if your Saved Games folder is elsewhere.")
|
|
511
|
+
return 1
|
|
512
|
+
|
|
513
|
+
# Scan several files: a site visited earlier in the session leaves no event
|
|
514
|
+
# in the newest log, and reporting "no sites" because of that would be a
|
|
515
|
+
# lie about the world rather than about the scan.
|
|
516
|
+
all_files = reader.journal_files()
|
|
517
|
+
# `--scan-files` with no number arrives as 0 and means "every file". The
|
|
518
|
+
# default is 120 because a shallower scan silently hides sites: on a real
|
|
519
|
+
# 621-file journal, 6 files showed 3 of 6 builds and left a fourth without
|
|
520
|
+
# a name, which reads as a gap in the data rather than a gap in the scan.
|
|
521
|
+
depth = len(all_files) if args.scan_files == 0 else max(1, args.scan_files)
|
|
522
|
+
events = []
|
|
523
|
+
for path in all_files[-depth:]:
|
|
524
|
+
events.extend(iter_events(path))
|
|
525
|
+
sites = sites_from_events(events, load_catalog())
|
|
526
|
+
places = locations_from_events(events, market_ids=sites.keys())
|
|
527
|
+
|
|
528
|
+
if not sites:
|
|
529
|
+
print("No construction sites found in the journal.")
|
|
530
|
+
print("Dock at one to record what it needs; nothing else is required.")
|
|
531
|
+
return 0
|
|
532
|
+
|
|
533
|
+
if args.list:
|
|
534
|
+
scanned = (f"all {depth}" if args.scan_files == 0 else f"the last {depth}")
|
|
535
|
+
rows = sorted(sites.items(), key=lambda kv: -kv[1].progress)
|
|
536
|
+
statuses = {m: s.status(stale_after_days=args.stale_after) for m, s in rows}
|
|
537
|
+
hidden = [m for m, st in statuses.items() if st != "active"]
|
|
538
|
+
if not args.all:
|
|
539
|
+
rows = [(m, s) for m, s in rows if statuses[m] == "active"]
|
|
540
|
+
|
|
541
|
+
if args.all:
|
|
542
|
+
print(f"{len(rows)} construction site(s) in {scanned} journal files:")
|
|
543
|
+
else:
|
|
544
|
+
print(f"{len(rows)} active of {len(sites)} construction site(s) "
|
|
545
|
+
f"in {scanned} journal files:")
|
|
546
|
+
print()
|
|
547
|
+
|
|
548
|
+
unnamed = 0
|
|
549
|
+
for market_id, site in rows:
|
|
550
|
+
where = places.get(market_id)
|
|
551
|
+
status = statuses[market_id]
|
|
552
|
+
age = site.age_days()
|
|
553
|
+
state = f"{site.progress*100:.1f}%" if status == "active" else status
|
|
554
|
+
name = where.short_station if where else "(name unknown)"
|
|
555
|
+
system = where.system if where else ""
|
|
556
|
+
seen = f"{age}d ago" if age is not None else ""
|
|
557
|
+
# Former names go last and in parentheses: a rename mid-build is
|
|
558
|
+
# real, and a reader whose notes say the old name needs to see the
|
|
559
|
+
# connection rather than conclude the site vanished.
|
|
560
|
+
former = ""
|
|
561
|
+
if where and where.former_names:
|
|
562
|
+
former = " (was " + ", ".join(where.former_names) + ")"
|
|
563
|
+
print(f" {state:>8} {name:<30} {system:<26} {seen:>9} {market_id}{former}")
|
|
564
|
+
unnamed += 0 if where else 1
|
|
565
|
+
print()
|
|
566
|
+
if hidden and not args.all:
|
|
567
|
+
kinds = {}
|
|
568
|
+
for market_id in hidden:
|
|
569
|
+
kinds[statuses[market_id]] = kinds.get(statuses[market_id], 0) + 1
|
|
570
|
+
summary = ", ".join(f"{n} {k}" for k, n in sorted(kinds.items()))
|
|
571
|
+
print(f"{len(hidden)} site(s) not shown ({summary}). Use --all to include them.")
|
|
572
|
+
if "stale" in kinds:
|
|
573
|
+
# Say what "stale" actually means, because the game never marks
|
|
574
|
+
# a lapsed build failed -- this is our inference, not its verdict.
|
|
575
|
+
print(f"A site is called stale after {args.stale_after} days without the game")
|
|
576
|
+
print("mentioning it. Elite Dangerous does not report an expired build as")
|
|
577
|
+
print("failed, so time is the only signal there is.")
|
|
578
|
+
print()
|
|
579
|
+
# Said ONCE, however many sites lack a name. The advice does not get
|
|
580
|
+
# truer by repetition, and a hint under every row buries the listing
|
|
581
|
+
# it is meant to annotate.
|
|
582
|
+
if unnamed:
|
|
583
|
+
print(f"{unnamed} site(s) have no name: the journal records what they need but")
|
|
584
|
+
print("no dock there, so only the MarketID identifies them. Dock once to fix,")
|
|
585
|
+
print("or widen the scan with --scan-files and no number.")
|
|
586
|
+
print()
|
|
587
|
+
print("Select one with --site NAME (add --system when two share a name), or --site MARKETID.")
|
|
588
|
+
return 0
|
|
589
|
+
|
|
590
|
+
# Which site? An explicit --site wins; otherwise wherever the commander is
|
|
591
|
+
# standing; otherwise the most recently updated one.
|
|
592
|
+
chosen = None
|
|
593
|
+
if args.site:
|
|
594
|
+
wanted = str(args.site).strip()
|
|
595
|
+
if wanted.isdigit() and int(wanted) in sites:
|
|
596
|
+
chosen = sites[int(wanted)]
|
|
597
|
+
else:
|
|
598
|
+
key = station_key(wanted)
|
|
599
|
+
system_key = normalize(args.system) if args.system else ""
|
|
600
|
+
matches = []
|
|
601
|
+
for market_id, site in sites.items():
|
|
602
|
+
where = places.get(market_id)
|
|
603
|
+
if not (key and where and where.answers_to(wanted)):
|
|
604
|
+
continue
|
|
605
|
+
if system_key and normalize(where.system) != system_key:
|
|
606
|
+
continue
|
|
607
|
+
matches.append((market_id, site, where))
|
|
608
|
+
if len(matches) > 1:
|
|
609
|
+
# Two builds can share a name, and picking the first silently
|
|
610
|
+
# would report one site's shortfall as another's.
|
|
611
|
+
print(f"Error: {args.site!r} matches {len(matches)} sites. Add --system:")
|
|
612
|
+
for market_id, _, where in matches:
|
|
613
|
+
print(f" --system {where.system!r} ({market_id})")
|
|
614
|
+
return 1
|
|
615
|
+
if matches:
|
|
616
|
+
chosen = matches[0][1]
|
|
617
|
+
if chosen is None:
|
|
618
|
+
print(f"Error: no construction site matching {args.site!r}.")
|
|
619
|
+
for market_id in sites:
|
|
620
|
+
where = places.get(market_id)
|
|
621
|
+
print(f" {market_id} {where.describe if where else '(name unknown -- dock there once)'}")
|
|
622
|
+
return 1
|
|
623
|
+
else:
|
|
624
|
+
here = reader.read_state(scan_files=min(depth, 6))
|
|
625
|
+
chosen = sites.get(here.market_id) if here.market_id else None
|
|
626
|
+
if chosen is None:
|
|
627
|
+
chosen = max(sites.values(), key=_site_recency)
|
|
628
|
+
|
|
629
|
+
payload = construction_payload(chosen)
|
|
630
|
+
|
|
631
|
+
if args.publish_to:
|
|
632
|
+
if not args.region:
|
|
633
|
+
print("Error: --publish-to needs --region, e.g. --region R1:AD60.")
|
|
634
|
+
print(" The region is declared rather than inferred because")
|
|
635
|
+
print(" publishing clears it: without knowing where the block")
|
|
636
|
+
print(" ends, a site that shrinks would leave the tail of its")
|
|
637
|
+
print(" last report sitting there looking current.")
|
|
638
|
+
return 1
|
|
639
|
+
sheet_id = get_sheet_id(args)
|
|
640
|
+
if not sheet_id:
|
|
641
|
+
print("Error: --publish-to needs a spreadsheet. Pass --sheet-id,")
|
|
642
|
+
print(' set ED_SHEET_ID, or add "sheet_id" to '
|
|
643
|
+
"~/.ed_capi_config.json.")
|
|
644
|
+
return 1
|
|
645
|
+
|
|
646
|
+
from .export import (CONSTRUCTION_REGION_TABLE_ROW,
|
|
647
|
+
construction_region_rows)
|
|
648
|
+
from .google import GoogleSheetsExporter
|
|
649
|
+
from .sheets import Destination, WriteGuard, WriteRefused
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
destination = Destination.region(args.publish_to, args.region)
|
|
653
|
+
except ValueError as exc:
|
|
654
|
+
print(f"Error: {exc}")
|
|
655
|
+
return 1
|
|
656
|
+
|
|
657
|
+
grid = construction_region_rows(chosen, places.get(chosen.market_id))
|
|
658
|
+
# The person naming a region on the command line IS the authorization;
|
|
659
|
+
# the guard still runs, so a grid that outgrew its reserve is refused
|
|
660
|
+
# rather than quietly spilling into the columns beside it.
|
|
661
|
+
guard = WriteGuard.build({destination.tab: [args.region]})
|
|
662
|
+
try:
|
|
663
|
+
GoogleSheetsExporter(region_guard=guard).export_grid(
|
|
664
|
+
grid, sheet_id=sheet_id, tab_name=destination,
|
|
665
|
+
)
|
|
666
|
+
except WriteRefused as exc:
|
|
667
|
+
print(f"Error: {exc}")
|
|
668
|
+
return 1
|
|
669
|
+
leading = CONSTRUCTION_REGION_TABLE_ROW - 1
|
|
670
|
+
print(f"Published {len(grid) - leading} commodities to "
|
|
671
|
+
f"{destination.describe()}")
|
|
672
|
+
return 0
|
|
673
|
+
|
|
674
|
+
if args.json:
|
|
675
|
+
if args.all_sites:
|
|
676
|
+
payload = {"sites": [construction_payload(s) for s in sites.values()]}
|
|
677
|
+
print(_json.dumps(payload, indent=2))
|
|
678
|
+
return 0
|
|
679
|
+
|
|
680
|
+
if formats:
|
|
681
|
+
from .export import ConstructionExporter
|
|
682
|
+
|
|
683
|
+
out_dir = _Path(args.output) if args.output else None
|
|
684
|
+
exporter = ConstructionExporter(out_dir)
|
|
685
|
+
for fmt in sorted(formats):
|
|
686
|
+
if fmt == "csv":
|
|
687
|
+
print(f"Exported csv: {exporter.export_csv(chosen)}")
|
|
688
|
+
else:
|
|
689
|
+
print(f"Exported json: {exporter.export_json(chosen)}")
|
|
690
|
+
|
|
691
|
+
site = chosen
|
|
692
|
+
state = "complete" if site.complete else ("FAILED" if site.failed else "in progress")
|
|
693
|
+
where = places.get(site.market_id)
|
|
694
|
+
if where and where.short_station:
|
|
695
|
+
print(f"Site : {where.short_station} ({state})")
|
|
696
|
+
print(f"System : {where.system or 'unknown'}")
|
|
697
|
+
else:
|
|
698
|
+
# Never docked there in the scanned window, so the journal has the
|
|
699
|
+
# contents but not the name. Say which, rather than printing a bare id.
|
|
700
|
+
print(f"Site : {site.market_id} ({state})")
|
|
701
|
+
print("System : unknown -- dock at the site once to record its name")
|
|
702
|
+
if site.timestamp:
|
|
703
|
+
print(f"Updated : {site.timestamp.isoformat()}")
|
|
704
|
+
print(f"Progress : {site.progress * 100:.1f}% "
|
|
705
|
+
f"({site.total_provided:,} of {site.total_required:,} t)")
|
|
706
|
+
print()
|
|
707
|
+
outstanding = site.outstanding
|
|
708
|
+
if not outstanding:
|
|
709
|
+
print("Nothing outstanding -- every commodity is delivered.")
|
|
710
|
+
return 0
|
|
711
|
+
|
|
712
|
+
width = max(len(r.name) for r in outstanding)
|
|
713
|
+
print(f" {'commodity':<{width}} {'still need':>10} {'delivered':>10} {'pays':>8}")
|
|
714
|
+
print(" " + "-" * (width + 34))
|
|
715
|
+
for r in outstanding:
|
|
716
|
+
print(f" {r.name:<{width}} {r.remaining:>10,} {r.provided:>10,} {r.payment:>8,}")
|
|
717
|
+
print()
|
|
718
|
+
print(f" {len(outstanding)} commodities outstanding, "
|
|
719
|
+
f"{site.total_remaining:,} t to deliver")
|
|
720
|
+
return 0
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
724
|
+
"""Keep MarketData and ShipCargo current while the game runs."""
|
|
725
|
+
from . import daemon as daemon_mod
|
|
726
|
+
from .sheets import SheetLayout
|
|
727
|
+
|
|
728
|
+
sheet_id = get_sheet_id(args)
|
|
729
|
+
if not sheet_id:
|
|
730
|
+
print("Error: serve needs a spreadsheet id. Pass --sheet-id,")
|
|
731
|
+
print(" set ED_SHEET_ID, or add \"sheet_id\" to ~/.ed_capi_config.json.")
|
|
732
|
+
return 1
|
|
733
|
+
|
|
734
|
+
journal_dir = Path(args.journal_dir) if args.journal_dir else None
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
regions = get_construction_regions(args)
|
|
738
|
+
except ValueError as exc:
|
|
739
|
+
print(f"Error: {exc}")
|
|
740
|
+
# Only say where it came from when it came from the config file; a
|
|
741
|
+
# bad value typed on the command line is already in front of you.
|
|
742
|
+
# The guard is an `if` rather than a ternary inside the print,
|
|
743
|
+
# because that form still printed an empty line for the flag case.
|
|
744
|
+
if not args.construction_region:
|
|
745
|
+
print(f" (from {settings.CONFIG_FILE})")
|
|
746
|
+
return 1
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
worker = daemon_mod.build(
|
|
750
|
+
sheet_id=sheet_id,
|
|
751
|
+
journal_dir=journal_dir,
|
|
752
|
+
layout=SheetLayout(totals_tab=args.totals_tab),
|
|
753
|
+
ship_tab=args.ship_tab,
|
|
754
|
+
write_location=args.write_location,
|
|
755
|
+
construction_regions=regions,
|
|
756
|
+
interval=args.interval,
|
|
757
|
+
debounce=args.debounce,
|
|
758
|
+
)
|
|
759
|
+
except ImportError:
|
|
760
|
+
print("Error: Google Sheets support not installed.")
|
|
761
|
+
print("Install with: pip install edapitool[gsheets]")
|
|
762
|
+
return 1
|
|
763
|
+
except Exception as exc:
|
|
764
|
+
# build() opens the Totals Tab, which raises ValueError for a tab name
|
|
765
|
+
# that is not there and gspread's own errors for a bad id or revoked
|
|
766
|
+
# credential -- none of them ImportError. `market` already reports these
|
|
767
|
+
# in one friendly line; without this, `serve` differed only by showing
|
|
768
|
+
# the user a traceback.
|
|
769
|
+
print(f"Error opening spreadsheet: {exc}")
|
|
770
|
+
return 1
|
|
771
|
+
|
|
772
|
+
if args.once:
|
|
773
|
+
targets = worker.publishers()
|
|
774
|
+
print(f"Publishing {len(targets)} target(s) once.")
|
|
775
|
+
for target in targets:
|
|
776
|
+
try:
|
|
777
|
+
print(daemon_mod.PublishResult.of(target.publish()).message)
|
|
778
|
+
except Exception as exc:
|
|
779
|
+
print(f" ! {target.name} failed: {exc}")
|
|
780
|
+
return 1
|
|
781
|
+
return 0
|
|
782
|
+
|
|
783
|
+
# Prime before the loop so the backlog of a session already in progress
|
|
784
|
+
# does not fire a publish for every dock that has already happened.
|
|
785
|
+
state = worker.watcher.prime()
|
|
786
|
+
where = state.station_display or state.system or "unknown"
|
|
787
|
+
print(f"Watching the journal from {where}.")
|
|
788
|
+
print(worker.describe_targets())
|
|
789
|
+
for gap in worker.describe_gaps():
|
|
790
|
+
print(f" {gap}")
|
|
791
|
+
if not regions:
|
|
792
|
+
print(" NOT published: any construction region -- declare one with "
|
|
793
|
+
"--construction-region 'Tab!R1:AC60=Site Name'")
|
|
794
|
+
print(f"Poll {args.interval}s, debounce {args.debounce}s. Ctrl+C to stop.")
|
|
795
|
+
print()
|
|
796
|
+
|
|
797
|
+
# Publish once before watching. Priming establishes position WITHOUT
|
|
798
|
+
# replaying the backlog, which is right -- but it means the tabs keep
|
|
799
|
+
# whatever they held when the last run stopped, and nothing corrects them
|
|
800
|
+
# until the game happens to emit an event. A restart is exactly when the
|
|
801
|
+
# sheet is most likely to be wrong, and "correct only once something
|
|
802
|
+
# happens" is indistinguishable from "correct" to anyone reading it.
|
|
803
|
+
print("Catching up:")
|
|
804
|
+
for target in worker.publishers():
|
|
805
|
+
try:
|
|
806
|
+
print(daemon_mod.PublishResult.of(target.publish()).message)
|
|
807
|
+
except Exception as exc:
|
|
808
|
+
print(f" ! {target.name} catch-up failed: {exc}")
|
|
809
|
+
print()
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
worker.run()
|
|
813
|
+
except KeyboardInterrupt:
|
|
814
|
+
stats = worker.stats
|
|
815
|
+
print()
|
|
816
|
+
# Every target, not just the two that predate regions -- a summary
|
|
817
|
+
# that names a subset is the same failure as a banner that does.
|
|
818
|
+
done = ", ".join(f"{n} x{c}" for n, c in stats.by_target.items()) or "nothing"
|
|
819
|
+
print(
|
|
820
|
+
f"Stopped. {stats.polls} polls, {stats.events} events, "
|
|
821
|
+
f"{stats.errors} errors. Published: {done}."
|
|
822
|
+
)
|
|
823
|
+
return 0
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _export_market(args, result, formats: list[str], sheet_id) -> list[str]:
|
|
827
|
+
"""
|
|
828
|
+
Emit the station market as data. Returns human-readable descriptions.
|
|
829
|
+
|
|
830
|
+
csv/json need no spreadsheet and no Google credentials -- that is the point
|
|
831
|
+
of them. market-tab writes a generated tab the spreadsheet can VLOOKUP.
|
|
832
|
+
"""
|
|
833
|
+
from .export import MarketExporter
|
|
834
|
+
|
|
835
|
+
done: list[str] = []
|
|
836
|
+
output_dir = Path(args.output) if args.output else None
|
|
837
|
+
|
|
838
|
+
if result.market is None and ("csv" in formats or "json" in formats):
|
|
839
|
+
print("No current market to export; skipping csv/json.")
|
|
840
|
+
|
|
841
|
+
if result.market is not None:
|
|
842
|
+
exporter = MarketExporter(output_dir)
|
|
843
|
+
if "csv" in formats:
|
|
844
|
+
done.append(f"csv: {exporter.export_csv(result.market)}")
|
|
845
|
+
if "json" in formats:
|
|
846
|
+
done.append(f"json: {exporter.export_json(result.market)}")
|
|
847
|
+
|
|
848
|
+
if "market-tab" in formats:
|
|
849
|
+
if not sheet_id:
|
|
850
|
+
raise ValueError("--export market-tab needs a spreadsheet id")
|
|
851
|
+
from .google import GoogleSheetsExporter
|
|
852
|
+
from .service import market_data_rows
|
|
853
|
+
|
|
854
|
+
# Writes the deliberately-empty grid when there is no current market,
|
|
855
|
+
# so the tab is actively cleared rather than left holding the previous
|
|
856
|
+
# station's prices under a fresh-looking header.
|
|
857
|
+
rows = GoogleSheetsExporter().export_grid(
|
|
858
|
+
market_data_rows(result), sheet_id=sheet_id
|
|
859
|
+
)
|
|
860
|
+
done.append(f"market-tab: {rows} commodity rows -> MarketData")
|
|
861
|
+
|
|
862
|
+
for line in done:
|
|
863
|
+
print(f"Exported {line}")
|
|
864
|
+
return done
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _market_result_json(result) -> dict:
|
|
868
|
+
"""Machine-readable form of a refresh, for scripts and the HTTP API."""
|
|
869
|
+
return {
|
|
870
|
+
"ok": result.ok,
|
|
871
|
+
"reason": result.reason,
|
|
872
|
+
"advice": result.advice(),
|
|
873
|
+
"system": result.system,
|
|
874
|
+
"station": result.station,
|
|
875
|
+
"docked": result.location.docked,
|
|
876
|
+
"market_id": result.location.market_id,
|
|
877
|
+
"market": None
|
|
878
|
+
if result.market is None
|
|
879
|
+
else {
|
|
880
|
+
"station": result.market.station,
|
|
881
|
+
"source": result.market.source,
|
|
882
|
+
"items": len(result.market),
|
|
883
|
+
"timestamp": result.market.timestamp.isoformat()
|
|
884
|
+
if result.market.timestamp
|
|
885
|
+
else None,
|
|
886
|
+
"age_seconds": result.market.age_seconds,
|
|
887
|
+
},
|
|
888
|
+
"matches": [
|
|
889
|
+
{
|
|
890
|
+
"row": m.row,
|
|
891
|
+
"commodity": m.name,
|
|
892
|
+
"need": m.need,
|
|
893
|
+
"state": m.state.value,
|
|
894
|
+
"stock": m.stock,
|
|
895
|
+
"unit_price": m.unit_price,
|
|
896
|
+
"buyable_qty": m.buyable_qty,
|
|
897
|
+
"estimated_cost": m.estimated_cost,
|
|
898
|
+
"mark": m.should_mark,
|
|
899
|
+
}
|
|
900
|
+
for m in sorted(result.matches, key=lambda x: x.row)
|
|
901
|
+
],
|
|
902
|
+
"marked_rows": result.plan.marked_rows if result.plan else [],
|
|
903
|
+
"written": result.written,
|
|
904
|
+
"summary": result.summary.describe() if result.ok else result.advice(),
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def cmd_ship(args: argparse.Namespace) -> int:
|
|
909
|
+
"""
|
|
910
|
+
Report the current ship's cargo hold.
|
|
911
|
+
|
|
912
|
+
A spreadsheet is required for exactly one thing here -- writing the
|
|
913
|
+
generated ShipCargo tab -- and for nothing else. Reading the hold, printing
|
|
914
|
+
it, and writing CSV or JSON all work with no Google credentials configured
|
|
915
|
+
and no sheet id anywhere. That asymmetry is deliberate: see issue #10,
|
|
916
|
+
where the market verb got this wrong and demanded a spreadsheet before it
|
|
917
|
+
would emit JSON.
|
|
918
|
+
"""
|
|
919
|
+
import json as _json
|
|
920
|
+
from pathlib import Path as _Path
|
|
921
|
+
|
|
922
|
+
from .catalog import load_catalog
|
|
923
|
+
from .export import ShipCargoExporter, ship_payload
|
|
924
|
+
from .journal import JournalReader
|
|
925
|
+
from . import ship as ship_mod
|
|
926
|
+
|
|
927
|
+
formats = {f.strip().lower() for f in (args.export or "").split(",") if f.strip()}
|
|
928
|
+
unknown = formats - {"csv", "json", "ship-tab"}
|
|
929
|
+
if unknown:
|
|
930
|
+
print(f"Error: unknown export format(s): {', '.join(sorted(unknown))}")
|
|
931
|
+
print(" Valid: csv, json, ship-tab")
|
|
932
|
+
return 1
|
|
933
|
+
|
|
934
|
+
reader = JournalReader(_Path(args.journal_dir) if args.journal_dir else None)
|
|
935
|
+
if not reader.exists():
|
|
936
|
+
print("Error: no Elite Dangerous journal directory found.")
|
|
937
|
+
print(" Set ED_JOURNAL_DIR if your Saved Games folder is elsewhere.")
|
|
938
|
+
return 1
|
|
939
|
+
|
|
940
|
+
raw = reader.read_cargo_json()
|
|
941
|
+
if raw is None:
|
|
942
|
+
print("No Cargo.json found. The game writes it when your hold changes;")
|
|
943
|
+
print("it appears once you have loaded or unloaded something.")
|
|
944
|
+
return 1
|
|
945
|
+
|
|
946
|
+
cargo = ship_mod.from_journal(raw, load_catalog())
|
|
947
|
+
|
|
948
|
+
if not cargo.is_ship:
|
|
949
|
+
# The SRV writes to the same file. Reporting its hold as the ship's
|
|
950
|
+
# would feed a real number about the wrong vessel into column M.
|
|
951
|
+
print(f"Cargo.json currently describes the {cargo.vessel or 'unknown vessel'},")
|
|
952
|
+
print("not your ship. Board your ship so the game rewrites it.")
|
|
953
|
+
return 1
|
|
954
|
+
|
|
955
|
+
if args.json:
|
|
956
|
+
print(_json.dumps(ship_payload(cargo), indent=2, ensure_ascii=False))
|
|
957
|
+
else:
|
|
958
|
+
stamp = cargo.timestamp.isoformat() if cargo.timestamp else "unknown"
|
|
959
|
+
print(f"Ship cargo as of {stamp}")
|
|
960
|
+
print(f" {cargo.total:,} t across {len(cargo)} commodit"
|
|
961
|
+
f"{'y' if len(cargo) == 1 else 'ies'}")
|
|
962
|
+
if cargo.items:
|
|
963
|
+
width = max(len(i.name) for i in cargo.items)
|
|
964
|
+
print()
|
|
965
|
+
for item in sorted(cargo.items, key=lambda i: i.key):
|
|
966
|
+
stolen = f" ({item.stolen} stolen)" if item.stolen else ""
|
|
967
|
+
print(f" {item.name:<{width}} {item.count:>7,}{stolen}")
|
|
968
|
+
if cargo.total != cargo.count:
|
|
969
|
+
print()
|
|
970
|
+
print(f" Note: the file reports {cargo.count:,} t total but itemises "
|
|
971
|
+
f"{cargo.total:,} t.")
|
|
972
|
+
|
|
973
|
+
written = []
|
|
974
|
+
if formats & {"csv", "json"}:
|
|
975
|
+
exporter = ShipCargoExporter(_Path(args.output) if args.output else None)
|
|
976
|
+
if "csv" in formats:
|
|
977
|
+
written.append(f"csv: {exporter.export_csv(cargo)}")
|
|
978
|
+
if "json" in formats:
|
|
979
|
+
written.append(f"json: {exporter.export_json(cargo)}")
|
|
980
|
+
|
|
981
|
+
if "ship-tab" in formats:
|
|
982
|
+
sheet_id = get_sheet_id(args)
|
|
983
|
+
if not sheet_id:
|
|
984
|
+
print()
|
|
985
|
+
print("Error: --export ship-tab needs a spreadsheet id. Pass --sheet-id,")
|
|
986
|
+
print(" set ED_SHEET_ID, or add \"sheet_id\" to ~/.ed_capi_config.json.")
|
|
987
|
+
return 1
|
|
988
|
+
grid = ship_mod.sheet_grid(cargo)
|
|
989
|
+
if args.dry_run:
|
|
990
|
+
print()
|
|
991
|
+
print(f"Would write {len(grid)} rows to '{args.ship_tab}':")
|
|
992
|
+
for row in grid[:6]:
|
|
993
|
+
print(f" {row}")
|
|
994
|
+
if len(grid) > 6:
|
|
995
|
+
print(f" ... {len(grid) - 6} more")
|
|
996
|
+
else:
|
|
997
|
+
try:
|
|
998
|
+
from .google import GoogleSheetsExporter
|
|
999
|
+
except ImportError:
|
|
1000
|
+
print("Error: Google Sheets support not installed.")
|
|
1001
|
+
print("Install with: pip install edapitool[gsheets]")
|
|
1002
|
+
return 1
|
|
1003
|
+
try:
|
|
1004
|
+
GoogleSheetsExporter().export_grid(grid, sheet_id, args.ship_tab)
|
|
1005
|
+
except Exception as exc:
|
|
1006
|
+
print(f"Error writing '{args.ship_tab}': {exc}")
|
|
1007
|
+
return 1
|
|
1008
|
+
written.append(f"tab: {args.ship_tab} ({len(grid) - 3} commodities)")
|
|
1009
|
+
|
|
1010
|
+
if written:
|
|
1011
|
+
print()
|
|
1012
|
+
for line in written:
|
|
1013
|
+
print(f" {line}")
|
|
1014
|
+
return 0
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def cmd_version(args: argparse.Namespace) -> int:
|
|
1018
|
+
"""Handle version command."""
|
|
1019
|
+
print(f"ED API Tool {get_version()}")
|
|
1020
|
+
return 0
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
1024
|
+
"""Main entry point."""
|
|
1025
|
+
# Create parent parser with common arguments
|
|
1026
|
+
parent_parser = argparse.ArgumentParser(add_help=False)
|
|
1027
|
+
parent_parser.add_argument(
|
|
1028
|
+
"--client-id",
|
|
1029
|
+
help="Frontier API client ID",
|
|
1030
|
+
)
|
|
1031
|
+
parent_parser.add_argument(
|
|
1032
|
+
"--redirect-uri",
|
|
1033
|
+
help="Custom OAuth redirect URI (for manual auth flow)",
|
|
1034
|
+
)
|
|
1035
|
+
parent_parser.add_argument(
|
|
1036
|
+
"--manual-auth",
|
|
1037
|
+
action="store_true",
|
|
1038
|
+
help="Use manual authorization (copy/paste code from browser)",
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
# Main parser
|
|
1042
|
+
parser = argparse.ArgumentParser(
|
|
1043
|
+
prog="edapitool",
|
|
1044
|
+
description="Elite Dangerous API Tool - Extract game data from CAPI",
|
|
1045
|
+
parents=[parent_parser],
|
|
1046
|
+
)
|
|
1047
|
+
parser.add_argument(
|
|
1048
|
+
"--version", "-V",
|
|
1049
|
+
action="store_true",
|
|
1050
|
+
help="Show version and exit",
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
1054
|
+
|
|
1055
|
+
# Auth command
|
|
1056
|
+
auth_parser = subparsers.add_parser(
|
|
1057
|
+
"auth",
|
|
1058
|
+
help="Authenticate with Frontier",
|
|
1059
|
+
parents=[parent_parser],
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# Profile command
|
|
1063
|
+
profile_parser = subparsers.add_parser(
|
|
1064
|
+
"profile",
|
|
1065
|
+
help="Get commander profile",
|
|
1066
|
+
parents=[parent_parser],
|
|
1067
|
+
)
|
|
1068
|
+
profile_parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
1069
|
+
|
|
1070
|
+
# Carrier command
|
|
1071
|
+
carrier_parser = subparsers.add_parser(
|
|
1072
|
+
"carrier",
|
|
1073
|
+
help="Get fleet carrier data",
|
|
1074
|
+
parents=[parent_parser],
|
|
1075
|
+
)
|
|
1076
|
+
carrier_parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
1077
|
+
carrier_parser.add_argument(
|
|
1078
|
+
"--export", "-e",
|
|
1079
|
+
type=str,
|
|
1080
|
+
help="Export formats: csv,gsheet,google,json (comma-separated for multiple)",
|
|
1081
|
+
)
|
|
1082
|
+
carrier_parser.add_argument(
|
|
1083
|
+
"--output", "-o",
|
|
1084
|
+
help="Output directory for exports",
|
|
1085
|
+
)
|
|
1086
|
+
carrier_parser.add_argument(
|
|
1087
|
+
"--include",
|
|
1088
|
+
type=str,
|
|
1089
|
+
default="",
|
|
1090
|
+
help="Include cargo types: stolen,mission (comma-separated). Default excludes both.",
|
|
1091
|
+
)
|
|
1092
|
+
carrier_parser.add_argument(
|
|
1093
|
+
"--sheet-id",
|
|
1094
|
+
type=str,
|
|
1095
|
+
help="Google Sheet ID for direct export (required with --export google)",
|
|
1096
|
+
)
|
|
1097
|
+
carrier_parser.add_argument(
|
|
1098
|
+
"--legacy",
|
|
1099
|
+
action="store_true",
|
|
1100
|
+
help="Use Legacy galaxy server instead of Live",
|
|
1101
|
+
)
|
|
1102
|
+
carrier_parser.add_argument(
|
|
1103
|
+
"--raw",
|
|
1104
|
+
action="store_true",
|
|
1105
|
+
help="Include raw CAPI response in JSON export",
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
# Market command
|
|
1109
|
+
market_parser = subparsers.add_parser(
|
|
1110
|
+
"market",
|
|
1111
|
+
help="Compare the current station's market against the spreadsheet",
|
|
1112
|
+
parents=[parent_parser],
|
|
1113
|
+
)
|
|
1114
|
+
market_parser.add_argument(
|
|
1115
|
+
"--sheet-id",
|
|
1116
|
+
help="Google Sheet ID (or set ED_SHEET_ID, or sheet_id in ~/.ed_capi_config.json)",
|
|
1117
|
+
)
|
|
1118
|
+
market_parser.add_argument(
|
|
1119
|
+
"--update-sheet",
|
|
1120
|
+
action="store_true",
|
|
1121
|
+
help="Write location cells and column markers to the spreadsheet",
|
|
1122
|
+
)
|
|
1123
|
+
market_parser.add_argument(
|
|
1124
|
+
"--dry-run",
|
|
1125
|
+
action="store_true",
|
|
1126
|
+
help="With --update-sheet, show exactly what would be written and write nothing",
|
|
1127
|
+
)
|
|
1128
|
+
market_parser.add_argument(
|
|
1129
|
+
"--no-sheet",
|
|
1130
|
+
action="store_true",
|
|
1131
|
+
help="Inspect location and market without opening the spreadsheet",
|
|
1132
|
+
)
|
|
1133
|
+
market_parser.add_argument(
|
|
1134
|
+
"--use-capi",
|
|
1135
|
+
action="store_true",
|
|
1136
|
+
help="Also query the Frontier CAPI market (live stock; needs authentication)",
|
|
1137
|
+
)
|
|
1138
|
+
market_parser.add_argument(
|
|
1139
|
+
"--journal-dir",
|
|
1140
|
+
help="Elite Dangerous journal directory (default: Saved Games location)",
|
|
1141
|
+
)
|
|
1142
|
+
market_parser.add_argument(
|
|
1143
|
+
"--totals-tab",
|
|
1144
|
+
default="Totals Tab",
|
|
1145
|
+
help="Name of the roll-up tab (default: 'Totals Tab')",
|
|
1146
|
+
)
|
|
1147
|
+
market_parser.add_argument(
|
|
1148
|
+
"--need-header",
|
|
1149
|
+
default="Left to buy",
|
|
1150
|
+
help="Header text of the outstanding-quantity column (default: 'Left to buy')",
|
|
1151
|
+
)
|
|
1152
|
+
market_parser.add_argument(
|
|
1153
|
+
"--need-sign",
|
|
1154
|
+
choices=["positive", "negative"],
|
|
1155
|
+
default="positive",
|
|
1156
|
+
help="Which sign means 'still to buy' (use 'negative' for a combined "
|
|
1157
|
+
"signed column where -229 means buy 229)",
|
|
1158
|
+
)
|
|
1159
|
+
market_parser.add_argument(
|
|
1160
|
+
"--marker-column",
|
|
1161
|
+
default="L",
|
|
1162
|
+
help="Column to write markers into (default: L)",
|
|
1163
|
+
)
|
|
1164
|
+
market_parser.add_argument(
|
|
1165
|
+
"--export", "-e",
|
|
1166
|
+
type=str,
|
|
1167
|
+
help="Emit the station market as data: csv,json (files), market-tab (a "
|
|
1168
|
+
"generated MarketData tab in the spreadsheet). Comma-separated. None "
|
|
1169
|
+
"of these depend on our marker formatting.",
|
|
1170
|
+
)
|
|
1171
|
+
market_parser.add_argument(
|
|
1172
|
+
"--output", "-o",
|
|
1173
|
+
help="Output directory for --export csv/json",
|
|
1174
|
+
)
|
|
1175
|
+
market_parser.add_argument(
|
|
1176
|
+
"--no-markers",
|
|
1177
|
+
action="store_true",
|
|
1178
|
+
help="Do not write the marker column. Pair with '--export market-tab' to let "
|
|
1179
|
+
"the spreadsheet render markers from the data using its own formulas.",
|
|
1180
|
+
)
|
|
1181
|
+
market_parser.add_argument(
|
|
1182
|
+
"--show-formula",
|
|
1183
|
+
action="store_true",
|
|
1184
|
+
help="Print the spreadsheet formula that reproduces the marker column from a "
|
|
1185
|
+
"MarketData tab, then exit",
|
|
1186
|
+
)
|
|
1187
|
+
market_parser.add_argument(
|
|
1188
|
+
"--no-show-covered",
|
|
1189
|
+
action="store_true",
|
|
1190
|
+
help="Do not mark commodities the station sells that you already have enough of "
|
|
1191
|
+
"(they are shown greyed out by default, so a blank cell means 'not sold here')",
|
|
1192
|
+
)
|
|
1193
|
+
market_parser.add_argument(
|
|
1194
|
+
"--no-colour", "--no-color",
|
|
1195
|
+
dest="no_colour",
|
|
1196
|
+
action="store_true",
|
|
1197
|
+
help="Write only the glyphs, leaving cell background and font colour alone",
|
|
1198
|
+
)
|
|
1199
|
+
market_parser.add_argument(
|
|
1200
|
+
"--write-marker-header",
|
|
1201
|
+
action="store_true",
|
|
1202
|
+
help="Also label the cell above the markers (default: leave it alone, "
|
|
1203
|
+
"it is yours)",
|
|
1204
|
+
)
|
|
1205
|
+
market_parser.add_argument(
|
|
1206
|
+
"--empty-marker",
|
|
1207
|
+
choices=["hollow", "small", "dotted"],
|
|
1208
|
+
default="hollow",
|
|
1209
|
+
help="Glyph for 'station sells it but has none right now': "
|
|
1210
|
+
"hollow circle (default, matches the filled/half-filled family), "
|
|
1211
|
+
"small white bullet, or dotted circle",
|
|
1212
|
+
)
|
|
1213
|
+
market_parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
|
1214
|
+
|
|
1215
|
+
# Version command
|
|
1216
|
+
ship_parser = subparsers.add_parser(
|
|
1217
|
+
"ship",
|
|
1218
|
+
help="Report the current ship's cargo hold",
|
|
1219
|
+
parents=[parent_parser],
|
|
1220
|
+
)
|
|
1221
|
+
ship_parser.add_argument(
|
|
1222
|
+
"--json", action="store_true", help="Output the hold as JSON on stdout"
|
|
1223
|
+
)
|
|
1224
|
+
ship_parser.add_argument(
|
|
1225
|
+
"--export", "-e",
|
|
1226
|
+
help="Comma-separated: csv, json, ship-tab (only ship-tab needs a spreadsheet)",
|
|
1227
|
+
)
|
|
1228
|
+
ship_parser.add_argument(
|
|
1229
|
+
"--output", "-o",
|
|
1230
|
+
help="Output directory for --export csv/json",
|
|
1231
|
+
)
|
|
1232
|
+
ship_parser.add_argument(
|
|
1233
|
+
"--sheet-id",
|
|
1234
|
+
help="Google Sheet ID; required only for --export ship-tab",
|
|
1235
|
+
)
|
|
1236
|
+
ship_parser.add_argument(
|
|
1237
|
+
"--ship-tab",
|
|
1238
|
+
default="ShipCargo",
|
|
1239
|
+
help="Name of the generated tab (default: 'ShipCargo')",
|
|
1240
|
+
)
|
|
1241
|
+
ship_parser.add_argument(
|
|
1242
|
+
"--dry-run",
|
|
1243
|
+
action="store_true",
|
|
1244
|
+
help="With --export ship-tab, show what would be written and write nothing",
|
|
1245
|
+
)
|
|
1246
|
+
ship_parser.add_argument(
|
|
1247
|
+
"--journal-dir",
|
|
1248
|
+
help="Elite Dangerous journal directory (default: Saved Games location)",
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
construction_parser = subparsers.add_parser(
|
|
1252
|
+
"construction",
|
|
1253
|
+
help="What a colony construction site still needs",
|
|
1254
|
+
# Deliberately NOT parents=[parent_parser]. That parent carries only
|
|
1255
|
+
# Frontier OAuth options -- client id, redirect uri, manual auth -- and
|
|
1256
|
+
# this verb never authenticates: it reads the commander's own journal.
|
|
1257
|
+
# Advertising three credential flags on the one verb whose selling
|
|
1258
|
+
# point is "no login required" tells the reader the opposite of the
|
|
1259
|
+
# truth about what it needs.
|
|
1260
|
+
)
|
|
1261
|
+
construction_parser.add_argument(
|
|
1262
|
+
"--json", action="store_true", help="Output the site as JSON on stdout"
|
|
1263
|
+
)
|
|
1264
|
+
construction_parser.add_argument(
|
|
1265
|
+
"--export", "-e", help="Comma-separated: csv, json (neither needs a spreadsheet)"
|
|
1266
|
+
)
|
|
1267
|
+
construction_parser.add_argument(
|
|
1268
|
+
"--output", "-o", help="Output directory for --export"
|
|
1269
|
+
)
|
|
1270
|
+
construction_parser.add_argument(
|
|
1271
|
+
"--site",
|
|
1272
|
+
help="Which site: a MarketID, or the station name as you would type it "
|
|
1273
|
+
"(the game's 'Planetary Construction Site: ' prefix is optional). "
|
|
1274
|
+
"Defaults to wherever you are docked, else the most recent",
|
|
1275
|
+
)
|
|
1276
|
+
construction_parser.add_argument(
|
|
1277
|
+
"--system",
|
|
1278
|
+
help="Disambiguate --site when two construction sites share a station name",
|
|
1279
|
+
)
|
|
1280
|
+
construction_parser.add_argument(
|
|
1281
|
+
"--all", action="store_true",
|
|
1282
|
+
help="Include completed, failed and stale sites (default: active only)",
|
|
1283
|
+
)
|
|
1284
|
+
construction_parser.add_argument(
|
|
1285
|
+
"--stale-after", type=int, default=60, metavar="DAYS",
|
|
1286
|
+
help="Days without the game mentioning a site before it counts as "
|
|
1287
|
+
"stale (default: 60). The game never marks a lapsed build failed, "
|
|
1288
|
+
"so elapsed time is the only available signal",
|
|
1289
|
+
)
|
|
1290
|
+
construction_parser.add_argument(
|
|
1291
|
+
"--list", action="store_true",
|
|
1292
|
+
help="List every construction site the journal knows about, and exit",
|
|
1293
|
+
)
|
|
1294
|
+
construction_parser.add_argument(
|
|
1295
|
+
"--all-sites", action="store_true",
|
|
1296
|
+
help="With --json, emit every known site rather than one",
|
|
1297
|
+
)
|
|
1298
|
+
construction_parser.add_argument(
|
|
1299
|
+
"--scan-files", type=int, nargs="?", default=120, const=0,
|
|
1300
|
+
metavar="N",
|
|
1301
|
+
help="How many journal files back to read (default: 120). Pass the "
|
|
1302
|
+
"flag with no number to read ALL of them. Measured on a 621-file "
|
|
1303
|
+
"journal: 6 files found 3 sites in 0.09s, 120 found all 6 in "
|
|
1304
|
+
"1.0s, and every file took 4.4s to find no more",
|
|
1305
|
+
)
|
|
1306
|
+
construction_parser.add_argument(
|
|
1307
|
+
"--journal-dir", help="Elite Dangerous journal directory"
|
|
1308
|
+
)
|
|
1309
|
+
construction_parser.add_argument(
|
|
1310
|
+
"--publish-to", metavar="TAB",
|
|
1311
|
+
help="Publish the site as a data block into a REGION of this tab, "
|
|
1312
|
+
"beside whatever else the tab already holds. Requires --region "
|
|
1313
|
+
"and --sheet-id",
|
|
1314
|
+
)
|
|
1315
|
+
construction_parser.add_argument(
|
|
1316
|
+
"--region", metavar="A1:B2",
|
|
1317
|
+
help="The rectangle on --publish-to that this block owns. Declared, "
|
|
1318
|
+
"never guessed: everything inside it is cleared on every publish, "
|
|
1319
|
+
"so a shrinking site leaves nothing behind, and everything "
|
|
1320
|
+
"outside it is never touched. Reserve more than you need -- "
|
|
1321
|
+
"growing inside the reserve costs nothing",
|
|
1322
|
+
)
|
|
1323
|
+
construction_parser.add_argument(
|
|
1324
|
+
"--sheet-id", help="Google Sheet ID (or ED_SHEET_ID, or the config file)"
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
serve_parser = subparsers.add_parser(
|
|
1328
|
+
"serve",
|
|
1329
|
+
help="Keep the generated tabs current while the game runs",
|
|
1330
|
+
parents=[parent_parser],
|
|
1331
|
+
)
|
|
1332
|
+
serve_parser.add_argument(
|
|
1333
|
+
"--construction-region", action="append", metavar="TAB!RANGE[=SITE]",
|
|
1334
|
+
help="Also keep a construction block current in a region of a tab "
|
|
1335
|
+
"this tool does not own, e.g. "
|
|
1336
|
+
"\"Agri Lrg. (ex)!R1:AC60=Badeaux Nutrition Centre\". The site "
|
|
1337
|
+
"may be named, named by a name it USED to have, or given as a "
|
|
1338
|
+
"market id; omit it and the block follows whichever site you "
|
|
1339
|
+
"are docked at. Repeat the flag for more than one region. To "
|
|
1340
|
+
"set this once instead of typing it each session, put a "
|
|
1341
|
+
"\"construction_regions\" list in the config file; this flag "
|
|
1342
|
+
"then overrides it outright rather than adding to it",
|
|
1343
|
+
)
|
|
1344
|
+
serve_parser.add_argument(
|
|
1345
|
+
"--sheet-id",
|
|
1346
|
+
help='Google Sheet ID (or ED_SHEET_ID, or "sheet_id" in the config '
|
|
1347
|
+
'file)',
|
|
1348
|
+
)
|
|
1349
|
+
serve_parser.add_argument(
|
|
1350
|
+
"--journal-dir", help="Elite Dangerous journal directory"
|
|
1351
|
+
)
|
|
1352
|
+
serve_parser.add_argument(
|
|
1353
|
+
"--ship-tab", default="ShipCargo", help="Tab for the ship's hold"
|
|
1354
|
+
)
|
|
1355
|
+
serve_parser.add_argument(
|
|
1356
|
+
"--totals-tab", default="Totals Tab",
|
|
1357
|
+
help="Name of the roll-up tab whose location cells are refreshed "
|
|
1358
|
+
"with --write-location (default: 'Totals Tab')",
|
|
1359
|
+
)
|
|
1360
|
+
serve_parser.add_argument(
|
|
1361
|
+
"--interval", type=float, default=2.0,
|
|
1362
|
+
help="Seconds between journal polls (default: 2)",
|
|
1363
|
+
)
|
|
1364
|
+
serve_parser.add_argument(
|
|
1365
|
+
"--debounce", type=float, default=5.0,
|
|
1366
|
+
help="Seconds of quiet before publishing. One play session emitted 19 "
|
|
1367
|
+
"Market events; without this each would be a separate write "
|
|
1368
|
+
"(default: 5)",
|
|
1369
|
+
)
|
|
1370
|
+
serve_parser.add_argument(
|
|
1371
|
+
"--once", action="store_true",
|
|
1372
|
+
help="Publish both tabs once and exit, instead of watching",
|
|
1373
|
+
)
|
|
1374
|
+
serve_parser.add_argument(
|
|
1375
|
+
"--write-location", action="store_true",
|
|
1376
|
+
help="Write the current system and station into the roll-up tab's "
|
|
1377
|
+
"location cells. Off by default: those cells are better as "
|
|
1378
|
+
"formulas reading the generated MarketData tab, and writing "
|
|
1379
|
+
"literals would overwrite them. Use only for a sheet that still "
|
|
1380
|
+
"expects the tool to paint them",
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
version_parser = subparsers.add_parser("version", help="Show version")
|
|
1384
|
+
|
|
1385
|
+
args = parser.parse_args(argv)
|
|
1386
|
+
|
|
1387
|
+
if args.version:
|
|
1388
|
+
return cmd_version(args)
|
|
1389
|
+
|
|
1390
|
+
if args.command == "auth":
|
|
1391
|
+
return cmd_auth(args)
|
|
1392
|
+
elif args.command == "profile":
|
|
1393
|
+
return cmd_profile(args)
|
|
1394
|
+
elif args.command == "carrier":
|
|
1395
|
+
return cmd_carrier(args)
|
|
1396
|
+
elif args.command == "market":
|
|
1397
|
+
return cmd_market(args)
|
|
1398
|
+
elif args.command == "ship":
|
|
1399
|
+
return cmd_ship(args)
|
|
1400
|
+
elif args.command == "construction":
|
|
1401
|
+
return cmd_construction(args)
|
|
1402
|
+
elif args.command == "serve":
|
|
1403
|
+
return cmd_serve(args)
|
|
1404
|
+
elif args.command == "version":
|
|
1405
|
+
return cmd_version(args)
|
|
1406
|
+
else:
|
|
1407
|
+
parser.print_help()
|
|
1408
|
+
return 0
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def carrier_main(argv: Optional[list[str]] = None) -> int:
|
|
1412
|
+
"""Shortcut entry point for carrier command."""
|
|
1413
|
+
if argv is None:
|
|
1414
|
+
argv = sys.argv[1:]
|
|
1415
|
+
return main(["carrier"] + list(argv))
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
if __name__ == "__main__":
|
|
1419
|
+
sys.exit(main())
|