thistle-db 0.6.0__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.
thistle_db/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from thistle_db.api import get_tles
2
+ from thistle_db.cli import main
3
+
4
+ __all__ = ["get_tles", "main"]
thistle_db/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.6.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
+
24
+ __commit_id__ = commit_id = None
thistle_db/api.py ADDED
@@ -0,0 +1,61 @@
1
+ """Programmatic query API for the thistle-db database."""
2
+
3
+ import datetime
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.engine import Engine
7
+ from sqlalchemy.orm import Session, sessionmaker
8
+
9
+ from thistle_db.config import Settings, load_config
10
+ from thistle_db.model import TLE, Base
11
+
12
+
13
+ def open_session(config: Settings) -> tuple[Session, Engine]:
14
+ """Create the schema if needed and open a session on the configured database."""
15
+ engine = config.database.engine
16
+ Base.metadata.create_all(engine)
17
+ session_factory = sessionmaker(bind=engine)
18
+ return session_factory(), engine
19
+
20
+
21
+ def tles_for_object(session: Session, satnum: int) -> list[TLE]:
22
+ """All TLEs for one satellite, ordered by epoch."""
23
+ stmt = select(TLE).where(TLE.norad_cat_id == satnum).order_by(TLE.epoch)
24
+ return list(session.execute(stmt).scalars().all())
25
+
26
+
27
+ def nearest_tles_for_date(
28
+ session: Session, date: datetime.date, days: float
29
+ ) -> list[TLE]:
30
+ """Nearest TLE per satellite to 12:00 UTC on `date`, within +/- `days`."""
31
+ center = datetime.datetime.combine(date, datetime.time(12))
32
+ window = datetime.timedelta(days=days)
33
+ stmt = select(TLE).where(
34
+ TLE.epoch >= center - window,
35
+ TLE.epoch <= center + window,
36
+ )
37
+
38
+ nearest: dict[int, TLE] = {}
39
+ for tle in session.execute(stmt).scalars():
40
+ if tle.norad_cat_id is None:
41
+ continue
42
+ best = nearest.get(tle.norad_cat_id)
43
+ if best is None or abs(tle.epoch - center) < abs(best.epoch - center):
44
+ nearest[tle.norad_cat_id] = tle
45
+ return [nearest[satnum] for satnum in sorted(nearest)]
46
+
47
+
48
+ def get_tles(satnum: int, config: Settings | None = None) -> list[tuple[str, str]]:
49
+ """Return (line1, line2) pairs for one satellite, ordered by epoch.
50
+
51
+ Opens a session on the configured database (config.toml / THISTLE_DB_*
52
+ env vars when `config` is None) and disposes it before returning.
53
+ """
54
+ if config is None:
55
+ config = load_config(None)
56
+ session, engine = open_session(config)
57
+ try:
58
+ return [(tle.line1, tle.line2) for tle in tles_for_object(session, satnum)]
59
+ finally:
60
+ session.close()
61
+ engine.dispose()
thistle_db/cli.py ADDED
@@ -0,0 +1,286 @@
1
+ import datetime
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+ from loguru import logger
8
+ from sgp4.alpha5 import from_alpha5
9
+
10
+ from thistle_db.api import nearest_tles_for_date, open_session, tles_for_object
11
+ from thistle_db.config import load_config
12
+ from thistle_db.generator import generate as generate_outputs
13
+ from thistle_db.ingest import FileStatus, ingest_source_file, ingest_sources
14
+
15
+ app = typer.Typer(
16
+ name="thistle-db",
17
+ help="TLE database management tool",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+
22
+ def _setup_logging(level: str) -> None:
23
+ logger.remove()
24
+ logger.add(sys.stderr, level=level.upper())
25
+
26
+
27
+ CONFIG_TEMPLATE = """\
28
+ # thistle-db configuration
29
+ # See: https://github.com/jolsten/thistle
30
+
31
+ # ----------------------------------------------
32
+ # Database connection
33
+ # ----------------------------------------------
34
+ [database]
35
+ # SQLAlchemy driver string. Examples:
36
+ # "sqlite" - local SQLite file (default)
37
+ # "mysql+pymysql" - MariaDB/MySQL via PyMySQL (install with: pip install thistle-db[mysql])
38
+ drivername = "sqlite"
39
+
40
+ # For SQLite: path to the database file (relative or absolute)
41
+ # For MariaDB/MySQL: the database name
42
+ name = "thistle-db.db"
43
+
44
+ # Uncomment for MariaDB/MySQL:
45
+ # host = "localhost"
46
+ # port = 3306
47
+ #
48
+ # Credentials are loaded separately (never put passwords here).
49
+ # Resolution order (highest priority first):
50
+ # 1. Environment variables: THISTLE_DB_DATABASE__USERNAME / THISTLE_DB_DATABASE__PASSWORD
51
+ # 2. User secrets file: ~/.config/thistle-db.toml
52
+ # 3. System secrets file: set secrets_file below
53
+ #
54
+ # secrets_file = "/etc/thistle-db/secrets.toml"
55
+
56
+ # ----------------------------------------------
57
+ # Ingest sources
58
+ # ----------------------------------------------
59
+ # Each [[ingest.sources]] entry defines a directory to scan for TLE/OMM files.
60
+ # You can have multiple entries. File format is auto-detected by extension:
61
+ # .tle / .txt / .3le -> Two-Line Element format
62
+ # .json -> Space-Track OMM JSON
63
+ # .csv -> OMM CSV
64
+ # .xml -> OMM XML
65
+
66
+ [[ingest.sources]]
67
+ path = "./incoming"
68
+ pattern = "*.tle" # glob pattern for matching files
69
+
70
+ # Add more sources as needed:
71
+ # [[ingest.sources]]
72
+ # path = "/data/spacetrack/daily"
73
+ # pattern = "*.json"
74
+
75
+ # ----------------------------------------------
76
+ # Output generation
77
+ # ----------------------------------------------
78
+ [output]
79
+ dir = "./output" # directory where generated files are written
80
+
81
+ # Which output formats to produce
82
+ [output.formats]
83
+ tle = true # two-line element format (.tle files)
84
+ omm = true # OMM CSV format (.omm files)
85
+
86
+ # Which output file types to generate
87
+ [output.types]
88
+ date_files = true # one file per date: YYYYMMDD.{tle,omm}
89
+ # contains the latest TLE per satellite for that date
90
+ object_files = true # one file per satellite: NORAD_ID.{tle,omm}
91
+ # contains all TLEs for that satellite, ordered by epoch
92
+
93
+ # ----------------------------------------------
94
+ # Logging
95
+ # ----------------------------------------------
96
+ [logging]
97
+ level = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
98
+ """
99
+
100
+ SECRETS_TEMPLATE = """\
101
+ # thistle-db user secrets
102
+ # This file stores database credentials for your user account.
103
+ # Keep this file private (chmod 600 on Linux/macOS).
104
+ #
105
+ # These values are overridden by environment variables:
106
+ # THISTLE_DB_DATABASE__USERNAME
107
+ # THISTLE_DB_DATABASE__PASSWORD
108
+
109
+ username = ""
110
+ password = ""
111
+ """
112
+
113
+
114
+ @app.callback()
115
+ def main_callback(
116
+ ctx: typer.Context,
117
+ config: Annotated[
118
+ Path,
119
+ typer.Option(
120
+ "-c",
121
+ "--config",
122
+ envvar="THISTLE_DB_CONFIG",
123
+ help="Path to config.toml (default: $THISTLE_DB_CONFIG or ./config.toml)",
124
+ ),
125
+ ] = Path("config.toml"),
126
+ ) -> None:
127
+ ctx.obj = config
128
+
129
+
130
+ @app.command()
131
+ def init(ctx: typer.Context) -> None:
132
+ """Scaffold config.toml and ~/.config/thistle-db.toml."""
133
+ config_path: Path = ctx.obj
134
+ secrets_path = Path.home() / ".config" / "thistle-db.toml"
135
+
136
+ created = []
137
+
138
+ if config_path.exists():
139
+ print(f"Config already exists: {config_path}")
140
+ else:
141
+ config_path.parent.mkdir(parents=True, exist_ok=True)
142
+ config_path.write_text(CONFIG_TEMPLATE)
143
+ created.append(str(config_path))
144
+
145
+ if secrets_path.exists():
146
+ print(f"Secrets file already exists: {secrets_path}")
147
+ else:
148
+ secrets_path.parent.mkdir(parents=True, exist_ok=True)
149
+ secrets_path.write_text(SECRETS_TEMPLATE)
150
+ created.append(str(secrets_path))
151
+
152
+ if created:
153
+ print("Created:")
154
+ for path in created:
155
+ print(f" {path}")
156
+ print("\nEdit these files to configure your database and credentials.")
157
+ else:
158
+ print("Nothing to do - all files already exist.")
159
+
160
+
161
+ @app.command()
162
+ def ingest(
163
+ ctx: typer.Context,
164
+ files: Annotated[
165
+ Optional[list[Path]],
166
+ typer.Argument(
167
+ help="Specific files to ingest (if omitted, scans configured source dirs)"
168
+ ),
169
+ ] = None,
170
+ force: Annotated[
171
+ bool,
172
+ typer.Option(
173
+ "--force",
174
+ help="Re-ingest files even if their recorded state is unchanged",
175
+ ),
176
+ ] = False,
177
+ ) -> None:
178
+ """Ingest TLE/OMM files into the database."""
179
+ config = load_config(ctx.obj)
180
+ _setup_logging(config.logging.level)
181
+
182
+ session, engine = open_session(config)
183
+ try:
184
+ if files:
185
+ total = 0
186
+ failed = 0
187
+ for file in files:
188
+ # Explicitly named files always parse; state is still recorded.
189
+ status, count = ingest_source_file(session, file, force=True)
190
+ if status == FileStatus.FAILED:
191
+ failed += 1
192
+ total += count
193
+ logger.info(
194
+ f"Ingested {total} new records from {len(files)} files"
195
+ + (f" ({failed} failed)" if failed else "")
196
+ )
197
+ else:
198
+ total = ingest_sources(session, config.ingest.sources, force=force)
199
+ logger.info(f"Ingested {total} new records from configured sources")
200
+ finally:
201
+ session.close()
202
+ engine.dispose()
203
+
204
+
205
+ @app.command()
206
+ def generate(ctx: typer.Context) -> None:
207
+ """Generate output TLE/OMM files from the database."""
208
+ config = load_config(ctx.obj)
209
+ _setup_logging(config.logging.level)
210
+
211
+ session, engine = open_session(config)
212
+ try:
213
+ generate_outputs(session, config.output)
214
+ finally:
215
+ session.close()
216
+ engine.dispose()
217
+
218
+
219
+ def _parse_target(value: str) -> int | datetime.date:
220
+ """Parse a get-tle target: 8-digit YYYYMMDD -> date, else alpha-5 NORAD ID."""
221
+ if len(value) == 8 and value.isdigit():
222
+ try:
223
+ return datetime.datetime.strptime(value, "%Y%m%d").date()
224
+ except ValueError:
225
+ raise typer.BadParameter(
226
+ f"{value!r} is not a valid YYYYMMDD date"
227
+ ) from None
228
+ try:
229
+ return from_alpha5(value.upper())
230
+ except ValueError:
231
+ raise typer.BadParameter(
232
+ f"{value!r} is neither an alpha-5 NORAD ID nor a YYYYMMDD date"
233
+ ) from None
234
+
235
+
236
+ @app.command(name="get-tle")
237
+ def get_tle(
238
+ ctx: typer.Context,
239
+ target: Annotated[
240
+ str,
241
+ typer.Argument(
242
+ help="NORAD ID (alpha-5, e.g. 25544 or E5693) or date (YYYYMMDD)",
243
+ show_default=False,
244
+ ),
245
+ ],
246
+ days: Annotated[
247
+ float,
248
+ typer.Option(
249
+ "--days",
250
+ "-d",
251
+ help="Date mode: search window around the date, in days",
252
+ ),
253
+ ] = 7.0,
254
+ ) -> None:
255
+ """Print TLEs to stdout.
256
+
257
+ With a NORAD ID (alpha-5): all TLEs for that object, ordered by epoch.
258
+ With a YYYYMMDD date: the nearest TLE per object to 12:00 UTC on that
259
+ date, within +/- DAYS days.
260
+ """
261
+ parsed = _parse_target(target)
262
+
263
+ config = load_config(ctx.obj)
264
+ _setup_logging(config.logging.level)
265
+
266
+ session, engine = open_session(config)
267
+ try:
268
+ if isinstance(parsed, int):
269
+ tles = tles_for_object(session, parsed)
270
+ else:
271
+ tles = nearest_tles_for_date(session, parsed, days)
272
+
273
+ for tle in tles:
274
+ print(tle.line1)
275
+ print(tle.line2)
276
+ finally:
277
+ session.close()
278
+ engine.dispose()
279
+
280
+ if not tles:
281
+ logger.warning(f"No TLEs found for {target}")
282
+ raise typer.Exit(code=1)
283
+
284
+
285
+ def main() -> None:
286
+ app()
thistle_db/config.py ADDED
@@ -0,0 +1,118 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+ from sqlalchemy import Engine, create_engine
8
+ from sqlalchemy.engine import URL
9
+
10
+
11
+ class Database(BaseModel):
12
+ drivername: str = "sqlite"
13
+ username: Optional[str] = None
14
+ password: Optional[str] = None
15
+ host: Optional[str] = None
16
+ port: Optional[int] = None
17
+ name: Optional[str] = ":memory:"
18
+ secrets_file: Optional[str] = None
19
+
20
+ @property
21
+ def url(self) -> URL:
22
+ return URL.create(
23
+ drivername=self.drivername,
24
+ username=self.username,
25
+ password=self.password,
26
+ host=self.host,
27
+ port=self.port,
28
+ database=self.name,
29
+ )
30
+
31
+ @property
32
+ def engine(self) -> Engine:
33
+ return create_engine(self.url)
34
+
35
+
36
+ class IngestSource(BaseModel):
37
+ path: str
38
+ pattern: str = "*.tle"
39
+
40
+
41
+ class IngestConfig(BaseModel):
42
+ sources: list[IngestSource] = []
43
+
44
+
45
+ class OutputFormats(BaseModel):
46
+ tle: bool = True
47
+ omm: bool = True
48
+
49
+
50
+ class OutputTypes(BaseModel):
51
+ date_files: bool = True
52
+ object_files: bool = True
53
+
54
+
55
+ class OutputConfig(BaseModel):
56
+ dir: str = "./output"
57
+ formats: OutputFormats = OutputFormats()
58
+ types: OutputTypes = OutputTypes()
59
+
60
+
61
+ class LoggingConfig(BaseModel):
62
+ level: str = "INFO"
63
+
64
+
65
+ class Settings(BaseSettings):
66
+ model_config = SettingsConfigDict(
67
+ env_prefix="THISTLE_DB_",
68
+ env_nested_delimiter="__",
69
+ )
70
+
71
+ database: Database = Database()
72
+ ingest: IngestConfig = IngestConfig()
73
+ output: OutputConfig = OutputConfig()
74
+ logging: LoggingConfig = LoggingConfig()
75
+
76
+
77
+ def load_config(path: Path | None = None) -> Settings:
78
+ """Load settings from TOML file with layered credential resolution.
79
+
80
+ Resolution order (highest priority first):
81
+ 1. Environment variables (THISTLE_DB_DATABASE__USERNAME, etc.)
82
+ 2. User secrets (~/.config/thistle-db.toml)
83
+ 3. System secrets file (database.secrets_file in config)
84
+ 4. Values in config.toml
85
+ """
86
+ toml_data: dict = {}
87
+
88
+ if path is not None and path.exists():
89
+ with open(path, "rb") as f:
90
+ toml_data = tomllib.load(f)
91
+
92
+ # Load system secrets file if specified
93
+ db_data = toml_data.get("database", {})
94
+ secrets_file = db_data.get("secrets_file")
95
+ if secrets_file:
96
+ secrets_path = Path(secrets_file)
97
+ if secrets_path.exists():
98
+ with open(secrets_path, "rb") as f:
99
+ secrets = tomllib.load(f)
100
+ # Merge secrets into database config (secrets_file has lower priority
101
+ # than values already in db_data, which have lower priority than env vars)
102
+ for key in ("username", "password"):
103
+ if key in secrets and key not in db_data:
104
+ db_data[key] = secrets[key]
105
+ toml_data["database"] = db_data
106
+
107
+ # Load user-local secrets (~/.config/thistle-db.toml)
108
+ user_secrets_path = Path.home() / ".config" / "thistle-db.toml"
109
+ if user_secrets_path.exists():
110
+ with open(user_secrets_path, "rb") as f:
111
+ user_secrets = tomllib.load(f)
112
+ for key in ("username", "password"):
113
+ if key in user_secrets and key not in db_data:
114
+ db_data[key] = user_secrets[key]
115
+ toml_data["database"] = db_data
116
+
117
+ # pydantic-settings handles env var overrides automatically
118
+ return Settings(**toml_data)
@@ -0,0 +1,155 @@
1
+ import datetime
2
+ import pathlib
3
+
4
+ from loguru import logger
5
+ from sgp4.api import Satrec
6
+ from sgp4.exporter import export_omm
7
+ from sqlalchemy import func, select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from thistle_db.config import OutputConfig
11
+ from thistle_db.model import TLE
12
+ from thistle_db.reader import TLETuple, write_omm_csv, write_tle
13
+
14
+
15
+ def _reconstruct_satrec(tle: TLE) -> Satrec:
16
+ """Reconstruct a Satrec object from stored TLE lines."""
17
+ return Satrec.twoline2rv(tle.line1, tle.line2)
18
+
19
+
20
+ def _safe_export_omm(sat: Satrec, name: str) -> dict | None:
21
+ """Export OMM dict from Satrec, returning None if export fails."""
22
+ try:
23
+ return export_omm(sat, name)
24
+ except (ValueError, AttributeError):
25
+ return None
26
+
27
+
28
+ def _get_object_name(tle: TLE) -> str:
29
+ """Get object name from OmmMetadata if available, else fall back to object_id."""
30
+ if tle.omm_metadata and tle.omm_metadata.object_name:
31
+ return tle.omm_metadata.object_name
32
+ return tle.object_id
33
+
34
+
35
+ def generate_date_files(
36
+ session: Session, output_dir: pathlib.Path, config: OutputConfig
37
+ ) -> None:
38
+ """Generate one file per date with the latest TLE per object."""
39
+ # Get distinct dates (UTC date truncation of epoch)
40
+ date_query = select(func.date(TLE.epoch).label("d")).distinct()
41
+ dates = session.execute(date_query).scalars().all()
42
+
43
+ logger.info(f"Generating date files for {len(dates)} dates")
44
+
45
+ for raw_date in dates:
46
+ if raw_date is None:
47
+ continue
48
+
49
+ # func.date() returns str on SQLite and datetime.date on MariaDB/MySQL.
50
+ if isinstance(raw_date, datetime.date):
51
+ date_val = raw_date
52
+ else:
53
+ date_val = datetime.date.fromisoformat(str(raw_date))
54
+
55
+ # Subquery: max epoch per norad_cat_id on this date
56
+ subq = (
57
+ select(
58
+ TLE.norad_cat_id,
59
+ func.max(TLE.epoch).label("max_epoch"),
60
+ )
61
+ .where(func.date(TLE.epoch) == date_val)
62
+ .group_by(TLE.norad_cat_id)
63
+ .subquery()
64
+ )
65
+
66
+ # Join back to get full TLE rows
67
+ stmt = (
68
+ select(TLE)
69
+ .join(
70
+ subq,
71
+ (TLE.norad_cat_id == subq.c.norad_cat_id)
72
+ & (TLE.epoch == subq.c.max_epoch),
73
+ )
74
+ .order_by(TLE.norad_cat_id)
75
+ )
76
+ tles = session.execute(stmt).scalars().all()
77
+
78
+ if not tles:
79
+ continue
80
+
81
+ filename_date = date_val.strftime("%Y%m%d")
82
+
83
+ # TLE output
84
+ if config.formats.tle:
85
+ tle_path = output_dir / f"{filename_date}.tle"
86
+ tle_tuples: list[TLETuple] = [(t.line1, t.line2) for t in tles]
87
+ write_tle(tle_path, tle_tuples)
88
+
89
+ # OMM output
90
+ if config.formats.omm:
91
+ omm_path = output_dir / f"{filename_date}.omm"
92
+ omm_records = []
93
+ for tle in tles:
94
+ sat = _reconstruct_satrec(tle)
95
+ name = _get_object_name(tle)
96
+ record = _safe_export_omm(sat, name)
97
+ if record is not None:
98
+ omm_records.append(record)
99
+ write_omm_csv(omm_path, omm_records)
100
+
101
+
102
+ def generate_object_files(
103
+ session: Session, output_dir: pathlib.Path, config: OutputConfig
104
+ ) -> None:
105
+ """Generate one file per satellite with all its TLEs."""
106
+ # Get distinct NORAD IDs
107
+ norad_ids = (
108
+ session.execute(select(TLE.norad_cat_id).distinct().order_by(TLE.norad_cat_id))
109
+ .scalars()
110
+ .all()
111
+ )
112
+
113
+ logger.info(f"Generating object files for {len(norad_ids)} satellites")
114
+
115
+ for norad_id in norad_ids:
116
+ if norad_id is None:
117
+ continue
118
+
119
+ stmt = select(TLE).where(TLE.norad_cat_id == norad_id).order_by(TLE.epoch)
120
+ tles = session.execute(stmt).scalars().all()
121
+
122
+ if not tles:
123
+ continue
124
+
125
+ # TLE output
126
+ if config.formats.tle:
127
+ tle_path = output_dir / f"{norad_id}.tle"
128
+ tle_tuples: list[TLETuple] = [(t.line1, t.line2) for t in tles]
129
+ write_tle(tle_path, tle_tuples)
130
+
131
+ # OMM output
132
+ if config.formats.omm:
133
+ omm_path = output_dir / f"{norad_id}.omm"
134
+ omm_records = []
135
+ for tle in tles:
136
+ sat = _reconstruct_satrec(tle)
137
+ name = _get_object_name(tle)
138
+ record = _safe_export_omm(sat, name)
139
+ if record is not None:
140
+ omm_records.append(record)
141
+ write_omm_csv(omm_path, omm_records)
142
+
143
+
144
+ def generate(session: Session, config: OutputConfig) -> None:
145
+ """Generate all configured output files."""
146
+ output_dir = pathlib.Path(config.dir)
147
+ output_dir.mkdir(parents=True, exist_ok=True)
148
+
149
+ if config.types.date_files:
150
+ generate_date_files(session, output_dir, config)
151
+
152
+ if config.types.object_files:
153
+ generate_object_files(session, output_dir, config)
154
+
155
+ logger.info(f"Output generation complete → {output_dir}")