contrails 0.4.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.
- contrail/__init__.py +10 -0
- contrail/airlines.py +268 -0
- contrail/airports.py +101 -0
- contrail/cli.py +760 -0
- contrail/config.py +179 -0
- contrail/data/__init__.py +5 -0
- contrail/data/airline_codes.csv +2225 -0
- contrail/emissions/__init__.py +24 -0
- contrail/emissions/base.py +31 -0
- contrail/emissions/tim.py +316 -0
- contrail/importers/__init__.py +33 -0
- contrail/importers/base.py +28 -0
- contrail/importers/flighty_csv.py +274 -0
- contrail/importers/tripit_ical.py +281 -0
- contrail/models.py +149 -0
- contrail/resync.py +249 -0
- contrail/storage/__init__.py +77 -0
- contrail/storage/base.py +24 -0
- contrail/storage/local_csv.py +184 -0
- contrail/storage/raw_log.py +107 -0
- contrails-0.4.0.dist-info/METADATA +530 -0
- contrails-0.4.0.dist-info/RECORD +25 -0
- contrails-0.4.0.dist-info/WHEEL +4 -0
- contrails-0.4.0.dist-info/entry_points.txt +2 -0
- contrails-0.4.0.dist-info/licenses/LICENSE +21 -0
contrail/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""contrail: estimate and log the CO2e emissions of flights you've taken or booked."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("contrail")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
8
|
+
__version__ = "0.0.0.dev0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
contrail/airlines.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Resolving an airline to its IATA code, from a name or from an ICAO code.
|
|
2
|
+
|
|
3
|
+
Two importers need this and neither has the code TIM wants:
|
|
4
|
+
|
|
5
|
+
- A calendar feed names the *operating* airline in prose ("British Airways 458")
|
|
6
|
+
while TIM's ``operatingCarrierCode`` wants ``BA``.
|
|
7
|
+
- A Flighty export gives the airline as ICAO (``BAW``), which TIM will not take
|
|
8
|
+
either.
|
|
9
|
+
|
|
10
|
+
One table answers both. Three sources, cheapest first:
|
|
11
|
+
|
|
12
|
+
1. **The feed itself.** On a non-codeshare segment the description repeats the
|
|
13
|
+
flight already given in the summary, which hands us a (name -> code) pair for
|
|
14
|
+
free. Someone who flies BA directly and also holds a BA-operated Iberia
|
|
15
|
+
codeshare gets the codeshare resolved with no network call at all.
|
|
16
|
+
2. **The bundled table** (``data/airline_codes.csv``), generated from Wikidata
|
|
17
|
+
and shipped in the wheel. Offline and deterministic, and it covers the great
|
|
18
|
+
majority of both lookups — a 261-flight Flighty export would otherwise mean
|
|
19
|
+
dozens of HTTP round trips on every daily sync, for data that changes about
|
|
20
|
+
never.
|
|
21
|
+
3. **Wikidata live**, for the names and codes the table misses. ``P229`` is the
|
|
22
|
+
IATA designator and ``P230`` the ICAO one. Structured data beats scraping the
|
|
23
|
+
Wikipedia infobox, whose wikitext yields things like ``BAW; SHT`` for a single
|
|
24
|
+
ICAO field.
|
|
25
|
+
|
|
26
|
+
``airline_lookup: false`` disables step 3 only. It exists to opt out of the
|
|
27
|
+
network call, and steps 1 and 2 need no network.
|
|
28
|
+
|
|
29
|
+
Every failure is soft: an unresolvable airline leaves the flight priced under its
|
|
30
|
+
marketing number, which is exactly what contrail did before any of this.
|
|
31
|
+
|
|
32
|
+
The table is derived from Wikidata, which is CC0, and is regenerated by
|
|
33
|
+
``scripts/refresh_airline_codes.py``. Nothing in it is hand-edited — fix it
|
|
34
|
+
upstream and re-run, or the next refresh silently reverts the change.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import csv
|
|
40
|
+
from importlib.resources import files
|
|
41
|
+
|
|
42
|
+
import requests
|
|
43
|
+
|
|
44
|
+
from contrail import __version__
|
|
45
|
+
|
|
46
|
+
WIKIDATA_API = "https://www.wikidata.org/w/api.php"
|
|
47
|
+
IATA_PROPERTY = "P229" # IATA airline designator
|
|
48
|
+
ICAO_PROPERTY = "P230" # ICAO airline designator
|
|
49
|
+
SEARCH_LIMIT = 8
|
|
50
|
+
REQUEST_TIMEOUT = 15
|
|
51
|
+
|
|
52
|
+
# Wikimedia asks for a descriptive User-Agent identifying the tool. Built from
|
|
53
|
+
# the installed version so there's no second literal to keep in step.
|
|
54
|
+
USER_AGENT = f"contrail/{__version__} (https://github.com/atdr/contrail)"
|
|
55
|
+
|
|
56
|
+
_BY_ICAO: dict[str, str] | None = None
|
|
57
|
+
_BY_NAME: dict[str, str] | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize(name: str) -> str:
|
|
61
|
+
"""Whitespace-collapsed casefold, the key both the table and the cache use."""
|
|
62
|
+
return " ".join(name.split()).casefold()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_table() -> tuple[dict[str, str], dict[str, str]]:
|
|
66
|
+
"""Read the bundled airline table once, on first use.
|
|
67
|
+
|
|
68
|
+
Lazy for the same reason ``airports._airports()`` is: a ``contrail sources``
|
|
69
|
+
or a config error should not pay to parse a couple of thousand rows.
|
|
70
|
+
"""
|
|
71
|
+
global _BY_ICAO, _BY_NAME
|
|
72
|
+
if _BY_ICAO is None or _BY_NAME is None:
|
|
73
|
+
by_icao: dict[str, str] = {}
|
|
74
|
+
by_name: dict[str, str] = {}
|
|
75
|
+
data = files("contrail.data").joinpath("airline_codes.csv")
|
|
76
|
+
with data.open(newline="", encoding="utf-8") as f:
|
|
77
|
+
for row in csv.DictReader(f):
|
|
78
|
+
iata = row["iata"]
|
|
79
|
+
by_icao[row["icao"]] = iata
|
|
80
|
+
for name in [row["name"], *row["aliases"].split("|")]:
|
|
81
|
+
if name:
|
|
82
|
+
by_name.setdefault(_normalize(name), iata)
|
|
83
|
+
_BY_ICAO, _BY_NAME = by_icao, by_name
|
|
84
|
+
return _BY_ICAO, _BY_NAME
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _iata_from_claims(entity: dict) -> str | None:
|
|
88
|
+
claims = entity.get("claims", {}).get(IATA_PROPERTY)
|
|
89
|
+
if not claims:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
code = claims[0]["mainsnak"]["datavalue"]["value"]
|
|
93
|
+
except (KeyError, IndexError, TypeError):
|
|
94
|
+
return None
|
|
95
|
+
return code if isinstance(code, str) and code.strip() else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def wikidata_iata_code(name: str, session: requests.Session | None = None) -> str | None:
|
|
99
|
+
"""Look up an airline's IATA code on Wikidata, or None.
|
|
100
|
+
|
|
101
|
+
Searches by name and returns the first hit that actually carries an IATA
|
|
102
|
+
designator. That check is what disambiguates: searching "Iberia" surfaces
|
|
103
|
+
the Iberian Peninsula first, and only the airline has a P229.
|
|
104
|
+
"""
|
|
105
|
+
if not name or not name.strip():
|
|
106
|
+
return None
|
|
107
|
+
session = session or requests.Session()
|
|
108
|
+
headers = {"User-Agent": USER_AGENT}
|
|
109
|
+
|
|
110
|
+
resp = session.get(
|
|
111
|
+
WIKIDATA_API,
|
|
112
|
+
params={
|
|
113
|
+
"action": "wbsearchentities",
|
|
114
|
+
"search": name.strip(),
|
|
115
|
+
"language": "en",
|
|
116
|
+
"limit": SEARCH_LIMIT,
|
|
117
|
+
"format": "json",
|
|
118
|
+
},
|
|
119
|
+
headers=headers,
|
|
120
|
+
timeout=REQUEST_TIMEOUT,
|
|
121
|
+
)
|
|
122
|
+
resp.raise_for_status()
|
|
123
|
+
ids = [hit["id"] for hit in resp.json().get("search", []) if hit.get("id")]
|
|
124
|
+
if not ids:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
resp = session.get(
|
|
128
|
+
WIKIDATA_API,
|
|
129
|
+
params={
|
|
130
|
+
"action": "wbgetentities",
|
|
131
|
+
"props": "claims",
|
|
132
|
+
"ids": "|".join(ids),
|
|
133
|
+
"format": "json",
|
|
134
|
+
},
|
|
135
|
+
headers=headers,
|
|
136
|
+
timeout=REQUEST_TIMEOUT,
|
|
137
|
+
)
|
|
138
|
+
resp.raise_for_status()
|
|
139
|
+
entities = resp.json().get("entities", {})
|
|
140
|
+
|
|
141
|
+
for qid in ids: # preserve the search ranking
|
|
142
|
+
code = _iata_from_claims(entities.get(qid, {}))
|
|
143
|
+
if code:
|
|
144
|
+
return code
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def wikidata_iata_for_icao(icao: str, session: requests.Session | None = None) -> str | None:
|
|
149
|
+
"""Look up an airline's IATA code from its ICAO code on Wikidata, or None.
|
|
150
|
+
|
|
151
|
+
Searches by statement rather than by text: ``haswbstatement:P230=BAW`` asks
|
|
152
|
+
for the entity that actually holds that designator. A plain text search for
|
|
153
|
+
"BAW" would return whatever mentions the string, which for a three-letter
|
|
154
|
+
code is a great deal.
|
|
155
|
+
"""
|
|
156
|
+
if not icao or not icao.strip():
|
|
157
|
+
return None
|
|
158
|
+
session = session or requests.Session()
|
|
159
|
+
headers = {"User-Agent": USER_AGENT}
|
|
160
|
+
|
|
161
|
+
resp = session.get(
|
|
162
|
+
WIKIDATA_API,
|
|
163
|
+
params={
|
|
164
|
+
"action": "query",
|
|
165
|
+
"list": "search",
|
|
166
|
+
"srsearch": f"haswbstatement:{ICAO_PROPERTY}={icao.strip().upper()}",
|
|
167
|
+
"srlimit": SEARCH_LIMIT,
|
|
168
|
+
"format": "json",
|
|
169
|
+
},
|
|
170
|
+
headers=headers,
|
|
171
|
+
timeout=REQUEST_TIMEOUT,
|
|
172
|
+
)
|
|
173
|
+
resp.raise_for_status()
|
|
174
|
+
ids = [
|
|
175
|
+
hit["title"] for hit in resp.json().get("query", {}).get("search", []) if hit.get("title")
|
|
176
|
+
]
|
|
177
|
+
if not ids:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
resp = session.get(
|
|
181
|
+
WIKIDATA_API,
|
|
182
|
+
params={
|
|
183
|
+
"action": "wbgetentities",
|
|
184
|
+
"props": "claims",
|
|
185
|
+
"ids": "|".join(ids),
|
|
186
|
+
"format": "json",
|
|
187
|
+
},
|
|
188
|
+
headers=headers,
|
|
189
|
+
timeout=REQUEST_TIMEOUT,
|
|
190
|
+
)
|
|
191
|
+
resp.raise_for_status()
|
|
192
|
+
entities = resp.json().get("entities", {})
|
|
193
|
+
|
|
194
|
+
for qid in ids: # preserve the search ranking
|
|
195
|
+
code = _iata_from_claims(entities.get(qid, {}))
|
|
196
|
+
if code:
|
|
197
|
+
return code
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class AirlineResolver:
|
|
202
|
+
"""Resolves airline names and ICAO codes to IATA codes, caching within a run."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, lookup: bool = True, session: requests.Session | None = None):
|
|
205
|
+
self.lookup = lookup
|
|
206
|
+
self.session = session
|
|
207
|
+
self._learned: dict[str, str] = {}
|
|
208
|
+
self._cache: dict[str, str | None] = {}
|
|
209
|
+
self._icao_cache: dict[str, str | None] = {}
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _key(name: str) -> str:
|
|
213
|
+
return _normalize(name)
|
|
214
|
+
|
|
215
|
+
def learn(self, name: str, code: str) -> None:
|
|
216
|
+
"""Record a (name -> code) pair observed directly in the source data."""
|
|
217
|
+
if name and code:
|
|
218
|
+
self._learned.setdefault(self._key(name), code)
|
|
219
|
+
|
|
220
|
+
def resolve(self, name: str | None) -> str | None:
|
|
221
|
+
"""IATA code for an airline name, or None if it can't be determined."""
|
|
222
|
+
if not name or not name.strip():
|
|
223
|
+
return None
|
|
224
|
+
key = self._key(name)
|
|
225
|
+
if key in self._learned:
|
|
226
|
+
return self._learned[key]
|
|
227
|
+
if key in self._cache:
|
|
228
|
+
return self._cache[key]
|
|
229
|
+
|
|
230
|
+
_, by_name = _load_table()
|
|
231
|
+
if key in by_name:
|
|
232
|
+
return by_name[key]
|
|
233
|
+
|
|
234
|
+
if not self.lookup:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
code = wikidata_iata_code(name, self.session)
|
|
239
|
+
except requests.RequestException:
|
|
240
|
+
# A lookup service being unreachable must never fail a sync.
|
|
241
|
+
code = None
|
|
242
|
+
self._cache[key] = code
|
|
243
|
+
return code
|
|
244
|
+
|
|
245
|
+
def resolve_icao(self, icao: str | None) -> str | None:
|
|
246
|
+
"""IATA code for an airline's ICAO code, or None if it can't be determined.
|
|
247
|
+
|
|
248
|
+
Unresolved is survivable: the flight keeps its ICAO code and prices to a
|
|
249
|
+
route average, which is market-based and needs no carrier code at all.
|
|
250
|
+
"""
|
|
251
|
+
if not icao or not icao.strip():
|
|
252
|
+
return None
|
|
253
|
+
code = icao.strip().upper()
|
|
254
|
+
|
|
255
|
+
by_icao, _ = _load_table()
|
|
256
|
+
if code in by_icao:
|
|
257
|
+
return by_icao[code]
|
|
258
|
+
if code in self._icao_cache:
|
|
259
|
+
return self._icao_cache[code]
|
|
260
|
+
if not self.lookup:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
resolved = wikidata_iata_for_icao(code, self.session)
|
|
265
|
+
except requests.RequestException:
|
|
266
|
+
resolved = None
|
|
267
|
+
self._icao_cache[code] = resolved
|
|
268
|
+
return resolved
|
contrail/airports.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Airport timezone lookup, used to derive a flight's local departure date.
|
|
2
|
+
|
|
3
|
+
The TIM API is explicit that ``departureDate`` is "the date of the flight in the
|
|
4
|
+
time zone of the origin airport". Calendar feeds routinely give departure times
|
|
5
|
+
in UTC instead — TripIt's does — so an evening departure west of Greenwich, or
|
|
6
|
+
an early-morning one far to the east, lands on the wrong calendar day if the UTC
|
|
7
|
+
date is taken at face value.
|
|
8
|
+
|
|
9
|
+
That costs twice over: the stored ``flight_date`` is wrong, *and* the exact
|
|
10
|
+
emissions lookup misses, silently downgrading the row to a route average.
|
|
11
|
+
|
|
12
|
+
Note that TIM wants a date, not a timestamp. The zone is only ever scaffolding
|
|
13
|
+
for picking the right calendar day.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import date, datetime
|
|
19
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
20
|
+
|
|
21
|
+
import airportsdata
|
|
22
|
+
|
|
23
|
+
_AIRPORTS: dict | None = None
|
|
24
|
+
_ZONES: dict[str, ZoneInfo | None] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _airports() -> dict:
|
|
28
|
+
"""Load the bundled IATA database once, on first use."""
|
|
29
|
+
global _AIRPORTS
|
|
30
|
+
if _AIRPORTS is None:
|
|
31
|
+
_AIRPORTS = airportsdata.load("IATA")
|
|
32
|
+
return _AIRPORTS
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def timezone_for(iata: str | None) -> ZoneInfo | None:
|
|
36
|
+
"""IANA timezone for an IATA airport code, or None if it isn't known."""
|
|
37
|
+
if not iata:
|
|
38
|
+
return None
|
|
39
|
+
code = iata.strip().upper()
|
|
40
|
+
if code in _ZONES:
|
|
41
|
+
return _ZONES[code]
|
|
42
|
+
|
|
43
|
+
entry = _airports().get(code)
|
|
44
|
+
zone: ZoneInfo | None = None
|
|
45
|
+
if entry and entry.get("tz"):
|
|
46
|
+
try:
|
|
47
|
+
zone = ZoneInfo(entry["tz"])
|
|
48
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
49
|
+
zone = None # no tz database on this platform
|
|
50
|
+
_ZONES[code] = zone
|
|
51
|
+
return zone
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def today_at(origin: str | None, now: datetime) -> date:
|
|
55
|
+
"""The current calendar date at an airport.
|
|
56
|
+
|
|
57
|
+
The freeze boundary compares a stored ``flight_date`` — which is local to the
|
|
58
|
+
origin — against "today". Using the UTC date instead is wrong for part of
|
|
59
|
+
every day by the origin's offset: east of UTC it keeps a departed flight
|
|
60
|
+
looking upcoming, west of UTC it freezes one that hasn't left yet.
|
|
61
|
+
"""
|
|
62
|
+
zone = timezone_for(origin)
|
|
63
|
+
return now.astimezone(zone).date() if zone else now.date()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def departure_datetime(dtstart, origin: str | None) -> datetime | None:
|
|
67
|
+
"""The departure as an instant, expressed in the origin's own timezone.
|
|
68
|
+
|
|
69
|
+
Stored so the boundary can be exact rather than a date comparison. All-day
|
|
70
|
+
events have no time and yield None; a naive time is RFC 5545 floating, i.e.
|
|
71
|
+
already local, so it is left as-is rather than shifted.
|
|
72
|
+
"""
|
|
73
|
+
if not isinstance(dtstart, datetime):
|
|
74
|
+
return None
|
|
75
|
+
if dtstart.tzinfo is None:
|
|
76
|
+
return dtstart
|
|
77
|
+
zone = timezone_for(origin)
|
|
78
|
+
return dtstart.astimezone(zone) if zone else dtstart
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def departure_date(dtstart, origin: str | None) -> date | None:
|
|
82
|
+
"""The local calendar date of a departure at ``origin``.
|
|
83
|
+
|
|
84
|
+
Falls back to the value as given whenever the conversion can't be trusted:
|
|
85
|
+
|
|
86
|
+
- an all-day event has no time to convert
|
|
87
|
+
- a naive datetime is already a local wall-clock time (RFC 5545 "floating"),
|
|
88
|
+
so converting it would be wrong
|
|
89
|
+
- an unrecognised airport code leaves nothing to convert against
|
|
90
|
+
"""
|
|
91
|
+
if dtstart is None:
|
|
92
|
+
return None
|
|
93
|
+
if not isinstance(dtstart, datetime):
|
|
94
|
+
return dtstart # already a plain date
|
|
95
|
+
if dtstart.tzinfo is None:
|
|
96
|
+
return dtstart.date()
|
|
97
|
+
|
|
98
|
+
zone = timezone_for(origin)
|
|
99
|
+
if zone is None:
|
|
100
|
+
return dtstart.date()
|
|
101
|
+
return dtstart.astimezone(zone).date()
|