tradedangerous 12.0.0__py3-none-any.whl → 12.0.1__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.

Potentially problematic release.


This version of tradedangerous might be problematic. Click here for more details.

@@ -0,0 +1,455 @@
1
+ # tradedangerous/db/orm_models.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from sqlalchemy import (
7
+ MetaData,
8
+ ForeignKey,
9
+ Integer,
10
+ BigInteger,
11
+ String,
12
+ CHAR,
13
+ Enum,
14
+ Index,
15
+ UniqueConstraint,
16
+ CheckConstraint,
17
+ text,
18
+ Column,
19
+ DateTime,
20
+ )
21
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, object_session
22
+ from sqlalchemy.sql import expression
23
+ from sqlalchemy.ext.compiler import compiles
24
+ from sqlalchemy.types import TypeDecorator
25
+
26
+
27
+ # ---- Dialect-aware time utilities (moved before model usage) ----
28
+ class now6(expression.FunctionElement):
29
+ """CURRENT_TIMESTAMP with microseconds on MySQL/MariaDB; plain CURRENT_TIMESTAMP elsewhere."""
30
+ type = DateTime()
31
+ inherit_cache = True
32
+
33
+
34
+ @compiles(now6, "mysql")
35
+ @compiles(now6, "mariadb")
36
+ def _mysql_now6(element, compiler, **kw):
37
+ return "CURRENT_TIMESTAMP(6)"
38
+
39
+
40
+ @compiles(now6)
41
+ def _default_now(element, compiler, **kw):
42
+ return "CURRENT_TIMESTAMP"
43
+
44
+
45
+ class DateTime6(TypeDecorator):
46
+ """DATETIME that is DATETIME(6) on MySQL/MariaDB, generic DateTime elsewhere."""
47
+ impl = DateTime
48
+ cache_ok = True
49
+
50
+ def load_dialect_impl(self, dialect):
51
+ if dialect.name in ("mysql", "mariadb"):
52
+ from sqlalchemy.dialects.mysql import DATETIME as _MYSQL_DATETIME
53
+ return dialect.type_descriptor(_MYSQL_DATETIME(fsp=6))
54
+ return dialect.type_descriptor(DateTime())
55
+
56
+
57
+ # ---------- Dialect Helpers --------
58
+ class CIString(TypeDecorator):
59
+ """
60
+ Case-insensitive string type.
61
+ - SQLite → uses NOCASE collation
62
+ - MySQL/MariaDB → uses utf8mb4_unicode_ci
63
+ - Others → plain String
64
+ """
65
+ impl = String
66
+ cache_ok = True
67
+
68
+ def __init__(self, length, **kwargs):
69
+ super().__init__(length=length, **kwargs)
70
+
71
+ def load_dialect_impl(self, dialect):
72
+ if dialect.name == "sqlite":
73
+ return dialect.type_descriptor(String(self.impl.length, collation="NOCASE"))
74
+ elif dialect.name in ("mysql", "mariadb"):
75
+ return dialect.type_descriptor(String(self.impl.length, collation="utf8mb4_unicode_ci"))
76
+ else:
77
+ return dialect.type_descriptor(String(self.impl.length))
78
+
79
+
80
+ # ---------- Naming & Base ----------
81
+ naming_convention = {
82
+ "ix": "ix_%(table_name)s__%(column_0_N_name)s",
83
+ "uq": "uq_%(table_name)s__%(column_0_N_name)s",
84
+ "ck": "ck_%(table_name)s__%(column_0_name)s", # use column name, not constraint_name
85
+ "fk": "fk_%(table_name)s__%(column_0_N_name)s__%(referred_table_name)s",
86
+ "pk": "pk_%(table_name)s",
87
+ }
88
+ metadata = MetaData(naming_convention=naming_convention)
89
+
90
+
91
+ class Base(DeclarativeBase):
92
+ metadata = metadata
93
+
94
+ # ---------- Enums ----------
95
+ TriState = Enum(
96
+ "Y",
97
+ "N",
98
+ "?",
99
+ native_enum=False,
100
+ create_constraint=True,
101
+ validate_strings=True,
102
+ )
103
+
104
+ PadSize = Enum(
105
+ "S",
106
+ "M",
107
+ "L",
108
+ "?",
109
+ native_enum=False,
110
+ create_constraint=True,
111
+ validate_strings=True,
112
+ )
113
+
114
+
115
+ # ---------- Core Domain ----------
116
+ class Added(Base):
117
+ __tablename__ = "Added"
118
+
119
+ added_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
120
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False, unique=True)
121
+
122
+ # Relationships
123
+ systems: Mapped[list["System"]] = relationship(back_populates="added")
124
+
125
+
126
+ class System(Base):
127
+ __tablename__ = "System"
128
+
129
+ system_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
130
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
131
+ pos_x: Mapped[float] = mapped_column(nullable=False)
132
+ pos_y: Mapped[float] = mapped_column(nullable=False)
133
+ pos_z: Mapped[float] = mapped_column(nullable=False)
134
+ added_id: Mapped[int | None] = mapped_column(
135
+ ForeignKey("Added.added_id", onupdate="CASCADE", ondelete="CASCADE")
136
+ )
137
+ modified: Mapped[str] = mapped_column(
138
+ DateTime6(),
139
+ server_default=now6(),
140
+ onupdate=now6(),
141
+ nullable=False,
142
+ )
143
+
144
+ # Relationships
145
+ added: Mapped[Optional["Added"]] = relationship(back_populates="systems")
146
+ stations: Mapped[list["Station"]] = relationship(back_populates="system", cascade="all, delete-orphan")
147
+
148
+ __table_args__ = (
149
+ Index("idx_system_by_pos", "pos_x", "pos_y", "pos_z", "system_id"),
150
+ Index("idx_system_by_name", "name"),
151
+ )
152
+
153
+
154
+
155
+
156
+ class Station(Base):
157
+ __tablename__ = "Station"
158
+
159
+ station_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
160
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
161
+
162
+ # type widened; cascade semantics unchanged (DELETE only)
163
+ system_id: Mapped[int] = mapped_column(
164
+ BigInteger,
165
+ ForeignKey("System.system_id", ondelete="CASCADE"),
166
+ nullable=False,
167
+ )
168
+
169
+ ls_from_star: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
170
+
171
+ blackmarket: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
172
+ max_pad_size: Mapped[str] = mapped_column(PadSize, nullable=False, server_default=text("'?'"))
173
+ market: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
174
+ shipyard: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
175
+ outfitting: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
176
+ rearm: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
177
+ refuel: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
178
+ repair: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
179
+ planetary: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
180
+
181
+ type_id: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
182
+ modified: Mapped[str] = mapped_column(DateTime6(), server_default=now6(), onupdate=now6(), nullable=False)
183
+
184
+ # Relationships
185
+ system: Mapped["System"] = relationship(back_populates="stations")
186
+ items: Mapped[list["StationItem"]] = relationship(back_populates="station", cascade="all, delete-orphan")
187
+ ship_vendors: Mapped[list["ShipVendor"]] = relationship(back_populates="station", cascade="all, delete-orphan")
188
+ upgrade_vendors: Mapped[list["UpgradeVendor"]] = relationship(back_populates="station", cascade="all, delete-orphan")
189
+
190
+ __table_args__ = (
191
+ Index("idx_station_by_system", "system_id"),
192
+ Index("idx_station_by_name", "name"),
193
+ )
194
+
195
+
196
+
197
+
198
+ class Category(Base):
199
+ __tablename__ = "Category"
200
+
201
+ category_id: Mapped[int] = mapped_column(Integer, primary_key=True)
202
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
203
+
204
+ # Relationships
205
+ items: Mapped[list["Item"]] = relationship(back_populates="category")
206
+
207
+ __table_args__ = (Index("idx_category_by_name", "name"),)
208
+
209
+
210
+ class Item(Base):
211
+ __tablename__ = "Item"
212
+
213
+ item_id: Mapped[int] = mapped_column(Integer, primary_key=True)
214
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
215
+ category_id: Mapped[int] = mapped_column(
216
+ ForeignKey("Category.category_id", onupdate="CASCADE", ondelete="CASCADE"),
217
+ nullable=False,
218
+ )
219
+ ui_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
220
+ avg_price: Mapped[int | None] = mapped_column(Integer)
221
+ fdev_id: Mapped[int | None] = mapped_column(Integer)
222
+
223
+ # Relationships
224
+ category: Mapped["Category"] = relationship(back_populates="items")
225
+ stations: Mapped[list["StationItem"]] = relationship(back_populates="item", cascade="all, delete-orphan")
226
+
227
+ __table_args__ = (
228
+ Index("idx_item_by_fdevid", "fdev_id"),
229
+ Index("idx_item_by_category", "category_id"),
230
+ )
231
+
232
+
233
+
234
+ class StationItem(Base):
235
+ __tablename__ = "StationItem"
236
+
237
+ station_id: Mapped[int] = mapped_column(
238
+ BigInteger,
239
+ ForeignKey("Station.station_id", ondelete="CASCADE", onupdate="CASCADE"),
240
+ primary_key=True,
241
+ )
242
+ item_id: Mapped[int] = mapped_column(
243
+ ForeignKey("Item.item_id", ondelete="CASCADE", onupdate="CASCADE"),
244
+ primary_key=True,
245
+ )
246
+ demand_price: Mapped[int] = mapped_column(Integer, nullable=False)
247
+ demand_units: Mapped[int] = mapped_column(Integer, nullable=False)
248
+ demand_level: Mapped[int] = mapped_column(Integer, nullable=False)
249
+ supply_price: Mapped[int] = mapped_column(Integer, nullable=False)
250
+ supply_units: Mapped[int] = mapped_column(Integer, nullable=False)
251
+ supply_level: Mapped[int] = mapped_column(Integer, nullable=False)
252
+ modified: Mapped[str] = mapped_column(DateTime6(), server_default=now6(), onupdate=now6(), nullable=False)
253
+ from_live: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
254
+
255
+ # Relationships
256
+ station: Mapped["Station"] = relationship(back_populates="items")
257
+ item: Mapped["Item"] = relationship(back_populates="stations")
258
+
259
+ __table_args__ = (
260
+ Index("si_mod_stn_itm", "modified", "station_id", "item_id"),
261
+ Index("si_itm_dmdpr", "item_id", "demand_price", sqlite_where=text("demand_price > 0")),
262
+ Index("si_itm_suppr", "item_id", "supply_price", sqlite_where=text("supply_price > 0")),
263
+ {"sqlite_with_rowid": False},
264
+ )
265
+
266
+
267
+
268
+
269
+ class Ship(Base):
270
+ __tablename__ = "Ship"
271
+
272
+ ship_id: Mapped[int] = mapped_column(Integer, primary_key=True)
273
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
274
+ cost: Mapped[int | None] = mapped_column(Integer)
275
+
276
+ # Relationships
277
+ vendors: Mapped[list["ShipVendor"]] = relationship(back_populates="ship")
278
+
279
+
280
+ class ShipVendor(Base):
281
+ __tablename__ = "ShipVendor"
282
+
283
+ ship_id: Mapped[int] = mapped_column(
284
+ ForeignKey("Ship.ship_id", ondelete="CASCADE", onupdate="CASCADE"),
285
+ primary_key=True,
286
+ )
287
+ station_id: Mapped[int] = mapped_column(
288
+ BigInteger,
289
+ ForeignKey("Station.station_id", ondelete="CASCADE", onupdate="CASCADE"),
290
+ primary_key=True,
291
+ )
292
+ modified: Mapped[str] = mapped_column(DateTime6(), server_default=now6(), onupdate=now6(), nullable=False)
293
+
294
+ # Relationships
295
+ ship: Mapped["Ship"] = relationship(back_populates="vendors")
296
+ station: Mapped["Station"] = relationship(back_populates="ship_vendors")
297
+
298
+ __table_args__ = (Index("idx_shipvendor_by_station", "station_id"),)
299
+
300
+
301
+ class Upgrade(Base):
302
+ __tablename__ = "Upgrade"
303
+
304
+ upgrade_id: Mapped[int] = mapped_column(Integer, primary_key=True)
305
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
306
+ class_: Mapped[int] = mapped_column("class", Integer, nullable=False)
307
+ rating: Mapped[str] = mapped_column(CHAR(1), nullable=False)
308
+ ship: Mapped[str | None] = mapped_column(CIString(128))
309
+
310
+ # Relationships
311
+ vendors: Mapped[list["UpgradeVendor"]] = relationship(back_populates="upgrade")
312
+
313
+
314
+ class UpgradeVendor(Base):
315
+ __tablename__ = "UpgradeVendor"
316
+
317
+ upgrade_id: Mapped[int] = mapped_column(
318
+ ForeignKey("Upgrade.upgrade_id", ondelete="CASCADE", onupdate="CASCADE"),
319
+ primary_key=True,
320
+ )
321
+ station_id: Mapped[int] = mapped_column(
322
+ BigInteger,
323
+ ForeignKey("Station.station_id", ondelete="CASCADE", onupdate="CASCADE"),
324
+ primary_key=True,
325
+ )
326
+ modified: Mapped[str] = mapped_column(DateTime6(), nullable=False, server_default=now6(), onupdate=now6())
327
+
328
+ # Relationships
329
+ upgrade: Mapped["Upgrade"] = relationship(back_populates="vendors")
330
+ station: Mapped["Station"] = relationship(back_populates="upgrade_vendors")
331
+
332
+ __table_args__ = (Index("idx_vendor_by_station_id", "station_id"),)
333
+
334
+
335
+ class RareItem(Base):
336
+ __tablename__ = "RareItem"
337
+
338
+ rare_id: Mapped[int] = mapped_column(Integer, primary_key=True)
339
+ station_id: Mapped[int] = mapped_column(
340
+ BigInteger,
341
+ ForeignKey("Station.station_id", ondelete="CASCADE", onupdate="CASCADE"),
342
+ nullable=False,
343
+ )
344
+ category_id: Mapped[int] = mapped_column(
345
+ ForeignKey("Category.category_id", onupdate="CASCADE", ondelete="CASCADE"),
346
+ nullable=False,
347
+ )
348
+ name: Mapped[str] = mapped_column(CIString(128), nullable=False)
349
+ cost: Mapped[int | None] = mapped_column(Integer)
350
+ max_allocation: Mapped[int | None] = mapped_column(Integer)
351
+ illegal: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
352
+ suppressed: Mapped[str] = mapped_column(TriState, nullable=False, server_default=text("'?'"))
353
+
354
+ __table_args__ = (UniqueConstraint("name", name="uq_rareitem_name"),)
355
+
356
+
357
+
358
+
359
+ class FDevShipyard(Base):
360
+ __tablename__ = "FDevShipyard"
361
+
362
+ id = Column(Integer, primary_key=True, unique=True, nullable=False)
363
+ symbol = Column(CIString(128))
364
+ name = Column(CIString(128))
365
+ entitlement = Column(String(50))
366
+
367
+
368
+ class FDevOutfitting(Base):
369
+ __tablename__ = "FDevOutfitting"
370
+
371
+ id = Column(Integer, primary_key=True, unique=True, nullable=False)
372
+ symbol = Column(CIString(128))
373
+ category = Column(String(10))
374
+ name = Column(CIString(128))
375
+ mount = Column(String(20))
376
+ guidance = Column(String(20))
377
+ ship = Column(CIString(128))
378
+ class_ = Column("class", String(1), nullable=False)
379
+ rating = Column(String(1), nullable=False)
380
+ entitlement = Column(String(50))
381
+
382
+ __table_args__ = (
383
+ CheckConstraint(
384
+ "category IN ('hardpoint','internal','standard','utility')",
385
+ name="ck_fdo_category",
386
+ ),
387
+ CheckConstraint(
388
+ "(mount IN ('Fixed','Gimballed','Turreted')) OR (mount IS NULL)",
389
+ name="ck_fdo_mount",
390
+ ),
391
+ CheckConstraint(
392
+ "(guidance IN ('Dumbfire','Seeker','Swarm')) OR (guidance IS NULL)",
393
+ name="ck_fdo_guidance",
394
+ ),
395
+ )
396
+
397
+
398
+ # ---------- Control & Staging ----------
399
+ class ExportControl(Base):
400
+ """
401
+ Singleton control row for hybrid export/watermarking.
402
+ - id: always 1
403
+ - last_full_dump_time: watermark
404
+ - last_reset_key: optional cursor for chunked from_live resets
405
+ """
406
+ __tablename__ = "ExportControl"
407
+
408
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
409
+ last_full_dump_time: Mapped[str] = mapped_column(DateTime6(), nullable=False)
410
+ last_reset_key: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
411
+
412
+
413
+ class StationItemStaging(Base):
414
+ """
415
+ Staging table for bulk loads (no FKs). Same columns as StationItem.
416
+ """
417
+ __tablename__ = "StationItem_staging"
418
+
419
+ station_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
420
+ item_id: Mapped[int] = mapped_column(Integer, primary_key=True)
421
+ demand_price: Mapped[int] = mapped_column(Integer, nullable=False)
422
+ demand_units: Mapped[int] = mapped_column(Integer, nullable=False)
423
+ demand_level: Mapped[int] = mapped_column(Integer, nullable=False)
424
+ supply_price: Mapped[int] = mapped_column(Integer, nullable=False)
425
+ supply_units: Mapped[int] = mapped_column(Integer, nullable=False)
426
+ supply_level: Mapped[int] = mapped_column(Integer, nullable=False)
427
+ modified: Mapped[str] = mapped_column(DateTime6(), server_default=now6(), onupdate=now6(), nullable=False)
428
+ from_live: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
429
+
430
+ __table_args__ = (Index("idx_sistaging_stn_itm", "station_id", "item_id"),)
431
+
432
+
433
+
434
+
435
+ __all__ = [
436
+ # Base
437
+ "Base",
438
+ # Core
439
+ "Added",
440
+ "System",
441
+ "Station",
442
+ "Category",
443
+ "Item",
444
+ "StationItem",
445
+ "Ship",
446
+ "ShipVendor",
447
+ "Upgrade",
448
+ "UpgradeVendor",
449
+ "RareItem",
450
+ "FDevShipyard",
451
+ "FDevOutfitting",
452
+ # Control & staging
453
+ "ExportControl",
454
+ "StationItemStaging",
455
+ ]
@@ -0,0 +1,112 @@
1
+ # tradedangerous/db/paths.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import os
6
+ from typing import Any
7
+
8
+ try:
9
+ import configparser
10
+ except Exception: # pragma: no cover
11
+ configparser = None # type: ignore
12
+
13
+ __all__ = [
14
+ "ensure_dir",
15
+ "resolve_data_dir",
16
+ "resolve_tmp_dir",
17
+ "resolve_db_config_path",
18
+ "get_sqlite_db_path",
19
+ ]
20
+
21
+ # --------------------------
22
+ # Helpers that tolerate either ConfigParser or dict
23
+ # --------------------------
24
+
25
+ def _is_cfg(obj: Any) -> bool:
26
+ return hasattr(obj, "has_option") and hasattr(obj, "get")
27
+
28
+ def _get_opt(cfg: Any, section: str, key: str, default: str | None = None) -> str | None:
29
+ """Return option from either a ConfigParser-like object or a nested dict.
30
+ Falls back to [database] overlay for convenience, matching legacy behaviour.
31
+ Safe even if cfg is None.
32
+ """
33
+ if cfg is None:
34
+ return default
35
+ # ConfigParser branch
36
+ if _is_cfg(cfg):
37
+ try:
38
+ if cfg.has_option(section, key):
39
+ return cfg.get(section, key) # type: ignore[arg-type]
40
+ if cfg.has_option("database", key):
41
+ return cfg.get("database", key) # type: ignore[arg-type]
42
+ except Exception:
43
+ return default
44
+ return default
45
+ # Mapping/dict branch
46
+ try:
47
+ sec = (cfg or {}).get(section, {}) or {}
48
+ if key in sec and sec[key] not in (None, ""):
49
+ return sec[key]
50
+ db = (cfg or {}).get("database", {}) or {}
51
+ if key in db and db[key] not in (None, ""):
52
+ return db[key]
53
+ except Exception:
54
+ pass
55
+ return default
56
+
57
+ def _resolve_dir(default_rel: str, env_key: str, cfg_value: str | None) -> Path:
58
+ cand = os.getenv(env_key) or (cfg_value or default_rel)
59
+ p = Path(cand).expanduser()
60
+ return p if p.is_absolute() else (Path.cwd() / p)
61
+
62
+ # --------------------------
63
+ # Public API
64
+ # --------------------------
65
+
66
+ def ensure_dir(pathlike: os.PathLike | str) -> Path:
67
+ """Create directory if missing (idempotent) and return the Path."""
68
+ p = Path(pathlike)
69
+ p.mkdir(parents=True, exist_ok=True)
70
+ return p
71
+
72
+ def resolve_data_dir(cfg: Any = None) -> Path:
73
+ """Resolve the persistent data directory.
74
+
75
+ Precedence: TD_DATA env > cfg[paths|database].data_dir > ./data
76
+ Always creates the directory.
77
+ """
78
+ val = _get_opt(cfg, "paths", "data_dir") or _get_opt(cfg, "database", "data_dir")
79
+ p = _resolve_dir("./data", "TD_DATA", val)
80
+ return ensure_dir(p)
81
+
82
+ def resolve_tmp_dir(cfg: Any = None) -> Path:
83
+ """Resolve the temporary directory.
84
+
85
+ Precedence: TD_TMP env > cfg[paths|database].tmp_dir > ./tmp
86
+ Always creates the directory.
87
+ """
88
+ val = _get_opt(cfg, "paths", "tmp_dir") or _get_opt(cfg, "database", "tmp_dir")
89
+ p = _resolve_dir("./tmp", "TD_TMP", val)
90
+ return ensure_dir(p)
91
+
92
+ def get_sqlite_db_path(cfg: Any = None) -> Path:
93
+ """Return full path to the SQLite DB file (does not create the file).
94
+
95
+ Data dir is resolved via resolve_data_dir(cfg). Filename comes from:
96
+ cfg[sqlite].sqlite_filename or cfg[database].sqlite_filename or legacy default 'TradeDangerous.db'.
97
+ """
98
+ data_dir = resolve_data_dir(cfg)
99
+ filename = (
100
+ _get_opt(cfg, "sqlite", "sqlite_filename")
101
+ or _get_opt(cfg, "database", "sqlite_filename")
102
+ or "TradeDangerous.db" # legacy default matches shipped tests/fixtures
103
+ )
104
+ return (data_dir / filename).resolve()
105
+
106
+ def resolve_db_config_path(default_name: str = "db_config.ini") -> Path:
107
+ """Honor TD_DB_CONFIG env var for config file path, else default_name in CWD.
108
+ Does not read or validate contents; just returns a Path.
109
+ """
110
+ cand = os.getenv("TD_DB_CONFIG") or default_name
111
+ p = Path(cand).expanduser()
112
+ return p if p.is_absolute() else (Path.cwd() / p)