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 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()