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/export.py
ADDED
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export functionality for Elite Dangerous data.
|
|
3
|
+
|
|
4
|
+
Supports exporting fleet carrier and other data to:
|
|
5
|
+
- CSV files
|
|
6
|
+
- JSON files
|
|
7
|
+
- (Future) Google Sheets
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Union, TextIO
|
|
16
|
+
|
|
17
|
+
from .models import (
|
|
18
|
+
FleetCarrier,
|
|
19
|
+
CargoItem,
|
|
20
|
+
CommodityOrder,
|
|
21
|
+
MicroResourceOrder,
|
|
22
|
+
LockerItem,
|
|
23
|
+
ServiceCrew,
|
|
24
|
+
JumpRecord,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CSVExporter:
|
|
29
|
+
"""Export data to CSV format."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, output_dir: Optional[Path] = None):
|
|
32
|
+
"""
|
|
33
|
+
Initialize CSV exporter.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
output_dir: Directory for output files (default: current directory)
|
|
37
|
+
"""
|
|
38
|
+
self.output_dir = output_dir or Path.cwd()
|
|
39
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
def _generate_filename(self, prefix: str, callsign: str = "") -> Path:
|
|
42
|
+
"""Generate a timestamped filename."""
|
|
43
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
44
|
+
if callsign:
|
|
45
|
+
return self.output_dir / f"{prefix}_{callsign}_{timestamp}.csv"
|
|
46
|
+
return self.output_dir / f"{prefix}_{timestamp}.csv"
|
|
47
|
+
|
|
48
|
+
def export_carrier_summary(
|
|
49
|
+
self,
|
|
50
|
+
carrier: FleetCarrier,
|
|
51
|
+
filepath: Optional[Path] = None,
|
|
52
|
+
) -> Path:
|
|
53
|
+
"""
|
|
54
|
+
Export carrier summary to CSV.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
carrier: FleetCarrier data
|
|
58
|
+
filepath: Optional specific filepath
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Path to created file
|
|
62
|
+
"""
|
|
63
|
+
filepath = filepath or self._generate_filename(
|
|
64
|
+
"carrier_summary", carrier.identity.callsign
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
68
|
+
writer = csv.writer(f)
|
|
69
|
+
|
|
70
|
+
# Header info
|
|
71
|
+
writer.writerow(["Fleet Carrier Summary"])
|
|
72
|
+
writer.writerow(["Export Date", datetime.now().isoformat()])
|
|
73
|
+
writer.writerow([])
|
|
74
|
+
|
|
75
|
+
# Identity
|
|
76
|
+
writer.writerow(["Identity"])
|
|
77
|
+
writer.writerow(["Callsign", carrier.identity.callsign])
|
|
78
|
+
writer.writerow(["Name", carrier.identity.display_name])
|
|
79
|
+
writer.writerow([])
|
|
80
|
+
|
|
81
|
+
# Location
|
|
82
|
+
writer.writerow(["Location & Status"])
|
|
83
|
+
writer.writerow(["Current System", carrier.location.system])
|
|
84
|
+
writer.writerow(["State", carrier.location.state])
|
|
85
|
+
writer.writerow(["Docking Access", carrier.location.docking_access])
|
|
86
|
+
writer.writerow(["Tritium Fuel", carrier.fuel])
|
|
87
|
+
writer.writerow(["Theme", carrier.theme])
|
|
88
|
+
writer.writerow([])
|
|
89
|
+
|
|
90
|
+
# Finances
|
|
91
|
+
writer.writerow(["Finances"])
|
|
92
|
+
writer.writerow(["Bank Balance", carrier.finance.balance])
|
|
93
|
+
writer.writerow(["Reserved Balance", carrier.finance.reserved_balance])
|
|
94
|
+
writer.writerow(["Weekly Core Cost", carrier.finance.core_cost])
|
|
95
|
+
writer.writerow(["Weekly Services Cost", carrier.finance.services_cost])
|
|
96
|
+
writer.writerow(["Weekly Upkeep", carrier.finance.weekly_upkeep])
|
|
97
|
+
writer.writerow([])
|
|
98
|
+
|
|
99
|
+
# Capacity
|
|
100
|
+
writer.writerow(["Capacity"])
|
|
101
|
+
writer.writerow(["Ship Packs", carrier.capacity.ship_packs])
|
|
102
|
+
writer.writerow(["Module Packs", carrier.capacity.module_packs])
|
|
103
|
+
writer.writerow(["Cargo For Sale", carrier.capacity.cargo_for_sale])
|
|
104
|
+
writer.writerow(["Cargo Not For Sale", carrier.capacity.cargo_not_for_sale])
|
|
105
|
+
writer.writerow(["Cargo Reserved", carrier.capacity.cargo_reserved])
|
|
106
|
+
writer.writerow([])
|
|
107
|
+
|
|
108
|
+
# Services
|
|
109
|
+
writer.writerow(["Enabled Services"])
|
|
110
|
+
for service in carrier.enabled_services:
|
|
111
|
+
writer.writerow(["", service])
|
|
112
|
+
|
|
113
|
+
return filepath
|
|
114
|
+
|
|
115
|
+
def export_commodity_orders(
|
|
116
|
+
self,
|
|
117
|
+
carrier: FleetCarrier,
|
|
118
|
+
filepath: Optional[Path] = None,
|
|
119
|
+
) -> Path:
|
|
120
|
+
"""
|
|
121
|
+
Export commodity orders to CSV.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
carrier: FleetCarrier data
|
|
125
|
+
filepath: Optional specific filepath
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Path to created file
|
|
129
|
+
"""
|
|
130
|
+
filepath = filepath or self._generate_filename(
|
|
131
|
+
"commodities", carrier.identity.callsign
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
135
|
+
writer = csv.writer(f)
|
|
136
|
+
|
|
137
|
+
writer.writerow([
|
|
138
|
+
"Commodity",
|
|
139
|
+
"Type",
|
|
140
|
+
"Stock/Demand",
|
|
141
|
+
"Outstanding",
|
|
142
|
+
"Price",
|
|
143
|
+
"Black Market",
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
# Sales
|
|
147
|
+
for order in carrier.commodity_sales:
|
|
148
|
+
writer.writerow([
|
|
149
|
+
order.name,
|
|
150
|
+
"SELL",
|
|
151
|
+
order.stock,
|
|
152
|
+
"",
|
|
153
|
+
order.price,
|
|
154
|
+
"Yes" if order.blackmarket else "No",
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
# Purchases
|
|
158
|
+
for order in carrier.commodity_purchases:
|
|
159
|
+
writer.writerow([
|
|
160
|
+
order.name,
|
|
161
|
+
"BUY",
|
|
162
|
+
order.demand,
|
|
163
|
+
order.outstanding,
|
|
164
|
+
order.price,
|
|
165
|
+
"",
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
return filepath
|
|
169
|
+
|
|
170
|
+
def export_microresources(
|
|
171
|
+
self,
|
|
172
|
+
carrier: FleetCarrier,
|
|
173
|
+
filepath: Optional[Path] = None,
|
|
174
|
+
) -> Path:
|
|
175
|
+
"""
|
|
176
|
+
Export microresource orders to CSV.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
carrier: FleetCarrier data
|
|
180
|
+
filepath: Optional specific filepath
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Path to created file
|
|
184
|
+
"""
|
|
185
|
+
filepath = filepath or self._generate_filename(
|
|
186
|
+
"microresources", carrier.identity.callsign
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
190
|
+
writer = csv.writer(f)
|
|
191
|
+
|
|
192
|
+
writer.writerow([
|
|
193
|
+
"Resource",
|
|
194
|
+
"Localized Name",
|
|
195
|
+
"Type",
|
|
196
|
+
"Stock/Demand",
|
|
197
|
+
"Outstanding",
|
|
198
|
+
"Price",
|
|
199
|
+
])
|
|
200
|
+
|
|
201
|
+
# Sales
|
|
202
|
+
for order in carrier.micro_sales:
|
|
203
|
+
writer.writerow([
|
|
204
|
+
order.name,
|
|
205
|
+
order.localized_name,
|
|
206
|
+
"SELL",
|
|
207
|
+
order.stock,
|
|
208
|
+
"",
|
|
209
|
+
order.price,
|
|
210
|
+
])
|
|
211
|
+
|
|
212
|
+
# Purchases
|
|
213
|
+
for order in carrier.micro_purchases:
|
|
214
|
+
writer.writerow([
|
|
215
|
+
order.name,
|
|
216
|
+
order.localized_name,
|
|
217
|
+
"BUY",
|
|
218
|
+
order.demand,
|
|
219
|
+
order.outstanding,
|
|
220
|
+
order.price,
|
|
221
|
+
])
|
|
222
|
+
|
|
223
|
+
return filepath
|
|
224
|
+
|
|
225
|
+
def export_locker_inventory(
|
|
226
|
+
self,
|
|
227
|
+
carrier: FleetCarrier,
|
|
228
|
+
filepath: Optional[Path] = None,
|
|
229
|
+
) -> Path:
|
|
230
|
+
"""
|
|
231
|
+
Export carrier locker inventory to CSV.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
carrier: FleetCarrier data
|
|
235
|
+
filepath: Optional specific filepath
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Path to created file
|
|
239
|
+
"""
|
|
240
|
+
filepath = filepath or self._generate_filename(
|
|
241
|
+
"locker", carrier.identity.callsign
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
245
|
+
writer = csv.writer(f)
|
|
246
|
+
|
|
247
|
+
writer.writerow([
|
|
248
|
+
"Item",
|
|
249
|
+
"Localized Name",
|
|
250
|
+
"Category",
|
|
251
|
+
"Quantity",
|
|
252
|
+
])
|
|
253
|
+
|
|
254
|
+
for item in carrier.locker_items:
|
|
255
|
+
writer.writerow([
|
|
256
|
+
item.name,
|
|
257
|
+
item.localized_name,
|
|
258
|
+
item.category.title(),
|
|
259
|
+
item.quantity,
|
|
260
|
+
])
|
|
261
|
+
|
|
262
|
+
return filepath
|
|
263
|
+
|
|
264
|
+
def export_cargo(
|
|
265
|
+
self,
|
|
266
|
+
carrier: FleetCarrier,
|
|
267
|
+
filepath: Optional[Path] = None,
|
|
268
|
+
) -> Path:
|
|
269
|
+
"""
|
|
270
|
+
Export fleet carrier cargo inventory to CSV.
|
|
271
|
+
|
|
272
|
+
This is the main commodity storage on the carrier - the items
|
|
273
|
+
like Meta-Alloys, Tea, Steel, etc. that the user needs to track.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
carrier: FleetCarrier data
|
|
277
|
+
filepath: Optional specific filepath
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Path to created file
|
|
281
|
+
"""
|
|
282
|
+
filepath = filepath or self._generate_filename(
|
|
283
|
+
"cargo", carrier.identity.callsign
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
287
|
+
writer = csv.writer(f)
|
|
288
|
+
|
|
289
|
+
writer.writerow([
|
|
290
|
+
"Commodity",
|
|
291
|
+
"Display Name",
|
|
292
|
+
"Quantity",
|
|
293
|
+
"Value (CR)",
|
|
294
|
+
"Stolen",
|
|
295
|
+
"Mission",
|
|
296
|
+
"Origin System",
|
|
297
|
+
])
|
|
298
|
+
|
|
299
|
+
# Sort by localized name for easier reading
|
|
300
|
+
sorted_cargo = sorted(carrier.cargo, key=lambda c: c.localized_name)
|
|
301
|
+
|
|
302
|
+
for item in sorted_cargo:
|
|
303
|
+
writer.writerow([
|
|
304
|
+
item.commodity,
|
|
305
|
+
item.localized_name,
|
|
306
|
+
item.quantity,
|
|
307
|
+
item.value,
|
|
308
|
+
"Yes" if item.stolen else "No",
|
|
309
|
+
"Yes" if item.mission else "No",
|
|
310
|
+
item.origin_system or "",
|
|
311
|
+
])
|
|
312
|
+
|
|
313
|
+
# Add summary row
|
|
314
|
+
if carrier.cargo:
|
|
315
|
+
writer.writerow([])
|
|
316
|
+
total_qty = sum(c.quantity for c in carrier.cargo)
|
|
317
|
+
total_value = sum(c.value for c in carrier.cargo)
|
|
318
|
+
writer.writerow(["TOTAL", "", total_qty, total_value, "", "", ""])
|
|
319
|
+
|
|
320
|
+
return filepath
|
|
321
|
+
|
|
322
|
+
def export_cargo_aggregated(
|
|
323
|
+
self,
|
|
324
|
+
carrier: FleetCarrier,
|
|
325
|
+
filepath: Optional[Path] = None,
|
|
326
|
+
) -> Path:
|
|
327
|
+
"""
|
|
328
|
+
Export fleet carrier cargo with quantities aggregated by commodity.
|
|
329
|
+
|
|
330
|
+
Same commodity from different sources is combined into a single row.
|
|
331
|
+
This is the simplified view for inventory tracking.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
carrier: FleetCarrier data
|
|
335
|
+
filepath: Optional specific filepath
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Path to created file
|
|
339
|
+
"""
|
|
340
|
+
filepath = filepath or self._generate_filename(
|
|
341
|
+
"cargo_summary", carrier.identity.callsign
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Aggregate by commodity name
|
|
345
|
+
aggregated: dict[str, dict] = {}
|
|
346
|
+
for item in carrier.cargo:
|
|
347
|
+
key = item.commodity
|
|
348
|
+
if key not in aggregated:
|
|
349
|
+
aggregated[key] = {
|
|
350
|
+
"commodity": item.commodity,
|
|
351
|
+
"localized_name": item.localized_name,
|
|
352
|
+
"quantity": 0,
|
|
353
|
+
"value": 0,
|
|
354
|
+
}
|
|
355
|
+
aggregated[key]["quantity"] += item.quantity
|
|
356
|
+
aggregated[key]["value"] += item.value
|
|
357
|
+
|
|
358
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
359
|
+
writer = csv.writer(f)
|
|
360
|
+
|
|
361
|
+
writer.writerow([
|
|
362
|
+
"Commodity",
|
|
363
|
+
"Display Name",
|
|
364
|
+
"Quantity",
|
|
365
|
+
"Value (CR)",
|
|
366
|
+
])
|
|
367
|
+
|
|
368
|
+
# Sort by localized name
|
|
369
|
+
sorted_items = sorted(aggregated.values(), key=lambda x: x["localized_name"])
|
|
370
|
+
|
|
371
|
+
for item in sorted_items:
|
|
372
|
+
writer.writerow([
|
|
373
|
+
item["commodity"],
|
|
374
|
+
item["localized_name"],
|
|
375
|
+
item["quantity"],
|
|
376
|
+
item["value"],
|
|
377
|
+
])
|
|
378
|
+
|
|
379
|
+
# Add summary row
|
|
380
|
+
if aggregated:
|
|
381
|
+
writer.writerow([])
|
|
382
|
+
total_qty = sum(item["quantity"] for item in aggregated.values())
|
|
383
|
+
total_value = sum(item["value"] for item in aggregated.values())
|
|
384
|
+
writer.writerow(["TOTAL", "", total_qty, total_value])
|
|
385
|
+
|
|
386
|
+
return filepath
|
|
387
|
+
|
|
388
|
+
def export_cargo_gsheet(
|
|
389
|
+
self,
|
|
390
|
+
carrier: FleetCarrier,
|
|
391
|
+
filepath: Optional[Path] = None,
|
|
392
|
+
include_stolen: bool = False,
|
|
393
|
+
include_mission: bool = False,
|
|
394
|
+
) -> Path:
|
|
395
|
+
"""
|
|
396
|
+
Export fleet carrier cargo in Google Sheets-ready format.
|
|
397
|
+
|
|
398
|
+
This format is optimized for VLOOKUP references:
|
|
399
|
+
- Row 1: Empty
|
|
400
|
+
- Row 2: Headers (Column A empty for margin)
|
|
401
|
+
- Row 3: Totals with formulas
|
|
402
|
+
- Row 4+: Data sorted alphabetically
|
|
403
|
+
|
|
404
|
+
Filters out stolen and mission cargo by default.
|
|
405
|
+
Unit price is derived from the highest-quantity stack.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
carrier: FleetCarrier data
|
|
409
|
+
filepath: Optional specific filepath
|
|
410
|
+
include_stolen: Include stolen cargo items
|
|
411
|
+
include_mission: Include mission-reserved cargo items
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Path to created file
|
|
415
|
+
"""
|
|
416
|
+
filepath = filepath or self._generate_filename(
|
|
417
|
+
"cargo_gsheet", carrier.identity.callsign
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Step 1: Filter cargo based on flags
|
|
421
|
+
filtered = [
|
|
422
|
+
c for c in carrier.cargo
|
|
423
|
+
if (include_stolen or not c.stolen)
|
|
424
|
+
and (include_mission or not c.mission)
|
|
425
|
+
and c.quantity > 0 # Skip zero-quantity items
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
# Step 2: Group by commodity, track max-qty stack for each
|
|
429
|
+
groups: dict[str, dict] = {}
|
|
430
|
+
for item in filtered:
|
|
431
|
+
key = item.commodity
|
|
432
|
+
if key not in groups:
|
|
433
|
+
groups[key] = {
|
|
434
|
+
"items": [],
|
|
435
|
+
"max_qty_item": item,
|
|
436
|
+
"total_qty": 0,
|
|
437
|
+
"display_name": item.localized_name,
|
|
438
|
+
}
|
|
439
|
+
groups[key]["items"].append(item)
|
|
440
|
+
groups[key]["total_qty"] += item.quantity
|
|
441
|
+
# Track the item with highest quantity for pricing
|
|
442
|
+
if item.quantity > groups[key]["max_qty_item"].quantity:
|
|
443
|
+
groups[key]["max_qty_item"] = item
|
|
444
|
+
|
|
445
|
+
# Step 3: Calculate unit prices and prepare data
|
|
446
|
+
data = []
|
|
447
|
+
for key, group in groups.items():
|
|
448
|
+
max_item = group["max_qty_item"]
|
|
449
|
+
# Unit price from the largest stack
|
|
450
|
+
unit_price = max_item.value // max_item.quantity if max_item.quantity > 0 else 0
|
|
451
|
+
data.append({
|
|
452
|
+
"display_name": group["display_name"],
|
|
453
|
+
"quantity": group["total_qty"],
|
|
454
|
+
"unit_price": unit_price,
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
# Step 4: Sort alphabetically by display name
|
|
458
|
+
data.sort(key=lambda x: x["display_name"])
|
|
459
|
+
|
|
460
|
+
# Step 5: Write CSV with Google Sheets layout
|
|
461
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
462
|
+
writer = csv.writer(f)
|
|
463
|
+
|
|
464
|
+
# Row 1: Empty
|
|
465
|
+
writer.writerow([])
|
|
466
|
+
|
|
467
|
+
# Row 2: Headers (Column A empty for margin)
|
|
468
|
+
writer.writerow(["", "Display Name", "Quantity", "Unit Price", "Total Value"])
|
|
469
|
+
|
|
470
|
+
# Row 3: Totals with formulas (Column A empty)
|
|
471
|
+
# Formulas reference Row 4 onwards
|
|
472
|
+
writer.writerow(["", "TOTAL", "=SUM(C4:C)", "", "=SUM(E4:E)"])
|
|
473
|
+
|
|
474
|
+
# Row 4+: Data with formulas for Total Value
|
|
475
|
+
for i, item in enumerate(data):
|
|
476
|
+
row_num = 4 + i
|
|
477
|
+
writer.writerow([
|
|
478
|
+
"", # Column A empty
|
|
479
|
+
item["display_name"],
|
|
480
|
+
item["quantity"],
|
|
481
|
+
item["unit_price"],
|
|
482
|
+
f"=C{row_num}*D{row_num}", # Total Value formula
|
|
483
|
+
])
|
|
484
|
+
|
|
485
|
+
return filepath
|
|
486
|
+
|
|
487
|
+
def export_crew(
|
|
488
|
+
self,
|
|
489
|
+
carrier: FleetCarrier,
|
|
490
|
+
filepath: Optional[Path] = None,
|
|
491
|
+
) -> Path:
|
|
492
|
+
"""
|
|
493
|
+
Export crew information to CSV.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
carrier: FleetCarrier data
|
|
497
|
+
filepath: Optional specific filepath
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
Path to created file
|
|
501
|
+
"""
|
|
502
|
+
filepath = filepath or self._generate_filename(
|
|
503
|
+
"crew", carrier.identity.callsign
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
507
|
+
writer = csv.writer(f)
|
|
508
|
+
|
|
509
|
+
writer.writerow([
|
|
510
|
+
"Service",
|
|
511
|
+
"Crew Name",
|
|
512
|
+
"Gender",
|
|
513
|
+
"Faction",
|
|
514
|
+
"Weekly Salary",
|
|
515
|
+
"Enabled",
|
|
516
|
+
])
|
|
517
|
+
|
|
518
|
+
for crew in carrier.crew:
|
|
519
|
+
writer.writerow([
|
|
520
|
+
crew.service,
|
|
521
|
+
crew.name,
|
|
522
|
+
crew.gender,
|
|
523
|
+
crew.faction,
|
|
524
|
+
crew.salary,
|
|
525
|
+
"Yes" if crew.enabled else "No",
|
|
526
|
+
])
|
|
527
|
+
|
|
528
|
+
return filepath
|
|
529
|
+
|
|
530
|
+
def export_jump_history(
|
|
531
|
+
self,
|
|
532
|
+
carrier: FleetCarrier,
|
|
533
|
+
filepath: Optional[Path] = None,
|
|
534
|
+
) -> Path:
|
|
535
|
+
"""
|
|
536
|
+
Export jump history to CSV.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
carrier: FleetCarrier data
|
|
540
|
+
filepath: Optional specific filepath
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Path to created file
|
|
544
|
+
"""
|
|
545
|
+
filepath = filepath or self._generate_filename(
|
|
546
|
+
"jumps", carrier.identity.callsign
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
550
|
+
writer = csv.writer(f)
|
|
551
|
+
|
|
552
|
+
writer.writerow([
|
|
553
|
+
"System",
|
|
554
|
+
"Arrival Time",
|
|
555
|
+
"Departure Time",
|
|
556
|
+
"Visit Duration (hrs)",
|
|
557
|
+
"Success",
|
|
558
|
+
])
|
|
559
|
+
|
|
560
|
+
for jump in carrier.jump_history:
|
|
561
|
+
duration_hrs = jump.visit_duration / 3600
|
|
562
|
+
writer.writerow([
|
|
563
|
+
jump.system,
|
|
564
|
+
jump.arrival_time.isoformat() if jump.arrival_time else "",
|
|
565
|
+
jump.departure_time.isoformat() if jump.departure_time else "",
|
|
566
|
+
f"{duration_hrs:.1f}",
|
|
567
|
+
"Yes" if jump.success else "No",
|
|
568
|
+
])
|
|
569
|
+
|
|
570
|
+
return filepath
|
|
571
|
+
|
|
572
|
+
def export_all(
|
|
573
|
+
self,
|
|
574
|
+
carrier: FleetCarrier,
|
|
575
|
+
prefix: str = "",
|
|
576
|
+
) -> dict[str, Path]:
|
|
577
|
+
"""
|
|
578
|
+
Export all carrier data to separate CSV files.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
carrier: FleetCarrier data
|
|
582
|
+
prefix: Optional prefix for filenames
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
Dictionary mapping export type to file path
|
|
586
|
+
"""
|
|
587
|
+
callsign = carrier.identity.callsign
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
"summary": self.export_carrier_summary(carrier),
|
|
591
|
+
"cargo": self.export_cargo(carrier),
|
|
592
|
+
"cargo_summary": self.export_cargo_aggregated(carrier),
|
|
593
|
+
"commodities": self.export_commodity_orders(carrier),
|
|
594
|
+
"microresources": self.export_microresources(carrier),
|
|
595
|
+
"locker": self.export_locker_inventory(carrier),
|
|
596
|
+
"crew": self.export_crew(carrier),
|
|
597
|
+
"jumps": self.export_jump_history(carrier),
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class MarketExporter:
|
|
602
|
+
"""
|
|
603
|
+
Export a station market as plain data.
|
|
604
|
+
|
|
605
|
+
Deliberately knows nothing about spreadsheets, requirements, or markers. A
|
|
606
|
+
market is a fact about a station -- what it sells, at what price, in what
|
|
607
|
+
quantity -- and it stays useful to anyone regardless of what they intend to
|
|
608
|
+
do with it. Whoever consumes this decides how to display it.
|
|
609
|
+
|
|
610
|
+
Mirrors CSVExporter/JSONExporter's shape so the market path and the carrier
|
|
611
|
+
path look the same to a caller.
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
def __init__(self, output_dir: Optional[Path] = None):
|
|
615
|
+
self.output_dir = output_dir or Path.cwd()
|
|
616
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
617
|
+
|
|
618
|
+
def _generate_filename(self, market, suffix: str) -> Path:
|
|
619
|
+
"""Timestamped, and named after the station it describes."""
|
|
620
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
621
|
+
slug = re.sub(r"[^A-Za-z0-9]+", "-", market.station or "market").strip("-")
|
|
622
|
+
return self.output_dir / f"market_{slug}_{timestamp}.{suffix}"
|
|
623
|
+
|
|
624
|
+
def export_csv(self, market, filepath: Optional[Path] = None) -> Path:
|
|
625
|
+
"""
|
|
626
|
+
One self-contained row per commodity.
|
|
627
|
+
|
|
628
|
+
Station, system and timestamp repeat on every row so files from
|
|
629
|
+
different stations concatenate into a usable dataset, and any single
|
|
630
|
+
row still says where and when it came from.
|
|
631
|
+
"""
|
|
632
|
+
from .market import FLAT_FIELDS, flat_rows
|
|
633
|
+
|
|
634
|
+
filepath = filepath or self._generate_filename(market, "csv")
|
|
635
|
+
rows = flat_rows(market)
|
|
636
|
+
with open(filepath, "w", newline="", encoding="utf-8") as handle:
|
|
637
|
+
writer = csv.DictWriter(handle, fieldnames=FLAT_FIELDS)
|
|
638
|
+
writer.writeheader()
|
|
639
|
+
writer.writerows(rows)
|
|
640
|
+
return filepath
|
|
641
|
+
|
|
642
|
+
def export_json(self, market, filepath: Optional[Path] = None) -> Path:
|
|
643
|
+
"""The same rows, with the market's identity hoisted out of them."""
|
|
644
|
+
from .market import flat_rows
|
|
645
|
+
|
|
646
|
+
filepath = filepath or self._generate_filename(market, "json")
|
|
647
|
+
payload = {
|
|
648
|
+
"station": market.station,
|
|
649
|
+
"system": market.system,
|
|
650
|
+
"market_id": market.market_id,
|
|
651
|
+
"timestamp": market.timestamp.isoformat() if market.timestamp else None,
|
|
652
|
+
"source": market.source,
|
|
653
|
+
"item_count": len(market.items),
|
|
654
|
+
"commodities": flat_rows(market),
|
|
655
|
+
}
|
|
656
|
+
with open(filepath, "w", encoding="utf-8") as handle:
|
|
657
|
+
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
|
658
|
+
return filepath
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
class ShipCargoExporter:
|
|
662
|
+
"""
|
|
663
|
+
Export the current ship's hold as plain data.
|
|
664
|
+
|
|
665
|
+
The ship's hold and the fleet carrier's hold are different inventories, so
|
|
666
|
+
this is a peer of the carrier exporters rather than a variation on them.
|
|
667
|
+
Like MarketExporter, it knows nothing about spreadsheets: what is aboard is
|
|
668
|
+
a fact about the ship, useful to anyone regardless of what they do next.
|
|
669
|
+
"""
|
|
670
|
+
|
|
671
|
+
def __init__(self, output_dir: Optional[Path] = None):
|
|
672
|
+
self.output_dir = output_dir or Path.cwd()
|
|
673
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
674
|
+
|
|
675
|
+
def _generate_filename(self, cargo, suffix: str) -> Path:
|
|
676
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
677
|
+
slug = re.sub(r"[^A-Za-z0-9]+", "-", cargo.vessel or "ship").strip("-").lower()
|
|
678
|
+
return self.output_dir / f"shipcargo_{slug}_{timestamp}.{suffix}"
|
|
679
|
+
|
|
680
|
+
def export_csv(self, cargo, filepath: Optional[Path] = None) -> Path:
|
|
681
|
+
"""
|
|
682
|
+
One self-contained row per commodity aboard.
|
|
683
|
+
|
|
684
|
+
Vessel and timestamp repeat on every row, so snapshots taken over a
|
|
685
|
+
trading run concatenate into something you can actually read.
|
|
686
|
+
"""
|
|
687
|
+
from .ship import FLAT_FIELDS, flat_rows
|
|
688
|
+
|
|
689
|
+
filepath = filepath or self._generate_filename(cargo, "csv")
|
|
690
|
+
rows = flat_rows(cargo)
|
|
691
|
+
with open(filepath, "w", newline="", encoding="utf-8") as handle:
|
|
692
|
+
writer = csv.DictWriter(handle, fieldnames=FLAT_FIELDS)
|
|
693
|
+
writer.writeheader()
|
|
694
|
+
writer.writerows(rows)
|
|
695
|
+
return filepath
|
|
696
|
+
|
|
697
|
+
def export_json(self, cargo, filepath: Optional[Path] = None) -> Path:
|
|
698
|
+
"""The same rows, with the hold's identity hoisted out of them."""
|
|
699
|
+
from .ship import flat_rows
|
|
700
|
+
|
|
701
|
+
filepath = filepath or self._generate_filename(cargo, "json")
|
|
702
|
+
with open(filepath, "w", encoding="utf-8") as handle:
|
|
703
|
+
json.dump(ship_payload(cargo), handle, indent=2, ensure_ascii=False)
|
|
704
|
+
return filepath
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def ship_payload(cargo) -> dict:
|
|
708
|
+
"""
|
|
709
|
+
The hold as one JSON-ready dict.
|
|
710
|
+
|
|
711
|
+
Shared by the file exporter and the CLI's ``--json``, so the structure a
|
|
712
|
+
script reads off stdout is byte-for-byte the structure it reads out of a
|
|
713
|
+
saved file. Two code paths producing "almost the same" JSON is a trap for
|
|
714
|
+
whoever writes the consumer.
|
|
715
|
+
"""
|
|
716
|
+
from .ship import flat_rows
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
"vessel": cargo.vessel,
|
|
720
|
+
"is_ship": cargo.is_ship,
|
|
721
|
+
"timestamp": cargo.timestamp.isoformat() if cargo.timestamp else None,
|
|
722
|
+
"reported_count": cargo.count,
|
|
723
|
+
"itemised_total": cargo.total,
|
|
724
|
+
"item_count": len(cargo.items),
|
|
725
|
+
"commodities": flat_rows(cargo),
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class JSONExporter:
|
|
730
|
+
"""Export data to JSON format."""
|
|
731
|
+
|
|
732
|
+
def __init__(self, output_dir: Optional[Path] = None):
|
|
733
|
+
"""
|
|
734
|
+
Initialize JSON exporter.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
output_dir: Directory for output files (default: current directory)
|
|
738
|
+
"""
|
|
739
|
+
self.output_dir = output_dir or Path.cwd()
|
|
740
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
741
|
+
|
|
742
|
+
def export_carrier(
|
|
743
|
+
self,
|
|
744
|
+
carrier: FleetCarrier,
|
|
745
|
+
filepath: Optional[Path] = None,
|
|
746
|
+
include_raw: bool = False,
|
|
747
|
+
) -> Path:
|
|
748
|
+
"""
|
|
749
|
+
Export carrier data to JSON.
|
|
750
|
+
|
|
751
|
+
Args:
|
|
752
|
+
carrier: FleetCarrier data
|
|
753
|
+
filepath: Optional specific filepath
|
|
754
|
+
include_raw: Include raw CAPI response
|
|
755
|
+
|
|
756
|
+
Returns:
|
|
757
|
+
Path to created file
|
|
758
|
+
"""
|
|
759
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
760
|
+
filepath = filepath or (
|
|
761
|
+
self.output_dir
|
|
762
|
+
/ f"carrier_{carrier.identity.callsign}_{timestamp}.json"
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
data = {
|
|
766
|
+
"export_date": datetime.now().isoformat(),
|
|
767
|
+
"identity": {
|
|
768
|
+
"callsign": carrier.identity.callsign,
|
|
769
|
+
"name": carrier.identity.display_name,
|
|
770
|
+
},
|
|
771
|
+
"location": {
|
|
772
|
+
"system": carrier.location.system,
|
|
773
|
+
"state": carrier.location.state,
|
|
774
|
+
"docking_access": carrier.location.docking_access,
|
|
775
|
+
},
|
|
776
|
+
"finance": {
|
|
777
|
+
"balance": carrier.finance.balance,
|
|
778
|
+
"weekly_upkeep": carrier.finance.weekly_upkeep,
|
|
779
|
+
},
|
|
780
|
+
"fuel": carrier.fuel,
|
|
781
|
+
"commodity_orders": [
|
|
782
|
+
{
|
|
783
|
+
"name": o.name,
|
|
784
|
+
"type": o.order_type,
|
|
785
|
+
"quantity": o.stock if o.order_type == "sell" else o.demand,
|
|
786
|
+
"price": o.price,
|
|
787
|
+
}
|
|
788
|
+
for o in carrier.all_commodities
|
|
789
|
+
],
|
|
790
|
+
"microresource_orders": [
|
|
791
|
+
{
|
|
792
|
+
"name": o.localized_name,
|
|
793
|
+
"type": o.order_type,
|
|
794
|
+
"quantity": o.stock if o.order_type == "sell" else o.demand,
|
|
795
|
+
"price": o.price,
|
|
796
|
+
}
|
|
797
|
+
for o in carrier.all_microresources
|
|
798
|
+
],
|
|
799
|
+
"locker": [
|
|
800
|
+
{
|
|
801
|
+
"name": i.localized_name,
|
|
802
|
+
"category": i.category,
|
|
803
|
+
"quantity": i.quantity,
|
|
804
|
+
}
|
|
805
|
+
for i in carrier.locker_items
|
|
806
|
+
],
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if include_raw:
|
|
810
|
+
data["raw_capi_response"] = carrier.raw_data
|
|
811
|
+
|
|
812
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
813
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
814
|
+
|
|
815
|
+
return filepath
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class ConstructionExporter:
|
|
819
|
+
"""
|
|
820
|
+
Export a colony construction site's requirements as plain data.
|
|
821
|
+
|
|
822
|
+
A peer of MarketExporter and ShipCargoExporter, and like them it knows
|
|
823
|
+
nothing about spreadsheets: what a site still needs is a fact about the
|
|
824
|
+
site, useful to anyone regardless of what they do with it next.
|
|
825
|
+
"""
|
|
826
|
+
|
|
827
|
+
def __init__(self, output_dir: Optional[Path] = None):
|
|
828
|
+
self.output_dir = output_dir or Path.cwd()
|
|
829
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
830
|
+
|
|
831
|
+
def _generate_filename(self, site, suffix: str) -> Path:
|
|
832
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
833
|
+
return self.output_dir / f"construction_{site.market_id or 'site'}_{timestamp}.{suffix}"
|
|
834
|
+
|
|
835
|
+
def export_csv(self, site, filepath: Optional[Path] = None) -> Path:
|
|
836
|
+
"""
|
|
837
|
+
One self-contained row per commodity the site wants.
|
|
838
|
+
|
|
839
|
+
The site's identity and progress repeat on every row, so snapshots
|
|
840
|
+
taken across a build concatenate into something readable -- the same
|
|
841
|
+
choice ShipCargoExporter makes, for the same reason.
|
|
842
|
+
"""
|
|
843
|
+
filepath = filepath or self._generate_filename(site, "csv")
|
|
844
|
+
fields = [
|
|
845
|
+
"market_id", "timestamp", "progress", "complete",
|
|
846
|
+
"commodity", "symbol", "required", "provided", "remaining", "payment",
|
|
847
|
+
]
|
|
848
|
+
stamp = site.timestamp.isoformat() if site.timestamp else ""
|
|
849
|
+
with open(filepath, "w", newline="", encoding="utf-8") as handle:
|
|
850
|
+
writer = csv.DictWriter(handle, fieldnames=fields)
|
|
851
|
+
writer.writeheader()
|
|
852
|
+
for resource in site.resources:
|
|
853
|
+
writer.writerow({
|
|
854
|
+
"market_id": site.market_id,
|
|
855
|
+
"timestamp": stamp,
|
|
856
|
+
"progress": round(site.progress, 6),
|
|
857
|
+
"complete": site.complete,
|
|
858
|
+
"commodity": resource.name,
|
|
859
|
+
"symbol": resource.symbol,
|
|
860
|
+
"required": resource.required,
|
|
861
|
+
"provided": resource.provided,
|
|
862
|
+
"remaining": resource.remaining,
|
|
863
|
+
"payment": resource.payment,
|
|
864
|
+
})
|
|
865
|
+
return filepath
|
|
866
|
+
|
|
867
|
+
def export_json(self, site, filepath: Optional[Path] = None) -> Path:
|
|
868
|
+
"""The same data, with the site's identity hoisted out of the rows."""
|
|
869
|
+
filepath = filepath or self._generate_filename(site, "json")
|
|
870
|
+
with open(filepath, "w", encoding="utf-8") as handle:
|
|
871
|
+
json.dump(construction_payload(site), handle, indent=2, ensure_ascii=False)
|
|
872
|
+
return filepath
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def construction_payload(site) -> dict:
|
|
876
|
+
"""
|
|
877
|
+
One construction site as a JSON-ready dict.
|
|
878
|
+
|
|
879
|
+
Shared by the file exporter and the CLI's ``--json`` for the same reason
|
|
880
|
+
ship_payload is: a script reading stdout and a script reading a saved file
|
|
881
|
+
must see the identical structure. Two near-identical builders is precisely
|
|
882
|
+
the defect that let a live regression through earlier in this project --
|
|
883
|
+
the CLI and the daemon each had their own copy of one grid function.
|
|
884
|
+
"""
|
|
885
|
+
return {
|
|
886
|
+
"market_id": site.market_id,
|
|
887
|
+
"timestamp": site.timestamp.isoformat() if site.timestamp else None,
|
|
888
|
+
"progress": site.progress,
|
|
889
|
+
"complete": site.complete,
|
|
890
|
+
"failed": site.failed,
|
|
891
|
+
"total_required": site.total_required,
|
|
892
|
+
"total_provided": site.total_provided,
|
|
893
|
+
"total_remaining": site.total_remaining,
|
|
894
|
+
"resources": [
|
|
895
|
+
{
|
|
896
|
+
"symbol": r.symbol,
|
|
897
|
+
"name": r.name,
|
|
898
|
+
"required": r.required,
|
|
899
|
+
"provided": r.provided,
|
|
900
|
+
"remaining": r.remaining,
|
|
901
|
+
"payment": r.payment,
|
|
902
|
+
}
|
|
903
|
+
for r in site.resources
|
|
904
|
+
],
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
# The two header rows the block carries, and where the commodity table
|
|
909
|
+
# begins. Named rather than counted at the call site so a sheet formula's
|
|
910
|
+
# range and the writer's own idea of where the table starts cannot drift.
|
|
911
|
+
CONSTRUCTION_REGION_META = [
|
|
912
|
+
"Site", "System", "MarketID", "Updated (UTC)", "State",
|
|
913
|
+
"Progress", "Required", "Provided", "Remaining",
|
|
914
|
+
]
|
|
915
|
+
CONSTRUCTION_REGION_HEADERS = [
|
|
916
|
+
"Symbol", "Commodity", "Required", "Provided", "Remaining", "Payment",
|
|
917
|
+
]
|
|
918
|
+
# Rows 1-2 are the metadata header and its values, row 3 is blank, row 4 is
|
|
919
|
+
# the table's own header. The table therefore starts at row 5 -- the same row
|
|
920
|
+
# the settlement tabs in this workbook start their commodities on, so a
|
|
921
|
+
# commodity in column B lands on the same line as its counterpart in the
|
|
922
|
+
# block and the two can be checked against each other by eye.
|
|
923
|
+
CONSTRUCTION_REGION_TABLE_ROW = 5
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def construction_region_rows(site, location=None) -> list[list]:
|
|
927
|
+
"""
|
|
928
|
+
One construction site as a grid, for publishing into a region of a tab.
|
|
929
|
+
|
|
930
|
+
A metadata header and its values, a blank line, then the commodity table
|
|
931
|
+
with its own headers. A spreadsheet reads the table with the same VLOOKUP
|
|
932
|
+
idiom it already uses for MarketData and ShipCargo. Nothing here decides
|
|
933
|
+
what the numbers mean: the tool states what the game said, and the sheet's
|
|
934
|
+
own formulas turn that into "buy this many".
|
|
935
|
+
|
|
936
|
+
Why the metadata is labelled
|
|
937
|
+
---------------------------
|
|
938
|
+
The first version emitted those nine values with no header at all, and the
|
|
939
|
+
first question anyone asked of it was "what are the numbers after
|
|
940
|
+
'active'?". A hidden region is still read by a person -- when they set it
|
|
941
|
+
up, and every time something looks wrong -- and ``8517`` alone in a cell
|
|
942
|
+
tells them nothing.
|
|
943
|
+
|
|
944
|
+
Why the table starts at row 5
|
|
945
|
+
-----------------------------
|
|
946
|
+
Because the settlement tabs this is published beside start their own
|
|
947
|
+
commodities on row 5. A commodity in column B therefore lands on the same
|
|
948
|
+
line as its counterpart in the block, and the two can be checked against
|
|
949
|
+
each other by eye without counting rows. A vertical label/value list was
|
|
950
|
+
considered and rejected for exactly this: it reads well on its own and
|
|
951
|
+
pushes the table down past any such alignment.
|
|
952
|
+
|
|
953
|
+
Pure, and separate from any writer, for the same reason the other grid
|
|
954
|
+
builders are: a grid you cannot construct without a network connection is
|
|
955
|
+
a grid nobody tests.
|
|
956
|
+
|
|
957
|
+
``location`` is optional because a site is identified by its MarketID and
|
|
958
|
+
is perfectly reportable without ever having learned its name -- which is
|
|
959
|
+
the normal case for a site seen only in passing.
|
|
960
|
+
"""
|
|
961
|
+
name = system = ""
|
|
962
|
+
if location is not None:
|
|
963
|
+
name = location.short_station or ""
|
|
964
|
+
system = location.system or ""
|
|
965
|
+
|
|
966
|
+
stamp = site.timestamp.isoformat() if site.timestamp else ""
|
|
967
|
+
state = "complete" if site.complete else ("failed" if site.failed else "active")
|
|
968
|
+
|
|
969
|
+
# The block is as wide as its widest row. Every row is padded to match, so
|
|
970
|
+
# the grid is rectangular -- a ragged one is a separate write per row.
|
|
971
|
+
width = len(CONSTRUCTION_REGION_META)
|
|
972
|
+
|
|
973
|
+
def pad(cells: list) -> list:
|
|
974
|
+
return list(cells) + [""] * (width - len(cells))
|
|
975
|
+
|
|
976
|
+
rows: list[list] = [
|
|
977
|
+
pad(CONSTRUCTION_REGION_META),
|
|
978
|
+
pad([name, system, site.market_id, stamp, state, site.progress,
|
|
979
|
+
site.total_required, site.total_provided, site.total_remaining]),
|
|
980
|
+
pad([]),
|
|
981
|
+
pad(CONSTRUCTION_REGION_HEADERS),
|
|
982
|
+
]
|
|
983
|
+
for r in site.resources:
|
|
984
|
+
rows.append(pad([
|
|
985
|
+
r.symbol, r.name, r.required, r.provided, r.remaining, r.payment,
|
|
986
|
+
]))
|
|
987
|
+
return rows
|