tradedangerous 12.7.6__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.
- py.typed +1 -0
- trade.py +49 -0
- tradedangerous/__init__.py +43 -0
- tradedangerous/cache.py +1381 -0
- tradedangerous/cli.py +136 -0
- tradedangerous/commands/TEMPLATE.py +74 -0
- tradedangerous/commands/__init__.py +244 -0
- tradedangerous/commands/buildcache_cmd.py +102 -0
- tradedangerous/commands/buy_cmd.py +427 -0
- tradedangerous/commands/commandenv.py +372 -0
- tradedangerous/commands/exceptions.py +94 -0
- tradedangerous/commands/export_cmd.py +150 -0
- tradedangerous/commands/import_cmd.py +222 -0
- tradedangerous/commands/local_cmd.py +243 -0
- tradedangerous/commands/market_cmd.py +207 -0
- tradedangerous/commands/nav_cmd.py +252 -0
- tradedangerous/commands/olddata_cmd.py +270 -0
- tradedangerous/commands/parsing.py +221 -0
- tradedangerous/commands/rares_cmd.py +298 -0
- tradedangerous/commands/run_cmd.py +1521 -0
- tradedangerous/commands/sell_cmd.py +262 -0
- tradedangerous/commands/shipvendor_cmd.py +60 -0
- tradedangerous/commands/station_cmd.py +68 -0
- tradedangerous/commands/trade_cmd.py +181 -0
- tradedangerous/commands/update_cmd.py +67 -0
- tradedangerous/corrections.py +55 -0
- tradedangerous/csvexport.py +234 -0
- tradedangerous/db/__init__.py +27 -0
- tradedangerous/db/adapter.py +192 -0
- tradedangerous/db/config.py +107 -0
- tradedangerous/db/engine.py +259 -0
- tradedangerous/db/lifecycle.py +332 -0
- tradedangerous/db/locks.py +208 -0
- tradedangerous/db/orm_models.py +500 -0
- tradedangerous/db/paths.py +113 -0
- tradedangerous/db/utils.py +661 -0
- tradedangerous/edscupdate.py +565 -0
- tradedangerous/edsmupdate.py +474 -0
- tradedangerous/formatting.py +210 -0
- tradedangerous/fs.py +156 -0
- tradedangerous/gui.py +1146 -0
- tradedangerous/mapping.py +133 -0
- tradedangerous/mfd/__init__.py +103 -0
- tradedangerous/mfd/saitek/__init__.py +3 -0
- tradedangerous/mfd/saitek/directoutput.py +678 -0
- tradedangerous/mfd/saitek/x52pro.py +195 -0
- tradedangerous/misc/checkpricebounds.py +287 -0
- tradedangerous/misc/clipboard.py +49 -0
- tradedangerous/misc/coord64.py +83 -0
- tradedangerous/misc/csvdialect.py +57 -0
- tradedangerous/misc/derp-sentinel.py +35 -0
- tradedangerous/misc/diff-system-csvs.py +159 -0
- tradedangerous/misc/eddb.py +81 -0
- tradedangerous/misc/eddn.py +349 -0
- tradedangerous/misc/edsc.py +437 -0
- tradedangerous/misc/edsm.py +121 -0
- tradedangerous/misc/importeddbstats.py +54 -0
- tradedangerous/misc/prices-json-exp.py +179 -0
- tradedangerous/misc/progress.py +194 -0
- tradedangerous/plugins/__init__.py +249 -0
- tradedangerous/plugins/edcd_plug.py +371 -0
- tradedangerous/plugins/eddblink_plug.py +861 -0
- tradedangerous/plugins/edmc_batch_plug.py +133 -0
- tradedangerous/plugins/spansh_plug.py +2647 -0
- tradedangerous/prices.py +211 -0
- tradedangerous/submit-distances.py +422 -0
- tradedangerous/templates/Added.csv +37 -0
- tradedangerous/templates/Category.csv +17 -0
- tradedangerous/templates/RareItem.csv +143 -0
- tradedangerous/templates/TradeDangerous.sql +338 -0
- tradedangerous/tools.py +40 -0
- tradedangerous/tradecalc.py +1302 -0
- tradedangerous/tradedb.py +2320 -0
- tradedangerous/tradeenv.py +313 -0
- tradedangerous/tradeenv.pyi +109 -0
- tradedangerous/tradeexcept.py +131 -0
- tradedangerous/tradeorm.py +183 -0
- tradedangerous/transfers.py +192 -0
- tradedangerous/utils.py +243 -0
- tradedangerous/version.py +16 -0
- tradedangerous-12.7.6.dist-info/METADATA +106 -0
- tradedangerous-12.7.6.dist-info/RECORD +87 -0
- tradedangerous-12.7.6.dist-info/WHEEL +5 -0
- tradedangerous-12.7.6.dist-info/entry_points.txt +3 -0
- tradedangerous-12.7.6.dist-info/licenses/LICENSE +373 -0
- tradedangerous-12.7.6.dist-info/top_level.txt +2 -0
- tradegui.py +24 -0
|
@@ -0,0 +1,2647 @@
|
|
|
1
|
+
# tradedangerous/plugins/spansh_plug.py
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Spansh Import Plugin (new, defragmented)
|
|
4
|
+
#
|
|
5
|
+
# Behavioural contract:
|
|
6
|
+
# - Optimised for modify/update (churn-safe via service timestamps)
|
|
7
|
+
# - Streaming reader for huge top-level JSON array
|
|
8
|
+
# - Options: -O url=… | -O file=… (mutually exclusive), -O maxage=<float days>
|
|
9
|
+
# - JSON/intermediate in tmp/, CSV & .prices in data/
|
|
10
|
+
# - Warnings gated by verbosity; low-verbosity uses single-line progress
|
|
11
|
+
# - After import: export CSVs (incl. RareItem) and regenerate TradeDangerous.prices
|
|
12
|
+
# - Returns True from finish() to stop default flow
|
|
13
|
+
#
|
|
14
|
+
# DB/dialect specifics live in tradedangerous.db.utils (parse_ts, batch sizing, etc.)
|
|
15
|
+
# -----------------------------------------------------------------------------
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
from email.utils import parsedate_to_datetime
|
|
21
|
+
from importlib.resources import files as implib_files, as_file as implib_as_file
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
import csv
|
|
24
|
+
import json # used for debug
|
|
25
|
+
import io
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import traceback
|
|
31
|
+
import typing
|
|
32
|
+
|
|
33
|
+
# SQLAlchemy
|
|
34
|
+
from sqlalchemy import MetaData, Table, select, insert, update, func, and_, or_, text, UniqueConstraint
|
|
35
|
+
from sqlalchemy.engine import Engine
|
|
36
|
+
from sqlalchemy.orm import Session
|
|
37
|
+
|
|
38
|
+
import ijson # Used for main stream
|
|
39
|
+
import urllib.request
|
|
40
|
+
|
|
41
|
+
# Framework modules
|
|
42
|
+
from tradedangerous import plugins, cache, csvexport # provided by project
|
|
43
|
+
from tradedangerous.cache import processImportFile
|
|
44
|
+
|
|
45
|
+
# DB helpers (dialect specifics live here)
|
|
46
|
+
from tradedangerous.db import utils as db_utils
|
|
47
|
+
from tradedangerous.db.lifecycle import ensure_fresh_db, reset_db
|
|
48
|
+
from tradedangerous.db.locks import station_advisory_lock
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if typing.TYPE_CHECKING:
|
|
52
|
+
from collections.abc import Generator, Iterable, Mapping
|
|
53
|
+
from typing import Any, Optional
|
|
54
|
+
from tradedangerous import TradeDB, TradeEnv
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
DEFAULT_URL = "https://downloads.spansh.co.uk/galaxy_stations.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ImportPlugin(plugins.ImportPluginBase):
|
|
61
|
+
"""
|
|
62
|
+
Spansh galaxy dump importer:
|
|
63
|
+
- Consumes galaxy_stations.json (local file or remote URL)
|
|
64
|
+
- Updates System, Station, Ship/ShipVendor, Upgrade/UpgradeVendor, Item/StationItem
|
|
65
|
+
- Respects per-service freshness & optional maxage (days)
|
|
66
|
+
- Imports RareItem.csv via cache.processImportFile() AFTER systems/stations exist
|
|
67
|
+
- Exports CSVs (+RareItem) and rebuilds TradeDangerous.prices
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
pluginInfo = {
|
|
71
|
+
"name": "spansh",
|
|
72
|
+
"author": "TD Team",
|
|
73
|
+
"version": "2.1",
|
|
74
|
+
"minimum-tb-version": "1.76",
|
|
75
|
+
"description": "Imports Spansh galaxy dump and refreshes cache artefacts.",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Correct option contract: dict name -> help text
|
|
79
|
+
pluginOptions = {
|
|
80
|
+
"url": "Remote URL to galaxy_stations.json (default if neither url nor file is given)",
|
|
81
|
+
"file": "Local path to galaxy_stations.json; use '-' to read from stdin",
|
|
82
|
+
"maxage": "Skip service sections older than <days> (float), evaluated per service",
|
|
83
|
+
"pricesonly": "Skip import/exports; regenerate TradeDangerous.prices only (for testing).",
|
|
84
|
+
"force_baseline": "If set, overwrite service blocks to Spansh baseline (from_live=0) and delete any extras.",
|
|
85
|
+
"skip_stationitems": "Skip exporting StationItem.csv (large). Env: TD_SKIP_STATIONITEM_EXPORT=1",
|
|
86
|
+
"progress_compact": "Use shorter one-line import status (or set env TD_PROGRESS_COMPACT=1).",
|
|
87
|
+
# --- EDCD sourcing (hardcoded URLs; can be disabled or overridden) ---
|
|
88
|
+
"no_edcd": "Disable EDCD preloads (categories, FDev tables) and EDCD rares import.",
|
|
89
|
+
"edcd_commodity": "Override URL or local path for EDCD commodity.csv.",
|
|
90
|
+
"edcd_outfitting": "Override URL or local path for EDCD outfitting.csv.",
|
|
91
|
+
"edcd_shipyard": "Override URL or local path for EDCD shipyard.csv.",
|
|
92
|
+
"edcd_rares": "Override URL or local path for EDCD rare_commodity.csv.",
|
|
93
|
+
# --- Extra Debug Options
|
|
94
|
+
"only_system": "Process only the system with this name or id64; still stream the real file.",
|
|
95
|
+
"debug_trace": "Emit compact JSONL decision logs to tmp/spansh_trace.jsonl (1 line per decision).",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Hardcoded EDCD sources (raw GitHub)
|
|
99
|
+
EDCD_URLS = {
|
|
100
|
+
"commodity": "https://raw.githubusercontent.com/EDCD/FDevIDs/master/commodity.csv",
|
|
101
|
+
"outfitting": "https://raw.githubusercontent.com/EDCD/FDevIDs/master/outfitting.csv",
|
|
102
|
+
"shipyard": "https://raw.githubusercontent.com/EDCD/FDevIDs/master/shipyard.csv",
|
|
103
|
+
"rares": "https://raw.githubusercontent.com/EDCD/FDevIDs/master/rare_commodity.csv",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
tdb: TradeDB
|
|
107
|
+
tdenv: TradeEnv
|
|
108
|
+
session: Session | None # this means you have to check it's been set, though
|
|
109
|
+
batch_size: int | None
|
|
110
|
+
|
|
111
|
+
# ------------------------------
|
|
112
|
+
# Construction & plumbing (REPLACEMENT)
|
|
113
|
+
#
|
|
114
|
+
def __init__(self, tdb, cmdenv):
|
|
115
|
+
super().__init__(tdb, cmdenv)
|
|
116
|
+
self.tdb = tdb
|
|
117
|
+
self.tdenv = cmdenv
|
|
118
|
+
self.session = None
|
|
119
|
+
|
|
120
|
+
# Paths (data/tmp) from env/config; fall back defensively
|
|
121
|
+
self.data_dir = Path(getattr(self.tdenv, "dataDir", getattr(self.tdb, "dataDir", "data"))).resolve()
|
|
122
|
+
self.tmp_dir = Path(getattr(self.tdenv, "tmpDir", getattr(self.tdb, "tmpDir", "tmp"))).resolve()
|
|
123
|
+
for p in (self.data_dir, self.tmp_dir):
|
|
124
|
+
try:
|
|
125
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
raise CleanExit(f"Failed to create directory {p}: {e!r}") from None
|
|
128
|
+
|
|
129
|
+
# Batch size decided AFTER session is opened (see finish())
|
|
130
|
+
self.batch_size = None
|
|
131
|
+
|
|
132
|
+
# Verbosity gates
|
|
133
|
+
self._is_tty = sys.stderr.isatty() or sys.stdout.isatty()
|
|
134
|
+
self._debug_level = int(getattr(self.tdenv, "debug", 0) or 0) # -v levels
|
|
135
|
+
self._warn_enabled = bool(getattr(self.tdenv, "warn", None)) or (self._debug_level >= 3)
|
|
136
|
+
|
|
137
|
+
# Progress state
|
|
138
|
+
self._last_progress_time = 0.0
|
|
139
|
+
|
|
140
|
+
# Station type mapping (existing helper in this module)
|
|
141
|
+
self._station_type_map = self._build_station_type_map()
|
|
142
|
+
|
|
143
|
+
# Debug trace option
|
|
144
|
+
self.debug_trace = str(self.getOption("debug_trace") or "0").strip().lower() not in ("0", "", "false", "no")
|
|
145
|
+
self._trace_fp = None
|
|
146
|
+
|
|
147
|
+
# --------------------------------------
|
|
148
|
+
# Small tracing helper
|
|
149
|
+
#
|
|
150
|
+
def _trace(self, **evt) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Lightweight debug tracer. Writes one compact JSON line per call
|
|
153
|
+
into tmp/spansh_trace.jsonl when -O debug_trace=1 is set.
|
|
154
|
+
Has no side-effects on existing logic if disabled.
|
|
155
|
+
"""
|
|
156
|
+
if not getattr(self, "debug_trace", False):
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
# lazily open file handle if not yet opened
|
|
160
|
+
if not hasattr(self, "_trace_fp") or self._trace_fp is None:
|
|
161
|
+
tmp = getattr(self, "tmp_dir", Path("tmp"))
|
|
162
|
+
tmp.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
self._trace_fp = (tmp / "spansh_trace.jsonl").open("a", encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
# sanitize datetimes
|
|
166
|
+
for k, v in list(evt.items()):
|
|
167
|
+
if hasattr(v, "isoformat"):
|
|
168
|
+
evt[k] = v.isoformat()
|
|
169
|
+
|
|
170
|
+
self._trace_fp.write(json.dumps(evt, ensure_ascii=False) + "\n")
|
|
171
|
+
self._trace_fp.flush()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass # never break main flow
|
|
174
|
+
|
|
175
|
+
# --- TD shim: seed 'Added' from templates (idempotent) ---
|
|
176
|
+
def _seed_added_from_templates(self, session) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Seed the legacy 'Added' table from the packaged CSV:
|
|
179
|
+
tradedangerous/templates/Added.csv
|
|
180
|
+
|
|
181
|
+
DB-agnostic; uses cache.processImportFile. No reliance on any templatesDir.
|
|
182
|
+
"""
|
|
183
|
+
# Obtain a Traversable for the packaged resource and materialize to a real path
|
|
184
|
+
res = implib_files("tradedangerous").joinpath("templates", "Added.csv")
|
|
185
|
+
with implib_as_file(res) as csv_path:
|
|
186
|
+
if not csv_path.exists():
|
|
187
|
+
# Graceful failure so schedulers can retry
|
|
188
|
+
raise CleanExit(f"Packaged Added.csv not found: {csv_path}")
|
|
189
|
+
try:
|
|
190
|
+
processImportFile(
|
|
191
|
+
tdenv=self.tdenv,
|
|
192
|
+
session=session,
|
|
193
|
+
importPath=csv_path,
|
|
194
|
+
tableName="Added",
|
|
195
|
+
)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
# Keep diagnostics, but avoid hard process exit
|
|
198
|
+
self._warn("Seeding 'Added' from templates failed; continuing without it.")
|
|
199
|
+
self._warn(f"{type(e).__name__}: {e}")
|
|
200
|
+
traceback.print_exc()
|
|
201
|
+
raise CleanExit("Failed to seed 'Added' table from templates.") from e # ^ contradiction?
|
|
202
|
+
|
|
203
|
+
# --------------------------------------
|
|
204
|
+
# EDCD Import Functions
|
|
205
|
+
#
|
|
206
|
+
def _acquire_edcd_files(self) -> dict[str, Path | None]:
|
|
207
|
+
"""
|
|
208
|
+
Download (or resolve) EDCD CSVs to tmp/ with conditional caching.
|
|
209
|
+
Honors -O no_edcd=1 and per-file overrides:
|
|
210
|
+
- edcd_commodity, edcd_outfitting, edcd_shipyard, edcd_rares
|
|
211
|
+
Each override may be a local path or an http(s) URL.
|
|
212
|
+
Returns dict: {commodity,outfitting,shipyard,rares} -> Path or None.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def _resolve_one(opt_key: str, default_url: str, basename: str) -> Optional[Path]:
|
|
216
|
+
override = self.getOption(opt_key)
|
|
217
|
+
target = self.tmp_dir / f"edcd_{basename}.csv"
|
|
218
|
+
label = f"EDCD {basename}.csv"
|
|
219
|
+
|
|
220
|
+
# Explicit disable via empty override
|
|
221
|
+
if override is not None and str(override).strip() == "":
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
# Local path override
|
|
225
|
+
if override and ("://" not in override):
|
|
226
|
+
p = Path(override)
|
|
227
|
+
if not p.exists():
|
|
228
|
+
cwd = getattr(self.tdenv, "cwDir", None)
|
|
229
|
+
if cwd:
|
|
230
|
+
p = Path(cwd, override)
|
|
231
|
+
if p.exists() and p.is_file():
|
|
232
|
+
return p.resolve()
|
|
233
|
+
override = None # fall back to URL
|
|
234
|
+
|
|
235
|
+
# URL (override or default)
|
|
236
|
+
url = override or default_url
|
|
237
|
+
try:
|
|
238
|
+
return self._download_with_cache(url, target, label=label)
|
|
239
|
+
except CleanExit:
|
|
240
|
+
return target if target.exists() else None
|
|
241
|
+
except Exception:
|
|
242
|
+
return target if target.exists() else None
|
|
243
|
+
|
|
244
|
+
if self.getOption("no_edcd"):
|
|
245
|
+
return {"commodity": None, "outfitting": None, "shipyard": None, "rares": None}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"commodity": _resolve_one("edcd_commodity", self.EDCD_URLS["commodity"], "commodity"),
|
|
249
|
+
"outfitting": _resolve_one("edcd_outfitting", self.EDCD_URLS["outfitting"], "outfitting"),
|
|
250
|
+
"shipyard": _resolve_one("edcd_shipyard", self.EDCD_URLS["shipyard"], "shipyard"),
|
|
251
|
+
"rares": _resolve_one("edcd_rares", self.EDCD_URLS["rares"], "rare_commodity"),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# ---------- EDCD: Categories (add-only) ----------
|
|
255
|
+
#
|
|
256
|
+
def _edcd_import_categories_add_only(
|
|
257
|
+
self,
|
|
258
|
+
session: Session,
|
|
259
|
+
tables: dict[str, Table],
|
|
260
|
+
commodity_csv: Path,
|
|
261
|
+
) -> int:
|
|
262
|
+
"""
|
|
263
|
+
Read EDCD commodity.csv, extract distinct category names, and add any
|
|
264
|
+
missing Category rows. No updates, no deletes.
|
|
265
|
+
|
|
266
|
+
Deterministic + append-only behaviour:
|
|
267
|
+
- If Category is empty: seed the TD canonical categories with fixed IDs (1..16).
|
|
268
|
+
- If Category is non-empty: validate the canonical ID→name mapping; abort if drifted.
|
|
269
|
+
- Any new categories found in EDCD are appended with IDs > current max ID,
|
|
270
|
+
in deterministic (case-insensitive) name order.
|
|
271
|
+
|
|
272
|
+
Yes, we shoulda done it alphabetical in the first place, but we didn't, so
|
|
273
|
+
here we are.
|
|
274
|
+
|
|
275
|
+
Returns: number of rows inserted (seed + appended).
|
|
276
|
+
"""
|
|
277
|
+
t_cat = tables["Category"]
|
|
278
|
+
|
|
279
|
+
# TD canonical mapping — frozen IDs
|
|
280
|
+
canonical_by_id: dict[int, str] = {
|
|
281
|
+
1: "Metals",
|
|
282
|
+
2: "Minerals",
|
|
283
|
+
3: "Chemicals",
|
|
284
|
+
4: "Foods",
|
|
285
|
+
5: "Textiles",
|
|
286
|
+
6: "Industrial Materials",
|
|
287
|
+
7: "Medicines",
|
|
288
|
+
8: "Legal Drugs",
|
|
289
|
+
9: "Machinery",
|
|
290
|
+
10: "Technology",
|
|
291
|
+
11: "Weapons",
|
|
292
|
+
12: "Consumer Items",
|
|
293
|
+
13: "Slavery",
|
|
294
|
+
14: "Waste",
|
|
295
|
+
15: "NonMarketable",
|
|
296
|
+
16: "Salvage",
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
inserted = 0
|
|
300
|
+
|
|
301
|
+
# Load existing categories
|
|
302
|
+
rows = session.execute(select(t_cat.c.category_id, t_cat.c.name)).all()
|
|
303
|
+
existing_by_id: dict[int, str] = {
|
|
304
|
+
int(cid): (str(name) if name is not None else "")
|
|
305
|
+
for (cid, name) in rows
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
# Seed canonical set if empty
|
|
309
|
+
if not existing_by_id:
|
|
310
|
+
seed_rows = [
|
|
311
|
+
{"category_id": cid, "name": name}
|
|
312
|
+
for cid, name in sorted(canonical_by_id.items(), key=lambda kv: kv[0])
|
|
313
|
+
]
|
|
314
|
+
session.execute(insert(t_cat), seed_rows)
|
|
315
|
+
inserted += len(seed_rows)
|
|
316
|
+
existing_by_id = {cid: name for cid, name in canonical_by_id.items()}
|
|
317
|
+
|
|
318
|
+
# Sanity guardrail: detect drift
|
|
319
|
+
else:
|
|
320
|
+
for cid, expected_name in canonical_by_id.items():
|
|
321
|
+
if cid not in existing_by_id:
|
|
322
|
+
raise CleanExit(
|
|
323
|
+
"Category ID drift detected: "
|
|
324
|
+
f"missing canonical category_id={cid} (expected '{expected_name}'). "
|
|
325
|
+
"Refusing to proceed."
|
|
326
|
+
)
|
|
327
|
+
actual = (existing_by_id.get(cid) or "").strip()
|
|
328
|
+
if actual.lower() != expected_name.lower():
|
|
329
|
+
raise CleanExit(
|
|
330
|
+
"Category ID drift detected: "
|
|
331
|
+
f"category_id={cid} expected '{expected_name}' but found '{actual}'. "
|
|
332
|
+
"Refusing to proceed."
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
existing_lc = {
|
|
336
|
+
(str(n) or "").strip().lower()
|
|
337
|
+
for n in existing_by_id.values()
|
|
338
|
+
if n is not None
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
# Parse EDCD commodity.csv and collect category spellings (case-insensitive)
|
|
342
|
+
with open(commodity_csv, "r", encoding="utf-8", newline="") as fh:
|
|
343
|
+
reader = csv.DictReader(fh)
|
|
344
|
+
|
|
345
|
+
cat_col = None
|
|
346
|
+
for h in (reader.fieldnames or []):
|
|
347
|
+
if h and str(h).strip().lower() == "category":
|
|
348
|
+
cat_col = h
|
|
349
|
+
break
|
|
350
|
+
if cat_col is None:
|
|
351
|
+
raise CleanExit(f"EDCD commodity.csv missing 'category' column: {commodity_csv}")
|
|
352
|
+
|
|
353
|
+
# lk -> set(spellings)
|
|
354
|
+
seen: dict[str, set[str]] = {}
|
|
355
|
+
|
|
356
|
+
for row in reader:
|
|
357
|
+
raw = row.get(cat_col)
|
|
358
|
+
if not raw:
|
|
359
|
+
continue
|
|
360
|
+
name = str(raw).strip()
|
|
361
|
+
if not name:
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
lk = name.lower()
|
|
365
|
+
if lk in existing_lc:
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
seen.setdefault(lk, set()).add(name)
|
|
369
|
+
|
|
370
|
+
if not seen:
|
|
371
|
+
return inserted
|
|
372
|
+
|
|
373
|
+
# Deterministic selection of display name per lk
|
|
374
|
+
def _choose_name(spellings: set[str]) -> str:
|
|
375
|
+
# stable across rebuilds even if EDCD row order changes
|
|
376
|
+
return min(spellings, key=lambda s: (s.casefold(), s))
|
|
377
|
+
|
|
378
|
+
new_names: list[str] = [_choose_name(seen[lk]) for lk in sorted(seen.keys())]
|
|
379
|
+
|
|
380
|
+
max_id = max(existing_by_id.keys(), default=0)
|
|
381
|
+
to_add = []
|
|
382
|
+
next_id = max_id + 1
|
|
383
|
+
for nm in new_names:
|
|
384
|
+
to_add.append({"category_id": next_id, "name": nm})
|
|
385
|
+
next_id += 1
|
|
386
|
+
|
|
387
|
+
session.execute(insert(t_cat), to_add)
|
|
388
|
+
inserted += len(to_add)
|
|
389
|
+
return inserted
|
|
390
|
+
|
|
391
|
+
# ---------- EDCD: FDev tables (direct load) ----------
|
|
392
|
+
#
|
|
393
|
+
|
|
394
|
+
def _edcd_import_table_direct(self, session: Session, table: Table, csv_path: Path) -> int:
|
|
395
|
+
"""
|
|
396
|
+
Upsert CSV rows into a table whose columns match CSV headers.
|
|
397
|
+
Prefers the table's primary key; if absent, falls back to a single-column
|
|
398
|
+
UNIQUE key (e.g. 'id' in FDev tables). Returns approx rows written.
|
|
399
|
+
"""
|
|
400
|
+
# --- choose key columns for upsert ---
|
|
401
|
+
pk_cols = tuple(c.name for c in table.primary_key.columns)
|
|
402
|
+
key_cols: tuple[str, ...] = pk_cols
|
|
403
|
+
|
|
404
|
+
if not key_cols:
|
|
405
|
+
# Common case for EDCD FDev tables: UNIQUE(id) but no PK
|
|
406
|
+
if "id" in table.c:
|
|
407
|
+
key_cols = ("id",)
|
|
408
|
+
else:
|
|
409
|
+
# Try to discover a single-column UNIQUE constraint via reflection
|
|
410
|
+
try:
|
|
411
|
+
uniq_single = []
|
|
412
|
+
for cons in getattr(table, "constraints", set()):
|
|
413
|
+
if isinstance(cons, UniqueConstraint):
|
|
414
|
+
cols = tuple(col.name for col in cons.columns)
|
|
415
|
+
if len(cols) == 1:
|
|
416
|
+
uniq_single.append(cols[0])
|
|
417
|
+
if uniq_single:
|
|
418
|
+
key_cols = (uniq_single[0],)
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
if not key_cols:
|
|
423
|
+
raise CleanExit(f"Table {table.name} has neither a primary key nor a single-column UNIQUE key; cannot upsert from EDCD")
|
|
424
|
+
|
|
425
|
+
# --- read CSV ---
|
|
426
|
+
with open(csv_path, "r", encoding="utf-8", newline="") as fh:
|
|
427
|
+
reader = csv.DictReader(fh)
|
|
428
|
+
cols = [c for c in (reader.fieldnames or []) if c in table.c]
|
|
429
|
+
if not cols:
|
|
430
|
+
return 0
|
|
431
|
+
rows = [{k: row.get(k) for k in cols} for row in reader]
|
|
432
|
+
|
|
433
|
+
if not rows:
|
|
434
|
+
return 0
|
|
435
|
+
|
|
436
|
+
# --- table-specific sanitation (fixes ck_fdo_mount / ck_fdo_guidance) ---
|
|
437
|
+
if table.name == "FDevOutfitting":
|
|
438
|
+
allowed_mount = {"Fixed", "Gimballed", "Turreted"}
|
|
439
|
+
allowed_guid = {"Dumbfire", "Seeker", "Swarm"}
|
|
440
|
+
|
|
441
|
+
def _norm(val, allowed):
|
|
442
|
+
if val is None:
|
|
443
|
+
return None
|
|
444
|
+
s = str(val).strip()
|
|
445
|
+
if not s or s not in allowed:
|
|
446
|
+
return None
|
|
447
|
+
return s
|
|
448
|
+
|
|
449
|
+
for r in rows:
|
|
450
|
+
if "mount" in r:
|
|
451
|
+
r["mount"] = _norm(r["mount"], allowed_mount)
|
|
452
|
+
if "guidance" in r:
|
|
453
|
+
r["guidance"] = _norm(r["guidance"], allowed_guid)
|
|
454
|
+
|
|
455
|
+
# --- perform upsert using chosen key columns ---
|
|
456
|
+
upd_cols = tuple(c for c in cols if c not in key_cols)
|
|
457
|
+
|
|
458
|
+
if db_utils.is_sqlite(session):
|
|
459
|
+
db_utils.sqlite_upsert_simple(session, table, rows=rows, key_cols=key_cols, update_cols=upd_cols)
|
|
460
|
+
return len(rows)
|
|
461
|
+
|
|
462
|
+
if db_utils.is_mysql(session):
|
|
463
|
+
db_utils.mysql_upsert_simple(session, table, rows=rows, key_cols=key_cols, update_cols=upd_cols)
|
|
464
|
+
return len(rows)
|
|
465
|
+
|
|
466
|
+
# Generic backend (read-then-insert/update)
|
|
467
|
+
for r in rows:
|
|
468
|
+
cond = and_(*[getattr(table.c, k) == r[k] for k in key_cols])
|
|
469
|
+
ext = session.execute(select(*[getattr(table.c, k) for k in key_cols]).where(cond)).first()
|
|
470
|
+
if ext is None:
|
|
471
|
+
session.execute(insert(table).values(**r))
|
|
472
|
+
elif upd_cols:
|
|
473
|
+
session.execute(update(table).where(cond).values(**{k: r[k] for k in upd_cols}))
|
|
474
|
+
return len(rows)
|
|
475
|
+
|
|
476
|
+
def _edcd_import_fdev_catalogs(self, session: Session, tables: dict[str, Table], *, outfitting_csv: Path, shipyard_csv: Path) -> tuple[int, int]:
|
|
477
|
+
u = self._edcd_import_table_direct(session, tables["FDevOutfitting"], outfitting_csv)
|
|
478
|
+
s = self._edcd_import_table_direct(session, tables["FDevShipyard"], shipyard_csv)
|
|
479
|
+
return (u, s)
|
|
480
|
+
|
|
481
|
+
# --------------------------------------
|
|
482
|
+
# Comparison Helpers
|
|
483
|
+
#
|
|
484
|
+
def _apply_vendor_block_per_rules(
|
|
485
|
+
self,
|
|
486
|
+
t_vendor: Table,
|
|
487
|
+
station_id: int,
|
|
488
|
+
ids: Iterable[int],
|
|
489
|
+
ts_sp: datetime,
|
|
490
|
+
*,
|
|
491
|
+
id_col: str,
|
|
492
|
+
) -> tuple[int, int, int]:
|
|
493
|
+
"""
|
|
494
|
+
Per-row rule for ShipVendor / UpgradeVendor:
|
|
495
|
+
- If db.modified > ts_sp: leave row.
|
|
496
|
+
- If db.modified == ts_sp: no-op.
|
|
497
|
+
- If db.modified < ts_sp: set modified = ts_sp.
|
|
498
|
+
Deletions:
|
|
499
|
+
- Remove rows missing in JSON if (db.modified <= ts_sp).
|
|
500
|
+
Returns (insert_count, update_count, delete_count).
|
|
501
|
+
"""
|
|
502
|
+
keep_ids = {int(x) for x in ids if x is not None}
|
|
503
|
+
inserts = updates = deletes = 0
|
|
504
|
+
|
|
505
|
+
# --- INSERT missing (batch) ---
|
|
506
|
+
if keep_ids:
|
|
507
|
+
# Find which of keep_ids are missing
|
|
508
|
+
existing_ids = {
|
|
509
|
+
int(r[0]) for r in self.session.execute(
|
|
510
|
+
select(getattr(t_vendor.c, id_col)).where(
|
|
511
|
+
and_(t_vendor.c.station_id == station_id,
|
|
512
|
+
getattr(t_vendor.c, id_col).in_(keep_ids))
|
|
513
|
+
)
|
|
514
|
+
).all()
|
|
515
|
+
}
|
|
516
|
+
to_insert = keep_ids - existing_ids
|
|
517
|
+
if to_insert:
|
|
518
|
+
self.session.execute(
|
|
519
|
+
insert(t_vendor),
|
|
520
|
+
[{id_col: vid, "station_id": station_id, "modified": ts_sp} for vid in to_insert]
|
|
521
|
+
)
|
|
522
|
+
inserts = len(to_insert)
|
|
523
|
+
|
|
524
|
+
# --- UPDATE only those with modified < ts_sp (batch) ---
|
|
525
|
+
if keep_ids:
|
|
526
|
+
res = self.session.execute(
|
|
527
|
+
update(t_vendor)
|
|
528
|
+
.where(
|
|
529
|
+
and_(
|
|
530
|
+
t_vendor.c.station_id == station_id,
|
|
531
|
+
getattr(t_vendor.c, id_col).in_(keep_ids),
|
|
532
|
+
or_(t_vendor.c.modified.is_(None), t_vendor.c.modified < ts_sp),
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
.values(modified=ts_sp)
|
|
536
|
+
)
|
|
537
|
+
# rowcount includes both existing rows (not inserts) whose modified was < ts_sp
|
|
538
|
+
updates = int(res.rowcount or 0)
|
|
539
|
+
|
|
540
|
+
# --- DELETE rows NOT in keep_ids, but only if <= ts_sp (single statement) ---
|
|
541
|
+
res = self.session.execute(
|
|
542
|
+
t_vendor.delete().where(
|
|
543
|
+
and_(
|
|
544
|
+
t_vendor.c.station_id == station_id,
|
|
545
|
+
~getattr(t_vendor.c, id_col).in_(keep_ids) if keep_ids else True,
|
|
546
|
+
or_(t_vendor.c.modified.is_(None), t_vendor.c.modified <= ts_sp),
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
deletes = int(res.rowcount or 0)
|
|
551
|
+
|
|
552
|
+
return inserts, updates, deletes
|
|
553
|
+
|
|
554
|
+
def _sync_vendor_block_fast(
|
|
555
|
+
self,
|
|
556
|
+
tables: dict[str, Table],
|
|
557
|
+
*,
|
|
558
|
+
station_id: int,
|
|
559
|
+
entries: list[dict[str, Any]],
|
|
560
|
+
ts_sp: Optional[datetime],
|
|
561
|
+
kind: str, # "ship" or "module"
|
|
562
|
+
) -> tuple[int, int]:
|
|
563
|
+
"""
|
|
564
|
+
Fast, set-based vendor sync for a single station and one service (shipyard/outfitting).
|
|
565
|
+
|
|
566
|
+
Returns: (number_of_inserts_or_updates_on_vendor_links, deletions_count).
|
|
567
|
+
"""
|
|
568
|
+
# Ensure we never write NULL into NOT NULL 'modified' columns.
|
|
569
|
+
ts_eff = (ts_sp or datetime.utcnow().replace(microsecond=0))
|
|
570
|
+
|
|
571
|
+
if kind == "ship":
|
|
572
|
+
t_master = tables["Ship"]
|
|
573
|
+
t_vendor = tables["ShipVendor"]
|
|
574
|
+
id_key = "shipId"
|
|
575
|
+
id_col = "ship_id"
|
|
576
|
+
master_rows = []
|
|
577
|
+
keep_ids: set[int] = set()
|
|
578
|
+
for e in entries:
|
|
579
|
+
if not isinstance(e, dict):
|
|
580
|
+
continue
|
|
581
|
+
ship_id = e.get(id_key)
|
|
582
|
+
name = e.get("name")
|
|
583
|
+
if ship_id is None or name is None:
|
|
584
|
+
continue
|
|
585
|
+
keep_ids.add(int(ship_id))
|
|
586
|
+
master_rows.append({"ship_id": ship_id, "name": name})
|
|
587
|
+
|
|
588
|
+
elif kind == "module":
|
|
589
|
+
t_master = tables["Upgrade"]
|
|
590
|
+
t_vendor = tables["UpgradeVendor"]
|
|
591
|
+
id_key = "moduleId"
|
|
592
|
+
id_col = "upgrade_id"
|
|
593
|
+
master_rows = []
|
|
594
|
+
keep_ids = set()
|
|
595
|
+
for e in entries:
|
|
596
|
+
if not isinstance(e, dict):
|
|
597
|
+
continue
|
|
598
|
+
up_id = e.get(id_key)
|
|
599
|
+
name = e.get("name")
|
|
600
|
+
if up_id is None or name is None:
|
|
601
|
+
continue
|
|
602
|
+
keep_ids.add(int(up_id))
|
|
603
|
+
master_rows.append({
|
|
604
|
+
"upgrade_id": up_id,
|
|
605
|
+
"name": name,
|
|
606
|
+
"class": e.get("class"),
|
|
607
|
+
"rating": e.get("rating"),
|
|
608
|
+
"ship": e.get("ship"),
|
|
609
|
+
})
|
|
610
|
+
else:
|
|
611
|
+
raise CleanExit(f"_sync_vendor_block_fast: unknown kind={kind!r}")
|
|
612
|
+
|
|
613
|
+
# 1) Ensure master rows exist (simple upsert, no timestamp guards).
|
|
614
|
+
if master_rows:
|
|
615
|
+
key_name = list(master_rows[0].keys())[0]
|
|
616
|
+
update_cols = tuple(k for k in master_rows[0].keys() if k != key_name)
|
|
617
|
+
if db_utils.is_sqlite(self.session):
|
|
618
|
+
db_utils.sqlite_upsert_simple(
|
|
619
|
+
self.session, t_master, rows=master_rows,
|
|
620
|
+
key_cols=(key_name,),
|
|
621
|
+
update_cols=update_cols,
|
|
622
|
+
)
|
|
623
|
+
elif db_utils.is_mysql(self.session):
|
|
624
|
+
db_utils.mysql_upsert_simple(
|
|
625
|
+
self.session, t_master, rows=master_rows,
|
|
626
|
+
key_cols=(key_name,),
|
|
627
|
+
update_cols=update_cols,
|
|
628
|
+
)
|
|
629
|
+
else:
|
|
630
|
+
for r in master_rows:
|
|
631
|
+
cond = bool(getattr(t_master.c, key_name) == r[key_name])
|
|
632
|
+
exists = self.session.execute(select(getattr(t_master.c, key_name)).where(cond)).first()
|
|
633
|
+
if exists is None:
|
|
634
|
+
self.session.execute(insert(t_master).values(**r))
|
|
635
|
+
else:
|
|
636
|
+
upd = {k: v for k, v in r.items() if k != key_name}
|
|
637
|
+
if upd:
|
|
638
|
+
self.session.execute(update(t_master).where(cond).values(**upd))
|
|
639
|
+
|
|
640
|
+
# 2) Link rows with timestamp guard for vendor tables.
|
|
641
|
+
wrote = 0
|
|
642
|
+
delc = 0
|
|
643
|
+
if keep_ids:
|
|
644
|
+
existing = {
|
|
645
|
+
int(r[0]): (r[1] or None)
|
|
646
|
+
for r in self.session.execute(
|
|
647
|
+
select(getattr(t_vendor.c, id_col), t_vendor.c.modified).where(
|
|
648
|
+
and_(t_vendor.c.station_id == station_id, getattr(t_vendor.c, id_col).in_(keep_ids))
|
|
649
|
+
)
|
|
650
|
+
).all()
|
|
651
|
+
}
|
|
652
|
+
to_insert = keep_ids - set(existing.keys())
|
|
653
|
+
to_update = {
|
|
654
|
+
vid for vid, mod in existing.items()
|
|
655
|
+
if (mod is None) or (ts_eff > mod)
|
|
656
|
+
}
|
|
657
|
+
wrote = len(to_insert) + len(to_update)
|
|
658
|
+
|
|
659
|
+
vendor_rows = [{id_col: vid, "station_id": station_id, "modified": ts_eff} for vid in keep_ids]
|
|
660
|
+
if db_utils.is_sqlite(self.session):
|
|
661
|
+
db_utils.sqlite_upsert_modified(
|
|
662
|
+
self.session, t_vendor, rows=vendor_rows,
|
|
663
|
+
key_cols=(id_col, "station_id"),
|
|
664
|
+
modified_col="modified",
|
|
665
|
+
update_cols=(),
|
|
666
|
+
)
|
|
667
|
+
elif db_utils.is_mysql(self.session):
|
|
668
|
+
db_utils.mysql_upsert_modified(
|
|
669
|
+
self.session, t_vendor, rows=vendor_rows,
|
|
670
|
+
key_cols=(id_col, "station_id"),
|
|
671
|
+
modified_col="modified",
|
|
672
|
+
update_cols=(),
|
|
673
|
+
)
|
|
674
|
+
else:
|
|
675
|
+
for r in vendor_rows:
|
|
676
|
+
cond = and_(getattr(t_vendor.c, id_col) == r[id_col], t_vendor.c.station_id == station_id)
|
|
677
|
+
cur = self.session.execute(select(t_vendor.c.modified).where(cond)).first()
|
|
678
|
+
if cur is None:
|
|
679
|
+
self.session.execute(insert(t_vendor).values(**r))
|
|
680
|
+
else:
|
|
681
|
+
mod = cur[0]
|
|
682
|
+
if (mod is None) or (ts_eff > mod):
|
|
683
|
+
self.session.execute(update(t_vendor).where(cond).values(modified=ts_eff))
|
|
684
|
+
|
|
685
|
+
return wrote, delc
|
|
686
|
+
|
|
687
|
+
def _cleanup_absent_stations(self, tables: dict[str, Table], present_station_ids: set[int], json_ts: datetime) -> tuple[int, int, int]:
|
|
688
|
+
"""
|
|
689
|
+
After streaming, delete baseline rows for stations absent from the JSON
|
|
690
|
+
if the JSON timestamp is >= row.modified. Never delete newer-than-JSON rows.
|
|
691
|
+
Returns (market_del, outfit_del, ship_del) counts.
|
|
692
|
+
"""
|
|
693
|
+
t_si, t_uv, t_sv, t_st = tables["StationItem"], tables["UpgradeVendor"], tables["ShipVendor"], tables["Station"]
|
|
694
|
+
|
|
695
|
+
# All station ids in DB
|
|
696
|
+
all_sids = [int(r[0]) for r in self.session.execute(select(t_st.c.station_id)).all()]
|
|
697
|
+
absent = [sid for sid in all_sids if sid not in present_station_ids]
|
|
698
|
+
if not absent:
|
|
699
|
+
return (0, 0, 0)
|
|
700
|
+
|
|
701
|
+
# Markets: delete baseline rows (from_live=0) with modified <= json_ts
|
|
702
|
+
del_m = self.session.execute(
|
|
703
|
+
t_si.delete().where(
|
|
704
|
+
and_(
|
|
705
|
+
t_si.c.station_id.in_(absent),
|
|
706
|
+
t_si.c.from_live == 0,
|
|
707
|
+
or_(t_si.c.modified.is_(None), t_si.c.modified <= json_ts),
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
).rowcount or 0
|
|
711
|
+
|
|
712
|
+
# Vendors: delete rows with modified <= json_ts
|
|
713
|
+
del_u = self.session.execute(
|
|
714
|
+
tables["UpgradeVendor"].delete().where(
|
|
715
|
+
and_(t_uv.c.station_id.in_(absent), or_(t_uv.c.modified.is_(None), t_uv.c.modified <= json_ts))
|
|
716
|
+
)
|
|
717
|
+
).rowcount or 0
|
|
718
|
+
del_s = self.session.execute(
|
|
719
|
+
tables["ShipVendor"].delete().where(
|
|
720
|
+
and_(t_sv.c.station_id.in_(absent), or_(t_sv.c.modified.is_(None), t_sv.c.modified <= json_ts))
|
|
721
|
+
)
|
|
722
|
+
).rowcount or 0
|
|
723
|
+
|
|
724
|
+
return (int(del_m), int(del_u), int(del_s))
|
|
725
|
+
|
|
726
|
+
def _sync_market_block_fast(
|
|
727
|
+
self,
|
|
728
|
+
tables: dict[str, Table],
|
|
729
|
+
categories: dict[str, int],
|
|
730
|
+
*,
|
|
731
|
+
station_id: int,
|
|
732
|
+
commodities: list[dict[str, Any]],
|
|
733
|
+
ts_sp: datetime,
|
|
734
|
+
) -> tuple[int, int]:
|
|
735
|
+
"""
|
|
736
|
+
Fast, set-based market sync for one station.
|
|
737
|
+
|
|
738
|
+
Returns: (number_of_inserts_or_updates_on_StationItem, deletions_count).
|
|
739
|
+
"""
|
|
740
|
+
t_item, t_si = tables["Item"], tables["StationItem"]
|
|
741
|
+
|
|
742
|
+
item_rows: list[dict[str, Any]] = []
|
|
743
|
+
link_rows: list[dict[str, Any]] = []
|
|
744
|
+
keep_ids: set[int] = set()
|
|
745
|
+
|
|
746
|
+
for co in commodities:
|
|
747
|
+
if not isinstance(co, dict):
|
|
748
|
+
continue
|
|
749
|
+
fdev_id = co.get("commodityId")
|
|
750
|
+
name = co.get("name")
|
|
751
|
+
cat_name = co.get("category")
|
|
752
|
+
if fdev_id is None or name is None or cat_name is None:
|
|
753
|
+
continue
|
|
754
|
+
|
|
755
|
+
cat_id = categories.get(str(cat_name).lower())
|
|
756
|
+
if cat_id is None:
|
|
757
|
+
raise CleanExit(f'Unknown commodity category "{cat_name}"')
|
|
758
|
+
|
|
759
|
+
keep_ids.add(int(fdev_id))
|
|
760
|
+
item_rows.append({
|
|
761
|
+
"item_id": fdev_id,
|
|
762
|
+
"name": name,
|
|
763
|
+
"category_id": cat_id,
|
|
764
|
+
"fdev_id": fdev_id,
|
|
765
|
+
"ui_order": 0,
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
demand = co.get("demand")
|
|
769
|
+
supply = co.get("supply")
|
|
770
|
+
buy = co.get("buyPrice")
|
|
771
|
+
sell = co.get("sellPrice")
|
|
772
|
+
|
|
773
|
+
link_rows.append({
|
|
774
|
+
"station_id": station_id,
|
|
775
|
+
"item_id": fdev_id,
|
|
776
|
+
"demand_price": sell,
|
|
777
|
+
"demand_units": demand,
|
|
778
|
+
"demand_level": -1,
|
|
779
|
+
"supply_price": buy,
|
|
780
|
+
"supply_units": supply,
|
|
781
|
+
"supply_level": -1,
|
|
782
|
+
"from_live": 0,
|
|
783
|
+
"modified": ts_sp,
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
# 1) Upsert Items (simple)
|
|
787
|
+
if item_rows:
|
|
788
|
+
if db_utils.is_sqlite(self.session):
|
|
789
|
+
db_utils.sqlite_upsert_simple(
|
|
790
|
+
self.session, t_item, rows=item_rows,
|
|
791
|
+
key_cols=("item_id",),
|
|
792
|
+
update_cols=("name", "category_id", "fdev_id", "ui_order"),
|
|
793
|
+
)
|
|
794
|
+
elif db_utils.is_mysql(self.session):
|
|
795
|
+
db_utils.mysql_upsert_simple(
|
|
796
|
+
self.session, t_item, rows=item_rows,
|
|
797
|
+
key_cols=("item_id",),
|
|
798
|
+
update_cols=("name", "category_id", "fdev_id", "ui_order"),
|
|
799
|
+
)
|
|
800
|
+
else:
|
|
801
|
+
for r in item_rows:
|
|
802
|
+
exists = self.session.execute(
|
|
803
|
+
select(t_item.c.item_id).where(t_item.c.item_id == r["item_id"])
|
|
804
|
+
).first()
|
|
805
|
+
if exists is None:
|
|
806
|
+
self.session.execute(insert(t_item).values(**r))
|
|
807
|
+
else:
|
|
808
|
+
self.session.execute(
|
|
809
|
+
update(t_item).where(t_item.c.item_id == r["item_id"]).values(
|
|
810
|
+
name=r["name"], category_id=r["category_id"], fdev_id=r["fdev_id"], ui_order=r["ui_order"]
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
# 2) Compute effective inserts/updates for StationItem (pre-check modified), then upsert
|
|
815
|
+
wrote = 0
|
|
816
|
+
if link_rows:
|
|
817
|
+
existing = {
|
|
818
|
+
(int(r[0]), int(r[1])): (r[2] or None)
|
|
819
|
+
for r in self.session.execute(
|
|
820
|
+
select(t_si.c.station_id, t_si.c.item_id, t_si.c.modified).where(
|
|
821
|
+
and_(t_si.c.station_id == station_id, t_si.c.item_id.in_(keep_ids))
|
|
822
|
+
)
|
|
823
|
+
).all()
|
|
824
|
+
}
|
|
825
|
+
to_insert = {
|
|
826
|
+
(station_id, rid) for rid in keep_ids
|
|
827
|
+
if (station_id, rid) not in existing
|
|
828
|
+
}
|
|
829
|
+
to_update = {
|
|
830
|
+
(station_id, rid) for rid, mod in ((rid, existing.get((station_id, rid))) for rid in keep_ids)
|
|
831
|
+
if (mod is None) or (ts_sp is not None and ts_sp > mod)
|
|
832
|
+
}
|
|
833
|
+
wrote = len(to_insert) + len(to_update)
|
|
834
|
+
|
|
835
|
+
if db_utils.is_sqlite(self.session):
|
|
836
|
+
db_utils.sqlite_upsert_modified(
|
|
837
|
+
self.session, t_si, rows=link_rows,
|
|
838
|
+
key_cols=("station_id", "item_id"),
|
|
839
|
+
modified_col="modified",
|
|
840
|
+
update_cols=("demand_price", "demand_units", "demand_level",
|
|
841
|
+
"supply_price", "supply_units", "supply_level", "from_live"),
|
|
842
|
+
)
|
|
843
|
+
elif db_utils.is_mysql(self.session):
|
|
844
|
+
db_utils.mysql_upsert_modified(
|
|
845
|
+
self.session, t_si, rows=link_rows,
|
|
846
|
+
key_cols=("station_id", "item_id"),
|
|
847
|
+
modified_col="modified",
|
|
848
|
+
update_cols=("demand_price", "demand_units", "demand_level",
|
|
849
|
+
"supply_price", "supply_units", "supply_level", "from_live"),
|
|
850
|
+
)
|
|
851
|
+
else:
|
|
852
|
+
for r in link_rows:
|
|
853
|
+
row = self.session.execute(
|
|
854
|
+
select(t_si.c.modified).where(and_(
|
|
855
|
+
t_si.c.station_id == r["station_id"],
|
|
856
|
+
t_si.c.item_id == r["item_id"],
|
|
857
|
+
))
|
|
858
|
+
).first()
|
|
859
|
+
if row is None:
|
|
860
|
+
self.session.execute(insert(t_si).values(**r))
|
|
861
|
+
else:
|
|
862
|
+
dbm = row[0]
|
|
863
|
+
if dbm is None or r["modified"] > dbm:
|
|
864
|
+
self.session.execute(
|
|
865
|
+
update(t_si)
|
|
866
|
+
.where(and_(t_si.c.station_id == r["station_id"], t_si.c.item_id == r["item_id"]))
|
|
867
|
+
.values(**r)
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
# 3) Delete baseline rows missing from JSON, not newer than ts_sp
|
|
871
|
+
delc = 0
|
|
872
|
+
base_where = and_(
|
|
873
|
+
t_si.c.station_id == station_id,
|
|
874
|
+
t_si.c.from_live == 0,
|
|
875
|
+
or_(t_si.c.modified.is_(None), t_si.c.modified <= ts_sp),
|
|
876
|
+
)
|
|
877
|
+
if keep_ids:
|
|
878
|
+
delete_stmt = t_si.delete().where(and_(base_where, ~t_si.c.item_id.in_(keep_ids)))
|
|
879
|
+
else:
|
|
880
|
+
delete_stmt = t_si.delete().where(base_where)
|
|
881
|
+
|
|
882
|
+
res = self.session.execute(delete_stmt)
|
|
883
|
+
try:
|
|
884
|
+
delc = int(res.rowcount or 0)
|
|
885
|
+
except Exception:
|
|
886
|
+
delc = 0
|
|
887
|
+
|
|
888
|
+
return wrote, delc
|
|
889
|
+
|
|
890
|
+
# ------------------------------
|
|
891
|
+
# Lifecycle hooks
|
|
892
|
+
#
|
|
893
|
+
def run(self) -> bool:
|
|
894
|
+
"""
|
|
895
|
+
Full orchestrator: acquisition → bootstrap → EDCD preload → import → rares → export.
|
|
896
|
+
Returns False to keep default flow suppressed.
|
|
897
|
+
"""
|
|
898
|
+
started = time.time()
|
|
899
|
+
|
|
900
|
+
if self.getOption("pricesonly"):
|
|
901
|
+
try:
|
|
902
|
+
self._print("Regenerating TradeDangerous.prices …")
|
|
903
|
+
cache.regeneratePricesFile(self.tdb, self.tdenv)
|
|
904
|
+
self._print("Prices file generated.")
|
|
905
|
+
except Exception as e:
|
|
906
|
+
self._error(f"Prices regeneration failed: {e!r}")
|
|
907
|
+
return False
|
|
908
|
+
return False
|
|
909
|
+
|
|
910
|
+
# Acquire Spansh JSON
|
|
911
|
+
try:
|
|
912
|
+
source_path = self._acquire_source()
|
|
913
|
+
except CleanExit as ce:
|
|
914
|
+
self._warn(str(ce))
|
|
915
|
+
return False
|
|
916
|
+
except Exception as e:
|
|
917
|
+
self._error(f"Acquisition failed: {e!r}")
|
|
918
|
+
return False
|
|
919
|
+
|
|
920
|
+
# -------- Bootstrap DB (no cache rebuild here) --------
|
|
921
|
+
try:
|
|
922
|
+
backend = self.tdb.engine.dialect.name.lower()
|
|
923
|
+
data_dir = Path(getattr(self.tdenv, "dataDir", getattr(self.tdb, "dataDir", "data")))
|
|
924
|
+
metadata = getattr(self.tdb, "metadata", None)
|
|
925
|
+
|
|
926
|
+
summary = ensure_fresh_db(
|
|
927
|
+
backend=backend,
|
|
928
|
+
engine=self.tdb.engine,
|
|
929
|
+
data_dir=data_dir,
|
|
930
|
+
metadata=metadata,
|
|
931
|
+
mode="auto",
|
|
932
|
+
tdb=self.tdb,
|
|
933
|
+
tdenv=self.tdenv,
|
|
934
|
+
rebuild=False, # do not run buildCache here
|
|
935
|
+
)
|
|
936
|
+
self._print(
|
|
937
|
+
f"DB bootstrap: action={summary.get('action','kept')} "
|
|
938
|
+
f"reason={summary.get('reason','ok')} backend={summary.get('backend')}"
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
# No valid DB? Create full schema now (SQLite from canonical SQL; MariaDB via ORM)
|
|
942
|
+
if summary.get("action") == "needs_rebuild":
|
|
943
|
+
db_path = Path(self.tdb.engine.url.database or (data_dir / "TradeDangerous.db")) # SQLite only
|
|
944
|
+
self._print("No valid DB detected — creating full schema…")
|
|
945
|
+
reset_db(self.tdb.engine, db_path=db_path)
|
|
946
|
+
|
|
947
|
+
# Seed 'Added' once on a fresh schema
|
|
948
|
+
self.session = self._open_session()
|
|
949
|
+
self._seed_added_from_templates(self.session)
|
|
950
|
+
self.session.commit()
|
|
951
|
+
self._safe_close_session()
|
|
952
|
+
|
|
953
|
+
except Exception as e:
|
|
954
|
+
self._error(f"Database bootstrap failed: {e!r}")
|
|
955
|
+
return False
|
|
956
|
+
|
|
957
|
+
# -------- Session + batch + reflection --------
|
|
958
|
+
try:
|
|
959
|
+
self.session = self._open_session()
|
|
960
|
+
self.batch_size = self._resolve_batch_size()
|
|
961
|
+
tables = self._reflect_tables(self.session.get_bind())
|
|
962
|
+
except Exception as e:
|
|
963
|
+
self._error(f"Failed to open/reflect DB session: {e!r}")
|
|
964
|
+
return False
|
|
965
|
+
|
|
966
|
+
# -------- EDCD preloads (hardcoded URLs; can be disabled) --------
|
|
967
|
+
edcd = self._acquire_edcd_files()
|
|
968
|
+
|
|
969
|
+
# Categories (add-only) — COMMIT immediately so they persist even if later phases fail.
|
|
970
|
+
try:
|
|
971
|
+
if edcd.get("commodity"):
|
|
972
|
+
added = self._edcd_import_categories_add_only(self.session, tables, edcd["commodity"])
|
|
973
|
+
if added:
|
|
974
|
+
self._print(f"EDCD categories: added {added} new categories")
|
|
975
|
+
self.session.commit()
|
|
976
|
+
except CleanExit as ce:
|
|
977
|
+
self._warn(str(ce))
|
|
978
|
+
return False
|
|
979
|
+
except Exception as e:
|
|
980
|
+
self._warn(f"EDCD categories skipped due to error: {e!r}")
|
|
981
|
+
|
|
982
|
+
# FDev catalogs (outfitting, shipyard) — COMMIT immediately as well.
|
|
983
|
+
try:
|
|
984
|
+
if edcd.get("outfitting") and edcd.get("shipyard"):
|
|
985
|
+
u, s = self._edcd_import_fdev_catalogs(
|
|
986
|
+
self.session, tables,
|
|
987
|
+
outfitting_csv=edcd["outfitting"],
|
|
988
|
+
shipyard_csv=edcd["shipyard"],
|
|
989
|
+
)
|
|
990
|
+
if (u + s) > 0:
|
|
991
|
+
self._print(f"EDCD FDev: Outfitting upserts={u:,} Shipyard upserts={s:,}")
|
|
992
|
+
self.session.commit()
|
|
993
|
+
except Exception as e:
|
|
994
|
+
self._warn(f"EDCD FDev catalogs skipped due to error: {e!r}")
|
|
995
|
+
|
|
996
|
+
# Load categories (may have grown) before Spansh import
|
|
997
|
+
try:
|
|
998
|
+
categories = self._load_categories(self.session, tables)
|
|
999
|
+
except Exception as e:
|
|
1000
|
+
self._error(f"Failed to load categories: {e!r}")
|
|
1001
|
+
return False
|
|
1002
|
+
|
|
1003
|
+
# -------- Import Spansh JSON --------
|
|
1004
|
+
try:
|
|
1005
|
+
if self._debug_level < 1:
|
|
1006
|
+
self._print("This will take at least several minutes.")
|
|
1007
|
+
self._print("You can increase verbosity (-v) to get a sense of progress")
|
|
1008
|
+
self._print("Importing spansh data")
|
|
1009
|
+
stats = self._import_stream(source_path, categories, tables)
|
|
1010
|
+
self._end_live_status()
|
|
1011
|
+
|
|
1012
|
+
mk_e = stats.get("market_writes", 0) + stats.get("market_stations", 0)
|
|
1013
|
+
of_e = stats.get("outfit_writes", 0) + stats.get("outfit_stations", 0)
|
|
1014
|
+
sh_e = stats.get("ship_writes", 0) + stats.get("ship_stations", 0)
|
|
1015
|
+
self._print(
|
|
1016
|
+
f"Import complete — systems: {stats.get('systems',0):,} "
|
|
1017
|
+
f"stations: {stats.get('stations',0):,} "
|
|
1018
|
+
f"evaluated: markets≈{mk_e:,} outfitters≈{of_e:,} shipyards≈{sh_e:,} "
|
|
1019
|
+
f"kept: markets≈{stats.get('market_stations',0):,} outfitters≈{stats.get('outfit_stations',0):,} shipyards≈{stats.get('ship_stations',0):,}"
|
|
1020
|
+
)
|
|
1021
|
+
except CleanExit as ce:
|
|
1022
|
+
self._warn(str(ce))
|
|
1023
|
+
self._safe_close_session()
|
|
1024
|
+
return False
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
self._error(f"Import failed: {e!r}")
|
|
1027
|
+
self._safe_close_session()
|
|
1028
|
+
return False
|
|
1029
|
+
|
|
1030
|
+
# Enforce Item.ui_order
|
|
1031
|
+
try:
|
|
1032
|
+
t0 = time.time()
|
|
1033
|
+
self._enforce_ui_order(self.session, tables)
|
|
1034
|
+
self._print(f"ui_order enforced in {time.time()-t0:.2f}s")
|
|
1035
|
+
except Exception as e:
|
|
1036
|
+
self._error(f"ui_order enforcement failed: {e!r}")
|
|
1037
|
+
self._safe_close_session()
|
|
1038
|
+
return False
|
|
1039
|
+
|
|
1040
|
+
# Final commit for import phase
|
|
1041
|
+
try:
|
|
1042
|
+
self.session.commit()
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
self._warn(f"Commit failed at end of import; rolling back. Cause: {e!r}")
|
|
1045
|
+
self.session.rollback()
|
|
1046
|
+
self._safe_close_session()
|
|
1047
|
+
return False
|
|
1048
|
+
|
|
1049
|
+
self._safe_close_session()
|
|
1050
|
+
|
|
1051
|
+
# -------- Rares (prefer EDCD; fallback to template) --------
|
|
1052
|
+
try:
|
|
1053
|
+
t0 = time.time()
|
|
1054
|
+
if edcd.get("rares"):
|
|
1055
|
+
self._import_rareitems_edcd(edcd["rares"])
|
|
1056
|
+
else:
|
|
1057
|
+
self._import_rareitems()
|
|
1058
|
+
self._print(f"Rares imported in {time.time()-t0:.2f}s")
|
|
1059
|
+
except CleanExit as ce:
|
|
1060
|
+
self._warn(str(ce))
|
|
1061
|
+
return False
|
|
1062
|
+
except Exception as e:
|
|
1063
|
+
self._error(f"RareItem import failed: {e!r}")
|
|
1064
|
+
return False
|
|
1065
|
+
|
|
1066
|
+
# -------- Export (uses your parallel exporter already present) --------
|
|
1067
|
+
try:
|
|
1068
|
+
self._export_and_mirror() # timing + final print handled inside
|
|
1069
|
+
except Exception as e:
|
|
1070
|
+
self._error(f"Export failed: {e!r}")
|
|
1071
|
+
return False
|
|
1072
|
+
|
|
1073
|
+
elapsed = self._format_hms(time.time() - started)
|
|
1074
|
+
self._print(f"{elapsed} Done")
|
|
1075
|
+
return False
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def finish(self) -> bool:
|
|
1080
|
+
"""No-op: handled in run(); finish() won’t be called."""
|
|
1081
|
+
return True
|
|
1082
|
+
|
|
1083
|
+
# ------------------------------
|
|
1084
|
+
# Acquisition (url/file/stdin)
|
|
1085
|
+
# ------------------------------
|
|
1086
|
+
|
|
1087
|
+
def _acquire_source(self) -> Path:
|
|
1088
|
+
"""Return a readable filesystem path to the JSON source (tmp/)."""
|
|
1089
|
+
url = self.getOption("url")
|
|
1090
|
+
file_ = self.getOption("file")
|
|
1091
|
+
cache_path = self.tmp_dir / "galaxy_stations.json"
|
|
1092
|
+
|
|
1093
|
+
if file_:
|
|
1094
|
+
if file_ == "-":
|
|
1095
|
+
self._print("Reading Spansh dump from stdin …")
|
|
1096
|
+
self._write_stream_to_file(sys.stdin.buffer, cache_path)
|
|
1097
|
+
return cache_path
|
|
1098
|
+
src = Path(file_)
|
|
1099
|
+
if not src.exists() or not src.is_file():
|
|
1100
|
+
raise CleanExit(f"Local file not found: {src}")
|
|
1101
|
+
return src.resolve()
|
|
1102
|
+
|
|
1103
|
+
if not url:
|
|
1104
|
+
url = DEFAULT_URL
|
|
1105
|
+
|
|
1106
|
+
# Pass a friendly label so progress says “Spansh dump”
|
|
1107
|
+
return self._download_with_cache(url, cache_path, label="Spansh dump")
|
|
1108
|
+
|
|
1109
|
+
def _download_with_cache(self, url: str, cache_path: Path, *, label: str = "download") -> Path:
|
|
1110
|
+
"""Conditional download with HEAD Last-Modified and atomic .part."""
|
|
1111
|
+
remote_lm: Optional[datetime] = None
|
|
1112
|
+
try:
|
|
1113
|
+
req = urllib.request.Request(url, method="HEAD")
|
|
1114
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
1115
|
+
lm_header = resp.headers.get("Last-Modified")
|
|
1116
|
+
if lm_header:
|
|
1117
|
+
try:
|
|
1118
|
+
remote_lm = parsedate_to_datetime(lm_header).astimezone(timezone.utc).replace(tzinfo=None)
|
|
1119
|
+
except Exception:
|
|
1120
|
+
remote_lm = None
|
|
1121
|
+
except Exception:
|
|
1122
|
+
pass
|
|
1123
|
+
|
|
1124
|
+
if cache_path.exists() and remote_lm:
|
|
1125
|
+
local_mtime = datetime.fromtimestamp(cache_path.stat().st_mtime, tz=timezone.utc).replace(tzinfo=None)
|
|
1126
|
+
if local_mtime >= remote_lm:
|
|
1127
|
+
self._print(f"Remote not newer; using cached {label}")
|
|
1128
|
+
return cache_path
|
|
1129
|
+
|
|
1130
|
+
self._print(f"Downloading {label} from {url} …")
|
|
1131
|
+
part = cache_path.with_suffix(cache_path.suffix + ".part")
|
|
1132
|
+
if part.exists():
|
|
1133
|
+
try:
|
|
1134
|
+
part.unlink()
|
|
1135
|
+
except Exception:
|
|
1136
|
+
pass
|
|
1137
|
+
|
|
1138
|
+
req = urllib.request.Request(url, method="GET")
|
|
1139
|
+
connect_timeout = 30
|
|
1140
|
+
chunk = 8 * 1024 * 1024 # 8 MiB
|
|
1141
|
+
|
|
1142
|
+
try:
|
|
1143
|
+
with urllib.request.urlopen(req, timeout=connect_timeout) as resp, open(part, "wb") as fh:
|
|
1144
|
+
total_hdr = resp.headers.get("Content-Length")
|
|
1145
|
+
total = int(total_hdr) if total_hdr and total_hdr.isdigit() else None
|
|
1146
|
+
downloaded = 0
|
|
1147
|
+
start = time.time()
|
|
1148
|
+
|
|
1149
|
+
while True:
|
|
1150
|
+
data = resp.read(chunk)
|
|
1151
|
+
if not data:
|
|
1152
|
+
break
|
|
1153
|
+
fh.write(data)
|
|
1154
|
+
downloaded += len(data)
|
|
1155
|
+
self._download_progress(downloaded, total, start, label=label)
|
|
1156
|
+
|
|
1157
|
+
part.replace(cache_path)
|
|
1158
|
+
|
|
1159
|
+
# Set mtime to Last-Modified if present on GET
|
|
1160
|
+
lm_header = None
|
|
1161
|
+
try:
|
|
1162
|
+
with urllib.request.urlopen(urllib.request.Request(url, method="HEAD"), timeout=10) as head2:
|
|
1163
|
+
lm_header = head2.headers.get("Last-Modified")
|
|
1164
|
+
except Exception:
|
|
1165
|
+
pass
|
|
1166
|
+
if lm_header:
|
|
1167
|
+
try:
|
|
1168
|
+
got_lm = parsedate_to_datetime(lm_header).astimezone(timezone.utc).replace(tzinfo=None)
|
|
1169
|
+
ts = got_lm.replace(tzinfo=timezone.utc).timestamp()
|
|
1170
|
+
os.utime(cache_path, (ts, ts))
|
|
1171
|
+
except Exception:
|
|
1172
|
+
pass
|
|
1173
|
+
|
|
1174
|
+
except Exception as e:
|
|
1175
|
+
try:
|
|
1176
|
+
if part.exists():
|
|
1177
|
+
part.unlink()
|
|
1178
|
+
except Exception:
|
|
1179
|
+
pass
|
|
1180
|
+
raise CleanExit(f"Download failed or timed out for {label}; skipping run ({e!r})") from None
|
|
1181
|
+
|
|
1182
|
+
self._print(f'Download complete: {label} → "{cache_path}"')
|
|
1183
|
+
return cache_path
|
|
1184
|
+
|
|
1185
|
+
def _download_progress(self, downloaded: int, total: Optional[int], start_ts: float, *, label: str = "download") -> None:
|
|
1186
|
+
now = time.time()
|
|
1187
|
+
if now - self._last_progress_time < 0.5 and self._debug_level < 1:
|
|
1188
|
+
return
|
|
1189
|
+
self._last_progress_time = now
|
|
1190
|
+
|
|
1191
|
+
rate = downloaded / max(now - start_ts, 1e-9)
|
|
1192
|
+
if total:
|
|
1193
|
+
pct = (downloaded / total) * 100.0
|
|
1194
|
+
msg = f"{label}: {self._fmt_bytes(downloaded)} / {self._fmt_bytes(total)} ({pct:5.1f}%) {self._fmt_bytes(rate)}/s"
|
|
1195
|
+
else:
|
|
1196
|
+
msg = f"{label}: {self._fmt_bytes(downloaded)} read {self._fmt_bytes(rate)}/s"
|
|
1197
|
+
self._live_status(msg)
|
|
1198
|
+
|
|
1199
|
+
def _write_stream_to_file(self, stream: io.BufferedReader, dest: Path) -> None:
|
|
1200
|
+
part = dest.with_suffix(dest.suffix + ".part")
|
|
1201
|
+
if part.exists():
|
|
1202
|
+
try:
|
|
1203
|
+
part.unlink()
|
|
1204
|
+
except Exception:
|
|
1205
|
+
pass
|
|
1206
|
+
written = 0
|
|
1207
|
+
start = time.time()
|
|
1208
|
+
try:
|
|
1209
|
+
with open(part, "wb") as fh:
|
|
1210
|
+
while True:
|
|
1211
|
+
buf = stream.read(8 * 1024 * 1024)
|
|
1212
|
+
if not buf:
|
|
1213
|
+
break
|
|
1214
|
+
fh.write(buf)
|
|
1215
|
+
written += len(buf)
|
|
1216
|
+
self._download_progress(written, None, start)
|
|
1217
|
+
part.replace(dest)
|
|
1218
|
+
except Exception as e:
|
|
1219
|
+
try:
|
|
1220
|
+
if part.exists():
|
|
1221
|
+
part.unlink()
|
|
1222
|
+
except Exception:
|
|
1223
|
+
pass
|
|
1224
|
+
raise CleanExit(f"Failed to read stdin into tmp file: {e!r})") from None
|
|
1225
|
+
|
|
1226
|
+
# ------------------------------
|
|
1227
|
+
# DB session / reflection
|
|
1228
|
+
# ------------------------------
|
|
1229
|
+
def _open_session(self) -> Session:
|
|
1230
|
+
"""
|
|
1231
|
+
Create a DB session and apply per-connection bulk settings.
|
|
1232
|
+
"""
|
|
1233
|
+
if hasattr(self.tdb, "Session") and callable(self.tdb.Session):
|
|
1234
|
+
sess = self.tdb.Session()
|
|
1235
|
+
elif hasattr(db_utils, "get_session"):
|
|
1236
|
+
sess = db_utils.get_session(self.tdb.engine)
|
|
1237
|
+
else:
|
|
1238
|
+
raise RuntimeError("No Session factory available")
|
|
1239
|
+
|
|
1240
|
+
# SQLite pragmas (non-fatal)
|
|
1241
|
+
try:
|
|
1242
|
+
if db_utils.is_sqlite(sess):
|
|
1243
|
+
db_utils.sqlite_set_bulk_pragmas(sess)
|
|
1244
|
+
except Exception:
|
|
1245
|
+
pass
|
|
1246
|
+
|
|
1247
|
+
# MySQL/MariaDB session tuning (non-fatal)
|
|
1248
|
+
try:
|
|
1249
|
+
if db_utils.is_mysql(sess):
|
|
1250
|
+
db_utils.mysql_set_bulk_session(sess)
|
|
1251
|
+
except Exception:
|
|
1252
|
+
pass
|
|
1253
|
+
|
|
1254
|
+
return sess
|
|
1255
|
+
|
|
1256
|
+
def _reflect_tables(self, engine: Engine) -> dict[str, Table]:
|
|
1257
|
+
meta = MetaData()
|
|
1258
|
+
names = [
|
|
1259
|
+
"System", "Station", "Item", "Category", "StationItem",
|
|
1260
|
+
"Ship", "ShipVendor", "Upgrade", "UpgradeVendor",
|
|
1261
|
+
"FDevOutfitting", "FDevShipyard", "RareItem",
|
|
1262
|
+
]
|
|
1263
|
+
return {n: Table(n, meta, autoload_with=engine) for n in names}
|
|
1264
|
+
|
|
1265
|
+
# ------------------------------
|
|
1266
|
+
# Import (streaming JSON → upserts)
|
|
1267
|
+
# ------------------------------
|
|
1268
|
+
def _import_stream(self, source_path: Path, categories: dict[str, int], tables: dict[str, Table]) -> dict[str, int]:
|
|
1269
|
+
"""
|
|
1270
|
+
Streaming importer with service-level maxage gating (FK-safe), using per-row rules.
|
|
1271
|
+
|
|
1272
|
+
FIXES:
|
|
1273
|
+
- Batch commits now honor utils.get_import_batch_size() across *all* parent/child ops.
|
|
1274
|
+
- System/Station increments are counted in stats and batch_ops.
|
|
1275
|
+
- Commit checks occur before each station is processed (outside advisory lock scope),
|
|
1276
|
+
reducing long transactions and making Ctrl-C loss less likely.
|
|
1277
|
+
"""
|
|
1278
|
+
batch_ops = 0
|
|
1279
|
+
stats = {
|
|
1280
|
+
"systems": 0, "stations": 0,
|
|
1281
|
+
"market_stations": 0, "outfit_stations": 0, "ship_stations": 0,
|
|
1282
|
+
"market_writes": 0, "outfit_writes": 0, "ship_writes": 0,
|
|
1283
|
+
"commodities": 0,
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
# NEW: initialize parse metrics for _progress_line(); iterator keeps these updated
|
|
1287
|
+
self._parse_bytes = 0
|
|
1288
|
+
self._parse_rate = 0.0
|
|
1289
|
+
|
|
1290
|
+
maxage_days = float(self.getOption("maxage")) if self.getOption("maxage") else None
|
|
1291
|
+
maxage_td = timedelta(days=maxage_days) if maxage_days is not None else None
|
|
1292
|
+
now_utc = datetime.utcnow()
|
|
1293
|
+
|
|
1294
|
+
try:
|
|
1295
|
+
json_ts = datetime.fromtimestamp(os.path.getmtime(source_path), tz=timezone.utc).replace(tzinfo=None)
|
|
1296
|
+
except Exception:
|
|
1297
|
+
json_ts = datetime.utcfromtimestamp(0)
|
|
1298
|
+
|
|
1299
|
+
seen_station_ids: set[int] = set()
|
|
1300
|
+
force_baseline = bool(self.getOption("force_baseline"))
|
|
1301
|
+
|
|
1302
|
+
def recent(ts: Optional[datetime]) -> bool:
|
|
1303
|
+
if ts is None:
|
|
1304
|
+
return not maxage_td # None -> True, otherwise False.
|
|
1305
|
+
if maxage_td is None:
|
|
1306
|
+
return True
|
|
1307
|
+
return (now_utc - ts) <= maxage_td
|
|
1308
|
+
|
|
1309
|
+
def svc_ts(st: dict[str, Any], key: str) -> Optional[datetime]:
|
|
1310
|
+
obj = st.get(key) or {}
|
|
1311
|
+
if not isinstance(obj, dict):
|
|
1312
|
+
return None
|
|
1313
|
+
return self._parse_ts(obj.get("updateTime"))
|
|
1314
|
+
|
|
1315
|
+
with open(source_path, "rb") as fh:
|
|
1316
|
+
for sys_idx, system_obj in enumerate(self._iter_top_level_json_array(fh), 1):
|
|
1317
|
+
sys_id64 = system_obj.get("id64")
|
|
1318
|
+
sys_name = system_obj.get("name")
|
|
1319
|
+
coords = system_obj.get("coords") or {}
|
|
1320
|
+
if sys_id64 is None or sys_name is None or not isinstance(coords, dict):
|
|
1321
|
+
if self._debug_level >= 3:
|
|
1322
|
+
self._warn(f"Skipping malformed system object at index {sys_idx}")
|
|
1323
|
+
continue
|
|
1324
|
+
|
|
1325
|
+
self._trace(phase="system", decision="consider", name=sys_name, id64=sys_id64)
|
|
1326
|
+
|
|
1327
|
+
# Collect stations (top-level + body-embedded)
|
|
1328
|
+
stations: list[dict[str, Any]] = []
|
|
1329
|
+
if isinstance(system_obj.get("stations"), list):
|
|
1330
|
+
stations.extend(system_obj["stations"])
|
|
1331
|
+
bodies = system_obj.get("bodies") or []
|
|
1332
|
+
if isinstance(bodies, list):
|
|
1333
|
+
for b in bodies:
|
|
1334
|
+
if isinstance(b, dict):
|
|
1335
|
+
stl = b.get("stations")
|
|
1336
|
+
if isinstance(stl, list):
|
|
1337
|
+
stations.extend(stl)
|
|
1338
|
+
|
|
1339
|
+
# --- System upsert ---
|
|
1340
|
+
t_system = tables["System"]
|
|
1341
|
+
x, y, z = coords.get("x"), coords.get("y"), coords.get("z")
|
|
1342
|
+
sys_modified = self._parse_ts(system_obj.get("updateTime"))
|
|
1343
|
+
self._upsert_system(t_system, int(sys_id64), str(sys_name), x, y, z, sys_modified)
|
|
1344
|
+
|
|
1345
|
+
# Count system progress and participate in batching
|
|
1346
|
+
stats["systems"] += 1
|
|
1347
|
+
batch_ops += 1
|
|
1348
|
+
|
|
1349
|
+
imported_station_modifieds: list[datetime] = []
|
|
1350
|
+
|
|
1351
|
+
for st in stations:
|
|
1352
|
+
# Periodic commit BEFORE processing the next station (outside any advisory locks)
|
|
1353
|
+
if (self.batch_size is not None) and (batch_ops >= self.batch_size):
|
|
1354
|
+
try:
|
|
1355
|
+
self.session.commit()
|
|
1356
|
+
batch_ops = 0
|
|
1357
|
+
except Exception as e:
|
|
1358
|
+
self._warn(f"Batch commit failed; rolling back. Cause: {e!r}")
|
|
1359
|
+
self.session.rollback()
|
|
1360
|
+
|
|
1361
|
+
name = st.get("name")
|
|
1362
|
+
sid = st.get("id")
|
|
1363
|
+
if not isinstance(name, str) or sid is None:
|
|
1364
|
+
continue
|
|
1365
|
+
station_id = int(sid)
|
|
1366
|
+
seen_station_ids.add(station_id)
|
|
1367
|
+
stats["stations"] += 1
|
|
1368
|
+
# Count at least one op per station so batching still progresses even if no vendor writes occur
|
|
1369
|
+
batch_ops += 1
|
|
1370
|
+
|
|
1371
|
+
# NEW: drive live progress from here (throttled inside _progress_line)
|
|
1372
|
+
self._progress_line(stats)
|
|
1373
|
+
|
|
1374
|
+
# Flags/timestamps
|
|
1375
|
+
has_market = bool(st.get("hasMarket") or ("market" in st))
|
|
1376
|
+
has_outfit = bool(st.get("hasOutfitting") or ("outfitting" in st))
|
|
1377
|
+
has_ship = bool(st.get("hasShipyard") or ("shipyard" in st))
|
|
1378
|
+
mkt_ts = svc_ts(st, "market")
|
|
1379
|
+
outf_ts = svc_ts(st, "outfitting")
|
|
1380
|
+
ship_ts = svc_ts(st, "shipyard")
|
|
1381
|
+
mkt_fresh = recent(mkt_ts)
|
|
1382
|
+
outf_fresh = recent(outf_ts)
|
|
1383
|
+
ship_fresh = recent(ship_ts)
|
|
1384
|
+
|
|
1385
|
+
# Station upsert (idempotent)
|
|
1386
|
+
t_station = tables["Station"]
|
|
1387
|
+
type_id, planetary = self._map_station_type(st.get("type"))
|
|
1388
|
+
pads = st.get("landingPads") or {}
|
|
1389
|
+
max_pad = self._derive_pad_size(pads)
|
|
1390
|
+
sflags = {
|
|
1391
|
+
"market": "Y" if has_market else "N",
|
|
1392
|
+
"blackmarket": "?" if st.get("hasBlackmarket") is None else ("Y" if st.get("hasBlackmarket") else "N"),
|
|
1393
|
+
"shipyard": "Y" if has_ship else "N",
|
|
1394
|
+
"outfitting": "Y" if has_outfit else "N",
|
|
1395
|
+
"rearm": "?" if st.get("hasRearm") is None else ("Y" if st.get("hasRearm") else "N"),
|
|
1396
|
+
"refuel": "?" if st.get("hasRefuel") is None else ("Y" if st.get("hasRefuel") else "N"),
|
|
1397
|
+
"repair": "?" if st.get("hasRepair") is None else ("Y" if st.get("hasRepair") else "N"),
|
|
1398
|
+
}
|
|
1399
|
+
st_modified = self._parse_ts(st.get("updateTime"))
|
|
1400
|
+
if st_modified:
|
|
1401
|
+
imported_station_modifieds.append(st_modified)
|
|
1402
|
+
|
|
1403
|
+
ls_from_star_val = st.get("distanceToArrival", 0)
|
|
1404
|
+
try:
|
|
1405
|
+
if ls_from_star_val is None:
|
|
1406
|
+
ls_from_star_val = 0
|
|
1407
|
+
else:
|
|
1408
|
+
ls_from_star_val = max(int(float(ls_from_star_val)), 0)
|
|
1409
|
+
except Exception:
|
|
1410
|
+
ls_from_star_val = 0
|
|
1411
|
+
|
|
1412
|
+
self._upsert_station(
|
|
1413
|
+
t_station, station_id=int(station_id), system_id=int(sys_id64), name=name,
|
|
1414
|
+
ls_from_star=ls_from_star_val, max_pad=max_pad,
|
|
1415
|
+
type_id=int(type_id), planetary=planetary, sflags=sflags, modified=st_modified
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
# ----------------------------
|
|
1419
|
+
# Ship vendor
|
|
1420
|
+
# ----------------------------
|
|
1421
|
+
if has_ship and ship_fresh:
|
|
1422
|
+
ships = (st.get("shipyard") or {}).get("ships") or []
|
|
1423
|
+
if isinstance(ships, list) and ships:
|
|
1424
|
+
if force_baseline:
|
|
1425
|
+
wrote, _, delc = self._apply_vendor_block_per_rules(
|
|
1426
|
+
tables["ShipVendor"], station_id, (s.get("shipId") for s in ships if isinstance(s, dict)),
|
|
1427
|
+
ship_ts, id_col="ship_id",
|
|
1428
|
+
)
|
|
1429
|
+
if wrote or delc:
|
|
1430
|
+
stats["ship_writes"] += 1
|
|
1431
|
+
batch_ops += (wrote + delc)
|
|
1432
|
+
stats["ship_stations"] += 1
|
|
1433
|
+
else:
|
|
1434
|
+
wrote, delc = self._sync_vendor_block_fast(
|
|
1435
|
+
tables, station_id=station_id, entries=ships, ts_sp=ship_ts, kind="ship"
|
|
1436
|
+
)
|
|
1437
|
+
if wrote or delc:
|
|
1438
|
+
stats["ship_writes"] += 1
|
|
1439
|
+
batch_ops += (wrote + delc)
|
|
1440
|
+
stats["ship_stations"] += 1
|
|
1441
|
+
else:
|
|
1442
|
+
stats["ship_stations"] += 1
|
|
1443
|
+
|
|
1444
|
+
# ----------------------------
|
|
1445
|
+
# Outfitting vendor
|
|
1446
|
+
# ----------------------------
|
|
1447
|
+
if has_outfit and outf_fresh:
|
|
1448
|
+
modules = (st.get("outfitting") or {}).get("modules") or []
|
|
1449
|
+
if isinstance(modules, list) and modules:
|
|
1450
|
+
if force_baseline:
|
|
1451
|
+
wrote = self._upsert_outfitting(tables, station_id, modules, outf_ts)
|
|
1452
|
+
_, _, delc = self._apply_vendor_block_per_rules(
|
|
1453
|
+
tables["UpgradeVendor"], station_id,
|
|
1454
|
+
(m.get("moduleId") for m in modules if isinstance(m, dict)),
|
|
1455
|
+
outf_ts, id_col="upgrade_id",
|
|
1456
|
+
)
|
|
1457
|
+
if wrote or delc:
|
|
1458
|
+
stats["outfit_writes"] += 1
|
|
1459
|
+
batch_ops += (wrote + delc)
|
|
1460
|
+
stats["outfit_stations"] += 1
|
|
1461
|
+
else:
|
|
1462
|
+
wrote, delc = self._sync_vendor_block_fast(
|
|
1463
|
+
tables, station_id=station_id, entries=modules, ts_sp=outf_ts, kind="module"
|
|
1464
|
+
)
|
|
1465
|
+
if wrote or delc:
|
|
1466
|
+
stats["outfit_writes"] += 1
|
|
1467
|
+
batch_ops += (wrote + delc)
|
|
1468
|
+
stats["outfit_stations"] += 1
|
|
1469
|
+
else:
|
|
1470
|
+
stats["outfit_stations"] += 1
|
|
1471
|
+
|
|
1472
|
+
# ----------------------------
|
|
1473
|
+
# Market (commit check already happened before this station)
|
|
1474
|
+
# ----------------------------
|
|
1475
|
+
if has_market and mkt_fresh:
|
|
1476
|
+
commodities = (st.get("market") or {}).get("commodities") or []
|
|
1477
|
+
if isinstance(commodities, list) and commodities:
|
|
1478
|
+
# The advisory lock context pins lock + DML to the same connection/txn.
|
|
1479
|
+
with station_advisory_lock(self.session, station_id, timeout_seconds=0.2, max_retries=4) as got:
|
|
1480
|
+
if not got:
|
|
1481
|
+
# Could not acquire; try this station on a later pass
|
|
1482
|
+
continue
|
|
1483
|
+
|
|
1484
|
+
self._trace(phase="market", decision="process",
|
|
1485
|
+
station_id=station_id, commodities=len(commodities))
|
|
1486
|
+
|
|
1487
|
+
if force_baseline:
|
|
1488
|
+
wrote_i, wrote_si = self._upsert_market(
|
|
1489
|
+
tables, categories, station_id, commodities, mkt_ts
|
|
1490
|
+
)
|
|
1491
|
+
# Remove any extras unconditionally (baseline reset)
|
|
1492
|
+
t_si = tables["StationItem"]
|
|
1493
|
+
keep_ids = {
|
|
1494
|
+
int(co.get("commodityId"))
|
|
1495
|
+
for co in commodities
|
|
1496
|
+
if isinstance(co, dict) and co.get("commodityId") is not None
|
|
1497
|
+
}
|
|
1498
|
+
if keep_ids:
|
|
1499
|
+
self.session.execute(
|
|
1500
|
+
t_si.delete().where(
|
|
1501
|
+
and_(t_si.c.station_id == station_id, ~t_si.c.item_id.in_(keep_ids))
|
|
1502
|
+
)
|
|
1503
|
+
)
|
|
1504
|
+
stats["commodities"] += wrote_si
|
|
1505
|
+
if wrote_si or wrote_i:
|
|
1506
|
+
stats["market_writes"] += 1
|
|
1507
|
+
batch_ops += (wrote_i + wrote_si)
|
|
1508
|
+
stats["market_stations"] += 1
|
|
1509
|
+
else:
|
|
1510
|
+
wrote_links, delc = self._sync_market_block_fast(
|
|
1511
|
+
tables, categories,
|
|
1512
|
+
station_id=station_id,
|
|
1513
|
+
commodities=commodities,
|
|
1514
|
+
ts_sp=mkt_ts,
|
|
1515
|
+
)
|
|
1516
|
+
if wrote_links or delc:
|
|
1517
|
+
stats["market_writes"] += 1
|
|
1518
|
+
batch_ops += (wrote_links + delc)
|
|
1519
|
+
stats["market_stations"] += 1
|
|
1520
|
+
else:
|
|
1521
|
+
stats["market_stations"] += 1
|
|
1522
|
+
|
|
1523
|
+
# Baseline absent-station cleanup (global, after full stream)
|
|
1524
|
+
# We only remove baseline content (from_live=0 for markets; vendor links)
|
|
1525
|
+
# and only where modified <= json_ts, so anything newer (e.g. live/ZMQ) is preserved.
|
|
1526
|
+
try:
|
|
1527
|
+
if force_baseline and seen_station_ids:
|
|
1528
|
+
m_del, u_del, s_del = self._cleanup_absent_stations(
|
|
1529
|
+
tables,
|
|
1530
|
+
present_station_ids=seen_station_ids,
|
|
1531
|
+
json_ts=json_ts,
|
|
1532
|
+
)
|
|
1533
|
+
if (m_del + u_del + s_del) > 0 and self._debug_level >= 1:
|
|
1534
|
+
self._print(
|
|
1535
|
+
f"Baseline cleanup: markets={m_del:,} upgrades={u_del:,} ships={s_del:,}"
|
|
1536
|
+
)
|
|
1537
|
+
except Exception as e:
|
|
1538
|
+
self._warn(f"Absent-station cleanup skipped due to error: {e!r}")
|
|
1539
|
+
|
|
1540
|
+
return stats
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
# ------------------------------
|
|
1544
|
+
# Upsert helpers
|
|
1545
|
+
# ------------------------------
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _upsert_system(
|
|
1549
|
+
self, t_system: Table, system_id: int, name: str,
|
|
1550
|
+
x: Optional[float], y: Optional[float], z: Optional[float],
|
|
1551
|
+
modified: Optional[datetime],
|
|
1552
|
+
) -> None:
|
|
1553
|
+
"""
|
|
1554
|
+
Upsert System with timestamp guard.
|
|
1555
|
+
'added' policy (when column exists):
|
|
1556
|
+
- INSERT: set added=20 (EDSM).
|
|
1557
|
+
- UPDATE: do not overwrite, unless existing added IS NULL → set to 20.
|
|
1558
|
+
"""
|
|
1559
|
+
if modified is None:
|
|
1560
|
+
modified = datetime.utcfromtimestamp(0)
|
|
1561
|
+
|
|
1562
|
+
has_added_col = hasattr(t_system.c, "added")
|
|
1563
|
+
|
|
1564
|
+
row = {
|
|
1565
|
+
"system_id": system_id,
|
|
1566
|
+
"name": name,
|
|
1567
|
+
"pos_x": x, "pos_y": y, "pos_z": z,
|
|
1568
|
+
"modified": modified,
|
|
1569
|
+
}
|
|
1570
|
+
if has_added_col:
|
|
1571
|
+
row["added"] = 20 # EDSM on INSERT
|
|
1572
|
+
|
|
1573
|
+
if db_utils.is_sqlite(self.session):
|
|
1574
|
+
db_utils.sqlite_upsert_modified(
|
|
1575
|
+
self.session, t_system,
|
|
1576
|
+
rows=[row],
|
|
1577
|
+
key_cols=("system_id",),
|
|
1578
|
+
modified_col="modified",
|
|
1579
|
+
update_cols=("name", "pos_x", "pos_y", "pos_z"),
|
|
1580
|
+
)
|
|
1581
|
+
if has_added_col:
|
|
1582
|
+
self.session.execute(
|
|
1583
|
+
update(t_system)
|
|
1584
|
+
.where((t_system.c.system_id == system_id) & (t_system.c.added.is_(None)))
|
|
1585
|
+
.values(added=20)
|
|
1586
|
+
)
|
|
1587
|
+
return
|
|
1588
|
+
|
|
1589
|
+
if db_utils.is_mysql(self.session):
|
|
1590
|
+
db_utils.mysql_upsert_modified(
|
|
1591
|
+
self.session, t_system,
|
|
1592
|
+
rows=[row],
|
|
1593
|
+
key_cols=("system_id",),
|
|
1594
|
+
modified_col="modified",
|
|
1595
|
+
update_cols=("name", "pos_x", "pos_y", "pos_z"),
|
|
1596
|
+
)
|
|
1597
|
+
if has_added_col:
|
|
1598
|
+
self.session.execute(
|
|
1599
|
+
update(t_system)
|
|
1600
|
+
.where((t_system.c.system_id == system_id) & (t_system.c.added.is_(None)))
|
|
1601
|
+
.values(added=20)
|
|
1602
|
+
)
|
|
1603
|
+
return
|
|
1604
|
+
|
|
1605
|
+
# Generic fallback
|
|
1606
|
+
sel_cols = [t_system.c.modified]
|
|
1607
|
+
if has_added_col:
|
|
1608
|
+
sel_cols.append(t_system.c.added)
|
|
1609
|
+
existing = self.session.execute(
|
|
1610
|
+
select(*sel_cols).where(t_system.c.system_id == system_id)
|
|
1611
|
+
).first()
|
|
1612
|
+
|
|
1613
|
+
if existing is None:
|
|
1614
|
+
self.session.execute(insert(t_system).values(**row))
|
|
1615
|
+
else:
|
|
1616
|
+
db_modified = existing[0]
|
|
1617
|
+
values = {"name": name, "pos_x": x, "pos_y": y, "pos_z": z}
|
|
1618
|
+
if db_modified is None or modified > db_modified:
|
|
1619
|
+
values["modified"] = modified
|
|
1620
|
+
self.session.execute(
|
|
1621
|
+
update(t_system)
|
|
1622
|
+
.where(t_system.c.system_id == system_id)
|
|
1623
|
+
.values(**values)
|
|
1624
|
+
)
|
|
1625
|
+
if has_added_col:
|
|
1626
|
+
db_added = existing[1] if len(existing) > 1 else None
|
|
1627
|
+
if db_added is None:
|
|
1628
|
+
self.session.execute(
|
|
1629
|
+
update(t_system)
|
|
1630
|
+
.where((t_system.c.system_id == system_id) & (t_system.c.added.is_(None)))
|
|
1631
|
+
.values(added=20)
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
def _upsert_station(
|
|
1635
|
+
self, t_station: Table, station_id: int, system_id: int, name: str,
|
|
1636
|
+
ls_from_star: Optional[float], max_pad: str, type_id: int, planetary: str,
|
|
1637
|
+
sflags: dict[str, str], modified: Optional[datetime],
|
|
1638
|
+
) -> None:
|
|
1639
|
+
"""
|
|
1640
|
+
Upsert Station with timestamp guard.
|
|
1641
|
+
"""
|
|
1642
|
+
if modified is None:
|
|
1643
|
+
modified = datetime.utcfromtimestamp(0)
|
|
1644
|
+
|
|
1645
|
+
if db_utils.is_sqlite(self.session):
|
|
1646
|
+
db_utils.sqlite_upsert_modified(
|
|
1647
|
+
self.session, t_station,
|
|
1648
|
+
rows=[{
|
|
1649
|
+
"station_id": station_id,
|
|
1650
|
+
"system_id": system_id,
|
|
1651
|
+
"name": name,
|
|
1652
|
+
"ls_from_star": ls_from_star,
|
|
1653
|
+
"max_pad_size": max_pad,
|
|
1654
|
+
"type_id": type_id,
|
|
1655
|
+
"planetary": planetary,
|
|
1656
|
+
"market": sflags["market"],
|
|
1657
|
+
"blackmarket": sflags["blackmarket"],
|
|
1658
|
+
"shipyard": sflags["shipyard"],
|
|
1659
|
+
"outfitting": sflags["outfitting"],
|
|
1660
|
+
"rearm": sflags["rearm"],
|
|
1661
|
+
"refuel": sflags["refuel"],
|
|
1662
|
+
"repair": sflags["repair"],
|
|
1663
|
+
"modified": modified,
|
|
1664
|
+
}],
|
|
1665
|
+
key_cols=("station_id",),
|
|
1666
|
+
modified_col="modified",
|
|
1667
|
+
update_cols=(
|
|
1668
|
+
"system_id", "name", "ls_from_star", "max_pad_size", "type_id", "planetary",
|
|
1669
|
+
"market", "blackmarket", "shipyard", "outfitting", "rearm", "refuel", "repair",
|
|
1670
|
+
),
|
|
1671
|
+
)
|
|
1672
|
+
return
|
|
1673
|
+
|
|
1674
|
+
if db_utils.is_mysql(self.session):
|
|
1675
|
+
db_utils.mysql_upsert_modified(
|
|
1676
|
+
self.session, t_station,
|
|
1677
|
+
rows=[{
|
|
1678
|
+
"station_id": station_id,
|
|
1679
|
+
"system_id": system_id,
|
|
1680
|
+
"name": name,
|
|
1681
|
+
"ls_from_star": ls_from_star,
|
|
1682
|
+
"max_pad_size": max_pad,
|
|
1683
|
+
"type_id": type_id,
|
|
1684
|
+
"planetary": planetary,
|
|
1685
|
+
"market": sflags["market"],
|
|
1686
|
+
"blackmarket": sflags["blackmarket"],
|
|
1687
|
+
"shipyard": sflags["shipyard"],
|
|
1688
|
+
"outfitting": sflags["outfitting"],
|
|
1689
|
+
"rearm": sflags["rearm"],
|
|
1690
|
+
"refuel": sflags["refuel"],
|
|
1691
|
+
"repair": sflags["repair"],
|
|
1692
|
+
"modified": modified,
|
|
1693
|
+
}],
|
|
1694
|
+
key_cols=("station_id",),
|
|
1695
|
+
modified_col="modified",
|
|
1696
|
+
update_cols=(
|
|
1697
|
+
"system_id", "name", "ls_from_star", "max_pad_size", "type_id", "planetary",
|
|
1698
|
+
"market", "blackmarket", "shipyard", "outfitting", "rearm", "refuel", "repair",
|
|
1699
|
+
),
|
|
1700
|
+
)
|
|
1701
|
+
return
|
|
1702
|
+
|
|
1703
|
+
# Generic fallback
|
|
1704
|
+
row = self.session.execute(
|
|
1705
|
+
select(t_station.c.system_id, t_station.c.modified)
|
|
1706
|
+
.where(t_station.c.station_id == station_id)
|
|
1707
|
+
).first()
|
|
1708
|
+
|
|
1709
|
+
if row is None:
|
|
1710
|
+
self.session.execute(
|
|
1711
|
+
insert(t_station).values(
|
|
1712
|
+
station_id=station_id,
|
|
1713
|
+
system_id=system_id,
|
|
1714
|
+
name=name,
|
|
1715
|
+
ls_from_star=ls_from_star,
|
|
1716
|
+
max_pad_size=max_pad,
|
|
1717
|
+
type_id=type_id,
|
|
1718
|
+
planetary=planetary,
|
|
1719
|
+
market=sflags["market"],
|
|
1720
|
+
blackmarket=sflags["blackmarket"],
|
|
1721
|
+
shipyard=sflags["shipyard"],
|
|
1722
|
+
outfitting=sflags["outfitting"],
|
|
1723
|
+
rearm=sflags["rearm"],
|
|
1724
|
+
refuel=sflags["refuel"],
|
|
1725
|
+
repair=sflags["repair"],
|
|
1726
|
+
modified=modified,
|
|
1727
|
+
)
|
|
1728
|
+
)
|
|
1729
|
+
else:
|
|
1730
|
+
db_system_id, db_modified = row
|
|
1731
|
+
values = {
|
|
1732
|
+
"name": name,
|
|
1733
|
+
"ls_from_star": ls_from_star,
|
|
1734
|
+
"max_pad_size": max_pad,
|
|
1735
|
+
"type_id": type_id,
|
|
1736
|
+
"planetary": planetary,
|
|
1737
|
+
"market": sflags["market"],
|
|
1738
|
+
"blackmarket": sflags["blackmarket"],
|
|
1739
|
+
"shipyard": sflags["shipyard"],
|
|
1740
|
+
"outfitting": sflags["outfitting"],
|
|
1741
|
+
"rearm": sflags["rearm"],
|
|
1742
|
+
"refuel": sflags["refuel"],
|
|
1743
|
+
"repair": sflags["repair"],
|
|
1744
|
+
}
|
|
1745
|
+
if db_system_id != system_id:
|
|
1746
|
+
values["system_id"] = system_id
|
|
1747
|
+
if db_modified is None or modified > db_modified:
|
|
1748
|
+
values["modified"] = modified
|
|
1749
|
+
|
|
1750
|
+
self.session.execute(
|
|
1751
|
+
update(t_station)
|
|
1752
|
+
.where(t_station.c.station_id == station_id)
|
|
1753
|
+
.values(**values)
|
|
1754
|
+
)
|
|
1755
|
+
|
|
1756
|
+
def _upsert_shipyard(self, tables: dict[str, Table], station_id: int, ships: list[dict[str, Any]], ts: datetime) -> int:
|
|
1757
|
+
t_ship, t_vendor = tables["Ship"], tables["ShipVendor"]
|
|
1758
|
+
ship_rows, vendor_rows = [], []
|
|
1759
|
+
|
|
1760
|
+
for sh in ships:
|
|
1761
|
+
ship_id = sh.get("shipId")
|
|
1762
|
+
name = sh.get("name")
|
|
1763
|
+
if ship_id is None or name is None:
|
|
1764
|
+
continue
|
|
1765
|
+
ship_rows.append({"ship_id": ship_id, "name": name})
|
|
1766
|
+
vendor_rows.append({"ship_id": ship_id, "station_id": station_id, "modified": ts})
|
|
1767
|
+
|
|
1768
|
+
if ship_rows:
|
|
1769
|
+
if db_utils.is_sqlite(self.session):
|
|
1770
|
+
db_utils.sqlite_upsert_simple(self.session, t_ship, rows=ship_rows, key_cols=("ship_id",), update_cols=("name",))
|
|
1771
|
+
elif db_utils.is_mysql(self.session):
|
|
1772
|
+
db_utils.mysql_upsert_simple(self.session, t_ship, rows=ship_rows, key_cols=("ship_id",), update_cols=("name",))
|
|
1773
|
+
else:
|
|
1774
|
+
for r in ship_rows:
|
|
1775
|
+
exists = self.session.execute(select(t_ship.c.name).where(t_ship.c.ship_id == r["ship_id"])).first()
|
|
1776
|
+
if exists is None:
|
|
1777
|
+
self.session.execute(insert(t_ship).values(**r))
|
|
1778
|
+
elif exists[0] != r["name"]:
|
|
1779
|
+
self.session.execute(update(t_ship).where(t_ship.c.ship_id == r["ship_id"]).values(name=r["name"]))
|
|
1780
|
+
|
|
1781
|
+
wrote = 0
|
|
1782
|
+
if vendor_rows:
|
|
1783
|
+
if db_utils.is_sqlite(self.session):
|
|
1784
|
+
db_utils.sqlite_upsert_modified(self.session, t_vendor, rows=vendor_rows,
|
|
1785
|
+
key_cols=("ship_id", "station_id"), modified_col="modified", update_cols=())
|
|
1786
|
+
wrote = len(vendor_rows)
|
|
1787
|
+
elif db_utils.is_mysql(self.session):
|
|
1788
|
+
db_utils.mysql_upsert_modified(self.session, t_vendor, rows=vendor_rows,
|
|
1789
|
+
key_cols=("ship_id", "station_id"), modified_col="modified", update_cols=())
|
|
1790
|
+
wrote = len(vendor_rows)
|
|
1791
|
+
else:
|
|
1792
|
+
for r in vendor_rows:
|
|
1793
|
+
ven = self.session.execute(
|
|
1794
|
+
select(t_vendor.c.modified).where(and_(t_vendor.c.ship_id == r["ship_id"], t_vendor.c.station_id == r["station_id"]))
|
|
1795
|
+
).first()
|
|
1796
|
+
if ven is None:
|
|
1797
|
+
self.session.execute(insert(t_vendor).values(**r))
|
|
1798
|
+
wrote += 1
|
|
1799
|
+
else:
|
|
1800
|
+
dbm = ven[0]
|
|
1801
|
+
if dbm is None or r["modified"] > dbm:
|
|
1802
|
+
self.session.execute(
|
|
1803
|
+
update(t_vendor)
|
|
1804
|
+
.where(and_(t_vendor.c.ship_id == r["ship_id"], t_vendor.c.station_id == r["station_id"]))
|
|
1805
|
+
.values(modified=r["modified"])
|
|
1806
|
+
)
|
|
1807
|
+
wrote += 1
|
|
1808
|
+
return wrote
|
|
1809
|
+
|
|
1810
|
+
def _upsert_outfitting(self, tables: dict[str, Table], station_id: int, modules: list[dict[str, Any]], ts: datetime) -> int:
|
|
1811
|
+
t_up, t_vendor = tables["Upgrade"], tables["UpgradeVendor"]
|
|
1812
|
+
up_rows, vendor_rows = [], []
|
|
1813
|
+
|
|
1814
|
+
for mo in modules:
|
|
1815
|
+
up_id = mo.get("moduleId")
|
|
1816
|
+
name = mo.get("name")
|
|
1817
|
+
cls = mo.get("class")
|
|
1818
|
+
rating = mo.get("rating")
|
|
1819
|
+
ship = mo.get("ship")
|
|
1820
|
+
if up_id is None or name is None:
|
|
1821
|
+
continue
|
|
1822
|
+
|
|
1823
|
+
up_rows.append({"upgrade_id": up_id, "name": name, "class": cls, "rating": rating, "ship": ship})
|
|
1824
|
+
vendor_rows.append({"upgrade_id": up_id, "station_id": station_id, "modified": ts})
|
|
1825
|
+
|
|
1826
|
+
if up_rows:
|
|
1827
|
+
if db_utils.is_sqlite(self.session):
|
|
1828
|
+
db_utils.sqlite_upsert_simple(self.session, t_up, rows=up_rows, key_cols=("upgrade_id",),
|
|
1829
|
+
update_cols=("name", "class", "rating", "ship"))
|
|
1830
|
+
elif db_utils.is_mysql(self.session):
|
|
1831
|
+
db_utils.mysql_upsert_simple(self.session, t_up, rows=up_rows, key_cols=("upgrade_id",),
|
|
1832
|
+
update_cols=("name", "class", "rating", "ship"))
|
|
1833
|
+
else:
|
|
1834
|
+
for r in up_rows:
|
|
1835
|
+
exists = self.session.execute(select(t_up.c.upgrade_id).where(t_up.c.upgrade_id == r["upgrade_id"])).first()
|
|
1836
|
+
if exists is None:
|
|
1837
|
+
self.session.execute(insert(t_up).values(**r))
|
|
1838
|
+
else:
|
|
1839
|
+
self.session.execute(
|
|
1840
|
+
update(t_up).where(t_up.c.upgrade_id == r["upgrade_id"]).values(
|
|
1841
|
+
name=r["name"], **{"class": r["class"]}, rating=r["rating"], ship=r["ship"]
|
|
1842
|
+
)
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
wrote = 0
|
|
1846
|
+
if vendor_rows:
|
|
1847
|
+
if db_utils.is_sqlite(self.session):
|
|
1848
|
+
db_utils.sqlite_upsert_modified(self.session, t_vendor, rows=vendor_rows,
|
|
1849
|
+
key_cols=("upgrade_id", "station_id"), modified_col="modified", update_cols=())
|
|
1850
|
+
wrote = len(vendor_rows)
|
|
1851
|
+
elif db_utils.is_mysql(self.session):
|
|
1852
|
+
db_utils.mysql_upsert_modified(self.session, t_vendor, rows=vendor_rows,
|
|
1853
|
+
key_cols=("upgrade_id", "station_id"), modified_col="modified", update_cols=())
|
|
1854
|
+
wrote = len(vendor_rows)
|
|
1855
|
+
else:
|
|
1856
|
+
for r in vendor_rows:
|
|
1857
|
+
ven = self.session.execute(
|
|
1858
|
+
select(t_vendor.c.modified).where(and_(t_vendor.c.upgrade_id == r["upgrade_id"], t_vendor.c.station_id == r["station_id"]))
|
|
1859
|
+
).first()
|
|
1860
|
+
if ven is None:
|
|
1861
|
+
self.session.execute(insert(t_vendor).values(**r))
|
|
1862
|
+
wrote += 1
|
|
1863
|
+
else:
|
|
1864
|
+
dbm = ven[0]
|
|
1865
|
+
if dbm is None or r["modified"] > dbm:
|
|
1866
|
+
self.session.execute(
|
|
1867
|
+
update(t_vendor)
|
|
1868
|
+
.where(and_(t_vendor.c.upgrade_id == r["upgrade_id"], t_vendor.c.station_id == r["station_id"]))
|
|
1869
|
+
.values(modified=r["modified"])
|
|
1870
|
+
)
|
|
1871
|
+
wrote += 1
|
|
1872
|
+
return wrote
|
|
1873
|
+
|
|
1874
|
+
def _upsert_market(
|
|
1875
|
+
self,
|
|
1876
|
+
tables: dict[str, Table],
|
|
1877
|
+
categories: dict[str, int],
|
|
1878
|
+
station_id: int,
|
|
1879
|
+
commodities: list[dict[str, Any]],
|
|
1880
|
+
ts: datetime,
|
|
1881
|
+
) -> tuple[int, int]:
|
|
1882
|
+
t_item, t_si = tables["Item"], tables["StationItem"]
|
|
1883
|
+
item_rows, link_rows = [], []
|
|
1884
|
+
wrote_items = 0
|
|
1885
|
+
|
|
1886
|
+
for co in commodities:
|
|
1887
|
+
fdev_id = co.get("commodityId")
|
|
1888
|
+
name = co.get("name")
|
|
1889
|
+
cat_name = co.get("category")
|
|
1890
|
+
if fdev_id is None or name is None or cat_name is None:
|
|
1891
|
+
continue
|
|
1892
|
+
|
|
1893
|
+
cat_id = categories.get(str(cat_name).lower())
|
|
1894
|
+
if cat_id is None:
|
|
1895
|
+
raise CleanExit(f'Unknown commodity category "{cat_name}"')
|
|
1896
|
+
|
|
1897
|
+
item_rows.append({"item_id": fdev_id, "name": name, "category_id": cat_id, "fdev_id": fdev_id, "ui_order": 0})
|
|
1898
|
+
|
|
1899
|
+
demand = co.get("demand")
|
|
1900
|
+
supply = co.get("supply")
|
|
1901
|
+
buy = co.get("buyPrice")
|
|
1902
|
+
sell = co.get("sellPrice")
|
|
1903
|
+
|
|
1904
|
+
link_rows.append({
|
|
1905
|
+
"station_id": station_id,
|
|
1906
|
+
"item_id": fdev_id,
|
|
1907
|
+
"demand_price": sell,
|
|
1908
|
+
"demand_units": demand,
|
|
1909
|
+
"demand_level": -1,
|
|
1910
|
+
"supply_price": buy,
|
|
1911
|
+
"supply_units": supply,
|
|
1912
|
+
"supply_level": -1,
|
|
1913
|
+
"from_live": 0,
|
|
1914
|
+
"modified": ts,
|
|
1915
|
+
})
|
|
1916
|
+
|
|
1917
|
+
if item_rows:
|
|
1918
|
+
if db_utils.is_sqlite(self.session):
|
|
1919
|
+
db_utils.sqlite_upsert_simple(self.session, t_item, rows=item_rows, key_cols=("item_id",),
|
|
1920
|
+
update_cols=("name", "category_id", "fdev_id", "ui_order"))
|
|
1921
|
+
elif db_utils.is_mysql(self.session):
|
|
1922
|
+
db_utils.mysql_upsert_simple(self.session, t_item, rows=item_rows, key_cols=("item_id",),
|
|
1923
|
+
update_cols=("name", "category_id", "fdev_id", "ui_order"))
|
|
1924
|
+
else:
|
|
1925
|
+
for r in item_rows:
|
|
1926
|
+
exists = self.session.execute(
|
|
1927
|
+
select(t_item.c.item_id, t_item.c.name, t_item.c.category_id).where(t_item.c.item_id == r["item_id"])
|
|
1928
|
+
).first()
|
|
1929
|
+
if exists is None:
|
|
1930
|
+
self.session.execute(insert(t_item).values(**r))
|
|
1931
|
+
wrote_items += 1
|
|
1932
|
+
else:
|
|
1933
|
+
_, db_name, db_cat = exists
|
|
1934
|
+
if (db_name != r["name"]) or (db_cat != r["category_id"]):
|
|
1935
|
+
self.session.execute(
|
|
1936
|
+
update(t_item).where(t_item.c.item_id == r["item_id"]).values(
|
|
1937
|
+
name=r["name"], category_id=r["category_id"]
|
|
1938
|
+
)
|
|
1939
|
+
)
|
|
1940
|
+
|
|
1941
|
+
wrote_links = 0
|
|
1942
|
+
if link_rows:
|
|
1943
|
+
if db_utils.is_sqlite(self.session):
|
|
1944
|
+
db_utils.sqlite_upsert_modified(self.session, t_si, rows=link_rows,
|
|
1945
|
+
key_cols=("station_id", "item_id"), modified_col="modified",
|
|
1946
|
+
update_cols=("demand_price", "demand_units", "demand_level",
|
|
1947
|
+
"supply_price", "supply_units", "supply_level", "from_live"))
|
|
1948
|
+
wrote_links = len(link_rows)
|
|
1949
|
+
elif db_utils.is_mysql(self.session):
|
|
1950
|
+
db_utils.mysql_upsert_modified(self.session, t_si, rows=link_rows,
|
|
1951
|
+
key_cols=("station_id", "item_id"), modified_col="modified",
|
|
1952
|
+
update_cols=("demand_price", "demand_units", "demand_level",
|
|
1953
|
+
"supply_price", "supply_units", "supply_level", "from_live"))
|
|
1954
|
+
wrote_links = len(link_rows)
|
|
1955
|
+
else:
|
|
1956
|
+
for r in link_rows:
|
|
1957
|
+
si = self.session.execute(
|
|
1958
|
+
select(t_si.c.modified).where(and_(t_si.c.station_id == r["station_id"], t_si.c.item_id == r["item_id"]))
|
|
1959
|
+
).first()
|
|
1960
|
+
if si is None:
|
|
1961
|
+
self.session.execute(insert(t_si).values(**r))
|
|
1962
|
+
wrote_links += 1
|
|
1963
|
+
else:
|
|
1964
|
+
dbm = si[0]
|
|
1965
|
+
if dbm is None or r["modified"] > dbm:
|
|
1966
|
+
self.session.execute(
|
|
1967
|
+
update(t_si)
|
|
1968
|
+
.where(and_(t_si.c.station_id == r["station_id"], t_si.c.item_id == r["item_id"]))
|
|
1969
|
+
.values(**r)
|
|
1970
|
+
)
|
|
1971
|
+
wrote_links += 1
|
|
1972
|
+
|
|
1973
|
+
return (wrote_items, wrote_links)
|
|
1974
|
+
|
|
1975
|
+
# ------------------------------
|
|
1976
|
+
# UI ordering
|
|
1977
|
+
# ------------------------------
|
|
1978
|
+
def _enforce_ui_order(self, session: Session, tables: dict[str, Table]) -> None:
|
|
1979
|
+
t_item, t_cat = tables["Item"], tables["Category"]
|
|
1980
|
+
cats = session.execute(select(t_cat.c.category_id)).all()
|
|
1981
|
+
for (cat_id,) in cats:
|
|
1982
|
+
rows = session.execute(
|
|
1983
|
+
select(t_item.c.item_id, t_item.c.name, t_item.c.ui_order)
|
|
1984
|
+
.where(t_item.c.category_id == cat_id)
|
|
1985
|
+
.order_by(func.lower(t_item.c.name).asc(), t_item.c.name.asc(), t_item.c.item_id.asc())
|
|
1986
|
+
).all()
|
|
1987
|
+
expected = 1
|
|
1988
|
+
for item_id, _name, ui_order in rows:
|
|
1989
|
+
if ui_order != expected:
|
|
1990
|
+
session.execute(update(t_item).where(t_item.c.item_id == item_id).values(ui_order=expected))
|
|
1991
|
+
expected += 1
|
|
1992
|
+
|
|
1993
|
+
# ------------------------------
|
|
1994
|
+
# Rares import (via cache.processImportFile)
|
|
1995
|
+
# ------------------------------
|
|
1996
|
+
def _import_rareitems_edcd(self, rares_csv: Path, commodity_csv: Optional[Path] = None) -> None:
|
|
1997
|
+
"""
|
|
1998
|
+
EDCD rares → TD.RareItem
|
|
1999
|
+
|
|
2000
|
+
Supports CSV shapes:
|
|
2001
|
+
A) name, system, station
|
|
2002
|
+
B) id, symbol, market_id, category, name (FDevIDs canonical)
|
|
2003
|
+
|
|
2004
|
+
Shape B maps: station_id = int(market_id), category by name.
|
|
2005
|
+
Clears RareItem then upserts by UNIQUE(name). Writes a CSV of skipped rows to tmp/.
|
|
2006
|
+
"""
|
|
2007
|
+
|
|
2008
|
+
def _norm(s: Optional[str]) -> str:
|
|
2009
|
+
if s is None:
|
|
2010
|
+
return ""
|
|
2011
|
+
s = s.strip().strip("'").strip('"')
|
|
2012
|
+
s = s.replace("’", "'").replace("‘", "'")
|
|
2013
|
+
s = s.replace("–", "-").replace("—", "-")
|
|
2014
|
+
s = " ".join(s.split())
|
|
2015
|
+
return s.casefold()
|
|
2016
|
+
|
|
2017
|
+
def _kwant(fieldnames, *aliases) -> Optional[str]:
|
|
2018
|
+
if not fieldnames:
|
|
2019
|
+
return None
|
|
2020
|
+
canon = {}
|
|
2021
|
+
for h in fieldnames or []:
|
|
2022
|
+
if not h:
|
|
2023
|
+
continue
|
|
2024
|
+
k = h.strip().lower().replace("_", "").replace(" ", "")
|
|
2025
|
+
canon[k] = h
|
|
2026
|
+
for a in aliases:
|
|
2027
|
+
k = a.strip().lower().replace("_", "").replace(" ", "")
|
|
2028
|
+
if k in canon:
|
|
2029
|
+
return canon[k]
|
|
2030
|
+
return None
|
|
2031
|
+
|
|
2032
|
+
sess = None
|
|
2033
|
+
try:
|
|
2034
|
+
sess = self._open_session()
|
|
2035
|
+
tables = self._reflect_tables(sess.get_bind())
|
|
2036
|
+
t_sys, t_stn, t_cat, t_rare = tables["System"], tables["Station"], tables["Category"], tables["RareItem"]
|
|
2037
|
+
|
|
2038
|
+
# Build lookups for Shape A
|
|
2039
|
+
stn_by_names: dict[tuple[str, str], int] = {}
|
|
2040
|
+
for sid, sys_name, stn_name in sess.execute(
|
|
2041
|
+
select(t_stn.c.station_id, t_sys.c.name, t_stn.c.name).where(t_stn.c.system_id == t_sys.c.system_id)
|
|
2042
|
+
).all():
|
|
2043
|
+
if sys_name and stn_name:
|
|
2044
|
+
stn_by_names[(_norm(sys_name), _norm(stn_name))] = int(sid)
|
|
2045
|
+
|
|
2046
|
+
# Category name -> id (from DB)
|
|
2047
|
+
cat_id_by_name = {
|
|
2048
|
+
_norm(n): int(cid)
|
|
2049
|
+
for cid, n in sess.execute(select(t_cat.c.category_id, t_cat.c.name)).all()
|
|
2050
|
+
if n is not None
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
kept = skipped = 0
|
|
2054
|
+
skipped_no_station = 0
|
|
2055
|
+
skipped_no_category = 0
|
|
2056
|
+
out_rows: list[dict] = []
|
|
2057
|
+
skipped_rows: list[dict] = [] # <-- record details
|
|
2058
|
+
|
|
2059
|
+
with open(rares_csv, "r", encoding="utf-8", newline="") as fh:
|
|
2060
|
+
reader = csv.DictReader(fh)
|
|
2061
|
+
hdr = [h for h in (reader.fieldnames or []) if h]
|
|
2062
|
+
hdr_canon = [h.lower().replace("_", "").replace(" ", "") for h in hdr]
|
|
2063
|
+
|
|
2064
|
+
has_market_shape = all(x in hdr_canon for x in ["id", "symbol", "marketid", "category", "name"])
|
|
2065
|
+
has_name_shape = all(x in hdr_canon for x in ["name", "system", "station"])
|
|
2066
|
+
|
|
2067
|
+
if not (has_market_shape or has_name_shape):
|
|
2068
|
+
raise CleanExit(
|
|
2069
|
+
"rare_commodity.csv headers not recognized. "
|
|
2070
|
+
f"Seen headers: {', '.join(reader.fieldnames or [])}. File: {rares_csv}"
|
|
2071
|
+
)
|
|
2072
|
+
|
|
2073
|
+
if has_market_shape:
|
|
2074
|
+
# FDevIDs: station_id = int(market_id)
|
|
2075
|
+
k_name = _kwant(reader.fieldnames, "name")
|
|
2076
|
+
k_market = _kwant(reader.fieldnames, "market_id", "marketid")
|
|
2077
|
+
k_cat = _kwant(reader.fieldnames, "category", "categoryname")
|
|
2078
|
+
|
|
2079
|
+
for row in reader:
|
|
2080
|
+
rn_raw = row.get(k_name)
|
|
2081
|
+
mk_raw = row.get(k_market)
|
|
2082
|
+
cat_raw= row.get(k_cat)
|
|
2083
|
+
|
|
2084
|
+
try:
|
|
2085
|
+
station_id = int(mk_raw) if mk_raw is not None else None
|
|
2086
|
+
except (TypeError, ValueError):
|
|
2087
|
+
station_id = None
|
|
2088
|
+
|
|
2089
|
+
# validate station exists
|
|
2090
|
+
if station_id is None or sess.execute(
|
|
2091
|
+
select(t_stn.c.station_id).where(t_stn.c.station_id == station_id)
|
|
2092
|
+
).first() is None:
|
|
2093
|
+
skipped += 1
|
|
2094
|
+
skipped_no_station += 1
|
|
2095
|
+
skipped_rows.append({"reason":"no_station","name":rn_raw,"market_id":mk_raw,"category":cat_raw})
|
|
2096
|
+
continue
|
|
2097
|
+
|
|
2098
|
+
cid = cat_id_by_name.get(_norm(cat_raw))
|
|
2099
|
+
if cid is None:
|
|
2100
|
+
skipped += 1
|
|
2101
|
+
skipped_no_category += 1
|
|
2102
|
+
skipped_rows.append({"reason":"no_category","name":rn_raw,"market_id":mk_raw,"category":cat_raw})
|
|
2103
|
+
continue
|
|
2104
|
+
|
|
2105
|
+
out_rows.append({
|
|
2106
|
+
"name": rn_raw,
|
|
2107
|
+
"station_id": station_id,
|
|
2108
|
+
"category_id": cid,
|
|
2109
|
+
"cost": None,
|
|
2110
|
+
"max_allocation": None,
|
|
2111
|
+
})
|
|
2112
|
+
kept += 1
|
|
2113
|
+
|
|
2114
|
+
else:
|
|
2115
|
+
# Legacy/community: need commodity.csv to map product -> category
|
|
2116
|
+
name_to_catid: dict[str, int] = {}
|
|
2117
|
+
if commodity_csv is None:
|
|
2118
|
+
files = self._acquire_edcd_files()
|
|
2119
|
+
commodity_csv = files.get("commodity")
|
|
2120
|
+
if commodity_csv and Path(commodity_csv).exists():
|
|
2121
|
+
with open(commodity_csv, "r", encoding="utf-8", newline="") as fh2:
|
|
2122
|
+
rd2 = csv.DictReader(fh2)
|
|
2123
|
+
k2_name = _kwant(rd2.fieldnames, "name","commodity","commodityname","product")
|
|
2124
|
+
k2_cat = _kwant(rd2.fieldnames, "category","categoryname")
|
|
2125
|
+
if k2_name and k2_cat:
|
|
2126
|
+
for r2 in rd2:
|
|
2127
|
+
n = _norm(r2.get(k2_name))
|
|
2128
|
+
c = _norm(r2.get(k2_cat))
|
|
2129
|
+
if n and c:
|
|
2130
|
+
cid = cat_id_by_name.get(c)
|
|
2131
|
+
if cid is not None:
|
|
2132
|
+
name_to_catid[n] = cid
|
|
2133
|
+
|
|
2134
|
+
k_name = _kwant(reader.fieldnames, "name","commodity","commodityname","product")
|
|
2135
|
+
k_system = _kwant(reader.fieldnames, "system","systemname")
|
|
2136
|
+
k_station = _kwant(reader.fieldnames, "station","stationname")
|
|
2137
|
+
|
|
2138
|
+
for row in reader:
|
|
2139
|
+
rn_raw = row.get(k_name)
|
|
2140
|
+
sys_raw = row.get(k_system)
|
|
2141
|
+
stn_raw = row.get(k_station)
|
|
2142
|
+
rn, sysn, stnn = _norm(rn_raw), _norm(sys_raw), _norm(stn_raw)
|
|
2143
|
+
|
|
2144
|
+
if not rn or not sysn or not stnn:
|
|
2145
|
+
skipped += 1
|
|
2146
|
+
skipped_rows.append({"reason":"missing_fields","name":rn_raw,"system":sys_raw,"station":stn_raw})
|
|
2147
|
+
continue
|
|
2148
|
+
|
|
2149
|
+
station_id = stn_by_names.get((sysn, stnn))
|
|
2150
|
+
if station_id is None:
|
|
2151
|
+
skipped += 1
|
|
2152
|
+
skipped_no_station += 1
|
|
2153
|
+
skipped_rows.append({"reason":"no_station","name":rn_raw,"system":sys_raw,"station":stn_raw})
|
|
2154
|
+
continue
|
|
2155
|
+
|
|
2156
|
+
cid = name_to_catid.get(rn)
|
|
2157
|
+
if cid is None:
|
|
2158
|
+
skipped += 1
|
|
2159
|
+
skipped_no_category += 1
|
|
2160
|
+
skipped_rows.append({"reason":"no_category","name":rn_raw,"system":sys_raw,"station":stn_raw})
|
|
2161
|
+
continue
|
|
2162
|
+
|
|
2163
|
+
out_rows.append({
|
|
2164
|
+
"name": rn_raw,
|
|
2165
|
+
"station_id": station_id,
|
|
2166
|
+
"category_id": cid,
|
|
2167
|
+
"cost": None,
|
|
2168
|
+
"max_allocation": None,
|
|
2169
|
+
})
|
|
2170
|
+
kept += 1
|
|
2171
|
+
|
|
2172
|
+
# Clear → upsert
|
|
2173
|
+
try:
|
|
2174
|
+
sess.execute(text('DELETE FROM "RareItem"'))
|
|
2175
|
+
except Exception:
|
|
2176
|
+
sess.execute(text("DELETE FROM RareItem"))
|
|
2177
|
+
|
|
2178
|
+
if out_rows:
|
|
2179
|
+
if db_utils.is_sqlite(sess):
|
|
2180
|
+
db_utils.sqlite_upsert_simple(
|
|
2181
|
+
sess, t_rare, rows=out_rows, key_cols=("name",),
|
|
2182
|
+
update_cols=tuple(k for k in out_rows[0].keys() if k != "name")
|
|
2183
|
+
)
|
|
2184
|
+
elif db_utils.is_mysql(sess):
|
|
2185
|
+
db_utils.mysql_upsert_simple(
|
|
2186
|
+
sess, t_rare, rows=out_rows, key_cols=("name",),
|
|
2187
|
+
update_cols=tuple(k for k in out_rows[0].keys() if k != "name")
|
|
2188
|
+
)
|
|
2189
|
+
else:
|
|
2190
|
+
for r in out_rows:
|
|
2191
|
+
ex = sess.execute(select(t_rare.c.name).where(t_rare.c.name == r["name"])).first()
|
|
2192
|
+
if ex is None:
|
|
2193
|
+
sess.execute(insert(t_rare).values(**r))
|
|
2194
|
+
else:
|
|
2195
|
+
sess.execute(
|
|
2196
|
+
update(t_rare).where(t_rare.c.name == r["name"])
|
|
2197
|
+
.values({k: r[k] for k in r.keys() if k != "name"})
|
|
2198
|
+
)
|
|
2199
|
+
sess.commit()
|
|
2200
|
+
|
|
2201
|
+
# Write a CSV with skipped details
|
|
2202
|
+
if skipped_rows:
|
|
2203
|
+
outp = self.tmp_dir / "edcd_rares_skipped.csv"
|
|
2204
|
+
keys = sorted({k for r in skipped_rows for k in r.keys()})
|
|
2205
|
+
with open(outp, "w", encoding="utf-8", newline="") as fh:
|
|
2206
|
+
w = csv.DictWriter(fh, fieldnames=keys)
|
|
2207
|
+
w.writeheader()
|
|
2208
|
+
w.writerows(skipped_rows)
|
|
2209
|
+
self._print(f"EDCD Rares: imported={kept:,} skipped={skipped:,} "
|
|
2210
|
+
f"(no_station={skipped_no_station:,}, no_category={skipped_no_category:,}) "
|
|
2211
|
+
f"→ details: {outp}")
|
|
2212
|
+
else:
|
|
2213
|
+
self._print(f"EDCD Rares: imported={kept:,} skipped={skipped:,} "
|
|
2214
|
+
f"(no_station={skipped_no_station:,}, no_category={skipped_no_category:,})")
|
|
2215
|
+
|
|
2216
|
+
except Exception as e:
|
|
2217
|
+
if sess is not None:
|
|
2218
|
+
try:
|
|
2219
|
+
sess.rollback()
|
|
2220
|
+
except Exception:
|
|
2221
|
+
pass
|
|
2222
|
+
raise CleanExit(f"RareItem import failed: {e!r}") from e
|
|
2223
|
+
finally:
|
|
2224
|
+
if sess is not None:
|
|
2225
|
+
try:
|
|
2226
|
+
sess.close()
|
|
2227
|
+
except Exception:
|
|
2228
|
+
pass
|
|
2229
|
+
|
|
2230
|
+
# ------------------------------
|
|
2231
|
+
# Export / cache refresh
|
|
2232
|
+
#
|
|
2233
|
+
def _export_cache(self) -> None:
|
|
2234
|
+
"""
|
|
2235
|
+
Export CSVs and regenerate TradeDangerous.prices — concurrently, with optional StationItem gating.
|
|
2236
|
+
|
|
2237
|
+
IMPORTANT:
|
|
2238
|
+
- CSV exports are written to tdenv.dataDir (private) so they remain authoritative.
|
|
2239
|
+
- A separate mirror step publishes selected/all CSVs to TD_CSV (public).
|
|
2240
|
+
"""
|
|
2241
|
+
|
|
2242
|
+
def _opt_true(val: Optional[str]) -> bool:
|
|
2243
|
+
if val is None:
|
|
2244
|
+
return False
|
|
2245
|
+
if isinstance(val, str):
|
|
2246
|
+
return val.strip().lower() in ("1", "true", "yes", "on", "y")
|
|
2247
|
+
return bool(val)
|
|
2248
|
+
|
|
2249
|
+
skip_stationitems = (
|
|
2250
|
+
_opt_true(self.getOption("skip_stationitems"))
|
|
2251
|
+
or _opt_true(os.environ.get("TD_SKIP_STATIONITEM_EXPORT"))
|
|
2252
|
+
)
|
|
2253
|
+
|
|
2254
|
+
# Export destination: always private dataDir
|
|
2255
|
+
export_dir = Path(self.tdenv.dataDir).resolve()
|
|
2256
|
+
try:
|
|
2257
|
+
export_dir.mkdir(parents=True, exist_ok=True)
|
|
2258
|
+
except Exception as e:
|
|
2259
|
+
raise CleanExit(f"Export failed: unable to create export directory {export_dir}: {e!r}") from None
|
|
2260
|
+
|
|
2261
|
+
# Heaviest tables first to maximize overlap
|
|
2262
|
+
tables = [
|
|
2263
|
+
"StationItem",
|
|
2264
|
+
"ShipVendor",
|
|
2265
|
+
"UpgradeVendor",
|
|
2266
|
+
"Station",
|
|
2267
|
+
"System",
|
|
2268
|
+
"Category", # <-- REQUIRED for correct downstream category mapping
|
|
2269
|
+
"Item",
|
|
2270
|
+
"Ship",
|
|
2271
|
+
"Upgrade",
|
|
2272
|
+
"RareItem",
|
|
2273
|
+
"FDevOutfitting",
|
|
2274
|
+
"FDevShipyard",
|
|
2275
|
+
]
|
|
2276
|
+
if skip_stationitems:
|
|
2277
|
+
tables = [t for t in tables if t != "StationItem"]
|
|
2278
|
+
|
|
2279
|
+
# Worker count (env override allowed); +1 slot reserved for prices task
|
|
2280
|
+
try:
|
|
2281
|
+
workers = int(os.environ.get("TD_EXPORT_WORKERS", "4"))
|
|
2282
|
+
except ValueError:
|
|
2283
|
+
workers = 4
|
|
2284
|
+
workers = max(1, workers) + 1 # extra slot for the prices job
|
|
2285
|
+
|
|
2286
|
+
def _export_one(table_name: str) -> str:
|
|
2287
|
+
sess = None
|
|
2288
|
+
try:
|
|
2289
|
+
sess = self._open_session() # fresh session per worker
|
|
2290
|
+
csvexport.exportTableToFile(sess, self.tdenv, table_name, csvPath=export_dir)
|
|
2291
|
+
return f"{table_name}.csv"
|
|
2292
|
+
finally:
|
|
2293
|
+
if sess is not None:
|
|
2294
|
+
try:
|
|
2295
|
+
sess.close()
|
|
2296
|
+
except Exception:
|
|
2297
|
+
pass
|
|
2298
|
+
|
|
2299
|
+
def _regen_prices() -> str:
|
|
2300
|
+
cache.regeneratePricesFile(self.tdb, self.tdenv)
|
|
2301
|
+
return "TradeDangerous.prices"
|
|
2302
|
+
|
|
2303
|
+
self._print(f"Exporting cache CSVs to: {export_dir}")
|
|
2304
|
+
for t in tables:
|
|
2305
|
+
self._print(f" - {t}.csv")
|
|
2306
|
+
if skip_stationitems:
|
|
2307
|
+
self._warn("Skipping StationItem.csv export (requested).")
|
|
2308
|
+
self._print("Regenerating TradeDangerous.prices …")
|
|
2309
|
+
|
|
2310
|
+
# Parallel export + prices regen, with conservative fallback
|
|
2311
|
+
try:
|
|
2312
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
2313
|
+
futures = {ex.submit(_export_one, t): f"{t}.csv" for t in tables}
|
|
2314
|
+
futures[ex.submit(_regen_prices)] = "TradeDangerous.prices"
|
|
2315
|
+
for fut in as_completed(futures):
|
|
2316
|
+
_ = fut.result() # raise on any worker failure
|
|
2317
|
+
except Exception as e:
|
|
2318
|
+
self._warn(f"Parallel export encountered an error ({e!r}); falling back to serial.")
|
|
2319
|
+
for t in tables:
|
|
2320
|
+
_export_one(t)
|
|
2321
|
+
_regen_prices()
|
|
2322
|
+
|
|
2323
|
+
self._print("Cache export completed.")
|
|
2324
|
+
|
|
2325
|
+
def _mirror_csv_exports(self) -> None:
|
|
2326
|
+
"""
|
|
2327
|
+
If TD_CSV is set, mirror all CSVs emitted into tdenv.dataDir to TD_CSV.
|
|
2328
|
+
|
|
2329
|
+
This is a publish step:
|
|
2330
|
+
- source: private exports in tdenv.dataDir (TD_DATA)
|
|
2331
|
+
- dest: public directory TD_CSV
|
|
2332
|
+
"""
|
|
2333
|
+
src_dir = Path(self.tdenv.dataDir).resolve()
|
|
2334
|
+
dst_env = os.environ.get("TD_CSV")
|
|
2335
|
+
if not dst_env:
|
|
2336
|
+
return
|
|
2337
|
+
dst_dir = Path(dst_env).expanduser().resolve()
|
|
2338
|
+
|
|
2339
|
+
if src_dir == dst_dir:
|
|
2340
|
+
# Nothing to do; already exporting directly into the public path
|
|
2341
|
+
return
|
|
2342
|
+
|
|
2343
|
+
try:
|
|
2344
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
2345
|
+
except Exception as e:
|
|
2346
|
+
self._warn(f"TD_CSV mirror: unable to create destination {dst_dir}: {e!r}")
|
|
2347
|
+
return
|
|
2348
|
+
|
|
2349
|
+
copied = 0
|
|
2350
|
+
for src in src_dir.glob("*.csv"):
|
|
2351
|
+
try:
|
|
2352
|
+
shutil.copy2(src, dst_dir / src.name)
|
|
2353
|
+
copied += 1
|
|
2354
|
+
except Exception as e:
|
|
2355
|
+
self._warn(f"TD_CSV mirror: failed to copy {src.name}: {e!r}")
|
|
2356
|
+
|
|
2357
|
+
self._print(f"TD_CSV mirror: copied {copied} csv file(s) → {dst_dir}")
|
|
2358
|
+
|
|
2359
|
+
|
|
2360
|
+
def _export_and_mirror(self) -> None:
|
|
2361
|
+
"""
|
|
2362
|
+
Run the normal cache/CSV export, then mirror CSVs to TD_CSV if set.
|
|
2363
|
+
Use this in place of a direct _export_cache() call.
|
|
2364
|
+
"""
|
|
2365
|
+
t0 = time.time()
|
|
2366
|
+
self._export_cache() # existing exporter (unchanged)
|
|
2367
|
+
self._print(f"Cache export completed in {time.time()-t0:.2f}s")
|
|
2368
|
+
self._mirror_csv_exports()
|
|
2369
|
+
|
|
2370
|
+
# ------------------------------
|
|
2371
|
+
# Categories cache
|
|
2372
|
+
#
|
|
2373
|
+
def _load_categories(self, session: Session, tables: dict[str, Table]) -> dict[str, int]:
|
|
2374
|
+
t_cat = tables["Category"]
|
|
2375
|
+
rows = session.execute(select(t_cat.c.category_id, t_cat.c.name)).all()
|
|
2376
|
+
return {str(name).lower(): int(cid) for (cid, name) in rows}
|
|
2377
|
+
|
|
2378
|
+
# ------------------------------
|
|
2379
|
+
# Streaming JSON reader
|
|
2380
|
+
#
|
|
2381
|
+
def _ijson_items(self, fh: io.BufferedReader, prefix: str):
|
|
2382
|
+
"""
|
|
2383
|
+
Use the fastest available ijson backend with clean fallback.
|
|
2384
|
+
Order: yajl2_cffi → yajl2_c → yajl2 → python.
|
|
2385
|
+
"""
|
|
2386
|
+
try:
|
|
2387
|
+
from ijson.backends import yajl2_cffi as ijson_fast
|
|
2388
|
+
return ijson_fast.items(fh, prefix)
|
|
2389
|
+
except Exception:
|
|
2390
|
+
pass
|
|
2391
|
+
try:
|
|
2392
|
+
from ijson.backends import yajl2_c as ijson_fast # ctypes wrapper
|
|
2393
|
+
return ijson_fast.items(fh, prefix)
|
|
2394
|
+
except Exception:
|
|
2395
|
+
pass
|
|
2396
|
+
try:
|
|
2397
|
+
from ijson.backends import yajl2 as ijson_fast
|
|
2398
|
+
return ijson_fast.items(fh, prefix)
|
|
2399
|
+
except Exception:
|
|
2400
|
+
pass
|
|
2401
|
+
# Fallback to whatever was imported at module top
|
|
2402
|
+
return ijson.items(fh, prefix)
|
|
2403
|
+
|
|
2404
|
+
def _iter_top_level_json_array(self, fh: io.BufferedReader) -> Generator[dict[str, Any]]:
|
|
2405
|
+
"""
|
|
2406
|
+
High-performance streaming reader for a huge top-level JSON array of systems.
|
|
2407
|
+
NOTE: As of 2025-10, we removed _parse_progress(). This iterator now
|
|
2408
|
+
maintains byte/rate metrics only; rendering is handled by _progress_line().
|
|
2409
|
+
"""
|
|
2410
|
+
start_ts = time.time()
|
|
2411
|
+
last_tick_systems = 0
|
|
2412
|
+
TICK_EVERY = 256
|
|
2413
|
+
|
|
2414
|
+
it = self._ijson_items(fh, 'item')
|
|
2415
|
+
for idx, obj in enumerate(it, 1):
|
|
2416
|
+
if (idx - last_tick_systems) >= TICK_EVERY:
|
|
2417
|
+
last_tick_systems = idx
|
|
2418
|
+
# Update parse metrics (no printing here)
|
|
2419
|
+
try:
|
|
2420
|
+
pos = fh.tell()
|
|
2421
|
+
elapsed = max(time.time() - start_ts, 1e-9)
|
|
2422
|
+
self._parse_bytes = pos
|
|
2423
|
+
self._parse_rate = pos / elapsed
|
|
2424
|
+
except Exception:
|
|
2425
|
+
pass
|
|
2426
|
+
yield obj
|
|
2427
|
+
|
|
2428
|
+
# Final metric update at EOF
|
|
2429
|
+
try:
|
|
2430
|
+
pos = fh.tell()
|
|
2431
|
+
elapsed = max(time.time() - start_ts, 1e-9)
|
|
2432
|
+
self._parse_bytes = pos
|
|
2433
|
+
self._parse_rate = pos / elapsed
|
|
2434
|
+
except Exception:
|
|
2435
|
+
pass
|
|
2436
|
+
|
|
2437
|
+
if self._is_tty:
|
|
2438
|
+
self._live_status("")
|
|
2439
|
+
|
|
2440
|
+
# ------------------------------
|
|
2441
|
+
# Mapping / derivations / misc
|
|
2442
|
+
#
|
|
2443
|
+
@staticmethod
|
|
2444
|
+
def _build_station_type_map() -> dict[Optional[str], tuple[int, bool]]:
|
|
2445
|
+
return {
|
|
2446
|
+
None: (0, False),
|
|
2447
|
+
"None": (0, False),
|
|
2448
|
+
"Outpost": (1, False),
|
|
2449
|
+
"Coriolis Starport": (2, False),
|
|
2450
|
+
"Ocellus Starport": (3, False),
|
|
2451
|
+
"Orbis Starport": (4, False),
|
|
2452
|
+
"Planetary Outpost": (11, True),
|
|
2453
|
+
"Planetary Port": (12, True),
|
|
2454
|
+
"Mega ship": (13, False),
|
|
2455
|
+
"Asteroid base": (14, False),
|
|
2456
|
+
"Drake-Class Carrier": (24, False),
|
|
2457
|
+
"Settlement": (25, True),
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
def _map_station_type(self, type_name: Optional[str]) -> tuple[int, str]:
|
|
2461
|
+
if isinstance(type_name, str):
|
|
2462
|
+
res = self._station_type_map.get(type_name)
|
|
2463
|
+
if res:
|
|
2464
|
+
type_id, is_planetary = res
|
|
2465
|
+
return type_id, "Y" if is_planetary else "N"
|
|
2466
|
+
return (0, "?")
|
|
2467
|
+
|
|
2468
|
+
@staticmethod
|
|
2469
|
+
def _derive_pad_size(landing: Mapping[str, Any]) -> str:
|
|
2470
|
+
try:
|
|
2471
|
+
if landing.get("large"):
|
|
2472
|
+
return "L"
|
|
2473
|
+
if landing.get("medium"):
|
|
2474
|
+
return "M"
|
|
2475
|
+
if landing.get("small"):
|
|
2476
|
+
return "S"
|
|
2477
|
+
except Exception:
|
|
2478
|
+
pass
|
|
2479
|
+
return "?"
|
|
2480
|
+
|
|
2481
|
+
def _resolve_batch_size(self) -> Optional[int]:
|
|
2482
|
+
"""
|
|
2483
|
+
Decide commit batch size for *spansh* profile.
|
|
2484
|
+
"""
|
|
2485
|
+
if self.session is not None and hasattr(db_utils, "get_import_batch_size"):
|
|
2486
|
+
try:
|
|
2487
|
+
val = db_utils.get_import_batch_size(self.session, profile="spansh")
|
|
2488
|
+
if val is not None:
|
|
2489
|
+
return val
|
|
2490
|
+
except Exception:
|
|
2491
|
+
pass
|
|
2492
|
+
|
|
2493
|
+
raw = os.environ.get("TD_LISTINGS_BATCH")
|
|
2494
|
+
if raw is not None:
|
|
2495
|
+
try:
|
|
2496
|
+
envv = int(raw)
|
|
2497
|
+
return envv if envv > 0 else None
|
|
2498
|
+
except ValueError:
|
|
2499
|
+
pass
|
|
2500
|
+
|
|
2501
|
+
try:
|
|
2502
|
+
if db_utils.is_sqlite(self.session):
|
|
2503
|
+
return None
|
|
2504
|
+
if db_utils.is_mysql(self.session):
|
|
2505
|
+
return 50_000
|
|
2506
|
+
except Exception:
|
|
2507
|
+
pass
|
|
2508
|
+
|
|
2509
|
+
return 5_000
|
|
2510
|
+
|
|
2511
|
+
# ---- ts/format/logging helpers ----
|
|
2512
|
+
def _parse_ts(self, value: Any) -> Optional[datetime]:
|
|
2513
|
+
try:
|
|
2514
|
+
return db_utils.parse_ts(value) # UTC-naive, μs=0
|
|
2515
|
+
except Exception:
|
|
2516
|
+
return None
|
|
2517
|
+
|
|
2518
|
+
@staticmethod
|
|
2519
|
+
def _format_hms(seconds: float) -> str:
|
|
2520
|
+
m, s = divmod(int(seconds), 60)
|
|
2521
|
+
h, m = divmod(m, 60)
|
|
2522
|
+
return f"{h}:{m:02d}:{s:02d}"
|
|
2523
|
+
|
|
2524
|
+
def _fmt_bytes(self, n: float) -> str:
|
|
2525
|
+
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
|
2526
|
+
i = 0
|
|
2527
|
+
while n >= 1024 and i < len(units) - 1:
|
|
2528
|
+
n /= 1024.0
|
|
2529
|
+
i += 1
|
|
2530
|
+
return f"{int(n)} {units[i]}" if i == 0 else f"{n:.1f} {units[i]}"
|
|
2531
|
+
|
|
2532
|
+
def _progress_line(self, stats: dict[str, int]) -> None:
|
|
2533
|
+
"""
|
|
2534
|
+
Single-line live status while importing.
|
|
2535
|
+
|
|
2536
|
+
Modes:
|
|
2537
|
+
- default (verbose-ish): rich long line
|
|
2538
|
+
- compact: shorter, log-friendly line (enable with -O progress_compact=1 or TD_PROGRESS_COMPACT=1)
|
|
2539
|
+
"""
|
|
2540
|
+
now = time.time()
|
|
2541
|
+
if now - self._last_progress_time < (0.5 if self._debug_level < 1 else 0.2):
|
|
2542
|
+
return
|
|
2543
|
+
self._last_progress_time = now
|
|
2544
|
+
self._started_importing = True
|
|
2545
|
+
|
|
2546
|
+
# Determine compact mode (CLI overrides env; default is rich/False)
|
|
2547
|
+
# Truthy whitelist: 1, true, yes, on, y (case-insensitive)
|
|
2548
|
+
_opt = self.getOption("progress_compact")
|
|
2549
|
+
if _opt is not None:
|
|
2550
|
+
_val = str(_opt).strip().lower()
|
|
2551
|
+
else:
|
|
2552
|
+
_env = os.getenv("TD_PROGRESS_COMPACT")
|
|
2553
|
+
_val = "" if _env is None else str(_env).strip().lower()
|
|
2554
|
+
compact = _val in {"1", "true", "yes", "on", "y"}
|
|
2555
|
+
|
|
2556
|
+
parse_bytes = getattr(self, "_parse_bytes", 0)
|
|
2557
|
+
parse_rate = getattr(self, "_parse_rate", 0.0)
|
|
2558
|
+
systems = stats.get("systems", 0)
|
|
2559
|
+
stations = stats.get("stations", 0)
|
|
2560
|
+
|
|
2561
|
+
wm = stats.get("market_writes", 0)
|
|
2562
|
+
wo = stats.get("outfit_writes", 0)
|
|
2563
|
+
ws = stats.get("ship_writes", 0)
|
|
2564
|
+
|
|
2565
|
+
km = stats.get("market_stations", 0)
|
|
2566
|
+
ko = stats.get("outfit_stations", 0)
|
|
2567
|
+
ks = stats.get("ship_stations", 0)
|
|
2568
|
+
|
|
2569
|
+
if compact:
|
|
2570
|
+
# Compact, log-friendly (newline prints)
|
|
2571
|
+
msg = (
|
|
2572
|
+
f"Importing… {parse_bytes/1048576:.1f} MiB read {parse_rate/1048576:.1f} MiB/s "
|
|
2573
|
+
f"systems:{systems:,} stations:{stations:,} "
|
|
2574
|
+
f"checked m/o/s:{km:,}/{ko:,}/{ks:,} written m/o/s:{wm:,}/{wo:,}/{ws:,}"
|
|
2575
|
+
)
|
|
2576
|
+
self._print(msg)
|
|
2577
|
+
return
|
|
2578
|
+
|
|
2579
|
+
# Rich/long line (TTY-optimized; truncated only on TTY)
|
|
2580
|
+
msg = (
|
|
2581
|
+
f"Importing… {parse_bytes/1048576:.1f} MiB read {parse_rate/1048576:.1f} MiB/s "
|
|
2582
|
+
f"[Parsed - Systems: {systems:,} Stations: {stations:,}] "
|
|
2583
|
+
f"Checked(stations): mkt={km:,} outf={ko:,} shp={ks:,} "
|
|
2584
|
+
f"Written(stations): mkt={wm:,} outf={wo:,} shp={ws:,}"
|
|
2585
|
+
|
|
2586
|
+
)
|
|
2587
|
+
self._live_status(msg)
|
|
2588
|
+
|
|
2589
|
+
def _live_line(self, msg: str) -> None:
|
|
2590
|
+
self._live_status(msg)
|
|
2591
|
+
|
|
2592
|
+
def _live_status(self, msg: str) -> None:
|
|
2593
|
+
"""
|
|
2594
|
+
Live status line for TTY; plain prints for non-TTY.
|
|
2595
|
+
IMPORTANT: only truncate when TTY so logs are not cut off.
|
|
2596
|
+
"""
|
|
2597
|
+
try:
|
|
2598
|
+
if self._is_tty:
|
|
2599
|
+
width = shutil.get_terminal_size(fallback=(120, 20)).columns
|
|
2600
|
+
if width and width > 4:
|
|
2601
|
+
msg = msg[: width - 2]
|
|
2602
|
+
s = f"\x1b[2K\r{msg}"
|
|
2603
|
+
sys.stderr.write(s)
|
|
2604
|
+
sys.stderr.flush()
|
|
2605
|
+
else:
|
|
2606
|
+
# Non-TTY: emit full line, no truncation, no control codes.
|
|
2607
|
+
self._print(msg)
|
|
2608
|
+
except Exception:
|
|
2609
|
+
self._print(msg)
|
|
2610
|
+
|
|
2611
|
+
def _end_live_status(self) -> None:
|
|
2612
|
+
try:
|
|
2613
|
+
if self._is_tty:
|
|
2614
|
+
sys.stderr.write("\x1b[2K\r\n")
|
|
2615
|
+
sys.stderr.flush()
|
|
2616
|
+
except Exception:
|
|
2617
|
+
pass
|
|
2618
|
+
|
|
2619
|
+
# ---- printing/warnings ----
|
|
2620
|
+
def _print(self, *args, **kwargs):
|
|
2621
|
+
printer = getattr(self.tdenv, "print", None)
|
|
2622
|
+
if callable(printer):
|
|
2623
|
+
printer(*args, **kwargs)
|
|
2624
|
+
else:
|
|
2625
|
+
print(*args, **kwargs)
|
|
2626
|
+
|
|
2627
|
+
def _warn(self, msg: str):
|
|
2628
|
+
if self._warn_enabled:
|
|
2629
|
+
self._print(f"WARNING: {msg}")
|
|
2630
|
+
|
|
2631
|
+
def _error(self, msg: str):
|
|
2632
|
+
self._print(f"ERROR: {msg}")
|
|
2633
|
+
|
|
2634
|
+
def _safe_close_session(self):
|
|
2635
|
+
try:
|
|
2636
|
+
if self.session is not None:
|
|
2637
|
+
self.session.close()
|
|
2638
|
+
except Exception:
|
|
2639
|
+
pass
|
|
2640
|
+
self.session = None
|
|
2641
|
+
|
|
2642
|
+
# -----------------------------------------------------------------------------
|
|
2643
|
+
# Exceptions
|
|
2644
|
+
# -----------------------------------------------------------------------------
|
|
2645
|
+
class CleanExit(Exception):
|
|
2646
|
+
"""Controlled early exit: log and stop this run so schedulers can retry later."""
|
|
2647
|
+
pass
|