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.
Files changed (87) hide show
  1. py.typed +1 -0
  2. trade.py +49 -0
  3. tradedangerous/__init__.py +43 -0
  4. tradedangerous/cache.py +1381 -0
  5. tradedangerous/cli.py +136 -0
  6. tradedangerous/commands/TEMPLATE.py +74 -0
  7. tradedangerous/commands/__init__.py +244 -0
  8. tradedangerous/commands/buildcache_cmd.py +102 -0
  9. tradedangerous/commands/buy_cmd.py +427 -0
  10. tradedangerous/commands/commandenv.py +372 -0
  11. tradedangerous/commands/exceptions.py +94 -0
  12. tradedangerous/commands/export_cmd.py +150 -0
  13. tradedangerous/commands/import_cmd.py +222 -0
  14. tradedangerous/commands/local_cmd.py +243 -0
  15. tradedangerous/commands/market_cmd.py +207 -0
  16. tradedangerous/commands/nav_cmd.py +252 -0
  17. tradedangerous/commands/olddata_cmd.py +270 -0
  18. tradedangerous/commands/parsing.py +221 -0
  19. tradedangerous/commands/rares_cmd.py +298 -0
  20. tradedangerous/commands/run_cmd.py +1521 -0
  21. tradedangerous/commands/sell_cmd.py +262 -0
  22. tradedangerous/commands/shipvendor_cmd.py +60 -0
  23. tradedangerous/commands/station_cmd.py +68 -0
  24. tradedangerous/commands/trade_cmd.py +181 -0
  25. tradedangerous/commands/update_cmd.py +67 -0
  26. tradedangerous/corrections.py +55 -0
  27. tradedangerous/csvexport.py +234 -0
  28. tradedangerous/db/__init__.py +27 -0
  29. tradedangerous/db/adapter.py +192 -0
  30. tradedangerous/db/config.py +107 -0
  31. tradedangerous/db/engine.py +259 -0
  32. tradedangerous/db/lifecycle.py +332 -0
  33. tradedangerous/db/locks.py +208 -0
  34. tradedangerous/db/orm_models.py +500 -0
  35. tradedangerous/db/paths.py +113 -0
  36. tradedangerous/db/utils.py +661 -0
  37. tradedangerous/edscupdate.py +565 -0
  38. tradedangerous/edsmupdate.py +474 -0
  39. tradedangerous/formatting.py +210 -0
  40. tradedangerous/fs.py +156 -0
  41. tradedangerous/gui.py +1146 -0
  42. tradedangerous/mapping.py +133 -0
  43. tradedangerous/mfd/__init__.py +103 -0
  44. tradedangerous/mfd/saitek/__init__.py +3 -0
  45. tradedangerous/mfd/saitek/directoutput.py +678 -0
  46. tradedangerous/mfd/saitek/x52pro.py +195 -0
  47. tradedangerous/misc/checkpricebounds.py +287 -0
  48. tradedangerous/misc/clipboard.py +49 -0
  49. tradedangerous/misc/coord64.py +83 -0
  50. tradedangerous/misc/csvdialect.py +57 -0
  51. tradedangerous/misc/derp-sentinel.py +35 -0
  52. tradedangerous/misc/diff-system-csvs.py +159 -0
  53. tradedangerous/misc/eddb.py +81 -0
  54. tradedangerous/misc/eddn.py +349 -0
  55. tradedangerous/misc/edsc.py +437 -0
  56. tradedangerous/misc/edsm.py +121 -0
  57. tradedangerous/misc/importeddbstats.py +54 -0
  58. tradedangerous/misc/prices-json-exp.py +179 -0
  59. tradedangerous/misc/progress.py +194 -0
  60. tradedangerous/plugins/__init__.py +249 -0
  61. tradedangerous/plugins/edcd_plug.py +371 -0
  62. tradedangerous/plugins/eddblink_plug.py +861 -0
  63. tradedangerous/plugins/edmc_batch_plug.py +133 -0
  64. tradedangerous/plugins/spansh_plug.py +2647 -0
  65. tradedangerous/prices.py +211 -0
  66. tradedangerous/submit-distances.py +422 -0
  67. tradedangerous/templates/Added.csv +37 -0
  68. tradedangerous/templates/Category.csv +17 -0
  69. tradedangerous/templates/RareItem.csv +143 -0
  70. tradedangerous/templates/TradeDangerous.sql +338 -0
  71. tradedangerous/tools.py +40 -0
  72. tradedangerous/tradecalc.py +1302 -0
  73. tradedangerous/tradedb.py +2320 -0
  74. tradedangerous/tradeenv.py +313 -0
  75. tradedangerous/tradeenv.pyi +109 -0
  76. tradedangerous/tradeexcept.py +131 -0
  77. tradedangerous/tradeorm.py +183 -0
  78. tradedangerous/transfers.py +192 -0
  79. tradedangerous/utils.py +243 -0
  80. tradedangerous/version.py +16 -0
  81. tradedangerous-12.7.6.dist-info/METADATA +106 -0
  82. tradedangerous-12.7.6.dist-info/RECORD +87 -0
  83. tradedangerous-12.7.6.dist-info/WHEEL +5 -0
  84. tradedangerous-12.7.6.dist-info/entry_points.txt +3 -0
  85. tradedangerous-12.7.6.dist-info/licenses/LICENSE +373 -0
  86. tradedangerous-12.7.6.dist-info/top_level.txt +2 -0
  87. tradegui.py +24 -0
@@ -0,0 +1,2320 @@
1
+ """
2
+ Copyright (C) Oliver 'kfsone' Smith 2014 <oliver@kfs.org>:
3
+ Copyright (C) Bernd 'Gazelle' Gollesch 2016, 2017
4
+ Copyright (C) Stefan 'Tromador' Morrell 2025
5
+ Copyright (C) Jonathan 'eyeonus' Jones 2018 - 2025
6
+
7
+ You are free to use, redistribute, or even print and eat a copy of
8
+ this software so long as you include this copyright notice.
9
+
10
+ I guarantee there is at least one bug neither of us knew about. -- Oliver
11
+ --------------------------------------------------------------------
12
+ TradeDangerous :: Modules :: Database Module
13
+
14
+ Provides the primary classes used within TradeDangerous:
15
+
16
+ TradeDB, System, Station, Ship, Item, and Trade.
17
+
18
+ These classes are primarily for describing the database.
19
+
20
+ Simplistic use might be:
21
+
22
+ import tradedb
23
+
24
+ # Create an instance: You can specify a debug level as a
25
+ # parameter, for more advanced configuration, see the
26
+ # tradeenv.TradeEnv() class.
27
+ tdb = tradedb.TradeDB()
28
+
29
+ # look up a System by name
30
+ sol = tdb.lookupSystem("SOL")
31
+ ibootis = tdb.lookupSystem("i BootiS")
32
+ ibootis = tdb.lookupSystem("ibootis")
33
+
34
+ # look up a Station by name
35
+ abe = tdb.lookupStation("Abraham Lincoln")
36
+ abe = tdb.lookupStation("Abraham Lincoln", sol)
37
+ abe = tdb.lookupStation("hamlinc")
38
+
39
+ # look up something that could be a system or station,
40
+ # where 'place' syntax can be:
41
+ # SYS, STN, SYS/STN, @SYS, /STN or @SYS/STN
42
+ abe = tdb.lookupPlace("Abraham Lincoln")
43
+ abe = tdb.lookupPlace("HamLinc")
44
+ abe = tdb.lookupPlace("@SOL/HamLinc")
45
+ abe = tdb.lookupPlace("so/haml")
46
+ abe = tdb.lookupPlace("sol/abraham lincoln")
47
+ abe = tdb.lookupPlace("@sol/abrahamlincoln")
48
+ james = tdb.lookupPlace("shin/jamesmem")
49
+ """
50
+
51
+ ######################################################################
52
+ # Imports
53
+ from __future__ import annotations
54
+
55
+ from collections import namedtuple
56
+ from functools import lru_cache
57
+ from math import floor as math_floor, sqrt as math_sqrt
58
+ from pathlib import Path
59
+ from typing import NamedTuple
60
+ import heapq
61
+ import itertools
62
+ import locale
63
+ import os
64
+ import re
65
+ import sys
66
+ import time
67
+ import typing
68
+
69
+ from .tradeenv import TradeEnv
70
+ from .tradeexcept import TradeException, AmbiguityError, SystemNotStationError
71
+ from . import cache, fs
72
+
73
+ from sqlalchemy import func, select, text
74
+ from sqlalchemy.exc import NoResultFound
75
+ from .db import make_engine_from_config, get_session_factory # type: ignore
76
+ from .db.lifecycle import ensure_fresh_db # type: ignore
77
+ from .db.utils import age_in_days # type: ignore
78
+
79
+ # --------------------------------------------------------------------
80
+ # SQLAlchemy ORM imports (aliased to avoid clashing with legacy wrappers).
81
+ # These map to the actual database tables via SQLAlchemy and are used
82
+ # internally in loaders/writers to replace raw sqlite3 queries.
83
+ #
84
+ # NOTE: We still instantiate and use legacy wrapper classes defined in
85
+ # this file (System, Station, Item, etc.) to maintain API compatibility
86
+ # across the rest of the codebase (Pass 1 migration).
87
+ #
88
+ # In a possible future cleanup (Pass 2), the wrappers may be removed
89
+ # entirely, and code updated to use ORM models directly.
90
+ # --------------------------------------------------------------------
91
+
92
+ from .db.orm_models import ( # noqa: F401 # pylint: disable=unused-import
93
+ Added as SA_Added,
94
+ System as SA_System,
95
+ Station as SA_Station,
96
+ Item as SA_Item,
97
+ Category as SA_Category,
98
+ StationItem as SA_StationItem,
99
+ RareItem as SA_RareItem,
100
+ Ship as SA_Ship,
101
+ ShipVendor as SA_ShipVendor,
102
+ Upgrade as SA_Upgrade,
103
+ UpgradeVendor as SA_UpgradeVendor,
104
+ ExportControl as SA_ExportControl,
105
+ StationItemStaging as SA_StationItemStaging,
106
+ )
107
+
108
+
109
+ locale.setlocale(locale.LC_ALL, '')
110
+
111
+
112
+ if typing.TYPE_CHECKING:
113
+ from collections.abc import Generator
114
+ from typing import Any, Optional
115
+
116
+
117
+ ######################################################################
118
+ # Classes
119
+
120
+ ######################################################################
121
+
122
+
123
+ def make_stellar_grid_key(x: float, y: float, z: float) -> tuple[int, int, int]:
124
+ """
125
+ The Stellar Grid is a map of systems based on their Stellar
126
+ co-ordinates rounded down to 32lys. This makes it much easier
127
+ to find stars within rectangular volumes.
128
+ """
129
+ # Originally we used int(x) >> 5, but this caused bunching around negatives.
130
+ # int(0.1) == 0 but so does int(-0.1). We should probably create the
131
+ # per-system stellar grid keys once, in the database, and store those
132
+ # against the system.
133
+ return math_floor(x) >> 5, math_floor(y) >> 5, math_floor(z) >> 5
134
+
135
+
136
+ class System:
137
+ """
138
+ Describes a star system which may contain one or more Station objects.
139
+
140
+ Caution: Do not use _rangeCache directly, use TradeDB.genSystemsInRange.
141
+ """
142
+
143
+ __slots__ = (
144
+ 'ID',
145
+ 'dbname', 'posX', 'posY', 'posZ', 'pos', 'stations',
146
+ 'addedID',
147
+ '_rangeCache'
148
+ )
149
+
150
+ class RangeCache:
151
+ """
152
+ Lazily populated cache of neighboring systems.
153
+ """
154
+ def __init__(self):
155
+ self.systems = []
156
+ self.probed_ly = 0.
157
+
158
+ def __init__(self, ID: int, dbname: str, posX: float, posY: float, posZ: float, addedID: int | None) -> None:
159
+ self.ID = ID
160
+ self.dbname = dbname
161
+ self.posX, self.posY, self.posZ = posX, posY, posZ
162
+ self.addedID = addedID or 0
163
+ self.stations: list['Station'] = []
164
+ self._rangeCache = None
165
+
166
+ def __repr__(self) -> str:
167
+ return f"<System ID={self.ID} dbname='{self.dbname}' pos=({self.posX},{self.posY},{self.posZ})>"
168
+
169
+ @property
170
+ def system(self) -> 'System':
171
+ """ Returns self for compatibility with the undefined 'Positional' interface. """
172
+ return self
173
+
174
+ def distanceTo(self, other: 'System') -> float:
175
+ """
176
+ Returns the distance (in ly) between two systems.
177
+
178
+ NOTE: If you are primarily testing/comparing
179
+ distances, consider using "distToSq" for the test.
180
+
181
+ Returns:
182
+ Distance in light years.
183
+
184
+ Example:
185
+ print("{} -> {}: {} ly".format(
186
+ lhs.name(), rhs.name(),
187
+ lhs.distanceTo(rhs),
188
+ ))
189
+ """
190
+ dx, dy, dz = self.posX - other.posX, self.posY - other.posY, self.posZ - other.posZ
191
+ return math_sqrt(dx * dx + dy * dy + dz * dz)
192
+
193
+ def getStation(self, name: str) -> 'Optional[Station]':
194
+ """
195
+ Quick case-insensitive lookup of a station name within the
196
+ stations in this system.
197
+
198
+ Returns:
199
+ Station() object if a match is found,
200
+ otherwise None.
201
+ """
202
+ name = name.upper()
203
+ for station in self.stations:
204
+ if station.name == name:
205
+ return station
206
+ return None
207
+
208
+ def name(self, detail: int = 0) -> str: # pylint: disable=unused-argument
209
+ """ Returns the display name for this System."""
210
+ return self.dbname
211
+
212
+ def text(self) -> str:
213
+ return self.dbname
214
+
215
+
216
+ ######################################################################
217
+
218
+ class Destination(NamedTuple):
219
+ system: 'System'
220
+ station: 'Station'
221
+ via: list['System']
222
+ distLy: float
223
+
224
+
225
+ class DestinationNode(NamedTuple):
226
+ system: 'System'
227
+ via: list['System']
228
+ distLy: float
229
+
230
+
231
+ class Station:
232
+ """
233
+ Describes a station (trading or otherwise) in a system.
234
+
235
+ For obtaining trade information for a given station see one of:
236
+ TradeCalc.getTrades (fast and cheap)
237
+ """
238
+ __slots__ = (
239
+ 'ID', 'system', 'dbname',
240
+ 'lsFromStar', 'market', 'blackMarket', 'shipyard', 'maxPadSize',
241
+ 'outfitting', 'rearm', 'refuel', 'repair', 'planetary','fleet',
242
+ 'odyssey', 'itemCount', 'dataAge',
243
+ )
244
+
245
+ def __init__(
246
+ self, ID: int, system: 'System', dbname: str,
247
+ lsFromStar: float, market: str, blackMarket: str, shipyard: str, maxPadSize: str,
248
+ outfitting: str, rearm: str, refuel: str, repair: str, planetary: str, fleet: str, odyssey: str,
249
+ itemCount: int = 0, dataAge: float | int | None = None,
250
+ ):
251
+ self.ID, self.system, self.dbname = ID, system, dbname # type: ignore
252
+ self.lsFromStar = int(lsFromStar)
253
+ self.market = market if itemCount == 0 else 'Y'
254
+ self.blackMarket = blackMarket
255
+ self.shipyard = shipyard
256
+ self.maxPadSize = maxPadSize
257
+ self.outfitting = outfitting
258
+ self.rearm = rearm
259
+ self.refuel = refuel
260
+ self.repair = repair
261
+ self.planetary = planetary
262
+ self.fleet = fleet
263
+ self.odyssey = odyssey
264
+ self.itemCount = itemCount
265
+ self.dataAge = dataAge
266
+ system.stations += [self]
267
+
268
+ def __repr__(self) -> str:
269
+ return f"<Station ID={self.ID} dbname='{self.dbname}' system_id={self.system.ID} system='{self.system.dbname}'>"
270
+
271
+ def name(self, detail: int = 0) -> str: # pylint: disable=unused-argument
272
+ return f"{self.system.dbname}/{self.dbname}"
273
+
274
+ def checkPadSize(self, maxPadSize: str) -> bool:
275
+ """
276
+ Tests if the Station's max pad size matches one of the
277
+ values in 'maxPadSize'.
278
+
279
+ Args:
280
+ maxPadSize
281
+ A string of one or more max pad size values that
282
+ you want to match against.
283
+
284
+ Returns:
285
+ True
286
+ If self.maxPadSize is None or empty, or matches a
287
+ member of maxPadSize
288
+ False
289
+ If maxPadSize was not empty but self.maxPadSize
290
+ did not match it.
291
+
292
+ Examples:
293
+ # Require a medium max pad size - not small or large
294
+ station.checkPadSize("M")
295
+ # Require medium or unknown
296
+ station.checkPadSize("M?")
297
+ # Require small, large or unknown
298
+ station.checkPadSize("SL?")
299
+ """
300
+ return (not maxPadSize or self.maxPadSize in maxPadSize)
301
+
302
+ def checkPlanetary(self, planetary: str) -> bool:
303
+ """
304
+ Tests if the Station's planetary matches one of the
305
+ values in 'planetary'.
306
+
307
+ Args:
308
+ planetary
309
+ A string of one or more planetary values that
310
+ you want to match against.
311
+
312
+ Returns:
313
+ True
314
+ If self.planetary is None or empty, or matches a
315
+ member of planetary
316
+ False
317
+ If planetary was not empty but self.planetary
318
+ did not match it.
319
+
320
+ Examples:
321
+ # Require a planetary station
322
+ station.checkPlanetary("Y")
323
+ # Require planetary or unknown
324
+ station.checkPlanetary("Y?")
325
+ # Require no planetary station
326
+ station.checkPlanetary("N")
327
+ """
328
+ return (not planetary or self.planetary in planetary)
329
+
330
+ def checkFleet(self, fleet: str) -> bool:
331
+ """
332
+ Same as checkPlanetary, but for fleet carriers.
333
+ """
334
+ return (not fleet or self.fleet in fleet)
335
+
336
+
337
+ def checkOdyssey(self, odyssey: str) -> bool:
338
+ """
339
+ Same as checkPlanetary, but for Odyssey.
340
+ """
341
+ return (not odyssey or self.odyssey in odyssey)
342
+
343
+
344
+ def distFromStar(self, addSuffix: bool = False) -> str:
345
+ """
346
+ Returns a textual description of the distance from this
347
+ Station to the parent star.
348
+
349
+ Args:
350
+ addSuffix[=False]:
351
+ Always add a unit suffix (ls, Kls, ly)
352
+ """
353
+ ls = self.lsFromStar
354
+ if not ls:
355
+ return "Unk" if addSuffix else "?"
356
+
357
+ suffix = "ls" if addSuffix else ""
358
+
359
+ if ls < 1000:
360
+ return f"{ls:n}{suffix}"
361
+ if ls < 10000:
362
+ return f"{ls / 1000:.2f}K{suffix}"
363
+ if ls < 1000000:
364
+ return f"{int(ls / 1000):n}K{suffix}"
365
+ return f'{ls / (365*24*60*60):.2f}ly'
366
+
367
+ @property
368
+ def isTrading(self) -> bool:
369
+ """
370
+ True if the station is thought to be trading.
371
+
372
+ A station is considered 'trading' if it has an item count > 0 or
373
+ if it's "market" column is flagged 'Y'.
374
+ """
375
+ return (self.itemCount > 0 or self.market == 'Y')
376
+
377
+ @property
378
+ def itemDataAgeStr(self):
379
+ """ Returns the age in days of item data if present, else "-". """
380
+ if self.itemCount and self.dataAge:
381
+ return f"{self.dataAge:7.2f}"
382
+ return "-"
383
+
384
+ def text(self) -> str:
385
+ return f"{self.system.dbname}/{self.dbname}"
386
+
387
+ ######################################################################
388
+
389
+
390
+ class Ship(NamedTuple):
391
+ """
392
+ Ship description.
393
+
394
+ Attributes:
395
+ ID -- FDevID as provided by the companion API.
396
+ dbname -- The name as present in the database
397
+ cost -- How many credits to buy
398
+ stations -- List of Stations ship is sold at.
399
+ """
400
+ ID: int
401
+ dbname: str
402
+ cost: int
403
+ stations: list[Station]
404
+
405
+ def name(self, _detail: int = 0) -> str:
406
+ return self.dbname
407
+
408
+ ######################################################################
409
+
410
+
411
+ class Category(NamedTuple):
412
+ """
413
+ Item Category
414
+
415
+ Items are organized into categories (Food, Drugs, Metals, etc).
416
+ Category object describes a category's ID, name and list of items.
417
+
418
+ Attributes:
419
+ ID
420
+ The database ID
421
+ dbname
422
+ The name as present in the database.
423
+ items
424
+ List of Item objects within this category.
425
+
426
+ Member Functions:
427
+ name()
428
+ Returns the display name for this Category.
429
+ """
430
+ ID: int
431
+ dbname: str
432
+ items: list['Item']
433
+
434
+ def name(self, _detail: int = 0) -> str:
435
+ return self.dbname.upper()
436
+
437
+ ######################################################################
438
+
439
+
440
+ class Item:
441
+ """
442
+ A product that can be bought/sold in the game.
443
+
444
+ Attributes:
445
+ ID -- Database ID.
446
+ dbname -- Name as it appears in-game and in the DB.
447
+ category -- Reference to the category.
448
+ fullname -- Combined category/dbname for lookups.
449
+ avgPrice -- Galactic average as shown in game.
450
+ fdevID -- FDevID as provided by the companion API.
451
+ """
452
+ __slots__ = ('ID', 'dbname', 'category', 'fullname', 'avgPrice', 'fdevID')
453
+
454
+ def __init__(self, ID: int, dbname: str, category: 'Category', fullname: str, avgPrice: int | None = None, fdevID: int | None = None) -> None:
455
+ self.ID = ID
456
+ self.dbname = dbname
457
+ self.category = category
458
+ self.fullname = fullname
459
+ self.avgPrice = avgPrice
460
+ self.fdevID = fdevID
461
+
462
+ def name(self, detail: int = 0):
463
+ return self.fullname if detail > 0 else self.dbname
464
+
465
+
466
+ ######################################################################
467
+
468
+
469
+ class Trade(NamedTuple):
470
+ """
471
+ Describes what it would cost and how much you would gain
472
+ when selling an item between two specific stations.
473
+ """
474
+ item: Item
475
+ costCr: int
476
+ gainCr: int
477
+ supply: int
478
+ supplyLevel: int
479
+ demand: int
480
+ demandLevel: int
481
+ srcAge: float | None
482
+ dstAge: float | None
483
+
484
+ def name(self, detail: int = 0) -> str:
485
+ return self.item.name(detail=detail)
486
+
487
+ ######################################################################
488
+
489
+
490
+ class TradeDB:
491
+ """
492
+ Encapsulation for the database layer.
493
+
494
+ Attributes:
495
+ dataPath
496
+ Path() to the data directory
497
+ dbPath
498
+ Path() of the .db location
499
+ tradingCount
500
+ Number of "profitable trade" items processed
501
+ tradingStationCount
502
+ Number of stations trade data has been loaded for
503
+ tdenv
504
+ The TradeEnv associated with this TradeDB
505
+ sqlPath
506
+ Path() of the .sql file
507
+ pricesPath
508
+ Path() of the .prices file
509
+ importTables
510
+ List of the .csv files
511
+
512
+ Static methods:
513
+ calculateDistance2(lx, ly, lz, rx, ry, rz)
514
+ Returns the square of the distance in ly between two points.
515
+
516
+ calculateDistance(lx, ly, lz, rx, ry, rz)
517
+ Returns the distance in ly between two points.
518
+
519
+ listSearch(...)
520
+ Performs partial and ambiguity matching of a word from a list
521
+ of potential values.
522
+
523
+ normalizedStr(text)
524
+ Case and punctuation normalizes a string to make it easier
525
+ to find approximate matches.
526
+ """
527
+
528
+ # Translation map for normalizing strings
529
+ normalizeTrans = str.maketrans(
530
+ 'abcdefghijklmnopqrstuvwxyz',
531
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
532
+ '[]()*+-.,{}:'
533
+ )
534
+ trimTrans = str.maketrans('', '', ' \'')
535
+
536
+ # The DB cache
537
+ defaultDB: str = 'TradeDangerous.db'
538
+ # File containing SQL to build the DB cache from
539
+ defaultSQL: str = 'TradeDangerous.sql'
540
+ persistFile: str = 'TradeDB.pj' # discontinued "pickle" snapshot
541
+ # # File containing text description of prices
542
+ # defaultPrices = 'TradeDangerous.prices'
543
+ # array containing standard tables, csvfilename and tablename
544
+ # WARNING: order is important because of dependencies!
545
+ defaultTables = (
546
+ ('Added.csv', 'Added'),
547
+ ('System.csv', 'System'),
548
+ ('Station.csv', 'Station'),
549
+ ('Ship.csv', 'Ship'),
550
+ ('ShipVendor.csv', 'ShipVendor'),
551
+ ('Upgrade.csv', 'Upgrade'),
552
+ ('UpgradeVendor.csv', 'UpgradeVendor'),
553
+ ('Category.csv', 'Category'),
554
+ ('Item.csv', 'Item'),
555
+ ('StationItem.csv', 'StationItem'),
556
+ ('RareItem.csv', 'RareItem'),
557
+ ('FDevShipyard.csv', 'FDevShipyard'),
558
+ ('FDevOutfitting.csv', 'FDevOutfitting'),
559
+ )
560
+
561
+ # Translation matrixes for attributes -> common presentation
562
+ marketStates = planetStates = fleetStates = odysseyStates = {'?': '?', 'Y': 'Yes', 'N': 'No'}
563
+ marketStatesExt = planetStatesExt = fleetStatesExt = odysseyStatesExt = {'?': 'Unk', 'Y': 'Yes', 'N': 'No'}
564
+ padSizes = {'?': '?', 'S': 'Sml', 'M': 'Med', 'L': 'Lrg'}
565
+ padSizesExt = {'?': 'Unk', 'S': 'Sml', 'M': 'Med', 'L': 'Lrg'}
566
+
567
+ def __init__(
568
+ self,
569
+ tdenv: TradeEnv | None = None,
570
+ load: bool = True,
571
+ debug: int | None = None,
572
+ ) -> None:
573
+ # --- SQLAlchemy engine/session (replaces sqlite3.Connection) ---
574
+ self.engine = None
575
+ self.Session = None
576
+ self.tradingCount = None
577
+
578
+ # Environment
579
+ tdenv = tdenv or TradeEnv(debug=(debug or 0))
580
+ self.tdenv = tdenv
581
+
582
+ # --- Path setup (unchanged) ---
583
+ self.templatePath = Path(tdenv.templateDir).resolve()
584
+ self.dataPath = dataPath = fs.ensurefolder(tdenv.dataDir)
585
+ self.csvPath = fs.ensurefolder(tdenv.csvDir)
586
+
587
+ # Template bootstrap files: copy ONLY if missing (never overwrite on pip upgrade).
588
+ fs.copy_if_missing(self.templatePath / "Added.csv", self.csvPath / "Added.csv")
589
+ fs.copy_if_missing(self.templatePath / "RareItem.csv", self.csvPath / "RareItem.csv")
590
+ fs.copy_if_missing(self.templatePath / "Category.csv", self.csvPath / "Category.csv")
591
+ fs.copy_if_newer(self.templatePath / "TradeDangerous.sql", self.dataPath / "TradeDangerous.sql")
592
+
593
+ self.dbPath = Path(tdenv.dbFilename or dataPath / TradeDB.defaultDB)
594
+ self.sqlPath = dataPath / Path(tdenv.sqlFilename or TradeDB.defaultSQL)
595
+ # pricePath = Path(tdenv.pricesFilename or TradeDB.defaultPrices)
596
+ # self.pricesPath = dataPath / pricePath
597
+
598
+ # If the "pickle jar" file we used temporarily is present, delete it.
599
+ persist_file = Path(self.dataPath, TradeDB.persistFile)
600
+ persist_file.unlink(missing_ok=True)
601
+
602
+ self.importTables = [
603
+ (str(self.csvPath / Path(fn)), tn)
604
+ for fn, tn in TradeDB.defaultTables
605
+ ]
606
+ self.importPaths = {tn: tp for tp, tn in self.importTables}
607
+
608
+ self.dbFilename = str(self.dbPath)
609
+ self.sqlFilename = str(self.sqlPath)
610
+ # self.pricesFilename = str(self.pricesPath)
611
+
612
+ # --- Cache attributes (unchanged) ---
613
+ self.avgSelling, self.avgBuying = None, None
614
+ self.tradingStationCount = 0
615
+ self.systemByID: dict[int, System] | None = None
616
+ self.systemByName: dict[str, list[System]] | None = None
617
+ self.stellarGrid: dict[tuple[int, int, int], list[System]] | None = None
618
+ self.stationByID: dict[int, Station] | None = None
619
+ self.categoryByID: dict[int, Category] | None = None
620
+ self.itemByID: dict[int, Item] | None = None
621
+ self.itemByName: dict[str, Item] | None = None
622
+ self.itemByFDevID: dict[int, Item] | None = None
623
+
624
+ # --- Engine bootstrap ---
625
+
626
+ # Determine user's real invocation directory, not venv/bin
627
+ user_cwd = Path(os.getenv("PWD", Path.cwd()))
628
+ data_dir = user_cwd / "data"
629
+
630
+ cfg = os.environ.get("TD_DB_CONFIG") or str(data_dir / "db_config.ini")
631
+
632
+ self.engine = make_engine_from_config(cfg)
633
+ self.Session = get_session_factory(self.engine)
634
+
635
+
636
+ # --- Initial load ---
637
+ if load:
638
+ self.reloadCache()
639
+ self.load()
640
+
641
+ # ------------------------------------------------------------------
642
+ # Legacy compatibility dataPath shim
643
+ # ------------------------------------------------------------------
644
+ @property
645
+ def dataDir(self) -> Path:
646
+ """
647
+ Legacy alias for self.dataPath (removed in SQLAlchemy refactor).
648
+ Falls back to './data' if configuration not yet loaded.
649
+ """
650
+ # Try the modern attribute first
651
+ if hasattr(self, "dataPath") and self.dataPath:
652
+ return self.dataPath
653
+ # If we have an environment object, use its dataDir
654
+ if hasattr(self, "tdenv") and getattr(self.tdenv, "dataDir", None):
655
+ return self.tdenv.dataDir
656
+ # Final fallback (first run, pre-bootstrap)
657
+ return Path("./data")
658
+
659
+
660
+ @staticmethod
661
+ def calculateDistance2(lx: float, ly: float, lz: float, rx: float, ry: float, rz: float) -> float:
662
+ """ calculateDistance2 returns the *square* (^2) of the euclidean
663
+ distance between two 3d coordinates. This is an optimization
664
+ for when you need to compare many coordinate pairs but do
665
+ not actually need to retain the distance value.
666
+
667
+ That is:
668
+ distance**2 <=> calculateDistance2(x,y,z, u,v,w)
669
+ always returns the same results as
670
+ distance <=> calculateDistance (x,y,z, u,v,w)
671
+ but is cheaper.
672
+ """
673
+ dx, dy, dz = lx - rx, ly - ry, lz - rz
674
+ return (dx * dx) + (dy * dy) + (dz * dz)
675
+
676
+ @staticmethod
677
+ def calculateDistance(lx: float, ly: float, lz: float, rx: float, ry: float, rz: float) -> float:
678
+ """
679
+ calculateDistance returns the euclidean distance in ly between two points.
680
+ @note When you are testing many pairs without retaining the calculated value
681
+ beyond the comparison, consider using calculateDistance2 instead.
682
+ """
683
+ dx, dy, dz = lx - rx, ly - ry, lz - rz
684
+ return math_sqrt((dx * dx) + (dy * dy) + (dz * dz))
685
+
686
+ @staticmethod
687
+ def _split_system_index(name: str) -> tuple[str, int | None]:
688
+ """
689
+ Split a trailing '@N' suffix from a system name, if present.
690
+
691
+ Examples:
692
+ 'Lorionis-SOC 13@2' -> ('Lorionis-SOC 13', 2)
693
+ 'Shinrarta Dezhra' -> ('Shinrarta Dezhra', None)
694
+ '@SOL' -> ('@SOL', None) # leading @ is a different annotation
695
+
696
+ Returns:
697
+ (base_name, index) where index is 1-based, or None if no valid suffix.
698
+ """
699
+ # Ignore a leading '@' which is used as an explicit system/station annotation
700
+ at = name.rfind('@')
701
+ if at <= 0:
702
+ return name, None
703
+
704
+ idx_str = name[at + 1:]
705
+ if not idx_str or not idx_str.isdigit():
706
+ return name, None
707
+
708
+ base = name[:at]
709
+ return base, int(idx_str)
710
+
711
+ ############################################################
712
+ # Access to the underlying database.
713
+
714
+ def getDB(self):
715
+ """
716
+ Return a new SQLAlchemy Session bound to this TradeDB engine.
717
+ """
718
+ if not self.engine:
719
+ raise TradeException("Database engine not initialised")
720
+ return self.Session()
721
+
722
+ def query(self, sql: str, *params):
723
+ """
724
+ Execute a SQL statement via the SQLAlchemy engine and return the result cursor.
725
+ """
726
+ with self.engine.connect() as conn:
727
+ return conn.execute(text(sql), params)
728
+
729
+ def queryColumn(self, sql: str, *params):
730
+ """
731
+ Execute a SQL statement and return the first column of the first row.
732
+ """
733
+ result = self.query(sql, *params).first()
734
+ return result[0] if result else None
735
+
736
+
737
+ def reloadCache(self) -> None:
738
+ """
739
+ Ensure DB is present and minimally populated using the central policy.
740
+
741
+ Delegates sanity checks to lifecycle.ensure_fresh_db (seconds-only checks):
742
+ - core tables exist (System, Station, Category, Item, StationItem)
743
+ - each has a primary key
744
+ - seed rows exist (Category > 0, System > 0)
745
+ - cheap connectivity probe
746
+
747
+ If checks fail (or lifecycle decides to force), it will call buildCache(self, self.tdenv)
748
+ to reset/populate via the authoritative path. Otherwise it is a no-op.
749
+ self.tdenv.DEBUG0("reloadCache: engine URL = {}", str(self.engine.url))
750
+ """
751
+
752
+ try:
753
+ summary = ensure_fresh_db(
754
+ backend=self.engine.dialect.name,
755
+ engine=self.engine,
756
+ data_dir=self.dataPath,
757
+ metadata=None,
758
+ mode="auto",
759
+ tdb=self,
760
+ tdenv=self.tdenv,
761
+ )
762
+ action = summary.get("action", "kept")
763
+ reason = summary.get("reason")
764
+ if reason:
765
+ self.tdenv.DEBUG0("reloadCache: ensure_fresh_db → {} (reason: {})", action, reason)
766
+ else:
767
+ self.tdenv.DEBUG0("reloadCache: ensure_fresh_db → {}", action)
768
+ except Exception as e:
769
+ self.tdenv.WARN("reloadCache: ensure_fresh_db failed: {}", e)
770
+ self.tdenv.DEBUG0("reloadCache: Falling back to buildCache()")
771
+ cache.buildCache(self, self.tdenv)
772
+
773
+ ############################################################
774
+ # [deprecated] "added" data.
775
+
776
+ def lookupAdded(self, name):
777
+ stmt = select(SA_Added.added_id).where(SA_Added.name == name)
778
+ with self.Session() as session:
779
+ try:
780
+ return session.execute(stmt).scalar_one()
781
+ except NoResultFound:
782
+ raise KeyError(name) from None
783
+
784
+ ############################################################
785
+ # Star system data.
786
+
787
+ # TODO: Defer to SA_System as much as possible
788
+ def systems(self) -> Generator['System', Any, None]:
789
+ """ Iterate through the list of systems. """
790
+ yield from self.systemByID.values()
791
+
792
+ def _loadSystems(self) -> None:
793
+ """
794
+ Initial load of the list of systems via SQLAlchemy.
795
+ CAUTION: Will orphan previously loaded objects.
796
+ """
797
+ systemByID: dict[int, System] = {}
798
+ systemByName: dict[str, list[System]] = {}
799
+ started = time.time()
800
+ with self.Session() as session:
801
+ for row in session.query(
802
+ SA_System.system_id,
803
+ SA_System.name,
804
+ SA_System.pos_x,
805
+ SA_System.pos_y,
806
+ SA_System.pos_z,
807
+ SA_System.added_id,
808
+ ):
809
+ system = System(
810
+ row.system_id,
811
+ row.name,
812
+ row.pos_x,
813
+ row.pos_y,
814
+ row.pos_z,
815
+ row.added_id,
816
+ )
817
+ systemByID[row.system_id] = system
818
+ key = system.dbname.upper()
819
+ bucket = systemByName.get(key)
820
+ if bucket is None:
821
+ systemByName[key] = [system]
822
+ else:
823
+ bucket.append(system)
824
+
825
+ # Ensure deterministic ordering for duplicate-name groups:
826
+ # sort by posX, then posY, posZ, ID so @1 is lowest X, stable.
827
+ for systems in systemByName.values():
828
+ systems.sort(key=lambda s: (s.posX, s.posY, s.posZ, s.ID))
829
+
830
+ self.systemByID = systemByID
831
+ self.systemByName = systemByName
832
+ self.tdenv.DEBUG1(
833
+ "Loaded {:n} Systems in {:.3f}s",
834
+ len(systemByID),
835
+ time.time() - started,
836
+ )
837
+
838
+
839
+ def lookupSystem(self, name):
840
+ """
841
+ Lookup a system by name or return a System object unchanged.
842
+ Accepts:
843
+ - System instance → returned directly
844
+ - Station instance → return station.system
845
+ - str → resolve by name, with @N disambiguation
846
+ """
847
+
848
+ # NEW: accept already-resolved objects
849
+ if isinstance(name, System):
850
+ return name
851
+ if isinstance(name, Station):
852
+ return name.system
853
+
854
+ if not isinstance(name, str):
855
+ raise TypeError(
856
+ f"lookupSystem expects str/System/Station, got {type(name)!r}"
857
+ )
858
+
859
+ # From here on, name is guaranteed a string.
860
+ base_name, index = self._split_system_index(name)
861
+ base_key = base_name.upper()
862
+
863
+ try:
864
+ systems_list = self.systemByName[base_key]
865
+ except KeyError:
866
+ # Fall back to original partial-match behaviour.
867
+ return TradeDB.listSearch(
868
+ "System",
869
+ name,
870
+ self.systems(),
871
+ key=lambda system: system.dbname,
872
+ )
873
+
874
+ # No explicit index
875
+ if index is None:
876
+ if len(systems_list) == 1:
877
+ return systems_list[0]
878
+ if len(systems_list) > 1:
879
+ anyMatch = [
880
+ (i + 1, system)
881
+ for i, system in enumerate(systems_list)
882
+ ]
883
+ raise AmbiguityError(
884
+ "System",
885
+ base_name,
886
+ anyMatch,
887
+ key=lambda entry: (
888
+ f"{entry[1].dbname}@{entry[0]} — "
889
+ f"({entry[1].posX:.1f}, {entry[1].posY:.1f}, {entry[1].posZ:.1f})"
890
+ ),
891
+ )
892
+ raise LookupError(f'Error: "{name}" doesn\'t match any known System')
893
+
894
+ # Explicit @N index
895
+ if 1 <= index <= len(systems_list):
896
+ return systems_list[index - 1]
897
+
898
+ # Out-of-range index
899
+ count = len(systems_list)
900
+ header = f'System "{base_name}" has {count} matching entries (@1..@{count}).'
901
+ invalid_line = f'"{base_name}@{index}" is not a valid index.'
902
+ lines = [header, invalid_line, "", "Use one of the available forms:", ""]
903
+ for idx, system in enumerate(systems_list, start=1):
904
+ lines.append(
905
+ f" {system.dbname}@{idx} — "
906
+ f"({system.posX:.1f}, {system.posY:.1f}, {system.posZ:.1f})"
907
+ )
908
+ message = "\n".join(lines)
909
+ raise TradeException(message)
910
+
911
+
912
+ def addLocalSystem(
913
+ self,
914
+ name,
915
+ x, y, z,
916
+ modified='now',
917
+ commit=True,
918
+ ) -> System:
919
+ """
920
+ Add a system to the local cache and memory copy using SQLAlchemy.
921
+ Note: 'added' field has been deprecated and is no longer populated.
922
+ """
923
+ with self.Session() as session:
924
+ # Create ORM System row (added_id is deprecated → NULL)
925
+ orm_system = SA_System(
926
+ name=name,
927
+ pos_x=x,
928
+ pos_y=y,
929
+ pos_z=z,
930
+ added_id=None,
931
+ modified=None if modified == 'now' else modified,
932
+ )
933
+ session.add(orm_system)
934
+ if commit:
935
+ session.commit()
936
+ else:
937
+ session.flush()
938
+
939
+ ID = orm_system.system_id
940
+
941
+ # Maintain legacy wrapper + caches (added_id always None now)
942
+ system = System(ID, name.upper(), x, y, z, None)
943
+ self.systemByID[ID] = system
944
+
945
+ key = system.dbname.upper()
946
+ bucket = self.systemByName.get(key)
947
+ if bucket is None:
948
+ self.systemByName[key] = [system]
949
+ else:
950
+ bucket.append(system)
951
+ bucket.sort(key=lambda s: (s.posX, s.posY, s.posZ, s.ID))
952
+
953
+ self.tdenv.NOTE(
954
+ "Added new system #{}: {} [{},{},{}]",
955
+ ID, name, x, y, z
956
+ )
957
+
958
+ return system
959
+
960
+
961
+ def updateLocalSystem(
962
+ self, system,
963
+ name, x, y, z, added="Local", modified='now',
964
+ force=False,
965
+ commit=True,
966
+ ) -> bool:
967
+ """
968
+ Update an entry for a local system using SQLAlchemy.
969
+ """
970
+ oldname = system.dbname
971
+ dbname = name.upper()
972
+
973
+ if not force:
974
+ if oldname == dbname and system.posX == x and system.posY == y and system.posZ == z:
975
+ return False
976
+
977
+ # Remove from old name bucket (if present)
978
+ old_key = oldname.upper()
979
+ bucket = self.systemByName.get(old_key)
980
+ if bucket is not None:
981
+ bucket = [s for s in bucket if s is not system]
982
+ if bucket:
983
+ self.systemByName[old_key] = bucket
984
+ else:
985
+ del self.systemByName[old_key]
986
+
987
+ with self.Session() as session:
988
+ # Find Added row for added_id
989
+ added_row = session.query(SA_Added).filter(SA_Added.name == added).first()
990
+ if not added_row:
991
+ raise TradeException(f"Added entry not found: {added}")
992
+
993
+ # Load ORM System row
994
+ orm_system = session.get(SA_System, system.ID)
995
+ if not orm_system:
996
+ raise TradeException(f"System ID not found: {system.ID}")
997
+
998
+ # Apply updates
999
+ orm_system.name = dbname
1000
+ orm_system.pos_x = x
1001
+ orm_system.pos_y = y
1002
+ orm_system.pos_z = z
1003
+ orm_system.added_id = added_row.added_id
1004
+ orm_system.modified = None if modified == 'now' else modified
1005
+
1006
+ if commit:
1007
+ session.commit()
1008
+ else:
1009
+ session.flush()
1010
+
1011
+ self.tdenv.NOTE(
1012
+ "{} (#{}) updated in {}: {}, {}, {}, {}, {}, {}",
1013
+ oldname, system.ID,
1014
+ self.dbPath if self.tdenv.detail > 1 else "local db",
1015
+ dbname, x, y, z, added, modified,
1016
+ )
1017
+
1018
+ # Update wrapper caches
1019
+ system.dbname = dbname
1020
+ system.posX, system.posY, system.posZ = x, y, z
1021
+ system.addedID = added_row.added_id
1022
+
1023
+ # Add to new name bucket
1024
+ new_key = dbname.upper()
1025
+ bucket = self.systemByName.get(new_key)
1026
+ if bucket is None:
1027
+ self.systemByName[new_key] = [system]
1028
+ else:
1029
+ bucket.append(system)
1030
+ bucket.sort(key=lambda s: (s.posX, s.posY, s.posZ, s.ID))
1031
+
1032
+ return True
1033
+
1034
+
1035
+ def removeLocalSystem(
1036
+ self, system,
1037
+ commit=True,
1038
+ ):
1039
+ """Remove a system and its stations from the local DB using SQLAlchemy."""
1040
+ # First remove stations attached to this system
1041
+ for stn in self.stations():
1042
+ if stn.system == system:
1043
+ self.removeLocalStation(stn, commit=False)
1044
+
1045
+ with self.Session() as session:
1046
+ orm_system = session.get(SA_System, system.ID)
1047
+ if orm_system:
1048
+ session.delete(orm_system)
1049
+ if commit:
1050
+ session.commit()
1051
+ else:
1052
+ session.flush()
1053
+
1054
+ # Update caches: remove from name bucket and ID map
1055
+ key = system.dbname.upper()
1056
+ bucket = self.systemByName.get(key)
1057
+ if bucket is not None:
1058
+ bucket = [s for s in bucket if s is not system]
1059
+ if bucket:
1060
+ self.systemByName[key] = bucket
1061
+ else:
1062
+ del self.systemByName[key]
1063
+ del self.systemByID[system.ID]
1064
+
1065
+ self.tdenv.NOTE(
1066
+ "{} (#{}) deleted from {}",
1067
+ system.dbname, system.ID,
1068
+ self.dbPath if self.tdenv.detail > 1 else "local db",
1069
+ )
1070
+
1071
+ system.dbname = "DELETED " + system.dbname
1072
+ del system
1073
+
1074
+
1075
+ def __buildStellarGrid(self) -> None:
1076
+ """
1077
+ Divides the galaxy into a fixed-sized grid allowing us to
1078
+ aggregate small numbers of stars by locality.
1079
+ """
1080
+ stellarGrid: dict[tuple[int, int, int], list[System]] = {}
1081
+ if not self.systemByID:
1082
+ raise RuntimeError("Stellar grid building requires systems to be pre-loaded")
1083
+ for system in self.systemByID.values():
1084
+ key = make_stellar_grid_key(system.posX, system.posY, system.posZ)
1085
+ try:
1086
+ stellarGrid[key].append(system)
1087
+ except KeyError:
1088
+ stellarGrid[key] = [system]
1089
+ self.stellarGrid = stellarGrid
1090
+
1091
+ def genStellarGrid(self, system: 'System', ly: float):
1092
+ """
1093
+ Yields Systems within a given radius of a specified System.
1094
+
1095
+ Args:
1096
+ system:
1097
+ The System to center the search on,
1098
+ ly:
1099
+ The radius of the search around system,
1100
+
1101
+ Yields:
1102
+ (candidate, distLySq)
1103
+ candidate:
1104
+ System that was found,
1105
+ distLySq:
1106
+ The *SQUARE* of the distance in light-years
1107
+ between system and candidate.
1108
+ """
1109
+ if self.stellarGrid is None:
1110
+ self.__buildStellarGrid()
1111
+
1112
+ sysX, sysY, sysZ = system.posX, system.posY, system.posZ
1113
+ lwrBound = make_stellar_grid_key(sysX - ly, sysY - ly, sysZ - ly)
1114
+ uprBound = make_stellar_grid_key(sysX + ly, sysY + ly, sysZ + ly)
1115
+ lySq = ly * ly # in 64-bit python, ** invokes a function call making it 4x expensive as *.
1116
+ stellarGrid = self.stellarGrid
1117
+ for x in range(lwrBound[0], uprBound[0]+1):
1118
+ for y in range(lwrBound[1], uprBound[1]+1):
1119
+ for z in range(lwrBound[2], uprBound[2]+1):
1120
+ try:
1121
+ grid = stellarGrid[(x, y, z)]
1122
+ except KeyError:
1123
+ continue
1124
+ for candidate in grid:
1125
+ delta = candidate.posX - sysX
1126
+ distSq = delta * delta
1127
+ if distSq > lySq:
1128
+ continue
1129
+ delta = candidate.posY - sysY
1130
+ distSq += delta * delta
1131
+ if distSq > lySq:
1132
+ continue
1133
+ delta = candidate.posZ - sysZ
1134
+ distSq += delta * delta
1135
+ if distSq > lySq:
1136
+ continue
1137
+ if candidate is not system:
1138
+ yield candidate, math_sqrt(distSq)
1139
+
1140
+ def genSystemsInRange(self, system: 'System', ly: float, includeSelf: bool = False)-> Generator[tuple[System, float], Any, None]:
1141
+ """
1142
+ Yields Systems within a given radius of a specified System.
1143
+ Results are sorted by distance and cached for subsequent
1144
+ queries in the same run.
1145
+
1146
+ Args:
1147
+ system:
1148
+ The System to center the search on,
1149
+ ly:
1150
+ The radius of the search around system,
1151
+ includeSelf:
1152
+ Whether to include 'system' in the results or not.
1153
+
1154
+ Yields:
1155
+ (candidate, distLy)
1156
+ candidate:
1157
+ System that was found,
1158
+ distLy:
1159
+ The distance in lightyears between system and candidate.
1160
+ """
1161
+
1162
+ cur_cache = system._rangeCache # pylint: disable=protected-access # noqa: SLF001
1163
+ if not cur_cache:
1164
+ cur_cache = system._rangeCache = System.RangeCache() # pylint: disable=protected-access # noqa: SLF001
1165
+ cached_systems = cur_cache.systems
1166
+
1167
+ if ly > cur_cache.probed_ly:
1168
+ # Consult the database for stars we haven't seen.
1169
+ cached_systems = cur_cache.systems = list(
1170
+ self.genStellarGrid(system, ly)
1171
+ )
1172
+ cached_systems.sort(key=lambda ent: ent[1])
1173
+ cur_cache.probed_ly = ly
1174
+
1175
+ if includeSelf:
1176
+ yield system, 0.
1177
+
1178
+ if cur_cache.probed_ly > ly:
1179
+ # Cache may contain values outside our view
1180
+ for candidate, dist in cached_systems:
1181
+ if dist <= ly:
1182
+ yield candidate, dist
1183
+ else:
1184
+ # No need to be conditional inside the loop
1185
+ yield from cached_systems
1186
+
1187
+ def getRoute(self, origin, dest, maxJumpLy, avoiding=None, stationInterval=0):
1188
+ """
1189
+ Find a shortest route between two systems with an additional
1190
+ constraint that each system be a maximum of maxJumpLy from
1191
+ the previous system.
1192
+
1193
+ Args:
1194
+ origin:
1195
+ System (or station) to start from,
1196
+ dest:
1197
+ System (or station) to terminate at,
1198
+ maxJumpLy:
1199
+ Maximum light years between systems,
1200
+ avoiding:
1201
+ List of systems being avoided
1202
+ stationInterval:
1203
+ If non-zero, require a station at least this many jumps,
1204
+ tdenv.padSize:
1205
+ Controls the pad size of stations for refuelling
1206
+
1207
+ Returns:
1208
+ None
1209
+ No route was found
1210
+
1211
+ [(origin, 0),...(dest, N)]
1212
+ A list of (system, distanceSoFar) values describing
1213
+ the route.
1214
+
1215
+ Example:
1216
+ If there are systems A, B and C such
1217
+ that A->B is 7ly and B->C is 8ly then:
1218
+
1219
+ origin = lookupPlace("A")
1220
+ dest = lookupPlace("C")
1221
+ route = tdb.getRoute(origin, dest, 9)
1222
+
1223
+ The route should be:
1224
+
1225
+ [(System(A), 0), (System(B), 7), System(C), 15)]
1226
+
1227
+ """
1228
+
1229
+ if avoiding is None:
1230
+ avoiding = []
1231
+
1232
+ if isinstance(origin, Station):
1233
+ origin = origin.system
1234
+ if isinstance(dest, Station):
1235
+ dest = dest.system
1236
+
1237
+ if origin == dest:
1238
+ return ((origin, 0), (dest, 0))
1239
+
1240
+ # openSet is the list of nodes we want to visit, which will be
1241
+ # used as a priority queue (heapq).
1242
+ # Each element is a tuple of the 'priority' (the combination of
1243
+ # the total distance to the node and the distance left from the
1244
+ # node to the destination.
1245
+ openSet = [(0, 0, origin.ID, 0)]
1246
+ # Track predecessor nodes for everwhere we visit
1247
+ distances = {origin: (None, 0)}
1248
+
1249
+ if avoiding:
1250
+ if dest in avoiding:
1251
+ raise ValueError("Destination is in avoidance list")
1252
+ for avoid in avoiding:
1253
+ if isinstance(avoid, System):
1254
+ distances[avoid] = (None, -1)
1255
+
1256
+ systemsInRange = self.genSystemsInRange
1257
+ heappop = heapq.heappop
1258
+ heappush = heapq.heappush
1259
+ distTo = float("inf")
1260
+ defaultDist = (None, distTo)
1261
+ getDist = distances.get
1262
+
1263
+ destID = dest.ID
1264
+ sysByID = self.systemByID
1265
+
1266
+ maxPadSize = self.tdenv.padSize
1267
+ if not maxPadSize:
1268
+ def checkStations(system: System) -> bool: # pylint: disable=function-redefined, missing-docstring
1269
+ return bool(system.stations())
1270
+ else:
1271
+ def checkStations(system: System) -> bool: # pylint: disable=function-redefined, missing-docstring
1272
+ return any(stn for stn in system.stations if stn.checkPadSize(maxPadSize))
1273
+
1274
+ while openSet:
1275
+ weight, curDist, curSysID, stnDist = heappop(openSet)
1276
+ # If we reached 'goal' we've found the shortest path.
1277
+ if curSysID == destID:
1278
+ break
1279
+ if curDist >= distTo:
1280
+ continue
1281
+ curSys = sysByID[curSysID]
1282
+ # A node might wind up multiple times on the open list,
1283
+ # so check if we've already found a shorter distance to
1284
+ # the system and if so, ignore it this time.
1285
+ if curDist > distances[curSys][1]:
1286
+ continue
1287
+
1288
+ system_iter = iter(systemsInRange(curSys, maxJumpLy))
1289
+ if stationInterval:
1290
+ if checkStations(curSys):
1291
+ stnDist = 0
1292
+ else:
1293
+ stnDist += 1
1294
+ if stnDist >= stationInterval:
1295
+ system_iter = iter(
1296
+ v for v in system_iter if checkStations(v[0])
1297
+ )
1298
+
1299
+ distFn = curSys.distanceTo
1300
+ for nSys, nDist in system_iter:
1301
+ newDist = curDist + nDist
1302
+ if getDist(nSys, defaultDist)[1] <= newDist:
1303
+ continue
1304
+ distances[nSys] = (curSys, newDist)
1305
+ weight = distFn(nSys)
1306
+ nID = nSys.ID
1307
+ heappush(openSet, (newDist + weight, newDist, nID, stnDist))
1308
+ if nID == destID:
1309
+ distTo = newDist
1310
+
1311
+ if dest not in distances:
1312
+ return None
1313
+
1314
+ path = []
1315
+
1316
+ while True:
1317
+ (prevSys, dist) = getDist(dest)
1318
+ path.append((dest, dist))
1319
+ if dest == origin:
1320
+ break
1321
+ dest = prevSys
1322
+
1323
+ path.reverse()
1324
+
1325
+ return path
1326
+
1327
+ ############################################################
1328
+ # Station data.
1329
+
1330
+ def stations(self) -> 'Generator[Station, None, None]':
1331
+ """ Iterate through the list of stations. """
1332
+ yield from self.stationByID.values()
1333
+
1334
+ def _loadStations(self):
1335
+ """
1336
+ Populate the Station list using SQLAlchemy.
1337
+ Station constructor automatically adds itself to the System object.
1338
+ CAUTION: Will orphan previously loaded objects.
1339
+ """
1340
+ # NOTE: Requires module-level import:
1341
+ # from tradedangerous.db.utils import age_in_days
1342
+ stationByID = {}
1343
+ systemByID = self.systemByID
1344
+ self.tradingStationCount = 0
1345
+
1346
+ # Fleet Carriers are station type 24.
1347
+ # Odyssey settlements are station type 25.
1348
+ # Assume type 0 (Unknown) are also Fleet Carriers.
1349
+ carrier_types = (24, 0)
1350
+ odyssey_type = 25
1351
+ cached_system = None
1352
+ cached_system_id = None
1353
+
1354
+ started = time.time()
1355
+ with self.Session() as session:
1356
+ # Query all stations
1357
+ rows = session.query(
1358
+ SA_Station.station_id,
1359
+ SA_Station.system_id,
1360
+ SA_Station.name,
1361
+ SA_Station.ls_from_star,
1362
+ SA_Station.market,
1363
+ SA_Station.blackmarket,
1364
+ SA_Station.shipyard,
1365
+ SA_Station.max_pad_size,
1366
+ SA_Station.outfitting,
1367
+ SA_Station.rearm,
1368
+ SA_Station.refuel,
1369
+ SA_Station.repair,
1370
+ SA_Station.planetary,
1371
+ SA_Station.type_id,
1372
+ )
1373
+
1374
+ for (
1375
+ ID, systemID, name,
1376
+ lsFromStar, market, blackMarket, shipyard,
1377
+ maxPadSize, outfitting, rearm, refuel, repair, planetary, type_id
1378
+ ) in rows:
1379
+ isFleet = 'Y' if type_id in carrier_types else 'N'
1380
+ isOdyssey = 'Y' if type_id == odyssey_type else 'N'
1381
+ if systemID != cached_system_id:
1382
+ cached_system_id = systemID
1383
+ cached_system = systemByID[cached_system_id]
1384
+ stationByID[ID] = Station(
1385
+ ID, cached_system, name,
1386
+ lsFromStar, market, blackMarket, shipyard,
1387
+ maxPadSize, outfitting, rearm, refuel, repair,
1388
+ planetary, isFleet, isOdyssey,
1389
+ 0, None,
1390
+ )
1391
+
1392
+ # Trading station info
1393
+ tradingCount = 0
1394
+ rows = (
1395
+ session.query(
1396
+ SA_StationItem.station_id,
1397
+ func.count().label("item_count"),
1398
+ # Dialect-safe average age in **days**
1399
+ func.avg(age_in_days(session, SA_StationItem.modified)).label("data_age_days"),
1400
+ )
1401
+ .group_by(SA_StationItem.station_id)
1402
+ .having(func.count() > 0)
1403
+ )
1404
+
1405
+ for ID, itemCount, dataAge in rows:
1406
+ station = stationByID[ID]
1407
+ station.itemCount = itemCount
1408
+ station.dataAge = dataAge
1409
+ tradingCount += 1
1410
+
1411
+ self.stationByID = stationByID
1412
+ self.tradingStationCount = tradingCount
1413
+ self.tdenv.DEBUG1("Loaded {:n} Stations in {:.3f}s", len(stationByID), (time.time() - started) * 1000)
1414
+ self.stellarGrid = None
1415
+
1416
+ def addLocalStation(
1417
+ self,
1418
+ system,
1419
+ name,
1420
+ lsFromStar,
1421
+ market,
1422
+ blackMarket,
1423
+ shipyard,
1424
+ maxPadSize,
1425
+ outfitting,
1426
+ rearm,
1427
+ refuel,
1428
+ repair,
1429
+ planetary,
1430
+ fleet,
1431
+ odyssey,
1432
+ modified='now',
1433
+ commit=True,
1434
+ ):
1435
+ """
1436
+ Add a station to the local cache and memory copy using SQLAlchemy.
1437
+ """
1438
+ # Normalise/validate inputs
1439
+ market = market.upper()
1440
+ blackMarket = blackMarket.upper()
1441
+ shipyard = shipyard.upper()
1442
+ maxPadSize = maxPadSize.upper()
1443
+ outfitting = outfitting.upper()
1444
+ rearm = rearm.upper()
1445
+ refuel = refuel.upper()
1446
+ repair = repair.upper()
1447
+ planetary = planetary.upper()
1448
+ assert market in "?YN"
1449
+ assert blackMarket in "?YN"
1450
+ assert shipyard in "?YN"
1451
+ assert maxPadSize in "?SML"
1452
+ assert outfitting in "?YN"
1453
+ assert rearm in "?YN"
1454
+ assert refuel in "?YN"
1455
+ assert repair in "?YN"
1456
+ assert planetary in "?YN"
1457
+ assert fleet in "?YN"
1458
+ assert odyssey in "?YN"
1459
+
1460
+ # Type mapping
1461
+ type_id = 0
1462
+ if fleet == 'Y':
1463
+ type_id = 24
1464
+ if odyssey == 'Y':
1465
+ type_id = 25
1466
+
1467
+ with self.Session() as session:
1468
+ orm_station = SA_Station(
1469
+ name=name,
1470
+ system_id=system.ID,
1471
+ ls_from_star=lsFromStar,
1472
+ market=market,
1473
+ blackmarket=blackMarket,
1474
+ shipyard=shipyard,
1475
+ max_pad_size=maxPadSize,
1476
+ outfitting=outfitting,
1477
+ rearm=rearm,
1478
+ refuel=refuel,
1479
+ repair=repair,
1480
+ planetary=planetary,
1481
+ type_id=type_id,
1482
+ modified=None if modified == 'now' else modified,
1483
+ )
1484
+ session.add(orm_station)
1485
+ if commit:
1486
+ session.commit()
1487
+ else:
1488
+ session.flush()
1489
+ ID = orm_station.station_id
1490
+
1491
+ # Legacy wrapper object
1492
+ station = Station(
1493
+ ID, system, name,
1494
+ lsFromStar=lsFromStar,
1495
+ market=market,
1496
+ blackMarket=blackMarket,
1497
+ shipyard=shipyard,
1498
+ maxPadSize=maxPadSize,
1499
+ outfitting=outfitting,
1500
+ rearm=rearm,
1501
+ refuel=refuel,
1502
+ repair=repair,
1503
+ planetary=planetary,
1504
+ fleet=fleet,
1505
+ odyssey=odyssey,
1506
+ itemCount=0,
1507
+ dataAge=0,
1508
+ )
1509
+ self.stationByID[ID] = station
1510
+
1511
+ self.tdenv.NOTE(
1512
+ "{} (#{}) added to {}: "
1513
+ "ls={}, mkt={}, bm={}, yard={}, pad={}, "
1514
+ "out={}, arm={}, ref={}, rep={}, plt={}, "
1515
+ "mod={}",
1516
+ station.name(), station.ID,
1517
+ self.dbPath if self.tdenv.detail > 1 else "local db",
1518
+ lsFromStar, market, blackMarket, shipyard, maxPadSize,
1519
+ outfitting, rearm, refuel, repair, planetary,
1520
+ modified,
1521
+ )
1522
+ return station
1523
+
1524
+ def updateLocalStation(
1525
+ self, station,
1526
+ name=None,
1527
+ lsFromStar=None,
1528
+ market=None,
1529
+ blackMarket=None,
1530
+ shipyard=None,
1531
+ maxPadSize=None,
1532
+ outfitting=None,
1533
+ rearm=None,
1534
+ refuel=None,
1535
+ repair=None,
1536
+ planetary=None,
1537
+ fleet=None,
1538
+ odyssey=None,
1539
+ modified='now',
1540
+ force=False,
1541
+ commit=True,
1542
+ ):
1543
+ """
1544
+ Alter the properties of a station in-memory and in the DB using SQLAlchemy.
1545
+ """
1546
+ changes = []
1547
+
1548
+ def _changed(label, old, new):
1549
+ changes.append(f"{label}('{old}'=>'{new}')")
1550
+
1551
+ # Mutate wrapper + record changes
1552
+ if name is not None:
1553
+ if force or name.upper() != station.dbname.upper():
1554
+ _changed("name", station.dbname, name)
1555
+ station.dbname = name
1556
+
1557
+ if lsFromStar is not None:
1558
+ assert lsFromStar >= 0
1559
+ if lsFromStar != station.lsFromStar:
1560
+ if lsFromStar > 0 or force:
1561
+ _changed("ls", station.lsFromStar, lsFromStar)
1562
+ station.lsFromStar = lsFromStar
1563
+
1564
+ def _check_setting(label, attr_name, newValue, allowed):
1565
+ if newValue is not None:
1566
+ newValue = newValue.upper()
1567
+ assert newValue in allowed
1568
+ oldValue = getattr(station, attr_name, '?')
1569
+ if newValue != oldValue and (force or newValue != '?'):
1570
+ _changed(label, oldValue, newValue)
1571
+ setattr(station, attr_name, newValue)
1572
+
1573
+ _check_setting("pad", "maxPadSize", maxPadSize, TradeDB.padSizes)
1574
+ _check_setting("mkt", "market", market, TradeDB.marketStates)
1575
+ _check_setting("blk", "blackMarket", blackMarket, TradeDB.marketStates)
1576
+ _check_setting("shp", "shipyard", shipyard, TradeDB.marketStates)
1577
+ _check_setting("out", "outfitting", outfitting, TradeDB.marketStates)
1578
+ _check_setting("arm", "rearm", rearm, TradeDB.marketStates)
1579
+ _check_setting("ref", "refuel", refuel, TradeDB.marketStates)
1580
+ _check_setting("rep", "repair", repair, TradeDB.marketStates)
1581
+ _check_setting("plt", "planetary", planetary, TradeDB.planetStates)
1582
+ _check_setting("flc", "fleet", fleet, TradeDB.fleetStates)
1583
+ _check_setting("ody", "odyssey", odyssey, TradeDB.odysseyStates)
1584
+
1585
+ if not changes:
1586
+ return False
1587
+
1588
+ with self.Session() as session:
1589
+ orm_station = session.get(SA_Station, station.ID)
1590
+ if not orm_station:
1591
+ raise TradeException(f"Station ID not found: {station.ID}")
1592
+
1593
+ orm_station.name = station.dbname
1594
+ orm_station.system_id = station.system.ID
1595
+ orm_station.ls_from_star = station.lsFromStar
1596
+ orm_station.market = station.market
1597
+ orm_station.blackmarket = station.blackMarket
1598
+ orm_station.shipyard = station.shipyard
1599
+ orm_station.max_pad_size = station.maxPadSize
1600
+ orm_station.outfitting = station.outfitting
1601
+ orm_station.rearm = station.rearm
1602
+ orm_station.refuel = station.refuel
1603
+ orm_station.repair = station.repair
1604
+ orm_station.planetary = station.planetary
1605
+ orm_station.type_id = (
1606
+ 24 if station.fleet == 'Y' else
1607
+ 25 if station.odyssey == 'Y' else 0
1608
+ )
1609
+ orm_station.modified = None if modified == 'now' else modified
1610
+
1611
+ if commit:
1612
+ session.commit()
1613
+ else:
1614
+ session.flush()
1615
+
1616
+ self.tdenv.NOTE(
1617
+ "{} (#{}) updated in {}: {}",
1618
+ station.name(), station.ID,
1619
+ self.dbPath if self.tdenv.detail > 1 else "local db",
1620
+ ", ".join(changes)
1621
+ )
1622
+
1623
+ return True
1624
+
1625
+ def removeLocalStation(self, station, commit=True):
1626
+ """
1627
+ Remove a station from the local database and memory image using SQLAlchemy.
1628
+ Be careful of any references to the station you may still have after this.
1629
+ """
1630
+ # Remove reference from parent system (wrapper-level)
1631
+ system = station.system
1632
+ if station in system.stations:
1633
+ system.stations.remove(station)
1634
+
1635
+ # Remove from ID lookup cache
1636
+ if station.ID in self.stationByID:
1637
+ del self.stationByID[station.ID]
1638
+
1639
+ # Delete from DB
1640
+ with self.Session() as session:
1641
+ orm_station = session.get(SA_Station, station.ID)
1642
+ if orm_station:
1643
+ session.delete(orm_station)
1644
+ if commit:
1645
+ session.commit()
1646
+ else:
1647
+ session.flush()
1648
+
1649
+ self.tdenv.NOTE(
1650
+ "{} (#{}) deleted from {}",
1651
+ station.name(), station.ID,
1652
+ self.dbPath if self.tdenv.detail > 1 else "local db",
1653
+ )
1654
+
1655
+ station.dbname = "DELETED " + station.dbname
1656
+ del station
1657
+
1658
+ def lookupPlace(self, name):
1659
+ """
1660
+ Lookup the station/system specified by 'name' which can be the
1661
+ name of a System or Station or it can be "System/Station" when
1662
+ the user needs to disambiguate a station. In this case, both
1663
+ system and station can be partial matches.
1664
+
1665
+ The system tries to allow partial matches as well as matches
1666
+ which omit whitespaces. In order to do this and still support
1667
+ the massive namespace of Stars and Systems, we rank the
1668
+ matches so that exact matches win, and only inferior close
1669
+ matches are looked at if no exacts are found.
1670
+
1671
+ Legal annotations:
1672
+ system
1673
+ station
1674
+ @system [explicitly a system name]
1675
+ /station [explicitly a station name]
1676
+ system/station
1677
+ @system/station
1678
+ """
1679
+ # Pass-through for already-resolved objects
1680
+ if isinstance(name, (System, Station)):
1681
+ return name
1682
+
1683
+ if not isinstance(name, str):
1684
+ raise TypeError(
1685
+ f"lookupPlace expects str/System/Station, got {type(name)!r}"
1686
+ )
1687
+
1688
+ # ------------------------------------------------------------------
1689
+ # Fast path: queries that look like "just a system name"
1690
+ #
1691
+ # This path is where the new name-collision behaviour lives so that
1692
+ # lookupPlace honours:
1693
+ # - multiple systems with the same name, and
1694
+ # - the "@N" index notation (e.g. "Lorionis-SOC 13@2").
1695
+ #
1696
+ # We exclude:
1697
+ # - leading "/" (explicit station)
1698
+ # - any "/" or "\\" inside the string (system/station combos)
1699
+ # ------------------------------------------------------------------
1700
+ if not name.startswith("/") and "/" not in name and "\\" not in name:
1701
+ sys_key = name[1:] if name.startswith("@") else name
1702
+ # lookupSystem can throw various TradeExceptions, which we will forward
1703
+ # or it can throw a LookupError which we want to discard, it just means
1704
+ # that this lookup isn't ready yet.
1705
+ try:
1706
+ return self.lookupSystem(sys_key)
1707
+ except LookupError:
1708
+ # Not a system (or no reasonable system match) – fall back to
1709
+ # the generic place logic below to search stations as well.
1710
+ pass
1711
+
1712
+ # ------------------------------------------------------------------
1713
+ # Legacy combined system/station matching
1714
+ # ------------------------------------------------------------------
1715
+
1716
+ # Determine whether the user specified a system, a station, or both.
1717
+ slash_pos = name.find("/")
1718
+ if slash_pos < 0:
1719
+ slash_pos = name.find("\\") # support old "sys\stn" syntax too
1720
+
1721
+ # Leading '@' indicates "this is a system name"
1722
+ name_off = 1 if name.startswith("@") else 0
1723
+
1724
+ if slash_pos > name_off:
1725
+ # "sys/station" or "@sys/station"
1726
+ sys_name = name[name_off:slash_pos].upper()
1727
+ stn_name = name[slash_pos + 1:]
1728
+ elif slash_pos == name_off:
1729
+ # "/station" — explicit station, no system
1730
+ sys_name, stn_name = None, name[name_off + 1:]
1731
+ elif name_off:
1732
+ # "@system" — explicit system, no station
1733
+ sys_name, stn_name = name[name_off:].upper(), None
1734
+ else:
1735
+ # Bare name: treat as both potential system and station.
1736
+ stn_name = name
1737
+ sys_name = stn_name.upper()
1738
+
1739
+ exact_match = []
1740
+ close_match = []
1741
+ word_match = []
1742
+ any_match = []
1743
+
1744
+ def _lookup(token, candidates):
1745
+ """Populate the match lists for the given search token."""
1746
+ norm_trans = TradeDB.normalizeTrans
1747
+ trim_trans = TradeDB.trimTrans
1748
+
1749
+ token_norm = token.translate(norm_trans)
1750
+ token_trim = token_norm.translate(trim_trans)
1751
+
1752
+ token_len = len(token)
1753
+ token_norm_len = len(token_norm)
1754
+ token_trim_len = len(token_trim)
1755
+
1756
+ for place in candidates:
1757
+ place_name = place.dbname
1758
+ place_norm = place_name.translate(norm_trans)
1759
+ place_norm_len = len(place_norm)
1760
+
1761
+ # If the trimmed needle is longer than the target, it can't match
1762
+ if token_trim_len > place_norm_len:
1763
+ continue
1764
+
1765
+ # 1) Exact name + normalization match
1766
+ if len(place_name) == token_len and place_norm == token_norm:
1767
+ exact_match.append(place)
1768
+ continue
1769
+
1770
+ # 2) Same normalized length and contents -> "close" match
1771
+ if place_norm_len == token_norm_len and place_norm == token_norm:
1772
+ close_match.append(place)
1773
+ continue
1774
+
1775
+ # 3) Substring of the normalized name, with word-boundary checks
1776
+ if token_norm_len < place_norm_len:
1777
+ pos = place_norm.find(token_norm)
1778
+ if pos == 0:
1779
+ # At the start of the name
1780
+ if place_norm[token_norm_len:token_norm_len + 1] == " ":
1781
+ word_match.append(place)
1782
+ else:
1783
+ any_match.append(place)
1784
+ continue
1785
+
1786
+ if pos > 0:
1787
+ before = place_norm[pos - 1:pos]
1788
+ after = place_norm[pos + token_norm_len:pos + token_norm_len + 1]
1789
+ if before == " " and after == " ":
1790
+ word_match.append(place)
1791
+ else:
1792
+ any_match.append(place)
1793
+ continue
1794
+
1795
+ # 4) Compare with whitespace and punctuation stripped
1796
+ place_trim = place_norm.translate(trim_trans)
1797
+ place_trim_len = len(place_trim)
1798
+ if place_trim_len == place_norm_len:
1799
+ # Normalization didn't change anything; nothing new to learn
1800
+ continue
1801
+
1802
+ # A fully-trimmed exact match is still "close"
1803
+ if place_trim_len == token_trim_len and place_trim == token_trim:
1804
+ close_match.append(place)
1805
+ continue
1806
+
1807
+ # Otherwise, any occurrence inside the trimmed name is "any"
1808
+ if token_trim and place_trim.find(token_trim) >= 0:
1809
+ any_match.append(place)
1810
+
1811
+ # First, resolve the system side if we have one.
1812
+ if sys_name:
1813
+ systems_bucket = self.systemByName.get(sys_name)
1814
+ if systems_bucket:
1815
+ # In older caches, systemByName held a single System; in the
1816
+ # new collision-aware form it holds a list[System].
1817
+ if isinstance(systems_bucket, System):
1818
+ exact_match.append(systems_bucket)
1819
+ else:
1820
+ # Assume it's an iterable of System instances.
1821
+ exact_match.extend(systems_bucket)
1822
+ else:
1823
+ _lookup(sys_name, self.systemByID.values())
1824
+
1825
+ # Now resolve the station side, if requested.
1826
+ if stn_name:
1827
+ # If both system and station were provided (sys/station form), we
1828
+ # try to narrow the station search to the systems we just matched.
1829
+ if slash_pos > name_off + 1 and (exact_match or close_match or word_match or any_match):
1830
+ station_candidates = []
1831
+ for system in itertools.chain(
1832
+ exact_match, close_match, word_match, any_match
1833
+ ):
1834
+ station_candidates.extend(system.stations)
1835
+
1836
+ # Reset the match tiers; from here on they refer to stations.
1837
+ exact_match = []
1838
+ close_match = []
1839
+ word_match = []
1840
+ any_match = []
1841
+ else:
1842
+ # No usable system context: search all stations.
1843
+ station_candidates = self.stationByID.values()
1844
+
1845
+ _lookup(stn_name, station_candidates)
1846
+
1847
+ # Consult the match tiers in order; any single-element tier is a winner.
1848
+ for tier in (exact_match, close_match, word_match, any_match):
1849
+ if len(tier) == 1:
1850
+ return tier[0]
1851
+
1852
+ # No matches at all
1853
+ if not (exact_match or close_match or word_match or any_match):
1854
+ # NOTE: Historically this was a TradeException; it was changed to
1855
+ # LookupError so callers can distinguish "nothing matched" from
1856
+ # "ambiguous".
1857
+ raise LookupError(f"Unrecognized place: {name}")
1858
+
1859
+ # Multiple matches – ambiguous. For mixed system/station cases we keep
1860
+ # the original "System/Station" label; pure system ambiguities should
1861
+ # already have been caught by lookupSystem above.
1862
+ raise AmbiguityError(
1863
+ "System/Station",
1864
+ name,
1865
+ exact_match + close_match + word_match + any_match,
1866
+ key=lambda place: place.name(),
1867
+ )
1868
+
1869
+ def lookupStation(self, name, system=None):
1870
+ """
1871
+ Look up a Station object by it's name or system.
1872
+ """
1873
+ if isinstance(name, Station):
1874
+ return name
1875
+ if isinstance(name, System):
1876
+ # When given a system with only one station, return the station.
1877
+ if len(name.stations) != 1:
1878
+ raise SystemNotStationError(f"System '{name}' has {len(name.stations)} stations, please specify a station instead.")
1879
+ return name.stations[0]
1880
+
1881
+ if system:
1882
+ system = self.lookupSystem(system)
1883
+ return TradeDB.listSearch(
1884
+ "Station", name, system.stations,
1885
+ key=lambda system: system.dbname)
1886
+
1887
+ station, system = None, None
1888
+ try:
1889
+ system = TradeDB.listSearch(
1890
+ "System", name, self.systemByID.values(),
1891
+ key=lambda system: system.dbname
1892
+ )
1893
+ except LookupError:
1894
+ pass
1895
+ try:
1896
+ station = TradeDB.listSearch(
1897
+ "Station", name, self.stationByID.values(),
1898
+ key=lambda station: station.dbname
1899
+ )
1900
+ except LookupError:
1901
+ pass
1902
+ # If neither matched, we have a lookup error.
1903
+ if not (station or system):
1904
+ raise LookupError(f"'{name}' did not match any station or system.")
1905
+
1906
+ # If we matched both a station and a system, make sure they resovle to
1907
+ # the same station otherwise we have an ambiguity. Some stations have
1908
+ # the same name as their star system (Aulin/Aulin Enterprise)
1909
+ if system and station and system != station.system:
1910
+ raise AmbiguityError(
1911
+ 'Station', name, [system.name(), station.name()]
1912
+ )
1913
+
1914
+ if station:
1915
+ return station
1916
+
1917
+ # If we only matched a system name, ensure that it's a single station
1918
+ # system otherwise they need to specify a station name.
1919
+ if len(system.stations) != 1:
1920
+ raise SystemNotStationError(
1921
+ f"System '{system.name()}' has {len(system.stations)} stations, please specify a station instead."
1922
+ )
1923
+ return system.stations[0]
1924
+
1925
+ def getDestinations(
1926
+ self,
1927
+ origin,
1928
+ maxJumps=None,
1929
+ maxLyPer=None,
1930
+ avoidPlaces=None,
1931
+ maxPadSize=None,
1932
+ maxLsFromStar=0,
1933
+ noPlanet=False,
1934
+ planetary=None,
1935
+ fleet=None,
1936
+ odyssey=None,
1937
+ ):
1938
+ """
1939
+ Gets a list of the Station destinations that can be reached
1940
+ from this Station within the specified constraints.
1941
+ Limits to stations we are trading with if trading is True.
1942
+ """
1943
+
1944
+ if maxJumps is None:
1945
+ maxJumps = sys.maxsize
1946
+ maxLyPer = maxLyPer or self.tdenv.maxSystemLinkLy
1947
+ if avoidPlaces is None:
1948
+ avoidPlaces = ()
1949
+
1950
+ # The open list is the list of nodes we should consider next for
1951
+ # potential destinations.
1952
+ # The path list is a list of the destinations we've found and the
1953
+ # shortest path to them. It doubles as the "closed list".
1954
+ # The closed list is the list of nodes we've already been to (so
1955
+ # that we don't create loops A->B->C->A->B->C->...)
1956
+
1957
+ origSys = origin.system if isinstance(origin, Station) else origin
1958
+ openList = [DestinationNode(origSys, [origSys], 0)]
1959
+ # I don't want to have to consult both the pathList
1960
+ # AND the avoid list every time I'm considering a
1961
+ # station, so copy the avoid list into the pathList
1962
+ # with a negative distance so I can ignore them again
1963
+ # when I scrape the pathList.
1964
+ # Don't copy stations because those only affect our
1965
+ # termination points, and not the systems we can
1966
+ # pass through en-route.
1967
+ pathList = {
1968
+ system.ID: DestinationNode(system, None, -1.0)
1969
+ for system in avoidPlaces
1970
+ if isinstance(system, System)
1971
+ }
1972
+ if origSys.ID not in pathList:
1973
+ pathList[origSys.ID] = openList[0]
1974
+
1975
+ # As long as the open list is not empty, keep iterating.
1976
+ jumps = 0
1977
+ while openList and jumps < maxJumps:
1978
+ # Expand the search domain by one jump; grab the list of
1979
+ # nodes that are this many hops out and then clear the list.
1980
+ ring, openList = openList, []
1981
+ # All of the destinations we are about to consider will
1982
+ # either be on the closed list or they will be +1 jump away.
1983
+ jumps += 1
1984
+
1985
+ ring.sort(key=lambda dn: dn.distLy)
1986
+
1987
+ for node in ring:
1988
+ for (destSys, destDist) in self.genSystemsInRange(
1989
+ node.system, maxLyPer, False
1990
+ ):
1991
+ dist = node.distLy + destDist
1992
+ # If we already have a shorter path, do nothing
1993
+ try:
1994
+ prevDist = pathList[destSys.ID].distLy
1995
+ except KeyError:
1996
+ pass
1997
+ else:
1998
+ if dist >= prevDist:
1999
+ continue
2000
+ # Add to the path list
2001
+ destNode = DestinationNode(
2002
+ destSys, node.via + [destSys], dist
2003
+ )
2004
+ pathList[destSys.ID] = destNode
2005
+ # Add to the open list but also include node to the via
2006
+ # list so that it serves as the via list for all next-hops.
2007
+ openList.append(destNode)
2008
+
2009
+ # We have a system-to-system path list, now we
2010
+ # need stations to terminate at.
2011
+ def path_iter_fn():
2012
+ for node in pathList.values():
2013
+ if node.distLy >= 0.0:
2014
+ for station in node.system.stations:
2015
+ yield node, station
2016
+
2017
+ fleet = fleet or "YN?"
2018
+ maxPadSize = maxPadSize or "SML?"
2019
+ odyssey = odyssey or "YN?"
2020
+ planetary = "N" if noPlanet else (planetary or "YN?")
2021
+
2022
+ path_iter = iter(
2023
+ (node, station) for (node, station) in path_iter_fn()
2024
+ if station.planetary in planetary and
2025
+ station not in avoidPlaces and
2026
+ station.maxPadSize in maxPadSize and
2027
+ station.fleet in fleet and
2028
+ station.odyssey in odyssey and
2029
+ (not maxLsFromStar or 0 < station.lsFromStar <= maxLsFromStar)
2030
+ )
2031
+ yield from (
2032
+ Destination(node.system, stn, node.via, node.distLy)
2033
+ for node, stn in path_iter
2034
+ )
2035
+
2036
+ ############################################################
2037
+ # Ship data.
2038
+
2039
+ @lru_cache
2040
+ def lookupShip(self, name):
2041
+ """ Look up a ship by name. """
2042
+ stmt = select(SA_Ship.ship_id, SA_Ship.name, SA_Ship.cost) \
2043
+ .where(Ship.name == name)
2044
+ with self.Session() as session:
2045
+ try:
2046
+ row = session.execute(stmt).scalar_one()
2047
+ return Ship(row.ship_id, row.name, row.cost, stations=[])
2048
+ except NoResultFound:
2049
+ raise LookupError(f"Error: '{name}' doesn't match any Ship") from None
2050
+
2051
+ ############################################################
2052
+ # Item data.
2053
+
2054
+ # TODO: Defer to SA_Category directly; requires migrating
2055
+ # all item references to the SA_Item table too (since then
2056
+ # the database relationship handles inheritance anyway)
2057
+ def categories(self):
2058
+ """
2059
+ Iterate through the list of categories.
2060
+ key = category name, value = list of items.
2061
+ """
2062
+ yield from self.categoryByID.items()
2063
+
2064
+ def _loadCategories(self):
2065
+ """
2066
+ Populate the list of item categories using SQLAlchemy.
2067
+ CAUTION: Will orphan previously loaded objects.
2068
+ """
2069
+ with self.Session() as session:
2070
+ rows = session.query(
2071
+ SA_Category.category_id,
2072
+ SA_Category.name,
2073
+ )
2074
+ self.categoryByID = {
2075
+ row.category_id: Category(row.category_id, row.name, [])
2076
+ for row in rows
2077
+ }
2078
+
2079
+ self.tdenv.DEBUG1("Loaded {} Categories", len(self.categoryByID))
2080
+
2081
+ def lookupCategory(self, name):
2082
+ """
2083
+ Look up a category by name
2084
+ """
2085
+ return TradeDB.listSearch(
2086
+ "Category", name,
2087
+ self.categoryByID.values(),
2088
+ key=lambda cat: cat.dbname
2089
+ )
2090
+
2091
+ # TODO: Defer to SA_Item directly.
2092
+ def items(self):
2093
+ """ Iterate through the list of items. """
2094
+ yield from self.itemByID.values()
2095
+
2096
+ def _loadItems(self):
2097
+ """
2098
+ Populate the Item list using SQLAlchemy.
2099
+ CAUTION: Will orphan previously loaded objects.
2100
+ """
2101
+ itemByID, itemByName, itemByFDevID = {}, {}, {}
2102
+ with self.Session() as session:
2103
+ rows = session.query(
2104
+ SA_Item.item_id,
2105
+ SA_Item.name,
2106
+ SA_Item.category_id,
2107
+ SA_Item.avg_price,
2108
+ SA_Item.fdev_id,
2109
+ )
2110
+ for ID, name, categoryID, avgPrice, fdevID in rows:
2111
+ category = self.categoryByID[categoryID]
2112
+ item = Item(
2113
+ ID, name, category,
2114
+ f"{category.dbname}/{name}",
2115
+ avgPrice, fdevID
2116
+ )
2117
+ itemByID[ID] = item
2118
+ itemByName[name] = item
2119
+ if fdevID:
2120
+ itemByFDevID[fdevID] = item
2121
+ category.items.append(item)
2122
+
2123
+ self.itemByID = itemByID
2124
+ self.itemByName = itemByName
2125
+ self.itemByFDevID = itemByFDevID
2126
+
2127
+ self.tdenv.DEBUG1("Loaded {:n} Items", len(self.itemByID))
2128
+
2129
+ def lookupItem(self, name):
2130
+ """
2131
+ Look up an Item by name using "CATEGORY/Item"
2132
+ """
2133
+ return TradeDB.listSearch(
2134
+ "Item", name, self.itemByName.items(),
2135
+ key=lambda kvTup: kvTup[0],
2136
+ val=lambda kvTup: kvTup[1]
2137
+ )
2138
+
2139
+ def getAverageSelling(self):
2140
+ """
2141
+ Query the database for average selling prices of all items using SQLAlchemy.
2142
+ """
2143
+ if not self.avgSelling:
2144
+ self.avgSelling = dict.fromkeys(self.itemByID, 0)
2145
+
2146
+ with self.Session() as session:
2147
+ rows = (
2148
+ session.query(
2149
+ SA_Item.item_id,
2150
+ func.ifnull(func.avg(SA_StationItem.supply_price), 0),
2151
+ )
2152
+ .outerjoin(
2153
+ SA_StationItem,
2154
+ (SA_Item.item_id == SA_StationItem.item_id) &
2155
+ (SA_StationItem.supply_price > 0),
2156
+ )
2157
+ .filter(SA_StationItem.supply_price > 0)
2158
+ .group_by(SA_Item.item_id)
2159
+ )
2160
+ for ID, cr in rows:
2161
+ self.avgSelling[ID] = int(cr)
2162
+
2163
+ return self.avgSelling
2164
+
2165
+ def getAverageBuying(self):
2166
+ """
2167
+ Query the database for average buying prices of all items using SQLAlchemy.
2168
+ """
2169
+ if not self.avgBuying:
2170
+ self.avgBuying = dict.fromkeys(self.itemByID, 0)
2171
+
2172
+ with self.Session() as session:
2173
+ rows = (
2174
+ session.query(
2175
+ SA_Item.item_id,
2176
+ func.ifnull(func.avg(SA_StationItem.demand_price), 0),
2177
+ )
2178
+ .outerjoin(
2179
+ SA_StationItem,
2180
+ (SA_Item.item_id == SA_StationItem.item_id) &
2181
+ (SA_StationItem.demand_price > 0),
2182
+ )
2183
+ .filter(SA_StationItem.demand_price > 0)
2184
+ .group_by(SA_Item.item_id)
2185
+ )
2186
+ for ID, cr in rows:
2187
+ self.avgBuying[ID] = int(cr)
2188
+
2189
+ return self.avgBuying
2190
+
2191
+ ############################################################
2192
+ # Price data.
2193
+ #
2194
+ def close(self, *, final: bool = False) -> None:
2195
+ if self.Session and final:
2196
+ del self.Session
2197
+ if self.engine:
2198
+ self.engine.dispose()
2199
+ if final:
2200
+ del self.engine
2201
+ self.engine = None
2202
+ # Keep engine + Session references so reloadCache/buildCache can reuse them
2203
+
2204
+ def load(self) -> None:
2205
+ """
2206
+ Populate/re-populate this instance of TradeDB with data.
2207
+ WARNING: This will orphan existing records you have
2208
+ taken references to:
2209
+ tdb.load()
2210
+ x = tdb.lookupPlace("Aulin")
2211
+ tdb.load() # x now points to an orphan Aulin
2212
+ """
2213
+
2214
+ self.tdenv.DEBUG1("Loading data")
2215
+
2216
+ started = time.time()
2217
+ self._loadSystems()
2218
+ self._loadStations()
2219
+ self._loadCategories()
2220
+ self._loadItems()
2221
+ self.tdenv.DEBUG0("Data load took {:.3f}s", time.time() - started)
2222
+
2223
+ @property
2224
+ def max_link_ly(self) -> float | int:
2225
+ return self.tdenv.maxSystemLinkLy
2226
+
2227
+ ############################################################
2228
+ # General purpose static methods.
2229
+ #
2230
+ @staticmethod
2231
+ def listSearch(
2232
+ listType, lookup, values,
2233
+ key=lambda item: item,
2234
+ val=lambda item: item
2235
+ ):
2236
+ """
2237
+ Searches [values] for 'lookup' for least-ambiguous matches,
2238
+ return the matching value as stored in [values].
2239
+
2240
+ GIVEN [values] contains "bread", "water", "biscuits and "It",
2241
+ searching "ea" will return "bread", "WaT" will return "water"
2242
+ and "i" will return "biscuits".
2243
+
2244
+ Searching for "a" would raise an AmbiguityError because "a" matches
2245
+ "bread" and "water", but searching for "it" will return "It"
2246
+ because it provides an exact match of a key.
2247
+ """
2248
+ ListSearchMatch = namedtuple('Match', ['key', 'value'])
2249
+
2250
+ normTrans = TradeDB.normalizeTrans
2251
+ trimTrans = TradeDB.trimTrans
2252
+ needle = lookup.translate(normTrans).translate(trimTrans)
2253
+ partialMatch, wordMatch = [], []
2254
+ # make a regex to match whole words
2255
+ wordRe = re.compile(f"\\b{lookup}\\b", re.IGNORECASE)
2256
+ # describe a match
2257
+ for entry in values:
2258
+ entryKey = key(entry)
2259
+ normVal = entryKey.translate(normTrans).translate(trimTrans)
2260
+ if normVal.find(needle) > -1:
2261
+ # If this is an exact match, ignore ambiguities.
2262
+ if len(normVal) == len(needle):
2263
+ return val(entry)
2264
+ match = ListSearchMatch(entryKey, val(entry))
2265
+ if wordRe.match(entryKey):
2266
+ wordMatch.append(match)
2267
+ else:
2268
+ partialMatch.append(match)
2269
+ # Whole word matches trump partial matches
2270
+ if wordMatch:
2271
+ if len(wordMatch) > 1:
2272
+ raise AmbiguityError(
2273
+ listType, lookup, wordMatch,
2274
+ key=lambda item: item.key,
2275
+ )
2276
+ return wordMatch[0].value
2277
+ # Fuzzy matches
2278
+ if partialMatch:
2279
+ if len(partialMatch) > 1:
2280
+ raise AmbiguityError(
2281
+ listType, lookup, partialMatch,
2282
+ key=lambda item: item.key,
2283
+ )
2284
+ return partialMatch[0].value
2285
+ # No matches
2286
+ raise LookupError(f"Error: '{lookup}' doesn't match any {listType}")
2287
+
2288
+ @staticmethod
2289
+ def normalizedStr(text: str) -> str:
2290
+ """
2291
+ Returns a case folded, sanitized version of 'str' suitable for
2292
+ performing simple and partial matches against. Removes various
2293
+ punctuation characters that don't contribute to name uniqueness.
2294
+ NOTE: No-longer removes whitespaces or apostrophes.
2295
+ """
2296
+ return text.translate(
2297
+ TradeDB.normalizeTrans
2298
+ ).translate(
2299
+ TradeDB.trimTrans
2300
+ )
2301
+
2302
+ ######################################################################
2303
+ # Assorted helpers
2304
+
2305
+ def describeAge(ageInSeconds: float | int) -> str:
2306
+ """
2307
+ Turns an age (in seconds) into a text representation.
2308
+ """
2309
+ hours = int(ageInSeconds / 3600)
2310
+ if hours < 1:
2311
+ return "<1 hr"
2312
+ if hours == 1:
2313
+ return "1 hr"
2314
+ if hours < 48:
2315
+ return f"{hours} hrs"
2316
+ days = int(hours / 24)
2317
+ if days < 90:
2318
+ return f"{days} days"
2319
+
2320
+ return f"{int(days / 31)} mths"