edfh-data 0.5.2__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.
edfh_data/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.5.2"
File without changes
edfh_data/db/crud.py ADDED
@@ -0,0 +1,218 @@
1
+ import datetime as dt
2
+ import json
3
+
4
+ from sqlmodel import and_
5
+ from sqlmodel import desc
6
+ from sqlmodel import select
7
+ from sqlmodel import Session
8
+
9
+ from edfh_data.db.models import FactionStateDB
10
+ from edfh_data.db.models import MarketCommodityDB
11
+ from edfh_data.db.models import StationDB
12
+ from edfh_data.db.models import SystemDB
13
+ from edfh_data.db.models import SystemFactionDB
14
+ from edfh_data.db.models import SystemFactionHistoryDB
15
+ from edfh_data.db.models import SystemPowerDB
16
+ from edfh_data.db.utils import common_keys_equal
17
+ from edfh_data.db.utils import engine
18
+ from edfh_data.models import Station
19
+ from edfh_data.models import StationMarket
20
+ from edfh_data.models import System
21
+
22
+ UTC = dt.timezone.utc
23
+
24
+
25
+ def create_update_system(system: System) -> None:
26
+
27
+ system_data = system.model_dump(
28
+ exclude_none=True, exclude={"system_factions", "system_powers"}
29
+ )
30
+
31
+ with Session(engine) as session:
32
+ stmt = select(SystemDB).where(SystemDB.system_name == system.system_name)
33
+ res = session.exec(stmt)
34
+ system_db = res.one_or_none()
35
+
36
+ if system_db:
37
+ system_update = system.update_datetime.replace(tzinfo=UTC)
38
+ system_db_update = system_db.update_datetime.replace(tzinfo=UTC)
39
+ if system_update > system_db_update:
40
+ system_db = system_db | system_data
41
+ else:
42
+ return
43
+ else:
44
+ system_db = SystemDB(**system_data)
45
+ session.add(system_db)
46
+ session.commit()
47
+ session.refresh(system_db)
48
+
49
+ system_factions = []
50
+ for faction in system.system_factions:
51
+
52
+ faction_data = faction.model_dump(exclude_none=True, exclude={"states"})
53
+
54
+ states_data = None
55
+ if faction.states:
56
+ states_data = json.dumps(faction.model_dump(mode="json")["states"])
57
+
58
+ stmt = select(SystemFactionDB).where(
59
+ and_(
60
+ SystemFactionDB.system_id == system_db.system_id,
61
+ SystemFactionDB.faction_name == faction.faction_name,
62
+ )
63
+ )
64
+ res = session.exec(stmt)
65
+ system_faction = res.one_or_none()
66
+
67
+ if system_faction:
68
+ system_faction = system_faction | faction_data
69
+ else:
70
+ system_faction = SystemFactionDB(**faction_data)
71
+
72
+ system_faction.faction_states = [
73
+ FactionStateDB(**state.model_dump()) for state in faction.states
74
+ ]
75
+
76
+ system_factions.append(system_faction)
77
+
78
+ # Get latest faction data from history
79
+ stmt = (
80
+ select(SystemFactionHistoryDB)
81
+ .where(
82
+ and_(
83
+ SystemFactionHistoryDB.system_id == system_db.system_id,
84
+ SystemFactionHistoryDB.faction_name == faction.faction_name,
85
+ )
86
+ )
87
+ .order_by(desc(SystemFactionHistoryDB.update_datetime))
88
+ )
89
+ res = session.exec(stmt)
90
+ system_faction_latest = res.first()
91
+
92
+ if system_faction_latest:
93
+ # Only update the update_datetime field if faction data has not changed
94
+ if common_keys_equal(faction_data, system_faction_latest.model_dump()):
95
+ system_faction_latest.update_datetime = system.update_datetime
96
+ session.add(system_faction_latest)
97
+ # Create new entry only if values have updated
98
+ else:
99
+ system_faction_latest = SystemFactionHistoryDB(
100
+ system_id=system_db.system_id, # type: ignore
101
+ update_datetime=system.update_datetime,
102
+ states=states_data,
103
+ **faction_data,
104
+ )
105
+ else:
106
+ system_faction_latest = SystemFactionHistoryDB(
107
+ system_id=system_db.system_id, # type: ignore
108
+ update_datetime=system.update_datetime,
109
+ states=states_data,
110
+ **faction_data,
111
+ )
112
+
113
+ session.add(system_faction_latest)
114
+
115
+ system_db.system_factions = system_factions
116
+
117
+ system_powers = []
118
+ for power in system.system_powers:
119
+
120
+ stmt = select(SystemPowerDB).where(
121
+ and_(
122
+ SystemPowerDB.system_id == system_db.system_id,
123
+ SystemPowerDB.power == power,
124
+ )
125
+ )
126
+ res = session.exec(stmt)
127
+ system_power = res.one_or_none()
128
+
129
+ if not system_power:
130
+ system_power = SystemPowerDB(
131
+ system_id=system_db.system_id, # type: ignore
132
+ power=power,
133
+ )
134
+
135
+ system_powers.append(system_power)
136
+
137
+ system_db.system_powers = system_powers
138
+
139
+ session.add(system_db)
140
+ session.commit()
141
+
142
+
143
+ def create_update_station(station: Station) -> None:
144
+
145
+ station_data = station.model_dump(exclude_none=True)
146
+
147
+ with Session(engine) as session:
148
+ # Get the parent system from the DB if it exists
149
+ stmt = select(SystemDB).where(SystemDB.system_name == station.system_name)
150
+ res = session.exec(stmt)
151
+ system_db = res.one_or_none()
152
+
153
+ # Stop if the system does not exist in the DB
154
+ if system_db is None:
155
+ return
156
+
157
+ # Get the station from the DB if it exists
158
+ stmt = select(StationDB).where(StationDB.market_id == station.market_id)
159
+ res = session.exec(stmt)
160
+ station_db = res.one_or_none()
161
+
162
+ if station_db:
163
+ station_update = station.update_datetime.replace(tzinfo=UTC)
164
+ station_db_update = station_db.update_datetime.replace(tzinfo=UTC)
165
+ if station_update > station_db_update:
166
+ station_db = station_db | station_data
167
+ station_db.system_id = system_db.system_id # type: ignore
168
+ else:
169
+ return
170
+ else:
171
+ station_db = StationDB(
172
+ system_id=system_db.system_id, # type: ignore
173
+ **station_data,
174
+ )
175
+
176
+ session.add(station_db)
177
+ session.commit()
178
+
179
+
180
+ def create_update_station_market(market: StationMarket):
181
+
182
+ with Session(engine) as session:
183
+ # Get the parent station from the DB
184
+ stmt = select(StationDB).where(StationDB.market_id == market.market_id)
185
+ res = session.exec(stmt)
186
+ station_db = res.one_or_none()
187
+
188
+ # Stop if the station does not exist in the DB
189
+ if station_db is None:
190
+ return
191
+
192
+ market_update = market.update_datetime.replace(tzinfo=UTC)
193
+ station_market_update = station_db.market_update_datetime
194
+ if (
195
+ station_market_update is None
196
+ or market_update > station_market_update.replace(tzinfo=UTC)
197
+ ):
198
+ existing_commodities = {c.name: c for c in station_db.market_commodities}
199
+ commodities = []
200
+
201
+ for commodity in market.commodities:
202
+ if commodity.name in existing_commodities:
203
+ commodities.append(
204
+ existing_commodities[commodity.name] | commodity.model_dump()
205
+ )
206
+ else:
207
+ commodities.append(
208
+ MarketCommodityDB(
209
+ station_id=station_db.station_id, # type: ignore
210
+ **commodity.model_dump(),
211
+ )
212
+ )
213
+
214
+ station_db.market_update_datetime = market.update_datetime
215
+ station_db.market_commodities = commodities
216
+
217
+ session.add(station_db)
218
+ session.commit()
edfh_data/db/models.py ADDED
@@ -0,0 +1,164 @@
1
+ import datetime as dt
2
+ from typing import Mapping, Self
3
+
4
+ from sqlmodel import BigInteger
5
+ from sqlmodel import Column
6
+ from sqlmodel import Field
7
+ from sqlmodel import Relationship
8
+ from sqlmodel import SQLModel
9
+ from sqlmodel import String
10
+ from sqlmodel import TIMESTAMP
11
+
12
+
13
+ class UpdatableSQLModel(SQLModel):
14
+
15
+ def update(self, **kwargs: Mapping) -> Self:
16
+ """Update the speficied fields of the model instance and return the instance."""
17
+ for field, value in kwargs.items():
18
+ if field in self.__class__.model_fields:
19
+ setattr(self, field, value)
20
+ return self
21
+
22
+ def __or__(self, other: Mapping) -> Self:
23
+ """Update the speficied fields of the model instance from a dictionary using
24
+ the or ('|') operator"""
25
+ return self.update(**other)
26
+
27
+
28
+ class SystemDB(UpdatableSQLModel, table=True):
29
+ __tablename__ = "systems" # type: ignore
30
+ system_id: int | None = Field(default=None, primary_key=True, sa_type=BigInteger)
31
+ system_name: str
32
+ system_address: int = Field(sa_type=BigInteger)
33
+ x: float
34
+ y: float
35
+ z: float
36
+ population: int = Field(sa_type=BigInteger)
37
+ system_factions: list["SystemFactionDB"] = Relationship(
38
+ back_populates="system", cascade_delete=True
39
+ )
40
+ update_datetime: dt.datetime = Field(
41
+ sa_column=Column(TIMESTAMP(timezone=True), nullable=False)
42
+ )
43
+ system_allegiance: str | None = Field(default=None)
44
+ controlling_faction: str | None = Field(default=None)
45
+ controlling_power: str | None = Field(default=None)
46
+ powerplay_state: str | None = Field(default=None)
47
+ powerplay_state_control_progress: float | None = Field(default=None)
48
+ powerplay_state_reinforcement: int | None = Field(default=None)
49
+ powerplay_state_undermining: int | None = Field(default=None)
50
+ primary_economy: str | None = Field(default=None)
51
+ secondary_economy: str | None = Field(default=None)
52
+ security: str | None = Field(default=None)
53
+ government: str | None = Field(default=None)
54
+ system_powers: list["SystemPowerDB"] = Relationship(
55
+ back_populates="system", cascade_delete=True
56
+ )
57
+ system_factions_history: list["SystemFactionHistoryDB"] = Relationship(
58
+ back_populates="system", cascade_delete=True
59
+ )
60
+ stations: list["StationDB"] = Relationship(
61
+ back_populates="system", cascade_delete=True
62
+ )
63
+
64
+
65
+ class SystemFactionDB(UpdatableSQLModel, table=True):
66
+ __tablename__ = "system_factions" # type: ignore
67
+ system_faction_id: int | None = Field(
68
+ default=None, primary_key=True, sa_type=BigInteger
69
+ )
70
+ system_id: int = Field(
71
+ foreign_key="systems.system_id", index=True, sa_type=BigInteger
72
+ )
73
+ faction_name: str
74
+ influence: float
75
+ faction_states: list["FactionStateDB"] = Relationship(
76
+ back_populates="system_faction", cascade_delete=True
77
+ )
78
+ system: SystemDB = Relationship(back_populates="system_factions")
79
+
80
+
81
+ class SystemPowerDB(UpdatableSQLModel, table=True):
82
+ __tablename__ = "system_powers" # type: ignore
83
+ system_power_id: int | None = Field(
84
+ default=None, primary_key=True, sa_type=BigInteger
85
+ )
86
+ system_id: int = Field(
87
+ foreign_key="systems.system_id", index=True, sa_type=BigInteger
88
+ )
89
+ power: str
90
+ system: SystemDB = Relationship(back_populates="system_powers")
91
+
92
+
93
+ class SystemFactionHistoryDB(SQLModel, table=True):
94
+ __tablename__ = "system_factions_history" # type: ignore
95
+ system_faction_history_id: int | None = Field(
96
+ default=None, primary_key=True, sa_type=BigInteger
97
+ )
98
+ system_id: int = Field(
99
+ foreign_key="systems.system_id", index=True, sa_type=BigInteger
100
+ )
101
+ faction_name: str
102
+ influence: float
103
+ states: str | None = Field(default=None, sa_type=String(1024))
104
+ update_datetime: dt.datetime = Field(
105
+ sa_column=Column(TIMESTAMP(timezone=True), nullable=False)
106
+ )
107
+ system: SystemDB = Relationship(back_populates="system_factions_history")
108
+
109
+
110
+ class FactionStateDB(SQLModel, table=True):
111
+ __tablename__ = "faction_states" # type: ignore
112
+ faction_state_id: int | None = Field(
113
+ default=None, primary_key=True, sa_type=BigInteger
114
+ )
115
+ system_faction_id: int = Field(
116
+ foreign_key="system_factions.system_faction_id", index=True, sa_type=BigInteger
117
+ )
118
+ state_phase: str
119
+ state: str
120
+ system_faction: SystemFactionDB = Relationship(back_populates="faction_states")
121
+
122
+
123
+ class StationDB(UpdatableSQLModel, table=True):
124
+ __tablename__ = "stations" # type: ignore
125
+ station_id: int | None = Field(default=None, primary_key=True, sa_type=BigInteger)
126
+ system_id: int = Field(
127
+ foreign_key="systems.system_id", index=True, sa_type=BigInteger
128
+ )
129
+ station_name: str
130
+ distance_from_star: float
131
+ market_id: int = Field(unique=True, index=True, sa_type=BigInteger)
132
+ station_type: str
133
+ is_planetary: bool
134
+ max_landing_pad_size: str
135
+ primary_economy: str
136
+ secondary_economy: str | None = Field(default=None)
137
+ station_faction_name: str = Field(index=True)
138
+ update_datetime: dt.datetime = Field(
139
+ sa_column=Column(TIMESTAMP(timezone=True), nullable=False)
140
+ )
141
+ system: SystemDB = Relationship(back_populates="stations")
142
+ market_commodities: list["MarketCommodityDB"] = Relationship(
143
+ back_populates="station", cascade_delete=True
144
+ )
145
+ market_update_datetime: dt.datetime | None = Field(
146
+ default=None, sa_column=Column(TIMESTAMP(timezone=True))
147
+ )
148
+
149
+
150
+ class MarketCommodityDB(UpdatableSQLModel, table=True):
151
+ __tablename__ = "market_commodities" # type: ignore
152
+ market_commodity_id: int | None = Field(
153
+ default=None, primary_key=True, sa_type=BigInteger
154
+ )
155
+ station_id: int = Field(
156
+ foreign_key="stations.station_id", index=True, sa_type=BigInteger
157
+ )
158
+ name: str
159
+ mean_price: int
160
+ buy_price: int
161
+ stock: int
162
+ sell_price: int
163
+ demand: int
164
+ station: StationDB = Relationship(back_populates="market_commodities")
edfh_data/db/utils.py ADDED
@@ -0,0 +1,47 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Mapping
4
+
5
+ from sqlmodel import create_engine
6
+ from sqlmodel import SQLModel
7
+
8
+ if Path(".env").is_file():
9
+ from dotenv import load_dotenv
10
+
11
+ load_dotenv()
12
+
13
+
14
+ def db_connect_string():
15
+ """Generate the DB connection string from different environment variables."""
16
+ user = os.getenv("DB_USER", "dbuser")
17
+ passwd = os.getenv("DB_PASSWD")
18
+ host = os.getenv("DB_HOST", "localhost")
19
+ port = os.getenv("DB_PORT", 3306)
20
+ name = os.getenv("DB_NAME")
21
+
22
+ return f"mysql+pymysql://{user}:{passwd}@{host}:{port}/{name}"
23
+
24
+
25
+ engine = create_engine(db_connect_string())
26
+
27
+
28
+ def init_db():
29
+ SQLModel.metadata.create_all(engine)
30
+
31
+
32
+ def update_entry_fields(entry: SQLModel, update: Mapping) -> SQLModel:
33
+ for field, value in update.items():
34
+ if field in entry.__class__.model_fields:
35
+ setattr(entry, field, value)
36
+ return entry
37
+
38
+
39
+ def exclude_none(data):
40
+ return {k: v for k, v in data.items() if v is not None}
41
+
42
+
43
+ def common_keys_equal(first: Mapping, second: Mapping) -> bool:
44
+ """Compares the values of the common keys in two dictionaries. Returns True if all
45
+ values are equal, False otherwise."""
46
+ common_keys = set(first.keys()) & set(second.keys())
47
+ return all(first[k] == second[k] for k in common_keys)
File without changes
@@ -0,0 +1,147 @@
1
+ import datetime as dt
2
+ import os
3
+ from typing import Mapping
4
+ import zlib
5
+
6
+ import orjson
7
+ import pika
8
+ from sqlalchemy.exc import OperationalError
9
+
10
+ from edfh_data.db.crud import create_update_station
11
+ from edfh_data.db.crud import create_update_station_market
12
+ from edfh_data.db.crud import create_update_system
13
+ from edfh_data.db.utils import init_db
14
+ from edfh_data.exceptions import EventTooOldError
15
+ from edfh_data.models import construction_station_types
16
+ from edfh_data.models import Station
17
+ from edfh_data.models import StationMarket
18
+ from edfh_data.models import System
19
+
20
+ JOURNAL_V1_SCHEMA = "https://eddn.edcd.io/schemas/journal/1"
21
+ COMMODITY_V3_SCHEMA = "https://eddn.edcd.io/schemas/commodity/3"
22
+
23
+
24
+ def handle_journal_v1_fsdjump(message: Mapping) -> None:
25
+ """Handle the journal/1 messages with event==FSDJump."""
26
+
27
+ system = System(**message)
28
+
29
+ create_update_system(system)
30
+
31
+
32
+ def handle_journal_v1_docked(message: Mapping) -> None:
33
+ """Handle the journal/1 message with event==Docked."""
34
+ # Ignore colonisation ships & construction sites
35
+ if (
36
+ message["StationType"] in construction_station_types
37
+ or "colonisationship" in message["StationName"].lower()
38
+ ):
39
+ return
40
+
41
+ station = Station(**message)
42
+
43
+ create_update_station(station)
44
+
45
+
46
+ def handle_commodity_v3(message: Mapping) -> None:
47
+ """Handle the commodity/3 message."""
48
+
49
+ market = StationMarket(**message)
50
+
51
+ create_update_station_market(market)
52
+ pass
53
+
54
+
55
+ def handle_eddn_event(
56
+ event: dict, max_age: dt.timedelta = dt.timedelta(hours=1)
57
+ ) -> bool:
58
+ """Handle any eddn event."""
59
+ handled = False
60
+ message = event["message"]
61
+
62
+ now = dt.datetime.now(tz=dt.timezone.utc)
63
+ message_datetime = dt.datetime.fromisoformat(message["timestamp"]).replace(
64
+ tzinfo=dt.timezone.utc
65
+ )
66
+ messag_age = now - message_datetime
67
+
68
+ if messag_age > max_age:
69
+ raise EventTooOldError(
70
+ f"The event timestamp is too old: {message['timestamp']}"
71
+ )
72
+
73
+ if event["$schemaRef"] == JOURNAL_V1_SCHEMA and message.get("event") == "FSDJump":
74
+ handle_journal_v1_fsdjump(message)
75
+ handled = True
76
+
77
+ elif event["$schemaRef"] == JOURNAL_V1_SCHEMA and message.get("event") == "Docked":
78
+ handle_journal_v1_docked(message)
79
+ handled = True
80
+
81
+ elif event["$schemaRef"] == COMMODITY_V3_SCHEMA:
82
+ handle_commodity_v3(message)
83
+ handled = True
84
+
85
+ return handled
86
+
87
+
88
+ def run():
89
+
90
+ init_db()
91
+
92
+ RMQ_EXCHANGE_NAME = "eddn_raw"
93
+ RMQ_QUEUE_NAME = "eddn_raw_queue"
94
+
95
+ rmq_connection = pika.BlockingConnection(
96
+ pika.ConnectionParameters(
97
+ host=os.getenv("RMQ_HOST", "localhost"),
98
+ credentials=pika.PlainCredentials(
99
+ username=os.getenv("RMQ_USER", "guest"),
100
+ password=os.getenv("RMQ_PASSWD", "guest"),
101
+ ),
102
+ )
103
+ )
104
+
105
+ rmq_channel = rmq_connection.channel()
106
+ rmq_channel.exchange_declare(exchange=RMQ_EXCHANGE_NAME, exchange_type="fanout")
107
+
108
+ rmq_channel.queue_declare(queue=RMQ_QUEUE_NAME, durable=True)
109
+ rmq_channel.queue_bind(exchange=RMQ_EXCHANGE_NAME, queue=RMQ_QUEUE_NAME)
110
+
111
+ def callback(ch, method, properties, body):
112
+ try:
113
+ event_json = zlib.decompress(body)
114
+ event = orjson.loads(event_json)
115
+ print(f" [x] Received {event["$schemaRef"]}")
116
+
117
+ handle_eddn_event(event, max_age=dt.timedelta(days=7))
118
+
119
+ ch.basic_ack(delivery_tag=method.delivery_tag)
120
+
121
+ except OperationalError as e:
122
+ if e.orig and e.orig.args[0] == 1020:
123
+ # OperationalError(1020, "Record has changed since last read in
124
+ # table 'systems'")
125
+ # Do not aknowledge and requeue for re-processing
126
+ ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
127
+ else:
128
+ ch.basic_ack(delivery_tag=method.delivery_tag)
129
+ print(e)
130
+
131
+ except Exception as e:
132
+ ch.basic_ack(delivery_tag=method.delivery_tag)
133
+ print(e)
134
+
135
+ rmq_channel.basic_qos(prefetch_count=int(os.getenv("RMQ_HANDLER_PREFETCH", 1)))
136
+ rmq_channel.basic_consume(queue=RMQ_QUEUE_NAME, on_message_callback=callback)
137
+
138
+ try:
139
+ rmq_channel.start_consuming()
140
+ except KeyboardInterrupt:
141
+ rmq_channel.stop_consuming()
142
+
143
+ rmq_connection.close()
144
+
145
+
146
+ if __name__ == "__main__":
147
+ run()
@@ -0,0 +1,96 @@
1
+ import os
2
+ from pathlib import Path
3
+ import sys
4
+ import time
5
+ import zlib
6
+
7
+ import orjson
8
+ import pika
9
+ import zmq
10
+
11
+
12
+ def run():
13
+
14
+ if Path(".env").is_file():
15
+ from dotenv import load_dotenv
16
+
17
+ load_dotenv()
18
+
19
+ EDDN_RELAY_URL = "tcp://eddn.edcd.io:9500"
20
+ EDDN_SOCKET_TIMEOUT = 600000
21
+ RMQ_EXCHANGE_NAME = "eddn_raw"
22
+
23
+ context = zmq.Context()
24
+ subscriber = context.socket(zmq.SUB)
25
+
26
+ subscriber.setsockopt(zmq.SUBSCRIBE, b"")
27
+ subscriber.setsockopt(zmq.RCVTIMEO, EDDN_SOCKET_TIMEOUT)
28
+
29
+ rmq_credentials = pika.PlainCredentials(
30
+ username=os.getenv("RMQ_USER", "guest"),
31
+ password=os.getenv("RMQ_PASSWD", "guest"),
32
+ )
33
+ rmq_connect_params = pika.ConnectionParameters(
34
+ host=os.getenv("RMQ_HOST", "localhost"),
35
+ credentials=rmq_credentials,
36
+ heartbeat=20,
37
+ )
38
+
39
+ while True:
40
+ try:
41
+ subscriber.connect(EDDN_RELAY_URL)
42
+
43
+ rmq_connection = None
44
+ rmq_connection = pika.BlockingConnection(rmq_connect_params)
45
+ rmq_channel = rmq_connection.channel()
46
+ rmq_channel.exchange_declare(
47
+ exchange=RMQ_EXCHANGE_NAME, exchange_type="fanout"
48
+ )
49
+
50
+ while True:
51
+ try:
52
+ event_raw = subscriber.recv()
53
+
54
+ if event_raw is False:
55
+ subscriber.disconnect(EDDN_RELAY_URL)
56
+ break
57
+
58
+ rmq_channel.basic_publish(
59
+ exchange=RMQ_EXCHANGE_NAME,
60
+ routing_key="",
61
+ body=event_raw,
62
+ properties=pika.BasicProperties(
63
+ delivery_mode=pika.DeliveryMode.Persistent
64
+ ),
65
+ )
66
+
67
+ event_json = zlib.decompress(event_raw)
68
+ event = orjson.loads(event_json)
69
+ print(f" [x] Published {event.get("$schemaRef")}")
70
+
71
+ except KeyError as e:
72
+ print(e)
73
+
74
+ except KeyboardInterrupt:
75
+ subscriber.disconnect(EDDN_RELAY_URL)
76
+ rmq_connection.close()
77
+ sys.exit()
78
+
79
+ except Exception as e:
80
+ print(repr(e))
81
+ try:
82
+ subscriber.disconnect(EDDN_RELAY_URL)
83
+ except Exception:
84
+ pass
85
+
86
+ try:
87
+ if rmq_connection and rmq_connection.is_open:
88
+ rmq_connection.close()
89
+ except Exception:
90
+ pass
91
+
92
+ time.sleep(5)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ run()
@@ -0,0 +1,11 @@
1
+ class RejectedEventError(ValueError):
2
+ """Top level exception for EDDN events that pass validation but are rejected for
3
+ other reasons."""
4
+
5
+ pass
6
+
7
+
8
+ class EventTooOldError(RejectedEventError):
9
+ """The EDDN event timestamp is too old."""
10
+
11
+ pass
edfh_data/models.py ADDED
@@ -0,0 +1,292 @@
1
+ import datetime as dt
2
+ from typing import Annotated, Literal, Self
3
+
4
+ from fdev_ids import load_table
5
+ from pydantic import AfterValidator
6
+ from pydantic import AliasPath
7
+ from pydantic import BaseModel
8
+ from pydantic import BeforeValidator
9
+ from pydantic import Field
10
+ from pydantic import model_validator
11
+
12
+ economy_ids = load_table("economy")
13
+ security_ids = load_table("security")
14
+ government_ids = load_table("government")
15
+ happiness_ids = load_table("happiness")
16
+ factionstate_ids = load_table("factionstate")
17
+ commodity_symbols = {c["symbol"].lower(): c for c in load_table("commodity").values()}
18
+ rare_commodity_symbols = {
19
+ c["symbol"].lower(): c for c in load_table("rare_commodity").values()
20
+ }
21
+
22
+ planetary_station_types = (
23
+ "CraterOutpost",
24
+ "CraterPort",
25
+ "OnFootSettlement",
26
+ "SurfaceStation",
27
+ )
28
+
29
+ construction_station_types = (
30
+ "PlanetaryConstructionDepot",
31
+ "SpaceConstructionDepot",
32
+ )
33
+
34
+ stronghold_carrier_names = (
35
+ "Stronghold Carrier",
36
+ "Hochburg-Carrier",
37
+ "Porte-vaisseaux de forteresse",
38
+ "Portanaves bastión",
39
+ "Носитель-база",
40
+ "Transportadora da potência",
41
+ )
42
+
43
+
44
+ def replace_none(value: str | None) -> str | None:
45
+ """Replace 'None' string and empty string values with None."""
46
+ if value in ("None", ""):
47
+ return None
48
+ return value
49
+
50
+
51
+ def map_economy(economy_id: str) -> str | None:
52
+ return replace_none(economy_ids.get(economy_id))
53
+
54
+
55
+ def map_security(security_id: str) -> str | None:
56
+ return security_ids.get(security_id)
57
+
58
+
59
+ def map_government(government_id: str) -> str | None:
60
+ return replace_none(government_ids.get(government_id))
61
+
62
+
63
+ def map_happiness(happiness_id: str) -> str | None:
64
+ return happiness_ids.get(happiness_id)
65
+
66
+
67
+ def map_faction_state(factionstate_id: str) -> str | None:
68
+ return replace_none(factionstate_ids.get(factionstate_id))
69
+
70
+
71
+ def map_commodity(commodity_symbol: str) -> str | None:
72
+ all_commodities = commodity_symbols | rare_commodity_symbols
73
+ return all_commodities[commodity_symbol.lower()]["name"]
74
+
75
+
76
+ class FactionState(BaseModel):
77
+ """A state of a faction in a system."""
78
+
79
+ state_phase: Literal["pending", "active", "recovering"]
80
+ state: Annotated[str, BeforeValidator(map_faction_state)]
81
+
82
+
83
+ class SystemFaction(BaseModel):
84
+ """Status of a faction in a star system.
85
+
86
+ Aliases are defined so that an instance can be created from a record in the
87
+ 'Factions' field of the message of a journal/1 event with `event=FSDJump`
88
+ """
89
+
90
+ faction_name: str = Field(alias="Name")
91
+ allegiance: str = Field(alias="Allegiance")
92
+ faction_state: str = Field(alias="FactionState")
93
+ government: str = Field(alias="Government")
94
+ Happiness: Annotated[str | None, BeforeValidator(map_happiness)] = Field(
95
+ default=None, alias="Happiness"
96
+ )
97
+ influence: float = Field(alias="Influence")
98
+ states: list[FactionState] = Field(default_factory=list)
99
+
100
+ @model_validator(mode="before")
101
+ @classmethod
102
+ def map_states(cls, data: dict) -> dict:
103
+ pending = [
104
+ FactionState(state=s["State"], state_phase="pending")
105
+ for s in data.get("PendingStates", list())
106
+ ]
107
+ active = [
108
+ FactionState(state=s["State"], state_phase="active")
109
+ for s in data.get("ActiveStates", list())
110
+ ]
111
+ recovering = [
112
+ FactionState(state=s["State"], state_phase="recovering")
113
+ for s in data.get("RecoveringStates", list())
114
+ ]
115
+ data["states"] = pending + active + recovering
116
+
117
+ return data
118
+
119
+
120
+ def influence_total_is_one(system_factions: list[SystemFaction]) -> list[SystemFaction]:
121
+ """Verify that the sum of faction influences is one (within 0.00001)."""
122
+ is_one = 1.00001 >= sum(f.influence for f in system_factions) >= 0.99999
123
+
124
+ if not is_one:
125
+ raise ValueError("Sum of faction influences must be one")
126
+
127
+ return system_factions
128
+
129
+
130
+ class System(BaseModel):
131
+ """Star system.
132
+
133
+ Aliases are defined so that an instance can be created from the message of a
134
+ journal/1 event with `event=FSDJump`.
135
+ """
136
+
137
+ system_name: str = Field(alias="StarSystem")
138
+ system_address: int = Field(alias="SystemAddress")
139
+ population: int = Field(alias="Population")
140
+ x: float = Field(validation_alias=AliasPath("StarPos", 0))
141
+ y: float = Field(validation_alias=AliasPath("StarPos", 1))
142
+ z: float = Field(validation_alias=AliasPath("StarPos", 2))
143
+ update_datetime: dt.datetime = Field(alias="timestamp")
144
+ system_allegiance: Annotated[str | None, AfterValidator(replace_none)] = Field(
145
+ default=None, alias="SystemAllegiance"
146
+ )
147
+ controlling_faction: Annotated[str | None, AfterValidator(replace_none)] = Field(
148
+ default=None, validation_alias=AliasPath("SystemFaction", "Name")
149
+ )
150
+ controlling_power: Annotated[str | None, AfterValidator(replace_none)] = Field(
151
+ default=None, alias="ControllingPower"
152
+ )
153
+ powerplay_state: Annotated[str | None, AfterValidator(replace_none)] = Field(
154
+ default=None, alias="PowerplayState"
155
+ )
156
+ powerplay_state_control_progress: float | None = Field(
157
+ default=None, alias="PowerplayStateControlProgress"
158
+ )
159
+ powerplay_state_reinforcement: int | None = Field(
160
+ default=None, alias="PowerplayStateReinforcement"
161
+ )
162
+ powerplay_state_undermining: int | None = Field(
163
+ default=None, alias="PowerplayStateUndermining"
164
+ )
165
+ primary_economy: Annotated[str | None, BeforeValidator(map_economy)] = Field(
166
+ default=None, alias="SystemEconomy"
167
+ )
168
+ secondary_economy: Annotated[str | None, BeforeValidator(map_economy)] = Field(
169
+ default=None, alias="SystemSecondEconomy"
170
+ )
171
+ security: Annotated[str | None, BeforeValidator(map_security)] = Field(
172
+ default=None, alias="SystemSecurity"
173
+ )
174
+ government: Annotated[str | None, BeforeValidator(map_government)] = Field(
175
+ default=None, alias="SystemGovernment"
176
+ )
177
+ system_factions: Annotated[
178
+ list[SystemFaction], AfterValidator(influence_total_is_one)
179
+ ] = Field(default_factory=list, alias="Factions")
180
+ system_powers: list[str] = Field(default_factory=list, alias="Powers")
181
+
182
+
183
+ def construction_type_not_allowed(station_type: str) -> str:
184
+ """Raise an exception if the station type is a construction depot."""
185
+ if station_type in construction_station_types:
186
+ raise ValueError("Construction site not allowed as station")
187
+ return station_type
188
+
189
+
190
+ def colonisation_ship_not_allowed(station_name: str) -> str:
191
+ """Raise an exception if the station name indicates a colonisation ship."""
192
+ if "colonisationship" in station_name.lower():
193
+ raise ValueError("Colonisation ship not allowed as station")
194
+ return station_name
195
+
196
+
197
+ class Station(BaseModel):
198
+ """Station.
199
+
200
+ Aliases are defined so that an instance can be created from the message of a
201
+ journal/1 event with `event=Docked`.
202
+ """
203
+
204
+ system_name: str = Field(alias="StarSystem")
205
+ station_name: Annotated[str, AfterValidator(colonisation_ship_not_allowed)] = Field(
206
+ alias="StationName"
207
+ )
208
+ distance_from_star: float = Field(alias="DistFromStarLS")
209
+ market_id: int | None = Field(default=None, alias="MarketID")
210
+ station_type: Annotated[str, AfterValidator(construction_type_not_allowed)] = Field(
211
+ alias="StationType"
212
+ )
213
+ is_planetary: bool = Field(default=False)
214
+ max_landing_pad_size: Literal["L", "M", "S"]
215
+ primary_economy: Annotated[str, BeforeValidator(map_economy)] = Field(
216
+ alias="StationEconomy"
217
+ )
218
+ secondary_economy: str | None = Field(default=None)
219
+ station_faction_name: str = Field(
220
+ validation_alias=AliasPath("StationFaction", "Name")
221
+ )
222
+ update_datetime: dt.datetime = Field(alias="timestamp")
223
+
224
+ @model_validator(mode="before")
225
+ @classmethod
226
+ def _secondary_economy(cls, data: dict) -> dict:
227
+ if len(data["StationEconomies"]) >= 2:
228
+ data["secondary_economy"] = map_economy(data["StationEconomies"][1]["Name"])
229
+ else:
230
+ data["secondary_economy"] = None
231
+ return data
232
+
233
+ @model_validator(mode="before")
234
+ @classmethod
235
+ def _is_planetary(cls, data: dict) -> dict:
236
+ data["is_planetary"] = data["StationType"] in planetary_station_types
237
+ return data
238
+
239
+ @model_validator(mode="before")
240
+ @classmethod
241
+ def _max_landing_pad_size(cls, data: dict) -> dict:
242
+ pads = data["LandingPads"]
243
+ if pads.get("Large", 0) > 0:
244
+ size = "L"
245
+ elif pads.get("Medium", 0) > 0:
246
+ size = "M"
247
+ else:
248
+ size = "S"
249
+ data["max_landing_pad_size"] = size
250
+ return data
251
+
252
+ @model_validator(mode="after")
253
+ def update_stronghold_carrier(self) -> Self:
254
+ if self.station_name in stronghold_carrier_names:
255
+ self.station_name = "Stronghold Carrier"
256
+ self.station_type = "StrongholdCarrier"
257
+ self.is_planetary = False
258
+ return self
259
+
260
+
261
+ class Commodity(BaseModel):
262
+ """A single commodity.
263
+
264
+ Aliases are defined so that an instance can be created from a record in the
265
+ commodities field of the commodity/3 event message.
266
+ """
267
+
268
+ name: Annotated[str, BeforeValidator(map_commodity)]
269
+ mean_price: int = Field(alias="meanPrice")
270
+ buy_price: int = Field(alias="buyPrice")
271
+ stock: int
272
+ sell_price: int = Field(alias="sellPrice")
273
+ demand: int
274
+
275
+
276
+ def exclude_zero_commodities(commodities: list[Commodity]) -> list[Commodity]:
277
+ """Exclude commodities with zero supply & demand."""
278
+ return [c for c in commodities if not all([c.stock == 0, c.demand == 0])]
279
+
280
+
281
+ class StationMarket(BaseModel):
282
+ """List of commodities in a station/market.
283
+
284
+ Aliases are defined so that an instance can be created the message of the
285
+ commodity/3 event.
286
+ """
287
+
288
+ market_id: int = Field(alias="marketId")
289
+ commodities: Annotated[
290
+ list[Commodity], AfterValidator(exclude_zero_commodities)
291
+ ] = Field(default_factory=list, alias="commodities")
292
+ update_datetime: dt.datetime = Field(alias="timestamp")
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: edfh-data
3
+ Version: 0.5.2
4
+ Summary: Python library for the E:D Faction Hub application backend data services
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://codeberg.org/jdlbt/edfh-data
7
+ Project-URL: Issue tracker, https://codeberg.org/jdlbt/edfh-data/issues
8
+ Project-URL: Source, https://codeberg.org/jdlbt/edfh-data
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Python: >=3.13
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: fdev-ids
19
+ Requires-Dist: pydantic
20
+ Provides-Extra: db
21
+ Requires-Dist: PyMySQL; extra == "db"
22
+ Requires-Dist: sqlmodel; extra == "db"
23
+ Provides-Extra: eddn
24
+ Requires-Dist: edfh-data[db]; extra == "eddn"
25
+ Requires-Dist: orjson; extra == "eddn"
26
+ Requires-Dist: pika; extra == "eddn"
27
+ Requires-Dist: zmq; extra == "eddn"
28
+ Provides-Extra: dev
29
+ Requires-Dist: edfh-data[db,eddn]; extra == "dev"
30
+ Requires-Dist: alembic; extra == "dev"
31
+ Requires-Dist: black==25.9.0; extra == "dev"
32
+ Requires-Dist: build; extra == "dev"
33
+ Requires-Dist: flake8==7.3.0; extra == "dev"
34
+ Requires-Dist: isort==6.1.0; extra == "dev"
35
+ Requires-Dist: pre-commit; extra == "dev"
36
+ Requires-Dist: python-dotenv; extra == "dev"
37
+ Requires-Dist: twine; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # edfh-data
41
+
42
+ Python library for the E:D Faction Hub application backend data services:
43
+
44
+ - EDDN listener service, receives messages from the EDDN stream and publishes them to a RabbitMQ exchange.
45
+ - EDDN handler service, polls message from a RabbitMQ queue, parses them and stores the relevant info in a relational database.
46
+
47
+ ## Requirements
48
+
49
+ - Python 3.13 available in the sytem path.
50
+ - Running RabbitMQ and MariaDB instances (the included `docker-compose.yaml` file can set them up for development).
51
+
52
+ ## Configuration
53
+
54
+ The following configuration variables are defined are defined as environment variables, or, for local development, specified in a `.env` configuration file:
55
+
56
+ ```ini
57
+ DB_USER=dbuser
58
+ DB_PASSWD=<db_password>
59
+ DB_HOST=localhost
60
+ DB_PORT=3306
61
+ DB_NAME=fgs_infra_db
62
+ RMQ_USER=rmquser
63
+ RMQ_PASSWD=<rabbitmq_password>
64
+ RMQ_HOST=localhost
65
+ RMQ_HANDLER_PREFETCH=10
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ Install the package with the `eddn` extra:
71
+
72
+ ```
73
+ python -m pip install edfh-data[eddn]
74
+ ```
75
+
76
+ Launch the EDDN message handler service with:
77
+
78
+ ```
79
+ eddn-handle
80
+ ```
81
+
82
+ Launch the EDDN listener service with:
83
+
84
+ ```
85
+ eddn-listen
86
+ ```
87
+
88
+ ## Development
89
+
90
+ ### Python environment
91
+
92
+ Create a virtual environment using Python 3.13:
93
+
94
+ ```
95
+ python3.13 -m venv .venv --prompt edfh-data --upgrade-deps
96
+ source .venv/bin/activate
97
+ ```
98
+
99
+ Install the package in development mode along with the development dependencies:
100
+
101
+ ```
102
+ python -m pip install -e .[dev]
103
+ ```
104
+
105
+ Install pre-commit hooks:
106
+
107
+ ```
108
+ pre-commit install
109
+ ```
110
+
111
+ ### Database service
112
+
113
+ Migrate the database with:
114
+
115
+ ```
116
+ alembic upgrade head
117
+ ```
118
+
119
+ Generate a new revision:
120
+
121
+ ```
122
+ alembic revision --autogenerate -m "Revision message"
123
+ ```
@@ -0,0 +1,16 @@
1
+ edfh_data/__init__.py,sha256=isJrmDBLRag7Zc2UK9ZovWGOv7ji1Oh-zJtJMNJFkXw,22
2
+ edfh_data/exceptions.py,sha256=AxBIxYYBciw17UrGspx2_r95ZBwNgBs7Q3Jz-tRdIq8,258
3
+ edfh_data/models.py,sha256=otcDn_G8by1PZDyhlxuXBVVkudZVWetWHA2x6yYXQ04,10051
4
+ edfh_data/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ edfh_data/db/crud.py,sha256=0vFWX2raeo1gzmu4BjHrX10h1HK81iOJP4WCc_z6ZsY,7786
6
+ edfh_data/db/models.py,sha256=3W9zI7S_Jda4vXK5-Cth7vQC-xcu32yEheYpFBrKe8w,6000
7
+ edfh_data/db/utils.py,sha256=lc403cOPDkbTbfGjMtf0V0WNLpj8j5scMkqZzNEyTJQ,1302
8
+ edfh_data/eddn/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ edfh_data/eddn/handlers.py,sha256=hvJPbdPvThIOadoZhlw44G4r0PxuA7-kV8-7FrDsfuA,4391
10
+ edfh_data/eddn/listener.py,sha256=-yGCrwiYWGrZSwS5JpyOORXNbsfTA1fDs0M-nTYt4uk,2592
11
+ edfh_data-0.5.2.dist-info/licenses/LICENSE,sha256=U1KpKZcXdpnNNT4Xh5cA-aRMvC0eaDEu_azpC70VT_I,1062
12
+ edfh_data-0.5.2.dist-info/METADATA,sha256=2fvxWecaFZ7CEwJ7D-d5FCfeLSUQX219XhA-ORsK4G8,3108
13
+ edfh_data-0.5.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ edfh_data-0.5.2.dist-info/entry_points.txt,sha256=NeJ61ITr5YFhdh-p2akshiPQF8LI9530iUD4SYko_CU,102
15
+ edfh_data-0.5.2.dist-info/top_level.txt,sha256=RsVo4mZIyr5Q_PZQ_-ix9ec1FHhvXra_8c-HIZA2nK8,10
16
+ edfh_data-0.5.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ eddn-handle = edfh_data.eddn.handlers:run
3
+ eddn-listen = edfh_data.eddn.listener:run
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 jdlbt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ edfh_data