Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions aztec_py/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
__version__ = "1.1.0"

from .batch import encode_batch
from .bcbp import BCBPSegment, build_bcbp_string
from .core import (
AztecCode,
Latch,
Expand All @@ -24,6 +25,8 @@

__all__ = [
'AztecCode',
'BCBPSegment',
'build_bcbp_string',
'encode_batch',
'Latch',
'Misc',
Expand Down
180 changes: 180 additions & 0 deletions aztec_py/bcbp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""IATA BCBP (Bar Coded Boarding Pass) payload builder for Aztec Code integration.

Implements the mandatory single-segment section of IATA Resolution 792
Format Code F (Version 7, 2020). Output is always exactly 60 characters
and is suitable for direct encoding with::

AztecCode.from_preset(bcbp_string, "boarding_pass")
"""

from __future__ import annotations

import datetime
import re
import warnings
from dataclasses import dataclass
from typing import Union


@dataclass(frozen=True)
class BCBPSegment:
"""Single-segment IATA BCBP Format Code F fields.

All string fields are trimmed or padded to their IATA-specified widths
by :func:`build_bcbp_string`. Validation errors raise :class:`ValueError`.
A long ``passenger_name`` is silently truncated to 20 characters with a
:class:`UserWarning`.

Attributes:
passenger_name: ``"SURNAME/GIVEN"`` format, max 20 characters.
pnr_code: Booking reference, max 7 characters.
from_airport: IATA 3-letter origin airport code (e.g. ``"LHR"``).
to_airport: IATA 3-letter destination airport code (e.g. ``"JFK"``).
carrier: Operating carrier designator, 2–3 characters (e.g. ``"BA"``).
flight_number: Flight number — digits are extracted and zero-padded to 4.
date_of_flight: Travel date as :class:`datetime.date` (auto-converted to
Julian day) or integer Julian day (1–366).
compartment_code: Single cabin-class character (``"Y"``, ``"C"``, etc.).
seat_number: Seat assignment, max 4 characters (e.g. ``"023A"``).
sequence_number: Check-in sequence number, 1–99999.
passenger_status: Single digit ``"0"``–``"7"`` per IATA spec.
electronic_ticket: ``True`` encodes ``"E"`` in the e-ticket indicator field.
"""

passenger_name: str
pnr_code: str
from_airport: str
to_airport: str
carrier: str
flight_number: str
date_of_flight: Union[datetime.date, int]
compartment_code: str
seat_number: str
sequence_number: int
passenger_status: str = "0"
electronic_ticket: bool = True


def _validate_airport(code: str, field: str) -> None:
if len(code) != 3 or not code.isalpha():
raise ValueError(
f"{field} must be a 3-letter IATA airport code (e.g. 'LHR'), got {code!r}"
)


def _to_julian(date_of_flight: Union[datetime.date, int]) -> int:
if isinstance(date_of_flight, int):
if not 1 <= date_of_flight <= 366:
raise ValueError(
f"date_of_flight as int must be a Julian day 1–366, got {date_of_flight}"
)
return date_of_flight
d = date_of_flight
return (d - datetime.date(d.year, 1, 1)).days + 1


def build_bcbp_string(segment: BCBPSegment) -> str:
"""Build a single-segment IATA BCBP Format Code F string.

Returns exactly 60 characters per IATA Resolution 792 Version 7
mandatory section layout. Pass the result directly to
``AztecCode.from_preset(bcbp, "boarding_pass")`` to produce a
standards-compliant mobile boarding pass Aztec symbol.

BCBP field layout (60 characters total):

.. code-block:: text

Pos Width Field
─────────────────────────────────────────
1 1 Format code → "M"
2 1 Number of legs → "1"
3–22 20 Passenger name left-justified, space-padded
23 1 E-ticket indicator "E" or " "
24–30 7 PNR code left-justified, space-padded
31–33 3 From airport uppercase IATA code
34–36 3 To airport uppercase IATA code
37–39 3 Carrier designator left-justified, space-padded
40–44 5 Flight number 4 digits zero-padded + " "
45–47 3 Date of flight Julian day zero-padded
48 1 Compartment code first character
49–52 4 Seat number left-justified, space-padded
53–57 5 Sequence number zero-padded
58 1 Passenger status first character
59–60 2 Conditional item size → "00"
─────────────────────────────────────────
Total: 1+1+20+1+7+3+3+3+5+3+1+4+5+1+2 = 60

Args:
segment: :class:`BCBPSegment` with all mandatory fields.

Returns:
60-character BCBP mandatory section string.

Raises:
ValueError: If any field fails IATA format validation.

Warns:
UserWarning: If ``passenger_name`` exceeds 20 characters (truncated).
"""
# --- validate ---
_validate_airport(segment.from_airport, "from_airport")
_validate_airport(segment.to_airport, "to_airport")

if not 2 <= len(segment.carrier) <= 3:
raise ValueError(
f"carrier must be 2–3 characters, got {segment.carrier!r}"
)

digits = re.sub(r"\D", "", segment.flight_number)[:4]
if not digits:
raise ValueError(
f"flight_number must contain at least one digit, got {segment.flight_number!r}"
)

julian = _to_julian(segment.date_of_flight)

if not segment.compartment_code:
raise ValueError("compartment_code must not be empty")

if not 1 <= segment.sequence_number <= 99999:
raise ValueError(
f"sequence_number must be 1–99999, got {segment.sequence_number}"
)

if (
len(segment.passenger_status) < 1
or segment.passenger_status[0] not in "01234567"
):
raise ValueError(
f"passenger_status must be a single digit 0–7, got {segment.passenger_status!r}"
)

# --- warn and truncate passenger name ---
name = segment.passenger_name
if len(name) > 20:
warnings.warn(
f"passenger_name {name!r} exceeds 20 characters and will be truncated",
UserWarning,
stacklevel=2,
)
name = name[:20].ljust(20)

# --- assemble exactly 60 characters ---
return "".join([
"M", # 1 — format code
"1", # 1 — number of legs
name, # 20 — passenger name
"E" if segment.electronic_ticket else " ", # 1 — e-ticket indicator
segment.pnr_code[:7].ljust(7), # 7 — PNR code
segment.from_airport[:3].upper(), # 3 — origin airport
segment.to_airport[:3].upper(), # 3 — destination airport
segment.carrier[:3].ljust(3), # 3 — carrier designator
digits.zfill(4) + " ", # 5 — flight number
str(julian).zfill(3), # 3 — Julian day
segment.compartment_code[0], # 1 — compartment code
segment.seat_number[:4].ljust(4), # 4 — seat number
str(segment.sequence_number).zfill(5), # 5 — sequence number
segment.passenger_status[0], # 1 — passenger status
"00", # 2 — conditional item size
])
145 changes: 145 additions & 0 deletions tests/test_bcbp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""IATA BCBP payload builder tests."""

from __future__ import annotations

import dataclasses
import datetime
import warnings

import pytest

from aztec_py import AztecCode, BCBPSegment, build_bcbp_string

# ---------------------------------------------------------------------------
# Shared fixture — a valid single-segment boarding pass
# June 15 2026 is Julian day 166 (2026 is not a leap year)
# ---------------------------------------------------------------------------

VALID = BCBPSegment(
passenger_name="SMITH/JOHN",
pnr_code="ABC123",
from_airport="LHR",
to_airport="JFK",
carrier="BA",
flight_number="0123",
date_of_flight=datetime.date(2026, 6, 15),
compartment_code="Y",
seat_number="023A",
sequence_number=42,
passenger_status="0",
electronic_ticket=True,
)


# ---------------------------------------------------------------------------
# Test 1 — output is exactly 60 characters
# ---------------------------------------------------------------------------

def test_output_is_60_characters() -> None:
result = build_bcbp_string(VALID)
assert len(result) == 60, f"Expected 60 chars, got {len(result)}: {result!r}"


# ---------------------------------------------------------------------------
# Test 2 — format code and number-of-legs fields
# ---------------------------------------------------------------------------

def test_format_code_and_legs() -> None:
result = build_bcbp_string(VALID)
assert result[0] == "M", "Position 1 must be format code 'M'"
assert result[1] == "1", "Position 2 must be number of legs '1'"


# ---------------------------------------------------------------------------
# Test 3 — passenger name is left-justified, space-padded to 20 chars
# ---------------------------------------------------------------------------

def test_passenger_name_padded() -> None:
result = build_bcbp_string(VALID)
name_field = result[2:22] # positions 3–22 (0-indexed 2–21)
assert len(name_field) == 20
assert name_field == "SMITH/JOHN " # 10 chars + 10 spaces


# ---------------------------------------------------------------------------
# Test 4 — datetime.date auto-converts to correct Julian day
# ---------------------------------------------------------------------------

def test_date_auto_converts_to_julian() -> None:
result = build_bcbp_string(VALID)
date_field = result[44:47] # positions 45–47 (0-indexed 44–46)
assert date_field == "166", f"Expected Julian day '166' for 2026-06-15, got {date_field!r}"


# ---------------------------------------------------------------------------
# Test 5 — integer Julian day passes through unchanged
# ---------------------------------------------------------------------------

def test_date_int_passthrough() -> None:
seg = dataclasses.replace(VALID, date_of_flight=166)
result = build_bcbp_string(seg)
assert result[44:47] == "166"


# ---------------------------------------------------------------------------
# Test 6 — from_airport wrong length raises ValueError
# ---------------------------------------------------------------------------

def test_invalid_airport_wrong_length() -> None:
seg = dataclasses.replace(VALID, from_airport="LH")
with pytest.raises(ValueError, match="from_airport"):
build_bcbp_string(seg)


# ---------------------------------------------------------------------------
# Test 7 — sequence_number=0 raises ValueError
# ---------------------------------------------------------------------------

def test_sequence_number_zero_raises() -> None:
seg = dataclasses.replace(VALID, sequence_number=0)
with pytest.raises(ValueError, match="sequence_number"):
build_bcbp_string(seg)


# ---------------------------------------------------------------------------
# Test 8 — passenger_status out of 0–7 range raises ValueError
# ---------------------------------------------------------------------------

def test_passenger_status_invalid_raises() -> None:
seg = dataclasses.replace(VALID, passenger_status="8")
with pytest.raises(ValueError, match="passenger_status"):
build_bcbp_string(seg)


# ---------------------------------------------------------------------------
# Test 9 — full output encodes into AztecCode without error
# ---------------------------------------------------------------------------

def test_bcbp_encodes_to_aztec_no_crash() -> None:
bcbp = build_bcbp_string(VALID)
code = AztecCode.from_preset(bcbp, "boarding_pass")
assert code is not None
assert code.size >= 15


# ---------------------------------------------------------------------------
# Test 10 — passenger_name > 20 chars is truncated with UserWarning
# ---------------------------------------------------------------------------

def test_long_passenger_name_truncated_with_warning() -> None:
long_name = "VERYLONGSURNAME/FIRSTNAME" # 25 chars
seg = dataclasses.replace(VALID, passenger_name=long_name)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = build_bcbp_string(seg)

# Output is still 60 chars
assert len(result) == 60

# Name field is exactly 20 chars, truncated
assert result[2:22] == "VERYLONGSURNAME/FIRS"

# Warning was issued
assert any(issubclass(w.category, UserWarning) for w in caught), (
"Expected a UserWarning for name truncation"
)
Loading