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,222 @@
1
+ # tradedangerous/commands/import_cmd.py
2
+ # DEPRECATED: The legacy “.prices” import path is deprecated.
3
+ # Prefer supported import plugins:
4
+ # - trade import -P spansh (authoritative bulk dump)
5
+ # - trade import -P eddblink (server listings pipeline)
6
+ # Solo / offline players: use the TradeDangerous DB-Update plugin for EDMC:
7
+ # https://github.com/bgol/UpdateTD
8
+ #
9
+ # This module remains available for compatibility with old “.prices” files,
10
+ # but the format is being phased out and may be removed in a future release.
11
+
12
+ from __future__ import annotations
13
+
14
+ from .exceptions import CommandLineError
15
+ from .parsing import ParseArgument, MutuallyExclusiveGroup
16
+ from itertools import chain
17
+ from pathlib import Path
18
+
19
+ from .. import cache, plugins, transfers
20
+ import re
21
+ import sys
22
+ import typing
23
+
24
+ try:
25
+ import tkinter
26
+ import tkinter.filedialog as tkfd
27
+ hasTkInter = True
28
+ except ImportError:
29
+ hasTkInter = False
30
+
31
+ if typing.TYPE_CHECKING:
32
+ from ..tradedb import TradeDB
33
+ from ..tradeenv import TradeEnv
34
+
35
+
36
+ ######################################################################
37
+ # Parser config
38
+
39
+ help = (
40
+ "TD data import system. On its own, this command lets you "
41
+ "merge station prices from a '.prices' file (entries in the "
42
+ "file that are older than your local data are not loaded)."
43
+ )
44
+ name = 'import'
45
+ epilog = (
46
+ "This sub-command provides a plugin infrastructure, and comes "
47
+ "with a module to import data from Tromador's Trading Dangerously server"
48
+ "(https://elite.tromador.com/).\n"
49
+ "See \"trade import -P eddblink -O help\" for more help."
50
+ )
51
+ wantsTradeDB = False
52
+ arguments = [
53
+ ]
54
+ switches = [
55
+ MutuallyExclusiveGroup(
56
+ ParseArgument('filename',
57
+ help = (
58
+ "Name of the file to read, or, used with '--url', "
59
+ "will save the downloaded file as this name."
60
+ ),
61
+ type = str,
62
+ default = None,
63
+ ),
64
+ ParseArgument('--plug', '-P',
65
+ help = "Use the specified import plugin.",
66
+ type = str,
67
+ default = None,
68
+ ),
69
+ ),
70
+ ParseArgument('--url',
71
+ help = 'URL to download filename (default "import.prices") from.',
72
+ type = str,
73
+ default = None,
74
+ ),
75
+ ParseArgument('--download',
76
+ help = 'Stop after downloading.',
77
+ action = 'store_true',
78
+ default = False,
79
+ ),
80
+ ParseArgument(
81
+ '--ignore-unknown', '-i',
82
+ default = False, action = 'store_true',
83
+ dest = 'ignoreUnknown',
84
+ help = (
85
+ "Data for systems, stations and items that are not "
86
+ "recognized is reported as warning but skipped."
87
+ ),
88
+ ),
89
+ ParseArgument(
90
+ '--option', '-O',
91
+ default = [], action = 'append',
92
+ dest = 'pluginOptions',
93
+ help = (
94
+ "Provides a way to pass additional arguments to plugins."
95
+ ),
96
+ ),
97
+ MutuallyExclusiveGroup(
98
+ ParseArgument('--reset-all',
99
+ help = 'Clear the database before importing.',
100
+ action = 'store_true',
101
+ default = False,
102
+ ),
103
+ ParseArgument('--merge-import', '-M',
104
+ help = (
105
+ 'Merge the import file with the existing local database: '
106
+ 'only loads values that have an explicit entry with a '
107
+ 'newer timestamp than the existing data. Local values '
108
+ 'are only removed if there is an explicit entry with a '
109
+ '0/0 demand/supply price.'
110
+ ),
111
+ action = 'store_true',
112
+ default = False,
113
+ dest = 'mergeImport',
114
+ ),
115
+ ),
116
+ ]
117
+
118
+ ######################################################################
119
+ # Helpers
120
+
121
+ ######################################################################
122
+ # Perform query and populate result set
123
+
124
+
125
+ def run(results, cmdenv: TradeEnv, tdb: TradeDB):
126
+ """
127
+ Dispatch import work:
128
+ • If a plugin (-P) is specified: load it and run it (no deprecation banner).
129
+ • Otherwise: proceed with legacy .prices/.url flow and show a deprecation notice.
130
+ """
131
+
132
+ # --- Plugin path (preferred; no banner) ---
133
+ if cmdenv.plug:
134
+ if cmdenv.pluginOptions:
135
+ cmdenv.pluginOptions = chain.from_iterable(
136
+ opt.split(',') for opt in cmdenv.pluginOptions
137
+ )
138
+ try:
139
+ pluginClass = plugins.load(cmdenv.plug, "ImportPlugin")
140
+ except plugins.PluginException as e:
141
+ raise CommandLineError("Plugin Error: " + str(e))
142
+
143
+ plugin = pluginClass(tdb, cmdenv)
144
+
145
+ # If plugin returns False, it fully handled the run → stop here.
146
+ if not plugin.run():
147
+ return False
148
+
149
+ # If plugin returns True, it’s handing control back to legacy flow below.
150
+ # Fall through intentionally (still no banner, as user invoked a plugin).
151
+
152
+ # --- Legacy .prices path (deprecated; show banner once) ---
153
+ # Only warn when the user is *not* using a plugin. Keep functionality intact.
154
+ if not cmdenv.plug:
155
+ print(
156
+ "NOTE:\n"
157
+ "=== DEPRECATION NOTICE ============================================\n"
158
+ "The legacy '.prices' import is deprecated.\n"
159
+ "Use a supported plugin instead:\n"
160
+ " • trade import -P spansh\n"
161
+ " • trade import -P eddblink\n"
162
+ "Solo/offline: TradeDangerous DB-Update for EDMC → https://github.com/bgol/UpdateTD\n"
163
+ "===================================================================\n"
164
+ )
165
+
166
+ # Refresh/close any cached handles before file ops (kept from original)
167
+ tdb.reloadCache()
168
+ tdb.close()
169
+ tdb.removePerist()
170
+
171
+ # Treat a bare http(s) string in 'filename' as a URL
172
+ if cmdenv.filename:
173
+ if re.match(r"^https?://", cmdenv.filename, re.IGNORECASE):
174
+ cmdenv.url, cmdenv.filename = cmdenv.filename, None
175
+
176
+ # Optional download step
177
+ if cmdenv.url:
178
+ cmdenv.filename = cmdenv.filename or "import.prices"
179
+ transfers.download(cmdenv, cmdenv.url, cmdenv.filename)
180
+ if cmdenv.download:
181
+ return False
182
+
183
+ # No filename? If Tk is available, prompt user (legacy behavior)
184
+ fh = None
185
+ if not cmdenv.filename and hasTkInter:
186
+ tk = tkinter.Tk()
187
+ tk.withdraw()
188
+ filetypes = (
189
+ ("TradeDangerous '.prices' Files", "*.prices"),
190
+ ("All Files", "*.*"),
191
+ )
192
+ filename = tkfd.askopenfilename(
193
+ title="Select the file to import",
194
+ initialfile="TradeDangerous.prices",
195
+ filetypes=filetypes,
196
+ initialdir='.',
197
+ )
198
+ if not filename:
199
+ raise SystemExit("Aborted")
200
+ cmdenv.filename = filename
201
+
202
+ # Validate path or use stdin
203
+ if cmdenv.filename != "-":
204
+ filePath = Path(cmdenv.filename)
205
+ if not filePath.is_file():
206
+ raise CommandLineError(f"File not found: {str(filePath)}")
207
+ else:
208
+ filePath = "stdin"
209
+ fh = sys.stdin
210
+
211
+ # If a plugin was also involved and wants to finish with default flow,
212
+ # honour that (unchanged behavior).
213
+ if cmdenv.plug:
214
+ # Plugins returning True above chose to hand control back.
215
+ # finish() may return False to suppress default regeneration.
216
+ if not plugin.finish():
217
+ cache.regeneratePricesFile()
218
+ return False
219
+
220
+ # Legacy .prices import
221
+ cache.importDataFromFile(tdb, cmdenv, filePath, pricesFh=fh, reset=cmdenv.reset)
222
+ return False
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+ from itertools import chain
3
+
4
+ from .commandenv import ResultRow
5
+ from .exceptions import NoDataError
6
+ from .parsing import (
7
+ ParseArgument, PadSizeArgument, MutuallyExclusiveGroup, NoPlanetSwitch,
8
+ PlanetaryArgument, FleetCarrierArgument, OdysseyArgument, BlackMarketSwitch,
9
+ ShipyardSwitch, OutfittingSwitch, RearmSwitch, RefuelSwitch, RepairSwitch,
10
+ )
11
+ from tradedangerous import TradeDB
12
+ from tradedangerous.formatting import RowFormat, ColumnFormat, max_len
13
+
14
+
15
+ ######################################################################
16
+ # Parser config
17
+
18
+ name='local'
19
+ help='Calculate local systems.'
20
+ epilog="See also the 'station' sub-command."
21
+ wantsTradeDB=True
22
+ arguments = [
23
+ ParseArgument(
24
+ 'near',
25
+ help='Name of the system to query from.',
26
+ type=str,
27
+ metavar='SYSTEMNAME',
28
+ ),
29
+ ]
30
+ switches = [
31
+ ParseArgument('--ly',
32
+ help='Maximum light years from system.',
33
+ dest='ly',
34
+ metavar='N.NN',
35
+ type=float,
36
+ default=None,
37
+ ),
38
+ PadSizeArgument(),
39
+ MutuallyExclusiveGroup(
40
+ NoPlanetSwitch(),
41
+ PlanetaryArgument(),
42
+ ),
43
+ FleetCarrierArgument(),
44
+ OdysseyArgument(),
45
+ ParseArgument('--stations',
46
+ help='Limit to systems which have stations.',
47
+ action='store_true',
48
+ ),
49
+ ParseArgument('--trading',
50
+ help='Limit stations to ones with price data or flagged as having '
51
+ 'a market.',
52
+ action='store_true',
53
+ ),
54
+ BlackMarketSwitch(),
55
+ ShipyardSwitch(),
56
+ OutfittingSwitch(),
57
+ RearmSwitch(),
58
+ RefuelSwitch(),
59
+ RepairSwitch(),
60
+ ]
61
+
62
+ ######################################################################
63
+ # Perform query and populate result set
64
+
65
+ def run(results, cmdenv, tdb):
66
+ cmdenv = results.cmdenv
67
+ tdb = cmdenv.tdb
68
+ srcSystem = cmdenv.nearSystem
69
+
70
+ # Allow the user to say '0' for system-only
71
+ ly = cmdenv.ly if cmdenv.ly is not None else cmdenv.maxSystemLinkLy
72
+
73
+ results.summary = ResultRow()
74
+ results.summary.near = srcSystem
75
+ results.summary.ly = ly
76
+ results.summary.stations = 0
77
+
78
+ distances = { srcSystem: 0.0 }
79
+
80
+ # Calculate the bounding dimensions
81
+ for destSys, dist in tdb.genSystemsInRange(srcSystem, ly):
82
+ distances[destSys] = dist
83
+
84
+ showStations = cmdenv.detail
85
+ wantStations = cmdenv.stations
86
+ padSize = cmdenv.padSize
87
+ planetary = cmdenv.planetary
88
+ fleet = cmdenv.fleet
89
+ odyssey = cmdenv.odyssey
90
+ wantNoPlanet = cmdenv.noPlanet
91
+ wantTrading = cmdenv.trading
92
+ wantShipYard = cmdenv.shipyard
93
+ wantBlackMarket = cmdenv.blackMarket
94
+ wantOutfitting = cmdenv.outfitting
95
+ wantRearm = cmdenv.rearm
96
+ wantRefuel = cmdenv.refuel
97
+ wantRepair = cmdenv.repair
98
+
99
+ def station_filter(stations):
100
+ for station in stations:
101
+ if wantNoPlanet and station.planetary != 'N':
102
+ continue
103
+ if wantTrading and not station.isTrading:
104
+ continue
105
+ if wantBlackMarket and station.blackMarket != 'Y':
106
+ continue
107
+ if wantShipYard and station.shipyard != 'Y':
108
+ continue
109
+ if padSize and not station.checkPadSize(padSize):
110
+ continue
111
+ if planetary and not station.checkPlanetary(planetary):
112
+ continue
113
+ if fleet and not station.checkFleet(fleet):
114
+ continue
115
+ if odyssey and not station.checkOdyssey(odyssey):
116
+ continue
117
+ if wantOutfitting and station.outfitting != 'Y':
118
+ continue
119
+ if wantRearm and station.rearm != 'Y':
120
+ continue
121
+ if wantRefuel and station.refuel != 'Y':
122
+ continue
123
+ if wantRepair and station.repair != 'Y':
124
+ continue
125
+ yield station
126
+
127
+ for (system, dist) in sorted(distances.items(), key=lambda x: x[1]):
128
+ if showStations or wantStations:
129
+ stations = []
130
+ for (station) in station_filter(system.stations):
131
+ stations.append(
132
+ ResultRow(
133
+ station=station,
134
+ age=station.itemDataAgeStr,
135
+ )
136
+ )
137
+ if not stations and wantStations:
138
+ continue
139
+
140
+ row = ResultRow()
141
+ row.system = system
142
+ row.dist = dist
143
+ row.stations = stations if showStations else []
144
+ results.rows.append(row)
145
+ results.summary.stations += len(row.stations)
146
+
147
+ return results
148
+
149
+
150
+ def render(results, cmdenv, tdb):
151
+ """ render transforms a result set into output for the CLI. """
152
+ if not results or not results.rows:
153
+ distance, origin = results.summary.ly, results.summary.near.name()
154
+ raise NoDataError(f"No suitable systems found within {distance}ly of {origin}.")
155
+
156
+ # Compare name lengths for formatting
157
+ maxSysLen = max_len(results.rows, key=lambda row: row.system.name())
158
+
159
+ sysRowFmt = RowFormat().append(
160
+ ColumnFormat("System", '<', maxSysLen,
161
+ key=lambda row: row.system.name())
162
+ ).append(
163
+ ColumnFormat("Dist", '>', '7', '.2f',
164
+ key=lambda row: row.dist)
165
+ )
166
+
167
+ showStations = cmdenv.detail
168
+ if showStations:
169
+ maxStnLen = max_len(
170
+ chain.from_iterable(row.stations for row in results.rows),
171
+ key=lambda row: row.station.dbname
172
+ )
173
+ maxLsLen = max_len(
174
+ chain.from_iterable(row.stations for row in results.rows),
175
+ key=lambda row: row.station.distFromStar()
176
+ )
177
+ maxLsLen = max(maxLsLen, 5)
178
+ stnRowFmt = RowFormat(prefix=' / ').append(
179
+ ColumnFormat("Station", '.<', maxStnLen + 2,
180
+ key=lambda row: row.station.dbname)
181
+ ).append(
182
+ ColumnFormat("StnLs", '>', maxLsLen,
183
+ key=lambda row: row.station.distFromStar())
184
+ ).append(
185
+ ColumnFormat("Age/days", '>', 7,
186
+ key=lambda row: row.age)
187
+ ).append(
188
+ ColumnFormat("Mkt", '>', '3',
189
+ key=lambda row: TradeDB.marketStates[row.station.market])
190
+ ).append(
191
+ ColumnFormat("BMk", '>', '3',
192
+ key=lambda row: TradeDB.marketStates[row.station.blackMarket])
193
+ ).append(
194
+ ColumnFormat("Shp", '>', '3',
195
+ key=lambda row: TradeDB.marketStates[row.station.shipyard])
196
+ ).append(
197
+ ColumnFormat("Out", '>', '3',
198
+ key=lambda row: TradeDB.marketStates[row.station.outfitting])
199
+ ).append(
200
+ ColumnFormat("Arm", '>', '3',
201
+ key=lambda row: TradeDB.marketStates[row.station.rearm])
202
+ ).append(
203
+ ColumnFormat("Ref", '>', '3',
204
+ key=lambda row: TradeDB.marketStates[row.station.refuel])
205
+ ).append(
206
+ ColumnFormat("Rep", '>', '3',
207
+ key=lambda row: TradeDB.marketStates[row.station.repair])
208
+ ).append(
209
+ ColumnFormat("Pad", '>', '3',
210
+ key=lambda row: TradeDB.padSizes[row.station.maxPadSize])
211
+ ).append(
212
+ ColumnFormat("Plt", '>', '3',
213
+ key=lambda row: TradeDB.planetStates[row.station.planetary])
214
+ ).append(
215
+ ColumnFormat("Flc", '>', '3',
216
+ key=lambda row: TradeDB.fleetStates[row.station.fleet])
217
+ ).append(
218
+ ColumnFormat("Ody", '>', '3',
219
+ key=lambda row: TradeDB.odysseyStates[row.station.odyssey])
220
+ )
221
+ if cmdenv.detail > 1:
222
+ stnRowFmt.append(
223
+ ColumnFormat("Itms", ">", 4,
224
+ key=lambda row: row.station.itemCount)
225
+ )
226
+
227
+ cmdenv.DEBUG0(
228
+ "Systems within {ly:<5.2f}ly of {sys}.\n",
229
+ sys=results.summary.near.name(),
230
+ ly=results.summary.ly,
231
+ )
232
+
233
+ if not cmdenv.quiet:
234
+ heading, underline = sysRowFmt.heading()
235
+ if showStations:
236
+ print(heading)
237
+ heading, underline = stnRowFmt.heading()
238
+ print(heading, underline, sep='\n')
239
+
240
+ for row in results.rows:
241
+ print(sysRowFmt.format(row))
242
+ for stnRow in row.stations:
243
+ print(stnRowFmt.format(stnRow))
@@ -0,0 +1,207 @@
1
+ from .commandenv import ResultRow
2
+ from .exceptions import CommandLineError
3
+ from .parsing import (
4
+ ParseArgument, MutuallyExclusiveGroup,
5
+ )
6
+ from ..formatting import RowFormat
7
+ from sqlalchemy import select, table, column
8
+ from sqlalchemy.orm import Session
9
+
10
+
11
+ ######################################################################
12
+ # Parser config
13
+
14
+ help='Lists items bought/sold at a given station.'
15
+ name='market'
16
+ epilog=None
17
+ wantsTradeDB=True
18
+ arguments = [
19
+ ParseArgument(
20
+ 'origin',
21
+ help='Station being queried.',
22
+ metavar='STATIONNAME',
23
+ type=str,
24
+ ),
25
+ ]
26
+ switches = [
27
+ MutuallyExclusiveGroup(
28
+ ParseArgument(
29
+ '--buying', '-B',
30
+ help='Show items station is buying',
31
+ action='store_true',
32
+ ),
33
+ ParseArgument(
34
+ '--selling', '-S',
35
+ help='Show items station is selling',
36
+ action='store_true',
37
+ ),
38
+ ),
39
+ ]
40
+
41
+ ######################################################################
42
+ # Perform query and populate result set
43
+
44
+
45
+ def render_units(units, level):
46
+ if level == 0:
47
+ return '-'
48
+ if units < 0:
49
+ return '?'
50
+ levelNames = { -1: '?', 1: 'L', 2: 'M', 3: 'H' }
51
+ return "{:n}{}".format(units, levelNames[level])
52
+
53
+
54
+ def run(results, cmdenv, tdb):
55
+ # Lazy import to avoid any import-time tangles elsewhere.
56
+ from tradedangerous.db.utils import age_in_days
57
+
58
+ origin = cmdenv.startStation
59
+ if not origin.itemCount:
60
+ raise CommandLineError(
61
+ "No trade data available for {}".format(origin.name())
62
+ )
63
+
64
+ buying, selling = cmdenv.buying, cmdenv.selling
65
+
66
+ results.summary = ResultRow()
67
+ results.summary.origin = origin
68
+ results.summary.buying = cmdenv.buying
69
+ results.summary.selling = cmdenv.selling
70
+
71
+ # Precompute averages (unchanged)
72
+ tdb.getAverageSelling()
73
+ tdb.getAverageBuying()
74
+
75
+ # --- Backend-neutral query using SQLAlchemy Core + age_in_days ---
76
+ si = table(
77
+ "StationItem",
78
+ column("item_id"),
79
+ column("station_id"),
80
+ column("demand_price"),
81
+ column("demand_units"),
82
+ column("demand_level"),
83
+ column("supply_price"),
84
+ column("supply_units"),
85
+ column("supply_level"),
86
+ column("modified"),
87
+ )
88
+
89
+ # Build session bound to current engine (needed by age_in_days)
90
+ session = Session(bind=tdb.engine)
91
+
92
+ stmt = (
93
+ select(
94
+ si.c.item_id,
95
+ si.c.demand_price, si.c.demand_units, si.c.demand_level,
96
+ si.c.supply_price, si.c.supply_units, si.c.supply_level,
97
+ age_in_days(session, si.c.modified).label("age_days"),
98
+ )
99
+ .where(si.c.station_id == origin.ID)
100
+ )
101
+
102
+ rows = session.execute(stmt).fetchall()
103
+ session.close()
104
+
105
+ for r in rows:
106
+ it = iter(r)
107
+ item = tdb.itemByID[next(it)]
108
+
109
+ row = ResultRow()
110
+ row.item = item
111
+
112
+ row.buyCr = int(next(it) or 0)
113
+ row.avgBuy = tdb.avgBuying.get(item.ID, 0)
114
+ units, level = int(next(it) or 0), int(next(it) or 0)
115
+ row.buyUnits = units
116
+ row.buyLevel = level
117
+ row.demand = render_units(units, level)
118
+ if not selling:
119
+ hasBuy = (row.buyCr or units or level)
120
+ else:
121
+ hasBuy = False
122
+
123
+ row.sellCr = int(next(it) or 0)
124
+ row.avgSell = tdb.avgSelling.get(item.ID, 0)
125
+ units, level = int(next(it) or 0), int(next(it) or 0)
126
+ row.sellUnits = units
127
+ row.sellLevel = level
128
+ row.supply = render_units(units, level)
129
+ if not buying:
130
+ hasSell = (row.sellCr or units or level)
131
+ else:
132
+ hasSell = False
133
+
134
+ age_days = next(it)
135
+ row.age = float(age_days or 0.0)
136
+
137
+ if hasBuy or hasSell:
138
+ results.rows.append(row)
139
+
140
+ if not results.rows:
141
+ raise CommandLineError("No items found")
142
+
143
+ results.rows.sort(key=lambda row: row.item.dbname)
144
+ results.rows.sort(key=lambda row: row.item.category.dbname)
145
+
146
+ return results
147
+
148
+ #######################################################################
149
+ ## Transform result set into output
150
+
151
+
152
+ def render(results, cmdenv, tdb):
153
+ longest = max(results.rows, key=lambda row: len(row.item.name()))
154
+ longestLen = len(longest.item.name())
155
+ longestDmd = max(results.rows, key=lambda row: len(row.demand)).demand
156
+ longestSup = max(results.rows, key=lambda row: len(row.supply)).supply
157
+ dmdLen = max(len(longestDmd), len("Demand"))
158
+ supLen = max(len(longestSup), len("Supply"))
159
+
160
+ showCategories = (cmdenv.detail > 0)
161
+
162
+ rowFmt = RowFormat()
163
+ if showCategories:
164
+ rowFmt.prefix = ' '
165
+
166
+ sellPred = lambda row: row.sellCr != 0 and row.supply != '-' # noqa: E731
167
+ buyPred = lambda row: row.buyCr != 0 and row.demand != '-' # noqa: E731
168
+
169
+ rowFmt.addColumn('Item', '<', longestLen,
170
+ key=lambda row: row.item.name())
171
+ if not cmdenv.selling:
172
+ rowFmt.addColumn('Buying', '>', 7, 'n',
173
+ key=lambda row: row.buyCr,
174
+ pred=buyPred)
175
+ if cmdenv.detail:
176
+ rowFmt.addColumn('Avg', '>', 7, 'n',
177
+ key=lambda row: row.avgBuy,
178
+ pred=buyPred)
179
+ if cmdenv.detail > 1:
180
+ rowFmt.addColumn('Demand', '>', dmdLen,
181
+ key=lambda row: row.demand,
182
+ pred=buyPred)
183
+ if not cmdenv.buying:
184
+ rowFmt.addColumn('Selling', '>', 7, 'n',
185
+ key=lambda row: row.sellCr,
186
+ pred=sellPred)
187
+ if cmdenv.detail:
188
+ rowFmt.addColumn('Avg', '>', 7, 'n',
189
+ key=lambda row: row.avgSell,
190
+ pred=sellPred)
191
+ rowFmt.addColumn('Supply', '>', supLen,
192
+ key=lambda row: row.supply,
193
+ pred=sellPred)
194
+ if cmdenv.detail:
195
+ rowFmt.addColumn('Age/Days', '>', 7, '.2f',
196
+ key=lambda row: row.age)
197
+
198
+ if not cmdenv.quiet:
199
+ heading, underline = rowFmt.heading()
200
+ print(heading, underline, sep='\n')
201
+
202
+ lastCat = None
203
+ for row in results.rows:
204
+ if showCategories and row.item.category is not lastCat:
205
+ print("+{}".format(row.item.category.name()))
206
+ lastCat = row.item.category
207
+ print(rowFmt.format(row))