xtransit 0.1.0a1__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 (39) hide show
  1. xtransit/__init__.py +1 -0
  2. xtransit/__main__.py +3 -0
  3. xtransit/collect.py +447 -0
  4. xtransit/config.py +15 -0
  5. xtransit/core/__init__.py +0 -0
  6. xtransit/core/service.py +18 -0
  7. xtransit/database.py +88 -0
  8. xtransit/ingestion/001_initial.sql +114 -0
  9. xtransit/ingestion/__init__.py +1 -0
  10. xtransit/ingestion/cli.py +241 -0
  11. xtransit/ingestion/parsers.py +301 -0
  12. xtransit/ingestion/poller.py +232 -0
  13. xtransit/ingestion/spool.py +94 -0
  14. xtransit/ingestion/store.py +629 -0
  15. xtransit/services/__init__.py +0 -0
  16. xtransit/services/docker.py +227 -0
  17. xtransit/services/kafka.py +106 -0
  18. xtransit/services/postgres.py +168 -0
  19. xtransit/web/__init__.py +1 -0
  20. xtransit/web/app.py +149 -0
  21. xtransit/web/data.py +510 -0
  22. xtransit/web/static/app.css +1415 -0
  23. xtransit/web/static/app.js +979 -0
  24. xtransit/web/static/index.html +262 -0
  25. xtransit/web/static/networks.mjs +92 -0
  26. xtransit/web/static/vendor/LICENSE +26 -0
  27. xtransit/web/static/vendor/images/layers-2x.png +0 -0
  28. xtransit/web/static/vendor/images/layers.png +0 -0
  29. xtransit/web/static/vendor/images/marker-icon-2x.png +0 -0
  30. xtransit/web/static/vendor/images/marker-icon.png +0 -0
  31. xtransit/web/static/vendor/images/marker-shadow.png +0 -0
  32. xtransit/web/static/vendor/leaflet.css +661 -0
  33. xtransit/web/static/vendor/leaflet.js +6 -0
  34. xtransit-0.1.0a1.dist-info/METADATA +226 -0
  35. xtransit-0.1.0a1.dist-info/RECORD +39 -0
  36. xtransit-0.1.0a1.dist-info/WHEEL +4 -0
  37. xtransit-0.1.0a1.dist-info/entry_points.txt +5 -0
  38. xtransit-0.1.0a1.dist-info/licenses/LICENSE.txt +9 -0
  39. xtransit-0.1.0a1.dist-info/licenses/src/xtransit/web/static/vendor/LICENSE +26 -0
xtransit/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a1"
xtransit/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from xtransit.collect import main
2
+
3
+ raise SystemExit(main())
xtransit/collect.py ADDED
@@ -0,0 +1,447 @@
1
+ """Download cataloged GTFS schedules and point-in-time realtime snapshots."""
2
+
3
+ import argparse
4
+ from collections import Counter
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ import csv
7
+ from datetime import datetime, timezone
8
+ import hashlib
9
+ import io
10
+ import json
11
+ from pathlib import Path
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import threading
16
+ import time
17
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
18
+ import zipfile
19
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
20
+
21
+
22
+ CATALOG_URL = "https://files.mobilitydatabase.org/feeds_v2.csv"
23
+ HOST_LOCK = threading.Lock()
24
+ HOST_LIMITS = {}
25
+
26
+
27
+ def utc_now():
28
+ return datetime.now(timezone.utc).isoformat()
29
+
30
+
31
+ def fetch(url, destination, timeout=90, max_bytes=512 * 1024 * 1024):
32
+ """Use curl's TLS verification, wall-clock timeout, and size limit."""
33
+ if urlsplit(url).scheme not in {"https", "http"}:
34
+ raise ValueError("Only HTTP(S) feed URLs are supported")
35
+ destination.parent.mkdir(parents=True, exist_ok=True)
36
+ with HOST_LOCK:
37
+ limit = HOST_LIMITS.setdefault(urlsplit(url).netloc, threading.Semaphore(4))
38
+ with limit:
39
+ result = subprocess.run(
40
+ [
41
+ "curl",
42
+ "--fail",
43
+ "--silent",
44
+ "--show-error",
45
+ "--location",
46
+ "--proto",
47
+ "=http,https",
48
+ "--proto-redir",
49
+ "=http,https",
50
+ "--connect-timeout",
51
+ "15",
52
+ "--max-time",
53
+ str(timeout),
54
+ "--max-filesize",
55
+ str(max_bytes),
56
+ "--user-agent",
57
+ "xtransit/0.0 GTFS collector",
58
+ "--output",
59
+ str(destination),
60
+ "--url",
61
+ url,
62
+ ],
63
+ capture_output=True,
64
+ text=True,
65
+ timeout=timeout + 10,
66
+ )
67
+ if result.returncode:
68
+ # Do not put URLs or credentials echoed by remote servers in logs.
69
+ http_error = re.search(r"error: (\d{3})", result.stderr)
70
+ reason = (
71
+ f"HTTP {http_error[1]}" if http_error else f"curl exit {result.returncode}"
72
+ )
73
+ raise RuntimeError(reason)
74
+ if destination.stat().st_size > max_bytes:
75
+ raise ValueError("Feed exceeds configured size limit")
76
+
77
+
78
+ def read_catalog(path):
79
+ with path.open(newline="", encoding="utf-8-sig") as file:
80
+ reader = csv.DictReader(file)
81
+ required = {
82
+ "id",
83
+ "data_type",
84
+ "location.country_code",
85
+ "status",
86
+ "urls.direct_download",
87
+ }
88
+ if not required.issubset(reader.fieldnames or []):
89
+ raise ValueError("Unrecognized MobilityDatabase CSV schema")
90
+ rows = list(reader)
91
+ if len({row["id"] for row in rows}) != len(rows):
92
+ raise ValueError("Catalog contains duplicate feed IDs")
93
+ return rows
94
+
95
+
96
+ def write_catalog(path, rows):
97
+ if not rows:
98
+ raise ValueError("No catalog entries found for the requested country")
99
+ with path.open("w", newline="", encoding="utf-8") as file:
100
+ writer = csv.DictWriter(file, fieldnames=list(rows[0]))
101
+ writer.writeheader()
102
+ writer.writerows(rows)
103
+
104
+
105
+ def feed_urls(row):
106
+ """Public schedule mirrors remain usable when the producer requires a key."""
107
+ urls = []
108
+ if row["data_type"] == "gtfs" and row.get("urls.latest"):
109
+ urls.append(row["urls.latest"])
110
+ if (
111
+ row.get("urls.authentication_type", "") in {"", "0"}
112
+ and row["urls.direct_download"]
113
+ ):
114
+ direct = row["urls.direct_download"]
115
+ urls.append(direct)
116
+ if row["data_type"] == "gtfs_rt":
117
+ parts = urlsplit(direct)
118
+ query = parse_qsl(parts.query, keep_blank_values=True)
119
+ if any(key.lower() == "debug" for key, _ in query):
120
+ # Some catalog URLs request a human-readable debug response.
121
+ query = [(key, value) for key, value in query if key.lower() != "debug"]
122
+ urls.append(urlunsplit(parts._replace(query=urlencode(query))))
123
+ return list(dict.fromkeys(urls))
124
+
125
+
126
+ def inspect_schedule(path):
127
+ with zipfile.ZipFile(path) as archive:
128
+ required = {"agency.txt", "routes.txt", "trips.txt", "stop_times.txt"}
129
+ directories = {}
130
+ for name in archive.namelist():
131
+ if name.endswith("/") or name.startswith("__MACOSX/"):
132
+ continue
133
+ parent, _, basename = name.rpartition("/")
134
+ members = directories.setdefault(parent, {})
135
+ if basename.lower() in members:
136
+ raise ValueError("ZIP has ambiguous GTFS table names")
137
+ members[basename.lower()] = name
138
+ candidates = [
139
+ members for members in directories.values() if required.issubset(members)
140
+ ]
141
+ if not candidates:
142
+ raise ValueError("ZIP lacks required GTFS schedule tables")
143
+ if len(candidates) != 1:
144
+ raise ValueError("ZIP contains multiple GTFS datasets")
145
+ members = candidates[0]
146
+ if not {"calendar.txt", "calendar_dates.txt"}.intersection(members):
147
+ raise ValueError("ZIP lacks a GTFS service calendar")
148
+ dates = []
149
+ for name in ["calendar.txt", "calendar_dates.txt"]:
150
+ if name not in members:
151
+ continue
152
+ if archive.getinfo(members[name]).file_size > 128 * 1024 * 1024:
153
+ raise ValueError("Service calendar exceeds inspection limit")
154
+ with archive.open(members[name]) as raw:
155
+ for row in csv.DictReader(io.TextIOWrapper(raw, encoding="utf-8-sig")):
156
+ if name == "calendar.txt":
157
+ dates.extend(
158
+ [row.get("start_date", ""), row.get("end_date", "")]
159
+ )
160
+ elif row.get("exception_type") == "1":
161
+ dates.append(row.get("date", ""))
162
+ dates = [date for date in dates if re.fullmatch(r"\d{8}", date)]
163
+ agency_timezones = set()
164
+ with archive.open(members["agency.txt"]) as raw:
165
+ for row in csv.DictReader(io.TextIOWrapper(raw, encoding="utf-8-sig")):
166
+ if row.get("agency_timezone"):
167
+ agency_timezones.add(row["agency_timezone"])
168
+ local_dates = []
169
+ for agency_timezone in agency_timezones:
170
+ try:
171
+ local_dates.append(
172
+ datetime.now(ZoneInfo(agency_timezone)).strftime("%Y%m%d")
173
+ )
174
+ except ZoneInfoNotFoundError:
175
+ pass
176
+ if not local_dates:
177
+ local_dates.append(datetime.now(timezone.utc).strftime("%Y%m%d"))
178
+ return {
179
+ "tables": sorted(members),
180
+ "archive_table_paths": members,
181
+ "nonstandard_table_paths": any(
182
+ name != member for name, member in members.items()
183
+ ),
184
+ "uncompressed_bytes": sum(info.file_size for info in archive.infolist()),
185
+ "service_date_min": min(dates) if dates else None,
186
+ "service_date_max": max(dates) if dates else None,
187
+ "agency_timezones": sorted(agency_timezones),
188
+ "calendar_range_expired": bool(dates and max(dates) < min(local_dates)),
189
+ "calendar_range_future": bool(dates and min(dates) > max(local_dates)),
190
+ }
191
+
192
+
193
+ def inspect_realtime(path):
194
+ from google.protobuf.json_format import MessageToDict
195
+ from google.transit import gtfs_realtime_pb2
196
+
197
+ feed = gtfs_realtime_pb2.FeedMessage()
198
+ feed.ParseFromString(path.read_bytes())
199
+ if not feed.IsInitialized() or not feed.HasField("header"):
200
+ raise ValueError("Response is not an initialized GTFS Realtime feed")
201
+ timestamp = feed.header.timestamp if feed.header.HasField("timestamp") else None
202
+ age = time.time() - timestamp if timestamp is not None else None
203
+ counts = Counter()
204
+ for entity in feed.entity:
205
+ for field in ["trip_update", "vehicle", "alert"]:
206
+ if entity.HasField(field):
207
+ counts[field] += 1
208
+ return {
209
+ "feed_timestamp": timestamp,
210
+ "feed_age_seconds": round(age) if age is not None else None,
211
+ "freshness": "timestamp_missing"
212
+ if age is None
213
+ else ("future_timestamp" if age < -300 else "stale" if age > 300 else "recent"),
214
+ "incrementality": int(feed.header.incrementality),
215
+ "entity_count": len(feed.entity),
216
+ "entity_counts": dict(counts),
217
+ }, MessageToDict(feed, preserving_proto_field_name=True)
218
+
219
+
220
+ def collect_feed(row, root, snapshot, timeout=90, refresh=False):
221
+ result = {
222
+ "id": row["id"],
223
+ "provider": row["provider"],
224
+ "data_type": row["data_type"],
225
+ "entity_type": row.get("entity_type", ""),
226
+ "static_reference": row.get("static_reference", "").split("|")
227
+ if row.get("static_reference")
228
+ else [],
229
+ "license_url": row.get("urls.license", ""),
230
+ "authentication_info": row.get("urls.authentication_info", ""),
231
+ "attempted_at": utc_now(),
232
+ }
233
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", row["id"]):
234
+ return dict(result, status="failed", error="Unsafe catalog feed ID")
235
+ schedule = row["data_type"] == "gtfs"
236
+ relative = (
237
+ Path("schedule") / (row["id"] + ".zip")
238
+ if schedule
239
+ else (Path("realtime") / snapshot / (row["id"] + ".pb"))
240
+ )
241
+ target = root / relative
242
+ partial = target.with_suffix(target.suffix + ".part")
243
+ urls = feed_urls(row)
244
+ if not urls:
245
+ status = (
246
+ "requires_auth"
247
+ if row.get("urls.authentication_type", "") not in {"", "0"}
248
+ else "missing_url"
249
+ )
250
+ return dict(result, status=status)
251
+ if schedule and target.exists() and not refresh:
252
+ try:
253
+ details = inspect_schedule(target)
254
+ return dict(
255
+ result,
256
+ status="cached",
257
+ path=str(relative),
258
+ bytes=target.stat().st_size,
259
+ **details,
260
+ )
261
+ except (ValueError, OSError, zipfile.BadZipFile):
262
+ pass
263
+ attempts = []
264
+ for url in urls:
265
+ try:
266
+ fetch(
267
+ url,
268
+ partial,
269
+ timeout=timeout,
270
+ max_bytes=(512 if schedule else 32) * 1024 * 1024,
271
+ )
272
+ if schedule:
273
+ details = inspect_schedule(partial)
274
+ else:
275
+ details, decoded = inspect_realtime(partial)
276
+ digest = hashlib.sha256()
277
+ with partial.open("rb") as file:
278
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
279
+ digest.update(chunk)
280
+ partial.replace(target)
281
+ if not schedule:
282
+ json_path = target.with_suffix(".json")
283
+ json_path.write_text(
284
+ json.dumps(decoded, ensure_ascii=False), encoding="utf-8"
285
+ )
286
+ result["json_path"] = str(json_path.relative_to(root))
287
+ return dict(
288
+ result,
289
+ status="downloaded",
290
+ downloaded_at=utc_now(),
291
+ path=str(relative),
292
+ bytes=target.stat().st_size,
293
+ sha256=digest.hexdigest(),
294
+ source_url=url,
295
+ **details,
296
+ )
297
+ except Exception as error:
298
+ # A bad or unavailable feed should not cancel the national collection.
299
+ attempts.append({"source_url": url, "error": str(error)[:300]})
300
+ finally:
301
+ partial.unlink(missing_ok=True)
302
+ return dict(result, status="failed", attempts=attempts)
303
+
304
+
305
+ def main(argv=None):
306
+ parser = argparse.ArgumentParser(description=__doc__)
307
+ parser.add_argument("--output", type=Path, default=Path("data/us"))
308
+ parser.add_argument(
309
+ "--catalog", type=Path, help="Use a previously downloaded catalog CSV"
310
+ )
311
+ parser.add_argument(
312
+ "--country", default="US", help="Catalog ISO country code (default: US)"
313
+ )
314
+ parser.add_argument(
315
+ "--kind", choices=["all", "schedule", "realtime"], default="all"
316
+ )
317
+ parser.add_argument(
318
+ "--feed-id", action="append", help="Collect only these catalog IDs; repeatable"
319
+ )
320
+ parser.add_argument("--workers", type=int, default=8)
321
+ parser.add_argument(
322
+ "--timeout", type=int, default=90, help="Maximum seconds per URL"
323
+ )
324
+ parser.add_argument(
325
+ "--refresh", action="store_true", help="Redownload existing schedules"
326
+ )
327
+ parser.add_argument("--catalog-only", action="store_true")
328
+ args = parser.parse_args(argv)
329
+ if args.workers < 1 or args.timeout < 1:
330
+ parser.error("workers and timeout must be positive")
331
+ if not shutil.which("curl"):
332
+ parser.error("curl is required")
333
+ if not args.catalog_only and args.kind in {"all", "realtime"}:
334
+ try:
335
+ from google.transit import gtfs_realtime_pb2 # noqa: F401
336
+ except ImportError:
337
+ parser.error("Install realtime support: pip install -e '.[data]'")
338
+ args.output.mkdir(parents=True, exist_ok=True)
339
+ snapshot = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
340
+ run_dir = args.output / "runs" / snapshot
341
+ run_dir.mkdir(parents=True)
342
+ catalog = args.catalog
343
+ if catalog is None:
344
+ catalog = run_dir / "catalog-global.csv"
345
+ fetch(CATALOG_URL, catalog, max_bytes=32 * 1024 * 1024)
346
+ rows = [
347
+ row
348
+ for row in read_catalog(catalog)
349
+ if row["location.country_code"] == args.country.upper()
350
+ ]
351
+ write_catalog(run_dir / "catalog.csv", rows)
352
+ shutil.copyfile(run_dir / "catalog.csv", args.output / "catalog.csv")
353
+ kinds = (
354
+ {"gtfs", "gtfs_rt"}
355
+ if args.kind == "all"
356
+ else {"gtfs" if args.kind == "schedule" else "gtfs_rt"}
357
+ )
358
+ selected = [
359
+ row for row in rows if row["status"] == "active" and row["data_type"] in kinds
360
+ ]
361
+ if args.feed_id:
362
+ selected = [row for row in selected if row["id"] in args.feed_id]
363
+ missing = set(args.feed_id) - {row["id"] for row in selected}
364
+ if missing:
365
+ parser.error(
366
+ f"Requested feeds are not active in this country/kind: {sorted(missing)}"
367
+ )
368
+ summary = {
369
+ "started_at": utc_now(),
370
+ "catalog_source": CATALOG_URL,
371
+ "catalog_file": str(catalog),
372
+ "country": args.country.upper(),
373
+ "run": snapshot,
374
+ "catalog_entries": len(rows),
375
+ "selected_entries": len(selected),
376
+ "selected_by_type": dict(Counter(row["data_type"] for row in selected)),
377
+ "catalog_status_counts": dict(Counter(row["status"] for row in rows)),
378
+ "note": "Catalog coverage is not universal coverage. Realtime is a collection of snapshots taken at different times. Calendar bounds do not prove service on a specific day. Structural checks are not full GTFS validation.",
379
+ }
380
+ print(json.dumps(summary, indent=2), flush=True)
381
+ results = []
382
+ if not args.catalog_only:
383
+ with (run_dir / "manifest.jsonl").open("w", encoding="utf-8") as manifest:
384
+ with ThreadPoolExecutor(max_workers=args.workers) as pool:
385
+ futures = [
386
+ pool.submit(
387
+ collect_feed,
388
+ row,
389
+ args.output,
390
+ snapshot,
391
+ args.timeout,
392
+ args.refresh,
393
+ )
394
+ for row in selected
395
+ ]
396
+ for future in as_completed(futures):
397
+ result = future.result()
398
+ results.append(result)
399
+ manifest.write(json.dumps(result, ensure_ascii=False) + "\n")
400
+ manifest.flush()
401
+ if len(results) % 25 == 0 or len(results) == len(selected):
402
+ print(
403
+ f"{len(results)}/{len(selected)}: {dict(Counter(r['status'] for r in results))}",
404
+ flush=True,
405
+ )
406
+ summary.update(
407
+ {
408
+ "finished_at": utc_now(),
409
+ "result_counts": dict(Counter(row["status"] for row in results)),
410
+ "results_by_type": {
411
+ kind: dict(
412
+ Counter(
413
+ row["status"] for row in results if row["data_type"] == kind
414
+ )
415
+ )
416
+ for kind in sorted(kinds)
417
+ },
418
+ "downloaded_bytes": sum(
419
+ row.get("bytes", 0) for row in results if row["status"] == "downloaded"
420
+ ),
421
+ "realtime_freshness": dict(
422
+ Counter(row["freshness"] for row in results if "freshness" in row)
423
+ ),
424
+ "expired_schedule_ranges": sum(
425
+ row.get("calendar_range_expired", False) for row in results
426
+ ),
427
+ "future_schedule_ranges": sum(
428
+ row.get("calendar_range_future", False) for row in results
429
+ ),
430
+ "realtime_entity_counts": dict(
431
+ sum(
432
+ (Counter(row.get("entity_counts", {})) for row in results),
433
+ Counter(),
434
+ )
435
+ ),
436
+ }
437
+ )
438
+ (run_dir / "summary.json").write_text(
439
+ json.dumps(summary, indent=2) + "\n", encoding="utf-8"
440
+ )
441
+ shutil.copyfile(run_dir / "summary.json", args.output / "summary.json")
442
+ print(json.dumps(summary, indent=2), flush=True)
443
+ return 0
444
+
445
+
446
+ if __name__ == "__main__":
447
+ raise SystemExit(main())
xtransit/config.py ADDED
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass, field
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ @dataclass
7
+ class Settings:
8
+ PKG_NAME: str = "xtransit"
9
+ XT_HOME: Path = field(default_factory=lambda: Path(os.environ.get("XT_HOME", Path.home() / ".xtransit")))
10
+
11
+ def __post_init__(self):
12
+ self.XT_HOME = Path(self.XT_HOME).expanduser()
13
+
14
+
15
+ settings = Settings()
File without changes
@@ -0,0 +1,18 @@
1
+ from dataclasses import dataclass
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ServiceConfig(BaseModel):
7
+ instance_id: str
8
+
9
+
10
+ @dataclass
11
+ class Service:
12
+ config: ServiceConfig
13
+
14
+ def start(self):
15
+ raise NotImplementedError
16
+
17
+ def stop(self):
18
+ raise NotImplementedError
xtransit/database.py ADDED
@@ -0,0 +1,88 @@
1
+ """The persistent PostgreSQL instance shared by this project's components."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import secrets
8
+ import tempfile
9
+ import threading
10
+
11
+ from sqlalchemy import text
12
+
13
+ from xtransit.config import settings
14
+ from xtransit.services.postgres import PostgreSQL, PostgreSQLConfig
15
+
16
+
17
+ _database = None
18
+ _database_lock = threading.Lock()
19
+
20
+
21
+ def _project_config(path: Path) -> PostgreSQLConfig:
22
+ if not path.exists():
23
+ config = PostgreSQLConfig(
24
+ user="xtransit",
25
+ database="xtransit",
26
+ password=secrets.token_urlsafe(32),
27
+ instance_id="xtransit.postgres.default",
28
+ published_port=55432,
29
+ bind_host="127.0.0.1",
30
+ )
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ # Publish a complete, private file atomically, including across processes.
33
+ descriptor, temporary = tempfile.mkstemp(prefix=".postgres-", dir=path.parent)
34
+ try:
35
+ with os.fdopen(descriptor, "w", encoding="utf-8") as file:
36
+ file.write(config.model_dump_json(indent=2) + "\n")
37
+ try:
38
+ os.link(temporary, path)
39
+ except FileExistsError:
40
+ pass
41
+ finally:
42
+ Path(temporary).unlink(missing_ok=True)
43
+ return PostgreSQLConfig.model_validate_json(path.read_text(encoding="utf-8"))
44
+
45
+
46
+ def get_database() -> PostgreSQL:
47
+ """Return the process-wide service; creation and connection happen lazily."""
48
+ global _database
49
+ with _database_lock:
50
+ if _database is None:
51
+ _database = PostgreSQL(_project_config(settings.XT_HOME / "postgres.json"))
52
+ return _database
53
+
54
+
55
+ async def check_database():
56
+ database = get_database()
57
+ try:
58
+ async with database.aconnect() as connection:
59
+ result = (
60
+ (
61
+ await connection.execute(
62
+ text(
63
+ "SELECT current_database() AS database, current_user AS username, "
64
+ "current_setting('server_version') AS version"
65
+ )
66
+ )
67
+ )
68
+ .mappings()
69
+ .one()
70
+ )
71
+ return {
72
+ **dict(result),
73
+ "container": database.config.instance_id,
74
+ "host": database.service_host,
75
+ "port": database.port,
76
+ "config_file": str(settings.XT_HOME / "postgres.json"),
77
+ }
78
+ finally:
79
+ await database.aclose()
80
+
81
+
82
+ def main():
83
+ """Create/reuse the instance and print a connection check without credentials."""
84
+ print(json.dumps(asyncio.run(check_database()), indent=2))
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()