Skip to content

Commit fbd33d4

Browse files
feat: IATA BCBP boarding pass payload builder (Item 2)
Add BCBPSegment dataclass and build_bcbp_string() that produces a standards-compliant 60-character IATA Resolution 792 Format Code F mandatory section string, ready for AztecCode.from_preset(bcbp, "boarding_pass"). - BCBPSegment: frozen dataclass for all 12 mandatory BCBP fields - build_bcbp_string: validates fields, auto-converts datetime.date to Julian day, warns+truncates passenger_name >20 chars, returns exactly 60 chars - 10 tests covering field validation, encoding, date conversion, warnings - All 108 tests pass, coverage 91% Co-Authored-By: Riafy agent <benny@riafy.me>
1 parent b54360c commit fbd33d4

3 files changed

Lines changed: 328 additions & 0 deletions

File tree

aztec_py/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
__version__ = "1.1.0"
44

55
from .batch import encode_batch
6+
from .bcbp import BCBPSegment, build_bcbp_string
67
from .core import (
78
AztecCode,
89
Latch,
@@ -24,6 +25,8 @@
2425

2526
__all__ = [
2627
'AztecCode',
28+
'BCBPSegment',
29+
'build_bcbp_string',
2730
'encode_batch',
2831
'Latch',
2932
'Misc',

aztec_py/bcbp.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""IATA BCBP (Bar Coded Boarding Pass) payload builder for Aztec Code integration.
2+
3+
Implements the mandatory single-segment section of IATA Resolution 792
4+
Format Code F (Version 7, 2020). Output is always exactly 60 characters
5+
and is suitable for direct encoding with::
6+
7+
AztecCode.from_preset(bcbp_string, "boarding_pass")
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import datetime
13+
import re
14+
import warnings
15+
from dataclasses import dataclass
16+
from typing import Union
17+
18+
19+
@dataclass(frozen=True)
20+
class BCBPSegment:
21+
"""Single-segment IATA BCBP Format Code F fields.
22+
23+
All string fields are trimmed or padded to their IATA-specified widths
24+
by :func:`build_bcbp_string`. Validation errors raise :class:`ValueError`.
25+
A long ``passenger_name`` is silently truncated to 20 characters with a
26+
:class:`UserWarning`.
27+
28+
Attributes:
29+
passenger_name: ``"SURNAME/GIVEN"`` format, max 20 characters.
30+
pnr_code: Booking reference, max 7 characters.
31+
from_airport: IATA 3-letter origin airport code (e.g. ``"LHR"``).
32+
to_airport: IATA 3-letter destination airport code (e.g. ``"JFK"``).
33+
carrier: Operating carrier designator, 2–3 characters (e.g. ``"BA"``).
34+
flight_number: Flight number — digits are extracted and zero-padded to 4.
35+
date_of_flight: Travel date as :class:`datetime.date` (auto-converted to
36+
Julian day) or integer Julian day (1–366).
37+
compartment_code: Single cabin-class character (``"Y"``, ``"C"``, etc.).
38+
seat_number: Seat assignment, max 4 characters (e.g. ``"023A"``).
39+
sequence_number: Check-in sequence number, 1–99999.
40+
passenger_status: Single digit ``"0"``–``"7"`` per IATA spec.
41+
electronic_ticket: ``True`` encodes ``"E"`` in the e-ticket indicator field.
42+
"""
43+
44+
passenger_name: str
45+
pnr_code: str
46+
from_airport: str
47+
to_airport: str
48+
carrier: str
49+
flight_number: str
50+
date_of_flight: Union[datetime.date, int]
51+
compartment_code: str
52+
seat_number: str
53+
sequence_number: int
54+
passenger_status: str = "0"
55+
electronic_ticket: bool = True
56+
57+
58+
def _validate_airport(code: str, field: str) -> None:
59+
if len(code) != 3 or not code.isalpha():
60+
raise ValueError(
61+
f"{field} must be a 3-letter IATA airport code (e.g. 'LHR'), got {code!r}"
62+
)
63+
64+
65+
def _to_julian(date_of_flight: Union[datetime.date, int]) -> int:
66+
if isinstance(date_of_flight, int):
67+
if not 1 <= date_of_flight <= 366:
68+
raise ValueError(
69+
f"date_of_flight as int must be a Julian day 1–366, got {date_of_flight}"
70+
)
71+
return date_of_flight
72+
d = date_of_flight
73+
return (d - datetime.date(d.year, 1, 1)).days + 1
74+
75+
76+
def build_bcbp_string(segment: BCBPSegment) -> str:
77+
"""Build a single-segment IATA BCBP Format Code F string.
78+
79+
Returns exactly 60 characters per IATA Resolution 792 Version 7
80+
mandatory section layout. Pass the result directly to
81+
``AztecCode.from_preset(bcbp, "boarding_pass")`` to produce a
82+
standards-compliant mobile boarding pass Aztec symbol.
83+
84+
BCBP field layout (60 characters total):
85+
86+
.. code-block:: text
87+
88+
Pos Width Field
89+
─────────────────────────────────────────
90+
1 1 Format code → "M"
91+
2 1 Number of legs → "1"
92+
3–22 20 Passenger name left-justified, space-padded
93+
23 1 E-ticket indicator "E" or " "
94+
24–30 7 PNR code left-justified, space-padded
95+
31–33 3 From airport uppercase IATA code
96+
34–36 3 To airport uppercase IATA code
97+
37–39 3 Carrier designator left-justified, space-padded
98+
40–44 5 Flight number 4 digits zero-padded + " "
99+
45–47 3 Date of flight Julian day zero-padded
100+
48 1 Compartment code first character
101+
49–52 4 Seat number left-justified, space-padded
102+
53–57 5 Sequence number zero-padded
103+
58 1 Passenger status first character
104+
59–60 2 Conditional item size → "00"
105+
─────────────────────────────────────────
106+
Total: 1+1+20+1+7+3+3+3+5+3+1+4+5+1+2 = 60
107+
108+
Args:
109+
segment: :class:`BCBPSegment` with all mandatory fields.
110+
111+
Returns:
112+
60-character BCBP mandatory section string.
113+
114+
Raises:
115+
ValueError: If any field fails IATA format validation.
116+
117+
Warns:
118+
UserWarning: If ``passenger_name`` exceeds 20 characters (truncated).
119+
"""
120+
# --- validate ---
121+
_validate_airport(segment.from_airport, "from_airport")
122+
_validate_airport(segment.to_airport, "to_airport")
123+
124+
if not 2 <= len(segment.carrier) <= 3:
125+
raise ValueError(
126+
f"carrier must be 2–3 characters, got {segment.carrier!r}"
127+
)
128+
129+
digits = re.sub(r"\D", "", segment.flight_number)[:4]
130+
if not digits:
131+
raise ValueError(
132+
f"flight_number must contain at least one digit, got {segment.flight_number!r}"
133+
)
134+
135+
julian = _to_julian(segment.date_of_flight)
136+
137+
if not segment.compartment_code:
138+
raise ValueError("compartment_code must not be empty")
139+
140+
if not 1 <= segment.sequence_number <= 99999:
141+
raise ValueError(
142+
f"sequence_number must be 1–99999, got {segment.sequence_number}"
143+
)
144+
145+
if (
146+
len(segment.passenger_status) < 1
147+
or segment.passenger_status[0] not in "01234567"
148+
):
149+
raise ValueError(
150+
f"passenger_status must be a single digit 0–7, got {segment.passenger_status!r}"
151+
)
152+
153+
# --- warn and truncate passenger name ---
154+
name = segment.passenger_name
155+
if len(name) > 20:
156+
warnings.warn(
157+
f"passenger_name {name!r} exceeds 20 characters and will be truncated",
158+
UserWarning,
159+
stacklevel=2,
160+
)
161+
name = name[:20].ljust(20)
162+
163+
# --- assemble exactly 60 characters ---
164+
return "".join([
165+
"M", # 1 — format code
166+
"1", # 1 — number of legs
167+
name, # 20 — passenger name
168+
"E" if segment.electronic_ticket else " ", # 1 — e-ticket indicator
169+
segment.pnr_code[:7].ljust(7), # 7 — PNR code
170+
segment.from_airport[:3].upper(), # 3 — origin airport
171+
segment.to_airport[:3].upper(), # 3 — destination airport
172+
segment.carrier[:3].ljust(3), # 3 — carrier designator
173+
digits.zfill(4) + " ", # 5 — flight number
174+
str(julian).zfill(3), # 3 — Julian day
175+
segment.compartment_code[0], # 1 — compartment code
176+
segment.seat_number[:4].ljust(4), # 4 — seat number
177+
str(segment.sequence_number).zfill(5), # 5 — sequence number
178+
segment.passenger_status[0], # 1 — passenger status
179+
"00", # 2 — conditional item size
180+
])

tests/test_bcbp.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""IATA BCBP payload builder tests."""
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
import datetime
7+
import warnings
8+
9+
import pytest
10+
11+
from aztec_py import AztecCode, BCBPSegment, build_bcbp_string
12+
13+
# ---------------------------------------------------------------------------
14+
# Shared fixture — a valid single-segment boarding pass
15+
# June 15 2026 is Julian day 166 (2026 is not a leap year)
16+
# ---------------------------------------------------------------------------
17+
18+
VALID = BCBPSegment(
19+
passenger_name="SMITH/JOHN",
20+
pnr_code="ABC123",
21+
from_airport="LHR",
22+
to_airport="JFK",
23+
carrier="BA",
24+
flight_number="0123",
25+
date_of_flight=datetime.date(2026, 6, 15),
26+
compartment_code="Y",
27+
seat_number="023A",
28+
sequence_number=42,
29+
passenger_status="0",
30+
electronic_ticket=True,
31+
)
32+
33+
34+
# ---------------------------------------------------------------------------
35+
# Test 1 — output is exactly 60 characters
36+
# ---------------------------------------------------------------------------
37+
38+
def test_output_is_60_characters() -> None:
39+
result = build_bcbp_string(VALID)
40+
assert len(result) == 60, f"Expected 60 chars, got {len(result)}: {result!r}"
41+
42+
43+
# ---------------------------------------------------------------------------
44+
# Test 2 — format code and number-of-legs fields
45+
# ---------------------------------------------------------------------------
46+
47+
def test_format_code_and_legs() -> None:
48+
result = build_bcbp_string(VALID)
49+
assert result[0] == "M", "Position 1 must be format code 'M'"
50+
assert result[1] == "1", "Position 2 must be number of legs '1'"
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Test 3 — passenger name is left-justified, space-padded to 20 chars
55+
# ---------------------------------------------------------------------------
56+
57+
def test_passenger_name_padded() -> None:
58+
result = build_bcbp_string(VALID)
59+
name_field = result[2:22] # positions 3–22 (0-indexed 2–21)
60+
assert len(name_field) == 20
61+
assert name_field == "SMITH/JOHN " # 10 chars + 10 spaces
62+
63+
64+
# ---------------------------------------------------------------------------
65+
# Test 4 — datetime.date auto-converts to correct Julian day
66+
# ---------------------------------------------------------------------------
67+
68+
def test_date_auto_converts_to_julian() -> None:
69+
result = build_bcbp_string(VALID)
70+
date_field = result[44:47] # positions 45–47 (0-indexed 44–46)
71+
assert date_field == "166", f"Expected Julian day '166' for 2026-06-15, got {date_field!r}"
72+
73+
74+
# ---------------------------------------------------------------------------
75+
# Test 5 — integer Julian day passes through unchanged
76+
# ---------------------------------------------------------------------------
77+
78+
def test_date_int_passthrough() -> None:
79+
seg = dataclasses.replace(VALID, date_of_flight=166)
80+
result = build_bcbp_string(seg)
81+
assert result[44:47] == "166"
82+
83+
84+
# ---------------------------------------------------------------------------
85+
# Test 6 — from_airport wrong length raises ValueError
86+
# ---------------------------------------------------------------------------
87+
88+
def test_invalid_airport_wrong_length() -> None:
89+
seg = dataclasses.replace(VALID, from_airport="LH")
90+
with pytest.raises(ValueError, match="from_airport"):
91+
build_bcbp_string(seg)
92+
93+
94+
# ---------------------------------------------------------------------------
95+
# Test 7 — sequence_number=0 raises ValueError
96+
# ---------------------------------------------------------------------------
97+
98+
def test_sequence_number_zero_raises() -> None:
99+
seg = dataclasses.replace(VALID, sequence_number=0)
100+
with pytest.raises(ValueError, match="sequence_number"):
101+
build_bcbp_string(seg)
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Test 8 — passenger_status out of 0–7 range raises ValueError
106+
# ---------------------------------------------------------------------------
107+
108+
def test_passenger_status_invalid_raises() -> None:
109+
seg = dataclasses.replace(VALID, passenger_status="8")
110+
with pytest.raises(ValueError, match="passenger_status"):
111+
build_bcbp_string(seg)
112+
113+
114+
# ---------------------------------------------------------------------------
115+
# Test 9 — full output encodes into AztecCode without error
116+
# ---------------------------------------------------------------------------
117+
118+
def test_bcbp_encodes_to_aztec_no_crash() -> None:
119+
bcbp = build_bcbp_string(VALID)
120+
code = AztecCode.from_preset(bcbp, "boarding_pass")
121+
assert code is not None
122+
assert code.size >= 15
123+
124+
125+
# ---------------------------------------------------------------------------
126+
# Test 10 — passenger_name > 20 chars is truncated with UserWarning
127+
# ---------------------------------------------------------------------------
128+
129+
def test_long_passenger_name_truncated_with_warning() -> None:
130+
long_name = "VERYLONGSURNAME/FIRSTNAME" # 25 chars
131+
seg = dataclasses.replace(VALID, passenger_name=long_name)
132+
with warnings.catch_warnings(record=True) as caught:
133+
warnings.simplefilter("always")
134+
result = build_bcbp_string(seg)
135+
136+
# Output is still 60 chars
137+
assert len(result) == 60
138+
139+
# Name field is exactly 20 chars, truncated
140+
assert result[2:22] == "VERYLONGSURNAME/FIRS"
141+
142+
# Warning was issued
143+
assert any(issubclass(w.category, UserWarning) for w in caught), (
144+
"Expected a UserWarning for name truncation"
145+
)

0 commit comments

Comments
 (0)