fgsmdb 0.1.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.
fgsmdb/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """fgsmdb — FlightGear Scenemodels Database Tools."""
2
+
3
+ __version__ = "0.1.0"
fgsmdb/__main__.py ADDED
@@ -0,0 +1,34 @@
1
+ """CLI entry point for fgsmdb.
2
+
3
+ Usage:
4
+ python3 -m fgsmdb export [options]
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+
10
+ from .commands import export
11
+
12
+
13
+ def main():
14
+ parser = argparse.ArgumentParser(
15
+ prog="fgsmdb",
16
+ description="FlightGear Scenemodels Database Tools",
17
+ )
18
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
19
+
20
+ # Register the export subcommand
21
+ export.register(subparsers)
22
+
23
+ args = parser.parse_args()
24
+
25
+ if args.command is None:
26
+ parser.print_help()
27
+ sys.exit(1)
28
+
29
+ # Dispatch to the command handler
30
+ args.func(args)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1 @@
1
+ # fgsmdb command modules
@@ -0,0 +1,180 @@
1
+ """Export command — orchestrates the full scenemodels export pipeline.
2
+
3
+ Usage:
4
+ python3 -m fgsmdb export [--env-file .env] [--steps sanitize,gndelev,export,mkdiridx,upload,distribute]
5
+ python3 -m fgsmdb export --all
6
+ python3 -m fgsmdb export --steps export,mkdiridx
7
+
8
+ Available steps:
9
+ sanitize - Fix data integrity (tile numbers, elevation offsets)
10
+ gndelev - Compute ground elevations using fgelev
11
+ export - Export models and STG files from the database
12
+ mkdiridx - Create .dirindex files and Airports archive
13
+ upload - Sync export directory to local scenery tree
14
+ distribute - Rsync scenery to mirror servers
15
+ """
16
+
17
+ import logging
18
+ import os
19
+ import tarfile
20
+
21
+ from ..config import load_config
22
+ from ..db import Database
23
+ from ..steps import sanitize, gndelev, export_models, export_stg, dirindex, sync, rsync_distribute
24
+
25
+ ALL_STEPS = ["sanitize", "gndelev", "export", "mkdiridx", "upload"]
26
+ VALID_STEPS = ALL_STEPS + ["distribute"]
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def register(subparsers):
32
+ """Register the export subcommand with the argument parser."""
33
+ parser = subparsers.add_parser(
34
+ "export",
35
+ help="Export scenemodels database to TerraSync file tree",
36
+ description="Export the FlightGear scenemodels database into a "
37
+ "TerraSync-compatible file tree and optionally distribute to mirrors.",
38
+ )
39
+ parser.add_argument(
40
+ "--env-file",
41
+ default=None,
42
+ help="Path to .env file (default: auto-detect)",
43
+ )
44
+ parser.add_argument(
45
+ "--steps",
46
+ default=None,
47
+ help="Comma-separated list of steps to run (default: all except distribute)",
48
+ )
49
+ parser.add_argument(
50
+ "--all",
51
+ action="store_true",
52
+ help="Run all steps (equivalent to: %s)" % ",".join(ALL_STEPS),
53
+ )
54
+ parser.add_argument(
55
+ "--skip-check",
56
+ action="store_true",
57
+ help="Skip the pending-updates threshold check",
58
+ )
59
+ parser.add_argument(
60
+ "--min-updates",
61
+ type=int,
62
+ default=None,
63
+ help="Override MIN_UPDATES threshold from .env",
64
+ )
65
+ parser.add_argument(
66
+ "--verbose", "-v",
67
+ action="store_true",
68
+ help="Enable debug logging",
69
+ )
70
+ parser.set_defaults(func=run)
71
+
72
+
73
+ def run(args):
74
+ """Execute the export pipeline."""
75
+ # Setup logging
76
+ log_level = logging.DEBUG if args.verbose else logging.INFO
77
+ logging.basicConfig(
78
+ level=log_level,
79
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
80
+ datefmt="%Y-%m-%d %H:%M:%S",
81
+ )
82
+
83
+ # Load configuration
84
+ config = load_config(args.env_file)
85
+
86
+ # Determine which steps to run
87
+ if args.steps:
88
+ steps = [s.strip() for s in args.steps.split(",")]
89
+ for s in steps:
90
+ if s not in VALID_STEPS:
91
+ logger.error("Unknown step: %s. Valid steps: %s", s, ", ".join(VALID_STEPS))
92
+ raise SystemExit(1)
93
+ else:
94
+ steps = ALL_STEPS
95
+
96
+ # Override min_updates if specified
97
+ min_updates = args.min_updates if args.min_updates is not None else config["min_updates"]
98
+
99
+ # Connect to the database
100
+ db = Database(config["pguri"])
101
+
102
+ try:
103
+ # Always update statistics first
104
+ logger.info("Updating statistics...")
105
+ db.execute("SELECT fn_update_statistics()")
106
+
107
+ # Check if there are enough pending updates
108
+ if not args.skip_check:
109
+ rows = db.execute(
110
+ "SELECT count(*) AS cnt FROM fgs_objects WHERE ob_gndelev = -9999"
111
+ )
112
+ count = rows[0]["cnt"] if rows else 0
113
+ logger.info("Pending model updates: %d (minimum: %d)", count, min_updates)
114
+ if count < min_updates:
115
+ logger.info("Not enough pending updates. Exiting.")
116
+ return
117
+
118
+ # Run pipeline steps
119
+ if "sanitize" in steps:
120
+ sanitize.run(db)
121
+
122
+ if "gndelev" in steps:
123
+ gndelev.run(
124
+ db,
125
+ fgelev_path=config["fgelev_path"],
126
+ fg_root=config["fg_root"],
127
+ fg_scenery=config["fg_scenery"],
128
+ )
129
+
130
+ if "export" in steps:
131
+ export_models.run(db, target=config["fg_scenery_export"])
132
+ export_stg.run(db, target=config["fg_scenery_export"])
133
+
134
+ if "mkdiridx" in steps:
135
+ dirindex.create_root_dirindex(
136
+ fg_scenery=config["fg_scenery"],
137
+ fg_scenery_export=config["fg_scenery_export"],
138
+ )
139
+
140
+ if "upload" in steps:
141
+ sync.run(
142
+ fg_scenery_export=config["fg_scenery_export"],
143
+ fg_scenery=config["fg_scenery"],
144
+ )
145
+
146
+ # Update statistics after export
147
+ if any(s in steps for s in ("export", "upload")):
148
+ db.execute("SELECT fn_update_statistics()")
149
+
150
+ # Create SharedModels tarball
151
+ if "upload" in steps:
152
+ _create_shared_models_tarball(config["fg_scenery"])
153
+
154
+ # Distribution (rsync to mirrors)
155
+ if "distribute" in steps:
156
+ rsync_distribute.run(
157
+ fg_scenery=config["fg_scenery"],
158
+ sync_targets=config["sync_targets"],
159
+ cdn_invalidate_cmd=config["cdn_invalidate_cmd"],
160
+ )
161
+
162
+ logger.info("Export pipeline complete.")
163
+
164
+ finally:
165
+ db.close()
166
+
167
+
168
+ def _create_shared_models_tarball(fg_scenery: str):
169
+ """Create SharedModels.txz from the Models directory."""
170
+ models_path = os.path.join(fg_scenery, "Models")
171
+ output_path = os.path.join(fg_scenery, "SharedModels.txz")
172
+
173
+ if not os.path.isdir(models_path):
174
+ logger.warning("No Models directory at %s, skipping tarball.", models_path)
175
+ return
176
+
177
+ logger.info("Creating SharedModels.txz...")
178
+ with tarfile.open(output_path, "w:xz") as tar:
179
+ tar.add(models_path, arcname="Models")
180
+ logger.info("SharedModels.txz created at %s", output_path)
fgsmdb/config.py ADDED
@@ -0,0 +1,37 @@
1
+ """Configuration loader — reads .env and provides settings to all modules."""
2
+
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+
7
+ def load_config(env_file: str | None = None) -> dict:
8
+ """Load configuration from .env file and environment variables.
9
+
10
+ Args:
11
+ env_file: Path to .env file. If None, searches in current directory
12
+ and parent directories.
13
+
14
+ Returns:
15
+ Dictionary with all configuration values.
16
+ """
17
+ if env_file:
18
+ load_dotenv(env_file)
19
+ else:
20
+ load_dotenv()
21
+
22
+ config = {
23
+ "pguri": os.environ["PGURI"],
24
+ "fgelev_path": os.environ.get("FGELEV_PATH", "/usr/local/bin/fgelev"),
25
+ "fg_root": os.environ.get("FG_ROOT", "/app/data"),
26
+ "fg_scenery": os.environ.get("FG_SCENERY", "./fgscenery"),
27
+ "fg_scenery_export": os.environ.get("FG_SCENERY_EXPORT", "./export"),
28
+ "groundnets_path": os.environ.get("GROUNDNETS_PATH", "./groundnets"),
29
+ "min_updates": int(os.environ.get("MIN_UPDATES", "1")),
30
+ "sync_targets": [
31
+ t.strip()
32
+ for t in os.environ.get("SYNC_TARGETS", "").split(",")
33
+ if t.strip()
34
+ ],
35
+ "cdn_invalidate_cmd": os.environ.get("CDN_INVALIDATE_CMD", ""),
36
+ }
37
+ return config
fgsmdb/db.py ADDED
@@ -0,0 +1,66 @@
1
+ """Database connection helper with automatic reconnect."""
2
+
3
+ import logging
4
+ import psycopg2
5
+ import psycopg2.extras
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class Database:
11
+ """Thin wrapper around psycopg2 with reconnect logic."""
12
+
13
+ def __init__(self, pguri: str):
14
+ self.pguri = pguri
15
+ self._conn = None
16
+
17
+ def connect(self):
18
+ """Establish database connection."""
19
+ if self._conn is None or self._conn.closed:
20
+ logger.info("Connecting to database...")
21
+ self._conn = psycopg2.connect(self.pguri)
22
+ self._conn.autocommit = True
23
+ return self._conn
24
+
25
+ @property
26
+ def conn(self):
27
+ return self.connect()
28
+
29
+ def cursor(self):
30
+ """Return a DictCursor, reconnecting if needed."""
31
+ return self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
32
+
33
+ def execute(self, sql: str, params=None, retries: int = 3):
34
+ """Execute a query with automatic reconnect on OperationalError.
35
+
36
+ Returns:
37
+ List of result rows, or empty list for non-SELECT statements.
38
+ """
39
+ for attempt in range(retries):
40
+ try:
41
+ cur = self.cursor()
42
+ cur.execute(sql, params)
43
+ if cur.description:
44
+ return cur.fetchall()
45
+ return []
46
+ except psycopg2.OperationalError:
47
+ logger.warning(
48
+ "Database connection lost (attempt %d/%d), reconnecting...",
49
+ attempt + 1,
50
+ retries,
51
+ )
52
+ self._conn = None
53
+ if attempt == retries - 1:
54
+ raise
55
+
56
+ def execute_batch(self, statements: list[str]):
57
+ """Execute multiple SQL statements in sequence."""
58
+ cur = self.cursor()
59
+ for stmt in statements:
60
+ cur.execute(stmt)
61
+
62
+ def close(self):
63
+ """Close the database connection."""
64
+ if self._conn and not self._conn.closed:
65
+ self._conn.close()
66
+ self._conn = None
@@ -0,0 +1 @@
1
+ # fgsmdb pipeline step modules
@@ -0,0 +1,147 @@
1
+ """Directory index generation — creates .dirindex files for TerraSync.
2
+
3
+ Equivalent to the legacy CreateDirectoryIndexes.py and mkdiridx.sh scripts.
4
+
5
+ TerraSync .dirindex format:
6
+ version:1
7
+ path:<relative_path>
8
+ f:<filename>:<sha1>:<size> (regular files)
9
+ t:<filename>:<sha1>:<size> (tar archives)
10
+ d:<dirname>:<sha1_of_dirindex> (subdirectories)
11
+ """
12
+
13
+ import hashlib
14
+ import logging
15
+ import os
16
+ import tarfile
17
+ from datetime import datetime, timezone
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ DIRINDEX_VERSION = 1
22
+ DIRINDEX_FILENAME = ".dirindex"
23
+
24
+
25
+ def sha1_of_file(path: str) -> str:
26
+ """Compute SHA1 hash of a file."""
27
+ h = hashlib.sha1()
28
+ try:
29
+ with open(path, "rb") as f:
30
+ for chunk in iter(lambda: f.read(4096), b""):
31
+ h.update(chunk)
32
+ except OSError:
33
+ pass
34
+ return h.hexdigest()
35
+
36
+
37
+ def create_dirindex(path: str, parent: str = "") -> str:
38
+ """Recursively create .dirindex files in a directory tree.
39
+
40
+ Args:
41
+ path: Absolute or relative path to the directory.
42
+ parent: Relative path prefix for the dirindex 'path:' field.
43
+
44
+ Returns:
45
+ SHA1 hash of the generated .dirindex file.
46
+ """
47
+ dirindex_path = os.path.join(path, DIRINDEX_FILENAME)
48
+
49
+ entries = sorted(os.listdir(path))
50
+
51
+ with open(dirindex_path, "w") as f:
52
+ f.write(f"version:{DIRINDEX_VERSION}\n")
53
+ f.write(f"path:{parent}\n")
54
+
55
+ for entry in entries:
56
+ full_path = os.path.join(path, entry)
57
+
58
+ if entry == DIRINDEX_FILENAME:
59
+ continue
60
+
61
+ if os.path.isfile(full_path):
62
+ file_hash = sha1_of_file(full_path)
63
+ file_size = os.stat(full_path).st_size
64
+ if entry.endswith(".tgz") or entry.endswith(".txz"):
65
+ f.write(f"t:{entry}:{file_hash}:{file_size}\n")
66
+ else:
67
+ f.write(f"f:{entry}:{file_hash}:{file_size}\n")
68
+
69
+ elif os.path.isdir(full_path) and entry != ".svn":
70
+ child_parent = os.path.join(parent, entry) if parent else entry
71
+ child_hash = create_dirindex(full_path, child_parent)
72
+ f.write(f"d:{entry}:{child_hash}\n")
73
+
74
+ return sha1_of_file(dirindex_path)
75
+
76
+
77
+ def create_root_dirindex(fg_scenery: str, fg_scenery_export: str):
78
+ """Create the root .dirindex and Airports archive.
79
+
80
+ This combines data from the remote scenery (Airports, Terrain) with
81
+ exported data (Objects, Models) into a unified root .dirindex.
82
+
83
+ Args:
84
+ fg_scenery: Path to the remote/mounted scenery (has Airports, Terrain).
85
+ fg_scenery_export: Path to the export directory (has Objects, Models).
86
+ """
87
+ logger.info("Creating directory indexes...")
88
+
89
+ # Create Airports dirindex and archive
90
+ airports_path = os.path.join(fg_scenery, "Airports")
91
+ if os.path.isdir(airports_path):
92
+ logger.info("Creating Airports .dirindex...")
93
+ create_dirindex(airports_path, "Airports")
94
+
95
+ # Create Airports archive
96
+ archive_path = os.path.join(fg_scenery, "Airports_archive.tgz")
97
+ logger.info("Creating Airports_archive.tgz...")
98
+ with tarfile.open(archive_path, "w:gz") as tar:
99
+ tar.add(airports_path, arcname="Airports")
100
+
101
+ # Create Objects and Models dirindexes in the export directory
102
+ objects_path = os.path.join(fg_scenery_export, "Objects")
103
+ if os.path.isdir(objects_path):
104
+ logger.info("Creating Objects .dirindex...")
105
+ create_dirindex(objects_path, "Objects")
106
+
107
+ models_path = os.path.join(fg_scenery_export, "Models")
108
+ if os.path.isdir(models_path):
109
+ logger.info("Creating Models .dirindex...")
110
+ create_dirindex(models_path, "Models")
111
+
112
+ # Build the root .dirindex
113
+ logger.info("Creating root .dirindex...")
114
+ root_dirindex_path = os.path.join(fg_scenery_export, DIRINDEX_FILENAME)
115
+ with open(root_dirindex_path, "w") as f:
116
+ f.write(f"version:{DIRINDEX_VERSION}\n")
117
+ f.write("path:\n")
118
+ f.write(f"time:{datetime.now(timezone.utc).strftime('%Y%m%d-%H:%MZ')}\n")
119
+
120
+ # Airports (from remote scenery)
121
+ airports_dirindex = os.path.join(airports_path, DIRINDEX_FILENAME)
122
+ if os.path.isfile(airports_dirindex):
123
+ f.write(f"d:Airports:{sha1_of_file(airports_dirindex)}\n")
124
+
125
+ # Models (from export)
126
+ models_dirindex = os.path.join(models_path, DIRINDEX_FILENAME)
127
+ if os.path.isfile(models_dirindex):
128
+ f.write(f"d:Models:{sha1_of_file(models_dirindex)}\n")
129
+
130
+ # Objects (from export)
131
+ objects_dirindex = os.path.join(objects_path, DIRINDEX_FILENAME)
132
+ if os.path.isfile(objects_dirindex):
133
+ f.write(f"d:Objects:{sha1_of_file(objects_dirindex)}\n")
134
+
135
+ # Terrain (from remote scenery)
136
+ terrain_dirindex = os.path.join(fg_scenery, "Terrain", DIRINDEX_FILENAME)
137
+ if os.path.isfile(terrain_dirindex):
138
+ f.write(f"d:Terrain:{sha1_of_file(terrain_dirindex)}\n")
139
+
140
+ # Airports archive tarball
141
+ archive_path = os.path.join(fg_scenery, "Airports_archive.tgz")
142
+ if os.path.isfile(archive_path):
143
+ archive_hash = sha1_of_file(archive_path)
144
+ archive_size = os.stat(archive_path).st_size
145
+ f.write(f"t:Airports_archive.tgz:{archive_hash}:{archive_size}\n")
146
+
147
+ logger.info("Root .dirindex created at %s", root_dirindex_path)
@@ -0,0 +1,104 @@
1
+ """Export models step — extract 3D model files from the database.
2
+
3
+ Equivalent to the legacy exportmodels.py script.
4
+ Exports shared models (to Models/) and per-hemisphere object models (to Objects/).
5
+ """
6
+
7
+ import base64
8
+ import io
9
+ import logging
10
+ import os
11
+ import tarfile
12
+ from ..db import Database
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def run(db: Database, target: str, incremental: bool = False):
18
+ """Export all models: shared models + east/west hemisphere objects.
19
+
20
+ Args:
21
+ db: Database connection wrapper.
22
+ target: Target directory for exported files.
23
+ incremental: If True, only export when new models exist.
24
+ """
25
+ os.makedirs(target, exist_ok=True)
26
+
27
+ if incremental and not _has_new_models(db):
28
+ logger.info("No new models to export (incremental mode).")
29
+ return
30
+
31
+ _export_shared_models(db, target)
32
+ _export_hemisphere_models(db, target, "west")
33
+ _export_hemisphere_models(db, target, "east")
34
+
35
+
36
+ def _has_new_models(db: Database) -> bool:
37
+ """Check if there are new models since the last export timestamp."""
38
+ rows = db.execute(
39
+ """SELECT count(*) AS cnt FROM fgs_models
40
+ WHERE mo_modified > (SELECT max(ti_stamp) FROM fgs_timestamps WHERE ti_type=1)"""
41
+ )
42
+ return rows[0]["cnt"] > 0 if rows else False
43
+
44
+
45
+ def _export_shared_models(db: Database, target: str):
46
+ """Export shared models (mo_shared > 0) to Models/ subdirectory."""
47
+ logger.info("Exporting shared models...")
48
+
49
+ rows = db.execute(
50
+ """SELECT m.mo_id AS id,
51
+ concat('Models/', g.mg_path) AS path,
52
+ m.mo_modelfile AS modelfile
53
+ FROM fgs_models AS m
54
+ LEFT JOIN fgs_modelgroups AS g ON m.mo_shared = g.mg_id
55
+ WHERE m.mo_shared > 0"""
56
+ )
57
+
58
+ count = 0
59
+ for row in rows:
60
+ _extract_model(row, target)
61
+ count += 1
62
+
63
+ logger.info("Exported %d shared models.", count)
64
+
65
+
66
+ def _export_hemisphere_models(db: Database, target: str, hemisphere: str):
67
+ """Export per-object models for a hemisphere.
68
+
69
+ Args:
70
+ hemisphere: "east" or "west"
71
+ """
72
+ constraint = ">= 0" if hemisphere == "east" else "< 0"
73
+ logger.info("Exporting %s hemisphere models...", hemisphere)
74
+
75
+ rows = db.execute(
76
+ f"""SELECT m.mo_id AS id,
77
+ concat('Objects/', fn_SceneDir(o.wkb_geometry), '/', fn_SceneSubDir(o.wkb_geometry), '/') AS path,
78
+ m.mo_modelfile AS modelfile
79
+ FROM fgs_objects AS o
80
+ LEFT JOIN fgs_models AS m ON o.ob_model = m.mo_id
81
+ WHERE o.ob_gndelev > -9999 AND m.mo_shared = 0 AND ST_X(o.wkb_geometry) {constraint}"""
82
+ )
83
+
84
+ count = 0
85
+ for row in rows:
86
+ _extract_model(row, target)
87
+ count += 1
88
+
89
+ logger.info("Exported %d %s hemisphere models.", count, hemisphere)
90
+
91
+
92
+ def _extract_model(row, target: str):
93
+ """Decode and extract a model's tar.gz archive to the target path."""
94
+ model_path = row["path"]
95
+ full_path = os.path.join(target, model_path)
96
+
97
+ os.makedirs(full_path, exist_ok=True)
98
+
99
+ try:
100
+ tar_bytes = io.BytesIO(base64.b64decode(row["modelfile"]))
101
+ with tarfile.open(fileobj=tar_bytes, mode="r:gz") as tar:
102
+ tar.extractall(path=full_path)
103
+ except Exception as e:
104
+ logger.warning("Failed to extract model %s: %s", row["id"], e)
@@ -0,0 +1,57 @@
1
+ """Export STG step — generate .stg files (object placement) for each scenery tile.
2
+
3
+ Equivalent to the legacy exportstg.py script.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ from ..db import Database
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def run(db: Database, target: str):
14
+ """Generate all .stg files from the database.
15
+
16
+ Queries all distinct tiles that have objects or signs, then generates
17
+ an .stg file for each tile using the database function fn_DumpStgRows().
18
+
19
+ Args:
20
+ db: Database connection wrapper.
21
+ target: Target directory for exported files.
22
+ """
23
+ logger.info("Querying tile list...")
24
+
25
+ sql_path = "concat('Objects/', fn_SceneDir(wkb_geometry), '/', fn_SceneSubDir(wkb_geometry), '/') AS obpath"
26
+ rows = db.execute(
27
+ f"""SELECT DISTINCT fn_gettilenumber(o.wkb_geometry) AS tile, {sql_path}
28
+ FROM fgs_objects AS o
29
+ UNION
30
+ SELECT DISTINCT fn_gettilenumber(s.wkb_geometry) AS tile, {sql_path}
31
+ FROM fgs_signs AS s
32
+ ORDER BY tile"""
33
+ )
34
+
35
+ num_tiles = len(rows)
36
+ logger.info("Generating .stg files for %d tiles...", num_tiles)
37
+
38
+ for i, row in enumerate(rows, 1):
39
+ ob_path = row["obpath"]
40
+ ob_tile = row["tile"]
41
+ stg_file = f"{ob_tile}.stg"
42
+
43
+ if i % 1000 == 0:
44
+ logger.info("Progress: %d / %d tiles", i, num_tiles)
45
+
46
+ # Get STG content from the database function
47
+ stg_rows = db.execute("SELECT fn_DumpStgRows(%s)", (ob_tile,))
48
+
49
+ if stg_rows and stg_rows[0][0]:
50
+ parent = os.path.join(target, ob_path)
51
+ os.makedirs(parent, exist_ok=True)
52
+
53
+ stg_full_path = os.path.join(parent, stg_file)
54
+ with open(stg_full_path, "w") as f:
55
+ f.write(f"{stg_rows[0][0]}\n")
56
+
57
+ logger.info("STG export complete: %d tiles processed.", num_tiles)
@@ -0,0 +1,107 @@
1
+ """Ground elevation step — compute ground elevation for new objects using fgelev.
2
+
3
+ Equivalent to the legacy gndelev.sh script.
4
+
5
+ This step requires:
6
+ - fgelev binary (from FlightGear/SimGear)
7
+ - Terrain data in FG_SCENERY
8
+ - FG_ROOT pointing to FlightGear data (defaults.xml)
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ import subprocess
14
+ from ..db import Database
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def run(db: Database, fgelev_path: str, fg_root: str, fg_scenery: str):
20
+ """Compute ground elevations for objects with ob_gndelev = -9999.
21
+
22
+ Queries unprocessed objects from the DB, pipes their coordinates through
23
+ fgelev, then updates the database with computed elevations.
24
+
25
+ Args:
26
+ db: Database connection wrapper.
27
+ fgelev_path: Path to the fgelev binary.
28
+ fg_root: FG_ROOT path (FlightGear data directory).
29
+ fg_scenery: FG_SCENERY path (terrain data directory).
30
+ """
31
+ terrain_path = os.path.join(fg_scenery, "Terrain")
32
+ if not os.path.isdir(terrain_path):
33
+ logger.error("No Terrain found in %s. Skipping gndelev.", fg_scenery)
34
+ return
35
+
36
+ logger.info("Querying objects needing ground elevation...")
37
+ rows = db.execute(
38
+ """SELECT ob_id, ST_X(wkb_geometry) AS lon, ST_Y(wkb_geometry) AS lat
39
+ FROM fgs_objects
40
+ WHERE ob_valid IS true AND ob_gndelev = -9999
41
+ ORDER BY ob_tile"""
42
+ )
43
+
44
+ if not rows:
45
+ logger.info("No objects need ground elevation updates.")
46
+ return
47
+
48
+ logger.info("Computing ground elevation for %d objects...", len(rows))
49
+
50
+ # Prepare input for fgelev: "id lon lat\n" per line
51
+ fgelev_input = ""
52
+ for row in rows:
53
+ fgelev_input += f"{row['ob_id']} {row['lon']} {row['lat']}\n"
54
+
55
+ # Run fgelev
56
+ env = os.environ.copy()
57
+ env["FG_ROOT"] = fg_root
58
+ env["FG_SCENERY"] = fg_scenery
59
+
60
+ try:
61
+ result = subprocess.run(
62
+ [fgelev_path],
63
+ input=fgelev_input,
64
+ capture_output=True,
65
+ text=True,
66
+ env=env,
67
+ )
68
+ except FileNotFoundError:
69
+ logger.error("fgelev binary not found at %s", fgelev_path)
70
+ return
71
+
72
+ # fgelev may return non-zero on EOF, that's expected
73
+ if result.stdout:
74
+ _process_fgelev_output(db, result.stdout)
75
+ else:
76
+ logger.warning("fgelev produced no output. stderr: %s", result.stderr[:500])
77
+
78
+
79
+ def _process_fgelev_output(db: Database, output: str):
80
+ """Parse fgelev output and update the database.
81
+
82
+ fgelev output format: "id:elevation\n"
83
+ """
84
+ update_count = 0
85
+ for line in output.strip().split("\n"):
86
+ line = line.strip()
87
+ if not line or ":" not in line:
88
+ continue
89
+
90
+ parts = line.split(":")
91
+ if len(parts) != 2:
92
+ continue
93
+
94
+ try:
95
+ ob_id = int(parts[0].strip())
96
+ elevation = float(parts[1].strip())
97
+ except ValueError:
98
+ logger.warning("Could not parse fgelev output line: %s", line)
99
+ continue
100
+
101
+ db.execute(
102
+ "UPDATE fgs_objects SET ob_gndelev = %s WHERE ob_id = %s AND ob_gndelev = -9999",
103
+ (elevation, ob_id),
104
+ )
105
+ update_count += 1
106
+
107
+ logger.info("Updated ground elevation for %d objects.", update_count)
@@ -0,0 +1,84 @@
1
+ """Distribution step — rsync exported scenery to mirror servers.
2
+
3
+ Equivalent to the legacy sync.sh script.
4
+ This step shells out to rsync since it's the right tool for efficient
5
+ network file transfer. SSH configuration (host, user, key) is read from
6
+ ~/.ssh/config as usual.
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import subprocess
12
+ import shlex
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def rsync_to_target(scenery_dir: str, target: str, items: list[str]) -> int:
19
+ """Rsync specific items to a target.
20
+
21
+ Args:
22
+ scenery_dir: Local scenery directory (source).
23
+ target: rsync target URI (e.g., "host:/path/").
24
+ items: List of file/directory names to sync.
25
+
26
+ Returns:
27
+ rsync exit code.
28
+ """
29
+ cmd = [
30
+ "rsync", "-av",
31
+ "--no-owner", "--no-group", "--no-perms",
32
+ "--delete", "--copy-links", "--omit-dir-times",
33
+ ] + items + [target]
34
+
35
+ logger.info("rsync to %s: %s", target, " ".join(items))
36
+ result = subprocess.run(cmd, cwd=scenery_dir, capture_output=True, text=True)
37
+
38
+ if result.returncode != 0:
39
+ logger.error("rsync to %s failed: %s", target, result.stderr[:500])
40
+ else:
41
+ logger.info("rsync to %s complete.", target)
42
+
43
+ return result.returncode
44
+
45
+
46
+ def run(fg_scenery: str, sync_targets: list[str], cdn_invalidate_cmd: str = ""):
47
+ """Distribute scenery to all mirror targets.
48
+
49
+ Args:
50
+ fg_scenery: Local scenery directory.
51
+ sync_targets: List of rsync target URIs.
52
+ cdn_invalidate_cmd: Optional shell command to invalidate CDN.
53
+ """
54
+ if not sync_targets:
55
+ logger.info("No sync targets configured. Skipping distribution.")
56
+ return
57
+
58
+ scenery_items = ["Objects", "Models", "Airports", ".dirindex", "Airports_archive.tgz"]
59
+ tarball = "SharedModels.txz"
60
+
61
+ with ThreadPoolExecutor(max_workers=len(sync_targets) * 2) as executor:
62
+ futures = []
63
+
64
+ for target in sync_targets:
65
+ # Main scenery sync
66
+ futures.append(
67
+ executor.submit(rsync_to_target, fg_scenery, target, scenery_items)
68
+ )
69
+
70
+ # SharedModels tarball to parent directory
71
+ tarball_path = os.path.join(fg_scenery, tarball)
72
+ if os.path.isfile(tarball_path):
73
+ parent_target = target.rsplit("/", 2)[0] + "/"
74
+ futures.append(
75
+ executor.submit(rsync_to_target, fg_scenery, parent_target, [tarball])
76
+ )
77
+
78
+ for future in as_completed(futures):
79
+ future.result()
80
+
81
+ # Invalidate CDN if configured
82
+ if cdn_invalidate_cmd:
83
+ logger.info("Invalidating CDN: %s", cdn_invalidate_cmd)
84
+ subprocess.run(shlex.split(cdn_invalidate_cmd), check=False)
@@ -0,0 +1,32 @@
1
+ """Sanitize step — fix data integrity issues in the database.
2
+
3
+ Equivalent to the legacy sanitize.sh script.
4
+ """
5
+
6
+ import logging
7
+ from ..db import Database
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def run(db: Database):
13
+ """Fix tile numbers and elevation offsets in the database."""
14
+ logger.info("Running sanitize...")
15
+
16
+ statements = [
17
+ # Update object tile numbers where they don't match geometry
18
+ """UPDATE fgs_objects SET ob_tile = fn_GetTileNumber(wkb_geometry)
19
+ WHERE ob_tile != fn_GetTileNumber(wkb_geometry) OR ob_tile IS NULL""",
20
+ # Update sign tile numbers where invalid
21
+ """UPDATE fgs_signs SET si_tile = fn_GetTileNumber(wkb_geometry)
22
+ WHERE si_tile < 1 OR si_tile IS NULL""",
23
+ # Nullify zero elevation offsets
24
+ """UPDATE fgs_objects SET ob_elevoffset = NULL
25
+ WHERE ob_elevoffset = 0""",
26
+ ]
27
+
28
+ for stmt in statements:
29
+ logger.debug("Executing: %s", stmt.strip()[:80])
30
+ db.execute(stmt)
31
+
32
+ logger.info("Sanitize complete.")
fgsmdb/steps/sync.py ADDED
@@ -0,0 +1,150 @@
1
+ """Sync step — synchronize exported scenery to the local scenery tree.
2
+
3
+ Equivalent to the legacy upload.sh + LocalSync.py scripts.
4
+ Uses .dirindex-based diffing to copy only changed files.
5
+ """
6
+
7
+ import hashlib
8
+ import logging
9
+ import os
10
+ import shutil
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DIRINDEX_FILENAME = ".dirindex"
15
+
16
+
17
+ def sha1_of_file(path: str) -> str:
18
+ """Compute SHA1 hash of a file."""
19
+ h = hashlib.sha1()
20
+ try:
21
+ with open(path, "rb") as f:
22
+ for chunk in iter(lambda: f.read(4096), b""):
23
+ h.update(chunk)
24
+ except OSError:
25
+ pass
26
+ return h.hexdigest()
27
+
28
+
29
+ class DirIndex:
30
+ """Parser for .dirindex files."""
31
+
32
+ def __init__(self, path: str):
33
+ self.directories: list[dict] = []
34
+ self.files: list[dict] = []
35
+ self.version = 0
36
+ self.path = ""
37
+
38
+ with open(path) as f:
39
+ for line in f:
40
+ line = line.strip()
41
+ if not line or line.startswith("#"):
42
+ continue
43
+
44
+ tokens = line.split(":")
45
+ if tokens[0] == "version":
46
+ self.version = int(tokens[1])
47
+ elif tokens[0] == "path":
48
+ self.path = tokens[1] if len(tokens) > 1 else ""
49
+ elif tokens[0] == "d":
50
+ self.directories.append({"name": tokens[1], "hash": tokens[2]})
51
+ elif tokens[0] in ("f", "t"):
52
+ self.files.append({
53
+ "name": tokens[1],
54
+ "hash": tokens[2],
55
+ "size": tokens[3] if len(tokens) > 3 else "0",
56
+ })
57
+
58
+ def get_file_by_name(self, name: str) -> dict | None:
59
+ """Find a file entry by name."""
60
+ for f in self.files:
61
+ if f["name"] == name:
62
+ return f
63
+ return None
64
+
65
+
66
+ def sync_path(src: str, dst: str, expected_hash: str | None = None) -> str | None:
67
+ """Sync a directory from src to dst using .dirindex-based diffing.
68
+
69
+ Only copies files whose hashes differ between source and destination.
70
+ Removes files present in destination but not in source.
71
+
72
+ Args:
73
+ src: Source directory path.
74
+ dst: Destination directory path.
75
+ expected_hash: Expected hash of the source .dirindex (for validation).
76
+
77
+ Returns:
78
+ SHA1 hash of the source .dirindex file.
79
+ """
80
+ src_dirindex_path = os.path.join(src, DIRINDEX_FILENAME)
81
+ if not os.path.isfile(src_dirindex_path):
82
+ logger.debug("No .dirindex at %s, skipping.", src)
83
+ return None
84
+
85
+ src_index = DirIndex(src_dirindex_path)
86
+
87
+ # Recurse into subdirectories first
88
+ for sub_dir in src_index.directories:
89
+ sync_path(
90
+ os.path.join(src, sub_dir["name"]),
91
+ os.path.join(dst, sub_dir["name"]),
92
+ sub_dir["hash"],
93
+ )
94
+
95
+ # Compute hash if not provided
96
+ if expected_hash is None:
97
+ expected_hash = sha1_of_file(src_dirindex_path)
98
+
99
+ dst_dirindex_path = os.path.join(dst, DIRINDEX_FILENAME)
100
+
101
+ if not os.path.isfile(dst_dirindex_path):
102
+ # No destination .dirindex — copy everything
103
+ os.makedirs(dst, exist_ok=True)
104
+ for file_entry in src_index.files:
105
+ _copy_file(file_entry["name"], src, dst)
106
+ logger.debug("copy (new) %s : %s -> %s", file_entry["name"], src, dst)
107
+ _copy_file(DIRINDEX_FILENAME, src, dst)
108
+ else:
109
+ # Have destination .dirindex — do incremental sync
110
+ dst_index = DirIndex(dst_dirindex_path)
111
+
112
+ for src_file in src_index.files:
113
+ dst_file = dst_index.get_file_by_name(src_file["name"])
114
+ if dst_file is None or dst_file["hash"] != src_file["hash"]:
115
+ os.makedirs(dst, exist_ok=True)
116
+ _copy_file(src_file["name"], src, dst)
117
+ logger.debug("copy (changed) %s : %s -> %s", src_file["name"], src, dst)
118
+
119
+ # Update .dirindex if hashes differ
120
+ if expected_hash != sha1_of_file(dst_dirindex_path):
121
+ _copy_file(DIRINDEX_FILENAME, src, dst)
122
+
123
+ # Remove files that no longer exist in source
124
+ for dst_file in dst_index.files:
125
+ if src_index.get_file_by_name(dst_file["name"]) is None:
126
+ dst_file_path = os.path.join(dst, dst_file["name"])
127
+ if os.path.exists(dst_file_path):
128
+ os.remove(dst_file_path)
129
+ logger.debug("delete %s", dst_file_path)
130
+
131
+ return expected_hash
132
+
133
+
134
+ def _copy_file(name: str, src_dir: str, dst_dir: str):
135
+ """Copy a single file from src_dir to dst_dir."""
136
+ src_path = os.path.join(src_dir, name)
137
+ dst_path = os.path.join(dst_dir, name)
138
+ shutil.copy2(src_path, dst_path)
139
+
140
+
141
+ def run(fg_scenery_export: str, fg_scenery: str):
142
+ """Sync the export directory into the scenery directory.
143
+
144
+ Args:
145
+ fg_scenery_export: Source export directory.
146
+ fg_scenery: Destination scenery directory.
147
+ """
148
+ logger.info("Syncing export to scenery: %s -> %s", fg_scenery_export, fg_scenery)
149
+ sync_path(fg_scenery_export, fg_scenery)
150
+ logger.info("Sync complete.")
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: fgsmdb
3
+ Version: 0.1.0
4
+ Summary: FlightGear Scenemodels Database Tools
5
+ Author-email: Torsten Dreyer <torsten@t3r.de>
6
+ License-Expression: GPL-2.0-only
7
+ Project-URL: Homepage, https://github.com/FlightGear/scenemodels
8
+ Project-URL: Repository, https://github.com/FlightGear/scenemodels
9
+ Project-URL: Issues, https://github.com/FlightGear/scenemodels/issues
10
+ Keywords: flightgear,scenery,terrasync,export
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Games/Entertainment :: Simulation
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: psycopg2-binary>=2.9
23
+ Requires-Dist: python-dotenv>=1.0
24
+
25
+ # fgsmdb — FlightGear Scenemodels Database Tools
26
+
27
+ Command-line tools for the FlightGear scenemodels database.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install fgsmdb
33
+ ```
34
+
35
+ Or install from source:
36
+
37
+ ```bash
38
+ cd export-legacy/fgsmdb
39
+ pip install .
40
+ ```
41
+
42
+ For development:
43
+
44
+ ```bash
45
+ pip install -e .
46
+ ```
47
+
48
+ ## Configuration
49
+
50
+ Copy `.env.example` to `.env` and fill in your values:
51
+
52
+ ```bash
53
+ cp .env.example .env
54
+ ```
55
+
56
+ | Variable | Description | Default |
57
+ |----------|-------------|---------|
58
+ | `PGURI` | PostgreSQL connection URI (required) | — |
59
+ | `FGELEV_PATH` | Path to the fgelev binary | `/usr/local/bin/fgelev` |
60
+ | `FG_ROOT` | FlightGear data root (needed by fgelev) | `/app/data` |
61
+ | `FG_SCENERY` | Local scenery path (Terrain, Airports, etc.) | `./fgscenery` |
62
+ | `FG_SCENERY_EXPORT` | Working directory for exported files | `./export` |
63
+ | `GROUNDNETS_PATH` | Path to groundnets git repository | `./groundnets` |
64
+ | `MIN_UPDATES` | Minimum pending updates to trigger export | `1` |
65
+ | `SYNC_TARGETS` | Comma-separated rsync target URIs | — |
66
+ | `CDN_INVALIDATE_CMD` | Shell command to run after distribution | — |
67
+
68
+ For remote distribution, rsync uses your `~/.ssh/config` for host aliases,
69
+ usernames, and identity files.
70
+
71
+ ## Usage
72
+
73
+ ```bash
74
+ # Run the export pipeline
75
+ python3 -m fgsmdb export
76
+
77
+ # Or use the installed command
78
+ fgsmdb export
79
+ ```
80
+
81
+ ### Export Command
82
+
83
+ Exports the scenemodels database to a TerraSync-compatible file tree.
84
+
85
+ ```bash
86
+ # Full pipeline (all steps except distribute)
87
+ python3 -m fgsmdb export
88
+
89
+ # With a specific .env file
90
+ python3 -m fgsmdb export --env-file /path/to/.env
91
+
92
+ # Run specific steps only
93
+ python3 -m fgsmdb export --steps sanitize,export,mkdiridx
94
+
95
+ # Include distribution to mirrors
96
+ python3 -m fgsmdb export --steps sanitize,gndelev,export,mkdiridx,upload,distribute
97
+
98
+ # Skip the pending-updates threshold check
99
+ python3 -m fgsmdb export --skip-check
100
+
101
+ # Override the minimum updates threshold
102
+ python3 -m fgsmdb export --min-updates 10
103
+
104
+ # Enable verbose (debug) logging
105
+ python3 -m fgsmdb export -v
106
+ ```
107
+
108
+ ### Pipeline Steps
109
+
110
+ | Step | Description |
111
+ |------|-------------|
112
+ | `sanitize` | Fix data integrity: update tile numbers, nullify zero elevation offsets |
113
+ | `gndelev` | Compute ground elevation for new objects using fgelev |
114
+ | `export` | Export shared/per-hemisphere models and generate .stg files from the database |
115
+ | `mkdiridx` | Create `.dirindex` files for TerraSync and the Airports archive |
116
+ | `upload` | Sync exported files to the local scenery tree (hash-based, copies only changes) |
117
+ | `distribute` | Rsync scenery to remote mirror servers (parallel) |
118
+
119
+ By default, all steps except `distribute` are run. Distribution must be explicitly
120
+ requested via `--steps`.
121
+
122
+ Before any steps execute, the pipeline calls `fn_update_statistics()` and checks
123
+ whether the number of objects pending ground elevation meets the configured threshold.
124
+ Use `--skip-check` to bypass this gate.
125
+
126
+ ### Command Line Options
127
+
128
+ ```
129
+ python3 -m fgsmdb export [-h] [--env-file ENV_FILE] [--steps STEPS] [--all]
130
+ [--skip-check] [--min-updates N] [-v]
131
+
132
+ Options:
133
+ --env-file ENV_FILE Path to .env file (default: auto-detect)
134
+ --steps STEPS Comma-separated steps: sanitize, gndelev, export,
135
+ mkdiridx, upload, distribute
136
+ --all Run all steps (same as default, without distribute)
137
+ --skip-check Skip the pending-updates threshold check
138
+ --min-updates N Override the MIN_UPDATES threshold from .env
139
+ -v, --verbose Enable debug logging
140
+ -h, --help Show help message
141
+ ```
142
+
143
+ ## Output Structure
144
+
145
+ ```
146
+ fgscenery/
147
+ ├── .dirindex
148
+ ├── Airports_archive.tgz
149
+ ├── SharedModels.txz
150
+ ├── Airports/
151
+ │ ├── .dirindex
152
+ │ └── ...
153
+ ├── Models/
154
+ │ ├── .dirindex
155
+ │ └── <group_path>/
156
+ ├── Objects/
157
+ │ ├── .dirindex
158
+ │ └── <e|w>NNN<n|s>NN/
159
+ │ └── <e|w>NNN<n|s>NN/
160
+ │ ├── <tile>.stg
161
+ │ └── <model_files>
162
+ └── Terrain/
163
+ └── .dirindex
164
+ ```
165
+
166
+ ## License
167
+
168
+ GNU General Public License v2.0 — see [LICENCE](../LICENCE).
@@ -0,0 +1,19 @@
1
+ fgsmdb/__init__.py,sha256=d0Xj6sy_mmFEazfAaqFG93uC37JJk5_o-eWDuDhAOws,79
2
+ fgsmdb/__main__.py,sha256=VEts6B7jmw5qF-6jdOIhXcZf9NdNcj-6xWAVYnMBSgw,643
3
+ fgsmdb/config.py,sha256=bviHS-NON-dTplcvb6JvVz91bsb84I047Ca9XBCSpcg,1252
4
+ fgsmdb/db.py,sha256=FP3_Cq4BnH2J-_qVPIYfox1Wzm7uiHhYS3vu960t2wk,2038
5
+ fgsmdb/commands/__init__.py,sha256=R8BQiqA6D0JentyffjXv6ffF6ZyrH25T0AaHogg1SwM,25
6
+ fgsmdb/commands/export.py,sha256=QJaNoo1HhYxy1-50x4QyHy_yhhnjGbcxWxXrjOXCEww,5937
7
+ fgsmdb/steps/__init__.py,sha256=H9UzBR9zBfZalAFewdzh3IZ0lQTa0xDsh19JJrPkv20,31
8
+ fgsmdb/steps/dirindex.py,sha256=UZ3VxVmEIiTVmXilolswlTkmUslmAbZrJAg9VCGJ7dU,5388
9
+ fgsmdb/steps/export_models.py,sha256=Ej08S_ijPAtWo88HmfWM1-qW0xSZRDMpxyM4Frl_OqU,3331
10
+ fgsmdb/steps/export_stg.py,sha256=qf9oH5s8pgl7nH8BhBbcs__PO8V1X4UNKQR-7hp_Y2w,1853
11
+ fgsmdb/steps/gndelev.py,sha256=h3pUQ0rsXsUxX2SOY944PpHN7bRrFFolKCLt4kJqXTQ,3239
12
+ fgsmdb/steps/rsync_distribute.py,sha256=mEPStPO6XeEY7OmgPCrkm0QLsHBHFYi2dvf7kY1qgJ8,2774
13
+ fgsmdb/steps/sanitize.py,sha256=IF0J4pgTxscA03F1U2BJ51hAT0keAkWKmx8o8gFvaPI,1035
14
+ fgsmdb/steps/sync.py,sha256=6PeVfIwL0q96W-VpMwX-7M8Afa5U0elGpOUEBbyEfjo,5100
15
+ fgsmdb-0.1.0.dist-info/METADATA,sha256=-7gLREoGkU9pMozI14E7ry8_qg8rVJ-l_WkPEmxuMHU,5122
16
+ fgsmdb-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
17
+ fgsmdb-0.1.0.dist-info/entry_points.txt,sha256=7anFRXVzUAzM8vzeKUKEr4LQjdAy8KaYA4Nnut0Umy8,48
18
+ fgsmdb-0.1.0.dist-info/top_level.txt,sha256=SAnULTGAK0uWFh-Yn9bD-uIz9iR1U_iQUJyCWfF4YKc,7
19
+ fgsmdb-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fgsmdb = fgsmdb.__main__:main
@@ -0,0 +1 @@
1
+ fgsmdb