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,372 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import os
5
+ import sys
6
+ import typing
7
+
8
+ import ijson
9
+
10
+ from .exceptions import (
11
+ CommandLineError, FleetCarrierError, OdysseyError,
12
+ PadSizeError, PlanetaryError,
13
+ )
14
+
15
+ from tradedangerous import TradeEnv
16
+ from tradedangerous.tradedb import AmbiguityError, Station
17
+
18
+
19
+ if typing.TYPE_CHECKING:
20
+ from argparse import Namespace
21
+ from typing import Any, ModuleType
22
+
23
+ from tradedangerous import TradeDB, TradeORM
24
+
25
+
26
+ # See: https://espterm.github.io/docs/VT100%20escape%20codes.html
27
+ # or : https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences
28
+ #
29
+ # ANSI-compliant "terminal" streams support changing the color (including boldness) of text
30
+ # with 'Color Sequence' codes, consisting of an initializer (CS), one or more semicolon-separated (;)
31
+ # parameters, and a command code.
32
+ #
33
+ # The CSI is ESC '[' where esc is 1b in hex or 033 in octal.
34
+ # For color-changes, the command is 'm'.
35
+ # To clear all color-code/effect changes, the sequence is : [escape, '[', '0', 'm'].
36
+ #
37
+ ANSI_CSI = "\033["
38
+ ANSI_COLOR_CMD = "m"
39
+ ANSI_COLOR = {
40
+ "CLEAR": "0",
41
+ "red": "31",
42
+ "green": "32",
43
+ "yellow": "33",
44
+ "blue": "34",
45
+ "magenta": "35",
46
+ "cyan": "36",
47
+ "lightGray": "37",
48
+ "darkGray": "90",
49
+ "lightRed": "91",
50
+ "lightGreen": "92",
51
+ "lightYellow": "93",
52
+ "lightBlue": "94",
53
+ "lightMagenta": "95",
54
+ "lightCyan": "96",
55
+ "white": "97",
56
+ }
57
+ ANSI_CLEAR = f"{ANSI_CSI}{ANSI_COLOR['CLEAR']}{ANSI_COLOR_CMD}"
58
+
59
+
60
+ class ResultRow:
61
+ """ ResultRow captures a data item returned by a command. It's really an abstract namespace. """
62
+ def __init__(self, **kwargs: typing.Any) -> None:
63
+ for k, v in kwargs.items():
64
+ setattr(self, k, v)
65
+
66
+
67
+ class CommandResults:
68
+ """ Encapsulates the results returned by running a command. """
69
+ cmdenv: 'CommandEnv'
70
+ summary: ResultRow
71
+ rows: list[ResultRow]
72
+
73
+ def __init__(self, cmdenv: 'CommandEnv') -> None:
74
+ self.cmdenv = cmdenv
75
+ self.summary = ResultRow()
76
+ self.rows = []
77
+
78
+ def render(self, cmdenv: 'CommandEnv' = None, tdb: TradeDB | TradeORM | None = None) -> None:
79
+ cmdenv = cmdenv or self.cmdenv
80
+ tdb = tdb or cmdenv.tdb
81
+ cmdenv._cmd.render(self, cmdenv, tdb)
82
+
83
+
84
+ class CommandEnv(TradeEnv):
85
+ """
86
+ Base class for a TradeDangerous sub-command which has auxilliary
87
+ "environment" data in the form of command line options.
88
+ """
89
+ def __init__(self, properties: dict[str, Any] | Namespace | None, argv: list[str] | None, cmdModule: ModuleType | None) -> None:
90
+ super().__init__(properties = properties)
91
+
92
+ self.tdb = None
93
+ self.mfd = None
94
+ self.argv = argv or sys.argv
95
+ self._preflight_done = False
96
+
97
+ if self.detail and self.quiet:
98
+ raise CommandLineError("'--detail' (-v) and '--quiet' (-q) are mutually exclusive.")
99
+
100
+ self._cmd = cmdModule
101
+ self.wantsTradeDB = getattr(cmdModule, 'wantsTradeDB', True)
102
+ self.usesTradeData = getattr(cmdModule, 'usesTradeData', False)
103
+
104
+ # We need to relocate to the working directory so that
105
+ # we can load a TradeDB after this without things going
106
+ # pear-shaped
107
+ if not self.cwd and argv[0]:
108
+ cwdPath = Path('.').resolve()
109
+ exePath = Path(argv[0]).parent.resolve()
110
+ if cwdPath != exePath:
111
+ self.cwd = str(exePath)
112
+ self.DEBUG1("cwd at launch was: {}, changing to {} to match trade.py", cwdPath, self.cwd)
113
+ if self.cwd:
114
+ os.chdir(self.cwd)
115
+
116
+ def preflight(self) -> None:
117
+ """
118
+ Phase A: quick validation that must be able to short-circuit before any
119
+ heavy TradeDB(load=True) path is invoked.
120
+
121
+ Commands may optionally implement validateRunArgumentsFast(cmdenv).
122
+ """
123
+ if self._preflight_done:
124
+ return
125
+
126
+ self._preflight_done = True
127
+
128
+ fast_validator = getattr(self._cmd, "validateRunArgumentsFast", None)
129
+ if fast_validator:
130
+ fast_validator(self)
131
+
132
+ def run(self, tdb: TradeDB | TradeORM) -> CommandResults | bool | None:
133
+ """ Try and execute the business logic of the command. Query commands
134
+ will return a result set for us to render, whereas operational
135
+ commands will likely do their own rendering as they work. """
136
+ # Ensure fast validation is executed for non-CLI call paths too.
137
+ self.preflight()
138
+
139
+ # Set the current database context for this env and check that
140
+ # the properties we have are valid.
141
+ self.tdb = tdb
142
+ update_database_schema(self.tdb)
143
+
144
+ if self.wantsTradeDB:
145
+ self.checkFromToNear()
146
+ self.checkAvoids()
147
+ self.checkVias()
148
+
149
+ self.checkPlanetary()
150
+ self.checkFleet()
151
+ self.checkOdyssey()
152
+ self.checkPadSize()
153
+ self.checkMFD()
154
+
155
+ results = CommandResults(self)
156
+ return self._cmd.run(results, self, tdb)
157
+
158
+ def render(self, results: CommandResults) -> None:
159
+ self._cmd.render(self, results, self, self.tdb)
160
+
161
+ def checkMFD(self) -> None:
162
+ self.mfd = None
163
+ try:
164
+ if not self.x52pro:
165
+ return
166
+ except AttributeError:
167
+ return
168
+
169
+ # The x52 module throws some hard errors, so we really only want to
170
+ # import it as a last resort when the user has asked. We can't do a
171
+ # soft "try and import and tell the user later".
172
+ from tradedangerous.mfd import X52ProMFD # noqa
173
+ self.mfd = X52ProMFD()
174
+
175
+ def checkFromToNear(self) -> None:
176
+ if not self.wantsTradeDB:
177
+ return
178
+
179
+ def check(label, fieldName, wantStation):
180
+ key = getattr(self, fieldName, None)
181
+ if not key:
182
+ return None
183
+
184
+ try:
185
+ place = self.tdb.lookupPlace(key)
186
+ except LookupError:
187
+ raise CommandLineError(
188
+ "Unrecognized {}: {}"
189
+ .format(label, key))
190
+ if not wantStation:
191
+ if isinstance(place, Station):
192
+ return place.system
193
+ return place
194
+
195
+ if isinstance(place, Station):
196
+ return place
197
+
198
+ # it's a system, we want a station
199
+ if not place.stations:
200
+ raise CommandLineError(
201
+ "Station name required for {}: "
202
+ "{} is a SYSTEM but has no stations.".format(
203
+ label, key
204
+ ))
205
+ if len(place.stations) > 1:
206
+ raise AmbiguityError(
207
+ label,
208
+ key,
209
+ place.stations,
210
+ key=lambda st: (
211
+ f"{st.text()} — "
212
+ f"({st.system.posX:.1f}, {st.system.posY:.1f}, {st.system.posZ:.1f})"
213
+ ),
214
+ )
215
+
216
+ return place.stations[0]
217
+
218
+ def lookupPlace(label, fieldName):
219
+ key = getattr(self, fieldName, None)
220
+ if key:
221
+ return self.tdb.lookupPlace(key)
222
+ return None
223
+
224
+ self.startStation = check('origin station', 'origin', True)
225
+ self.stopStation = check('destination station', 'dest', True)
226
+ self.origPlace = lookupPlace('origin', 'starting')
227
+ self.destPlace = lookupPlace('destination', 'ending')
228
+ self.nearSystem = check('system', 'near', False)
229
+
230
+ def checkAvoids(self) -> None:
231
+ """
232
+ Process a list of avoidances.
233
+ """
234
+
235
+ avoidItems = self.avoidItems = []
236
+ avoidPlaces = self.avoidPlaces = []
237
+ avoidances = self.avoid
238
+ if not self.avoid:
239
+ return
240
+ avoidances = self.avoid
241
+
242
+ tdb = self.tdb
243
+
244
+ # You can use --avoid to specify an item, system or station.
245
+ # and you can group them together with commas or list them
246
+ # individually.
247
+ for avoid in ','.join(avoidances).split(','):
248
+ # Is it an item?
249
+ item, place = None, None
250
+ try:
251
+ item = tdb.lookupItem(avoid)
252
+ avoidItems.append(item)
253
+ if tdb.normalizedStr(item.name()) == tdb.normalizedStr(avoid):
254
+ continue
255
+ except LookupError:
256
+ pass
257
+ # Or is it a place?
258
+ try:
259
+ place = tdb.lookupPlace(avoid)
260
+ avoidPlaces.append(place)
261
+ if tdb.normalizedStr(place.name()) == tdb.normalizedStr(avoid):
262
+ continue
263
+ continue
264
+ except LookupError:
265
+ pass
266
+
267
+ # If it was none of the above, whine about it
268
+ if not (item or place):
269
+ raise CommandLineError("Unknown item/system/station: {}".format(avoid))
270
+
271
+ # But if it matched more than once, whine about ambiguity
272
+ if item and place:
273
+ raise AmbiguityError('Avoidance', avoid, [ item, place.text() ])
274
+
275
+ self.DEBUG0("Avoiding items {}, places {}",
276
+ [ item.name() for item in avoidItems ],
277
+ [ place.name() for place in avoidPlaces ],
278
+ )
279
+
280
+ def checkVias(self) -> None:
281
+ """ Process a list of station names and build them into a list of waypoints. """
282
+ viaPlaceNames = getattr(self, 'via', None)
283
+ viaPlaces = self.viaPlaces = []
284
+ # accept [ "a", "b,c", "d" ] by joining everything and then splitting it.
285
+ if viaPlaceNames:
286
+ for via in ",".join(viaPlaceNames).split(","):
287
+ viaPlaces.append(self.tdb.lookupPlace(via))
288
+
289
+ def checkPadSize(self) -> None:
290
+ padSize = getattr(self, 'padSize', None)
291
+ if not padSize:
292
+ return
293
+ padSize = ''.join(sorted(set(padSize))).upper()
294
+ if padSize == '?LMS':
295
+ self.padSize = None
296
+ return
297
+ self.padSize = padSize = padSize.upper()
298
+ for value in padSize:
299
+ if value not in 'SML?':
300
+ raise PadSizeError(padSize)
301
+ self.padSize = padSize
302
+
303
+ def checkPlanetary(self) -> None:
304
+ planetary = getattr(self, 'planetary', None)
305
+ if not planetary:
306
+ return
307
+ planetary = ''.join(sorted(set(planetary))).upper()
308
+ if planetary == '?NY':
309
+ self.planetary = None
310
+ return
311
+ self.planetary = planetary = planetary.upper()
312
+ for value in planetary:
313
+ if value not in 'YN?':
314
+ raise PlanetaryError(planetary)
315
+ self.planetary = planetary
316
+
317
+ def checkFleet(self) -> None:
318
+ fleet = getattr(self, 'fleet', None)
319
+ if not fleet:
320
+ return
321
+ fleet = ''.join(sorted(set(fleet))).upper()
322
+ for value in fleet:
323
+ if value not in 'YN?':
324
+ raise FleetCarrierError(fleet)
325
+ if fleet == '?NY':
326
+ self.fleet = None
327
+ return
328
+ self.fleet = fleet = fleet.upper()
329
+
330
+ def checkOdyssey(self) -> None:
331
+ odyssey = getattr(self, 'odyssey', None)
332
+ if not odyssey:
333
+ return
334
+ odyssey = ''.join(sorted(set(odyssey))).upper()
335
+ for value in odyssey:
336
+ if value not in 'YN?':
337
+ raise OdysseyError(odyssey)
338
+ if odyssey == '?NY':
339
+ self.odyssey = None
340
+ return
341
+ self.odyssey = odyssey.upper()
342
+
343
+ def colorize(self, color: str, raw_text: str) -> str:
344
+ """
345
+ Set up some coloring for readability.
346
+ TODO: Rich already does this, use it instead?
347
+ """
348
+ if (code := ANSI_COLOR.get(color)):
349
+ # Only do anything if there's a code for that.
350
+ return f"{ANSI_CSI}{code}{ANSI_COLOR_CMD}{raw_text}{ANSI_CLEAR}"
351
+ # Otherwise, keep it raw.
352
+ return raw_text
353
+
354
+
355
+ def update_database_schema(tdb: TradeDB | TradeORM) -> None:
356
+ """ Check if there are database changes to be made, and if so, execute them. """
357
+ # TODO: This should really be a function of the DB itself and not something
358
+ # the caller has to ask the database to do for it.
359
+ template_folder = getattr(tdb, "templatePath", None)
360
+ if not template_folder:
361
+ return
362
+
363
+ db_change = Path(template_folder, "database_changes.json")
364
+ if not db_change.exists():
365
+ return
366
+
367
+ try:
368
+ with db_change.open("r", encoding="utf-8") as file:
369
+ for change in ijson.items(file, 'item'):
370
+ tdb.getDB().execute(change)
371
+ finally:
372
+ db_change.unlink()
@@ -0,0 +1,94 @@
1
+ from ..tradeexcept import TradeException
2
+
3
+
4
+ ######################################################################
5
+ # Exceptions
6
+
7
+ class UsageError(TradeException):
8
+ def __init__(self, title, usage):
9
+ self.title, self.usage = title, usage
10
+
11
+ def __str__(self):
12
+ return f"{self.title}\n\n{self.usage}"
13
+
14
+
15
+ class CommandLineError(TradeException):
16
+ """
17
+ Raised when you provide invalid input on the command line.
18
+ Attributes:
19
+ errorstr What to tell the user.
20
+ """
21
+ def __init__(self, errorStr, usage=None):
22
+ self.errorStr, self.usage = errorStr, usage
23
+
24
+ def __str__(self):
25
+ if self.usage:
26
+ return f"ERROR: {self.errorStr}\n\n{self.usage}"
27
+ return f"ERROR: {self.errorStr}"
28
+
29
+
30
+ class NoDataError(TradeException):
31
+ """
32
+ Raised when a request is made for which no data can be found.
33
+ Attributes:
34
+ errorStr Describe the problem to the user.
35
+ """
36
+ def __init__(self, errorStr):
37
+ self.errorStr = errorStr
38
+
39
+ def __str__(self):
40
+ return f"""Error: {self.errorStr}
41
+ Possible causes:
42
+ - No profitable runs or routes meet your criteria,
43
+ - Missing Systems or Stations along the route (see "local -vv"),
44
+ - Missing price data (see "market -vv" or "update -h"),
45
+
46
+ If you are not sure where to get data from, consider using a crowd-sourcing
47
+ project such as EDDBlink (https://github.com/eyeonus/EDDBlink).
48
+
49
+ For more help, see the TradeDangerous Wiki:
50
+ https://github.com/eyeonus/Trade-Dangerous/wiki
51
+ """
52
+
53
+
54
+ class PadSizeError(CommandLineError):
55
+ """ Raised when an invalid pad-size option is given. """
56
+ def __init__(self, value):
57
+ super().__init__(
58
+ f"Invalid --pad-size '{value}': Use a combination of one or more "
59
+ "from 'S' for Small, 'M' for Medium, 'L' for Large or "
60
+ "'?' for unknown, e.g. 'SML?' matches any pad size while "
61
+ "'M?' matches medium or unknown or 'L' matches only large."
62
+ )
63
+
64
+
65
+ class PlanetaryError(CommandLineError):
66
+ """ Raised when an invalid planetary option is given. """
67
+ def __init__(self, value):
68
+ super().__init__(
69
+ f"Invalid --planetary '{value}': Use a combination of one or more "
70
+ "from 'Y' for Yes, 'N' for No or '?' for unknown, "
71
+ "e.g. 'YN?' matches any station while 'Y?' matches "
72
+ "yes or unknown, or 'N' matches only non-planetary stations."
73
+ )
74
+
75
+
76
+ class FleetCarrierError(CommandLineError):
77
+ """ Raised when an invalid fleet-carrier option is given. """
78
+ def __init__(self, value):
79
+ super().__init__(
80
+ f"Invalid --fleet-carrier '{value}': Use a combination of one or more "
81
+ "from 'Y' for Yes, 'N' for No or '?' for unknown, "
82
+ "e.g. 'YN?' matches any station while 'Y?' matches "
83
+ "yes or unknown, or 'N' matches only non-fleet-carrier stations."
84
+ )
85
+
86
+ class OdysseyError(CommandLineError):
87
+ """ Raised when an invalid odyssey option is given. """
88
+ def __init__(self, value):
89
+ super().__init__(
90
+ f"Invalid --odyssey '{value}': Use a combination of one or more "
91
+ "from 'Y' for Yes, 'N' for No or '?' for unknown, "
92
+ "e.g. 'YN?' matches any station while 'Y?' matches "
93
+ "yes or unknown, or 'N' matches only non-odyssey stations."
94
+ )
@@ -0,0 +1,150 @@
1
+ from ..csvexport import exportTableToFile
2
+ from .parsing import ParseArgument, MutuallyExclusiveGroup
3
+ from .exceptions import CommandLineError
4
+ from pathlib import Path
5
+
6
+ ######################################################################
7
+ # TradeDangerous :: Commands :: Export
8
+ #
9
+ # Generate the CSV files for the master data of the database.
10
+ #
11
+ ######################################################################
12
+ # CAUTION: If the database structure gets changed this script might
13
+ # need some corrections.
14
+ ######################################################################
15
+
16
+ ######################################################################
17
+ # Parser config
18
+
19
+ help='CSV exporter for TradeDangerous database.'
20
+ name='export'
21
+ epilog=(
22
+ "CAUTION: If you don't specify a different path, the current "
23
+ "CSV files in the data directory will be overwritten with "
24
+ "the current content of the database.\n "
25
+ "If you have changed any CSV file and didn't rebuild the "
26
+ "database, they will be lost.\n "
27
+ "Use the 'buildcache' command first to rebuild the database."
28
+ )
29
+ wantsTradeDB=False # because we don't want the DB to be rebuild
30
+ arguments = [
31
+ ]
32
+ switches = [
33
+ ParseArgument('--path',
34
+ help="Specify a different save location of the CSV files than the default.",
35
+ type=str,
36
+ default=None
37
+ ),
38
+ MutuallyExclusiveGroup(
39
+ ParseArgument('--tables', "-T",
40
+ help='Specify comma separated tablenames to export.',
41
+ metavar='TABLE[,TABLE,...]',
42
+ type=str,
43
+ default=None
44
+ ),
45
+ ParseArgument('--all-tables',
46
+ help='Include the price tables for export.',
47
+ dest='allTables',
48
+ action='store_true',
49
+ default=False
50
+ ),
51
+ ),
52
+ ParseArgument('--delete-empty',
53
+ help='Delete CSV files without content.',
54
+ dest='deleteEmpty',
55
+ action='store_true',
56
+ default=False
57
+ ),
58
+ ]
59
+
60
+ ######################################################################
61
+ # Perform query and populate result set
62
+
63
+ def run(results, cmdenv, tdb):
64
+ """
65
+ Backend-neutral export of DB tables to CSV.
66
+
67
+ Changes:
68
+ * Use tradedangerous.db.lifecycle.ensure_fresh_db(rebuild=False) to verify a usable DB
69
+ without rebuilding (works for SQLite and MariaDB).
70
+ * Backend-aware announcement of the source DB (file path for SQLite, DSN for others).
71
+ * Table enumeration via SQLAlchemy inspector (no sqlite_master, no COLLATE quirks).
72
+ """
73
+ # --- Sanity check the database without rebuilding (works for both backends) ---
74
+ from tradedangerous.db.lifecycle import ensure_fresh_db # local import avoids import-time tangles
75
+ summary = ensure_fresh_db(
76
+ backend=getattr(tdb.engine, "dialect", None).name if getattr(tdb, "engine", None) else "unknown",
77
+ engine=getattr(tdb, "engine", None),
78
+ data_dir=tdb.dataPath,
79
+ metadata=None,
80
+ mode="auto",
81
+ rebuild=False, # IMPORTANT: never rebuild from here; just report health
82
+ )
83
+ if summary.get("sane") != "Y":
84
+ reason = summary.get("reason", "unknown")
85
+ raise CommandLineError(
86
+ f"Database is not initialized/healthy (reason: {reason}). "
87
+ "Use 'buildcache' or an importer to (re)build it."
88
+ )
89
+
90
+ # --- Determine export target directory (same behavior as before) ---
91
+ exportPath = Path(cmdenv.path) if cmdenv.path else Path(tdb.dataDir)
92
+ if not exportPath.is_dir():
93
+ raise CommandLineError("Save location '{}' not found.".format(str(exportPath)))
94
+
95
+ # --- Announce which DB we will read from, backend-aware ---
96
+ try:
97
+ dialect = tdb.engine.dialect.name
98
+ if dialect == "sqlite":
99
+ source_label = f"SQLite file '{tdb.dbPath}'"
100
+ else:
101
+ # Hide password in DSN
102
+ source_label = f"{dialect} @ {tdb.engine.url.render_as_string(hide_password=True)}"
103
+ except Exception:
104
+ source_label = str(getattr(tdb, "dbPath", "Unknown DB"))
105
+ cmdenv.NOTE("Using database {}", source_label)
106
+
107
+ # --- Enumerate tables using SQLAlchemy inspector (backend-neutral) ---
108
+ from sqlalchemy import inspect
109
+ inspector = inspect(tdb.engine)
110
+ all_tables = inspector.get_table_names() # current schema / database
111
+
112
+ # Optional ignore list (preserve legacy default: skip StationItem unless --all-tables)
113
+ ignoreList = []
114
+ if not getattr(cmdenv, "allTables", False):
115
+ ignoreList.append("StationItem")
116
+
117
+ # --tables filtering (case-insensitive, like old COLLATE NOCASE)
118
+ if getattr(cmdenv, "tables", None):
119
+ requested = [t.strip() for t in cmdenv.tables.split(",") if t.strip()]
120
+ lower_map = {t.lower(): t for t in all_tables}
121
+ resolved = []
122
+ for name in requested:
123
+ found = lower_map.get(name.lower())
124
+ if found:
125
+ resolved.append(found)
126
+ else:
127
+ cmdenv.NOTE("Requested table '{}' not found; skipping", name)
128
+ table_list = sorted(set(resolved))
129
+ else:
130
+ table_list = sorted(set(all_tables))
131
+
132
+ # --- Export each table via csvexport (already refactored elsewhere) ---
133
+ for tableName in table_list:
134
+ if tableName in ignoreList:
135
+ cmdenv.NOTE("Ignore Table '{table}'", table=tableName)
136
+ continue
137
+
138
+ cmdenv.NOTE("Export Table '{table}'", table=tableName)
139
+
140
+ lineCount, filePath = exportTableToFile(tdb, cmdenv, tableName, exportPath)
141
+
142
+ # Optionally delete empty CSVs
143
+ if getattr(cmdenv, "deleteEmpty", False) and lineCount == 0:
144
+ try:
145
+ filePath.unlink(missing_ok=True)
146
+ cmdenv.DEBUG0("Delete empty file {file}", file=filePath)
147
+ except Exception as e:
148
+ cmdenv.DEBUG0("Failed to delete empty file {file}: {err}", file=filePath, err=e)
149
+
150
+ return False # we've handled everything