Parse airline e-ticket PDFs into strongly-typed JSON with high accuracy.
Project description
eticket-document-sdk
Parse airline e-ticket PDFs and convert them into strongly-typed JSON with very high accuracy.
The SDK primarily targets PDFs that already contain a text layer (the common case for e-ticket receipts, booking confirmations and itineraries). OCR is used only as a fallback when text extraction is insufficient.
from eticket_document_sdk import ETicketParser
parser = ETicketParser()
booking = parser.parse("ticket.pdf")
print(booking.model_dump())
{
"booking_code": "F65A7Y",
"ticket_number": "7382421531079",
"passenger": { "first_name": "YUMIKO", "last_name": "KOHNO" },
"currency": "JPY",
"total_price": 467510,
"segments": [{ "flight_number": "VN311", "origin": "NRT", "destination": "HAN" }]
}
Table of Contents
- Installation
- Quick Start
- Advanced Usage
- Data Model
- Parser Architecture
- Custom Parser Plugin
- Error Handling
- Development Guide
- Release Guide
- Roadmap
Installation
pip install eticket-document-sdk
Base install includes PDF text extraction (PyMuPDF + pdfplumber). The OCR fallback backend is a heavy optional extra:
# Only needed for scanned / image-only PDFs
pip install "eticket-document-sdk[ocr]"
| Extra | Pulls in | When you need it |
|---|---|---|
| (base) | pydantic, pymupdf, pdfplumber, python-dateutil |
Text-layer PDFs (the common case) |
ocr |
paddleocr, opencv-python, numpy |
Scanned/image PDFs requiring OCR |
dev |
pytest, pytest-cov, ruff, build |
Contributing / running tests |
Requires Python 3.10+. URL input requires no extra dependencies (uses stdlib urllib).
Quick Start
from eticket_document_sdk import ETicketParser
parser = ETicketParser()
# 1) From a local file path (PDF or image)
booking = parser.parse("ticket.pdf")
# 2) From a remote URL (http/https — GCS, S3, CDN, …)
booking = parser.parse("https://storage.googleapis.com/bucket/ticket.pdf")
# 3) From raw bytes
booking = parser.parse_bytes(pdf_bytes)
# 4) From already-extracted text (no PDF/OCR needed)
booking = parser.parse_text(raw_text)
print(booking.model_dump()) # python types
print(booking.model_dump(mode="json")) # JSON-serializable (datetimes -> str)
# Convenience top-level accessors:
booking.booking_code # "F65A7Y"
booking.ticket_number # "7382421531079"
booking.currency # "JPY"
booking.total_price # 467510
booking.passenger.full_name
booking.segments[0].origin
Advanced Usage
Configuration
parser = ETicketParser(
enable_ocr=True, # allow OCR fallback (default True)
ocr_langs=["vi", "en", "ja"],# OCR languages (Vietnamese/English/Japanese)
debug=False, # enable DEBUG logging for the SDK
strict_validation=False, # raise ValidationError on invalid output
text_quality_threshold=0.35, # below this, OCR fallback kicks in
)
URL input
parse() accepts any HTTP or HTTPS URL. The file is downloaded automatically
using the Python standard library (no extra dependency required):
booking = parser.parse(
"https://storage.googleapis.com/meetingsvn/cms/vnzamazing-tour/"
"passport_images/F65A7Y.pdf"
)
Supported URL patterns:
| Provider | Example |
|---|---|
| Google Cloud Storage | https://storage.googleapis.com/bucket/path/ticket.pdf |
| AWS S3 (public) | https://s3.amazonaws.com/bucket/ticket.pdf |
| Azure Blob Storage | https://account.blob.core.windows.net/container/ticket.pdf |
| Any CDN / direct link | https://cdn.example.com/tickets/ABC123.pdf |
Limit: 50 MB maximum. Private URLs (requiring auth headers/signed tokens) are not currently supported via this method — download the file first and use
parse_bytes()instead.
Detailed result envelope
parse() returns a Booking. For metadata about how the document was parsed,
use the *_detailed variants, which return a ParseResult:
result = parser.parse_detailed("ticket.pdf")
# or
result = parser.parse_detailed("https://storage.googleapis.com/bucket/ticket.pdf")
result.success # True
result.parser # "vietnam_airlines"
result.extraction_method # ExtractionMethod.TEXT_LAYER | OCR | RAW_TEXT
result.confidence # 0.0 .. 1.0 completeness heuristic
result.warnings # list of non-fatal validation issues
result.booking # the Booking object
Also available: parse_bytes_detailed(...) and parse_text_detailed(...).
Thread-safety & performance
A single ETicketParser instance is reusable and thread-safe. The
PaddleOCR model, compiled regexes and the parser registry are created once and
shared, so models are never reloaded between calls. Create one instance and
reuse it (e.g. for batch processing — see examples/batch_parse.py).
Data Model
All models are Pydantic v2 (eticket_document_sdk.models, re-exported from
eticket_document_sdk.schemas.pydantic_models).
Booking
├── status: TICKETED | CONFIRMED | PENDING | CANCELLED | UNKNOWN
├── passenger: Passenger
│ ├── title, first_name, last_name, full_name
│ ├── membership_number # frequent-flyer number
│ └── passenger_type # ADT | CHD | INF
├── segments: list[FlightSegment]
│ ├── segment_id, flight_number, airline
│ ├── origin, destination # IATA codes
│ ├── departure_datetime, arrival_datetime
│ ├── departure_terminal, arrival_terminal
│ ├── cabin_class, booking_class, fare_basis, seat
│ ├── free_baggage, status
│ └── trip_direction # OUTBOUND | RETURN | None — see below
├── ticket: Ticket
│ ├── booking_code, ticket_number, currency
│ ├── base_fare, taxes, total_price
│ ├── tax_breakdown: list[TaxItem] # {code, amount}
│ ├── fare_basis, fare_calculation, endorsement
│ ├── issue_date, payment
└── booking_code / ticket_number / currency / total_price # convenience mirrors
trip_direction: outbound vs. return
Most e-ticket receipts don't print an explicit "outbound/return" label — flight
segments are just listed in chronological order. The Vietnam Airlines parser
infers trip_direction (OUTBOUND / RETURN / None) per segment using a
weighted scoring heuristic, rather than a single strict rule — an
all-or-nothing gate rejects too many otherwise-clear itineraries.
The itinerary must first return to its starting airport (round-trip or
closed-loop multi-city); a genuine one-way routing has no return leg, so
trip_direction is left None immediately. Past that, points accumulate
(out of 100):
| Signal | Points |
|---|---|
| Returns to its starting airport (closed loop) | +50 |
| Exactly 2 segments (split point is unambiguous) | +20 |
| Largest gap between consecutive segments exceeds 24h | +15 |
| That gap is the only one exceeding 24h (no competing boundary) | +10 |
| The stopover airport differs from the itinerary's home airport | +5 |
| Ticket's own fare calculation confirms the same break point | +30 |
trip_direction is assigned only when the score reaches 60/100. The split
falls right after the segment preceding the largest gap (or, for an
unambiguous 2-segment round trip, always after the first segment) — unless
the ticket's fare calculation line gives a cleaner answer (see below), in
which case its break point wins instead.
Fare calculation is the most reliable signal, when present. IATA fare
calculation lines (ticket.fare_calculation) price each leg as a separate
fare component — e.g. PAR780.50 marks a component ending in Paris. When the
line shows exactly two components and the break lands on a segment boundary
(matched via IATA metropolitan-area codes, so PAR correctly matches CDG),
that break point overrides the gap-based split and adds +30 — reliable
enough on its own, combined with the +50 closed-loop requirement, to clear the
threshold even when stopovers are short or ambiguous.
Example — booking F65A7Y (NRT→HAN→CDG→SGN→NRT, with a 3-day stopover in CDG
before the journey continues): score = 50 (closed loop) + 15 (gap > 24h) + 10
(only one large gap) + 5 (CDG ≠ NRT) + 30 (fare calc PAR780.50…TYO780.50
confirms the same CDG break) = 110. Segments 1–2 (NRT→HAN→CDG) are
OUTBOUND, segments 3–4 (CDG→SGN→NRT) are RETURN — matching the GDS's own
Leg grouping.
Parser Architecture
The SDK follows a clean, layered pipeline:
┌─────────────────────────────────────────────────────────┐
source ──▶ │ 1. detect type (PDF / IMAGE / TEXT) core.classifier│
│ 2. extract text layer (PyMuPDF → pdfplumber) pdf.* │
│ 3. measure quality pdf.text_extr. │
│ 4. OCR fallback if needed (PaddleOCR) ocr.* │
│ 5. classify airline → pick parser core.classifier│
│ 6. parse fields parsers.* │
│ 7. validate (business rules) core.validator │
│ 8. return Pydantic Booking models.* │
└─────────────────────────────────────────────────────────┘
Key design points:
- Text-first. OCR is only attempted when the extracted text layer falls below the quality threshold. PyMuPDF is the primary extractor; pdfplumber is the fallback engine.
- Lazy heavy deps. PyMuPDF, pdfplumber, PaddleOCR, OpenCV and numpy are all imported lazily, so importing the SDK is cheap and the OCR stack is optional.
- Centralized regex (
utils/regex.py) compiled once at import time. - Pluggable parsers via a thread-safe registry (see below).
Custom Parser Plugin
Add a new airline without modifying the SDK core. Implement BaseParser
and register it:
from eticket_document_sdk import (
BaseParser, Booking, Ticket, FlightSegment, Passenger, register_parser,
)
from eticket_document_sdk.parsers.generic.parser import GenericExtractors
class JapanAirlinesParser(BaseParser):
code = "JL"
name = "japan_airlines"
def can_parse(self, text: str) -> float:
# Return 0..1 confidence that this layout is yours.
return 0.9 if "JAPAN AIRLINES" in text.upper() else 0.0
def parse(self, text: str) -> Booking:
return Booking(
ticket=Ticket(
booking_code=GenericExtractors.find_pnr(text),
ticket_number=GenericExtractors.find_ticket_number(text),
currency=GenericExtractors.find_currency(text),
),
passenger=Passenger(),
segments=[],
)
register_parser("JL", JapanAirlinesParser())
# From now on ETicketParser auto-selects it for matching tickets.
The classifier calls can_parse() on every registered parser and selects the
highest scorer; airline-specific parsers always win ties against the generic
fallback. See examples/custom_parser.py for a runnable version.
This is the same mechanism by which future airlines (ANA, Air France, Emirates, Singapore Airlines, …) are added.
Error Handling
All exceptions derive from ETicketSDKError:
| Exception | Raised when |
|---|---|
DocumentReadError |
File can't be opened/read, or is empty/corrupt |
UnsupportedDocumentError |
Input type/layout not supported |
OCRFailedError |
OCR needed but disabled/unavailable, or produced nothing |
ParserError |
No parser matched, or a parser failed |
ValidationError |
Output failed business-rule validation (strict mode) |
from eticket_document_sdk import ETicketParser, ETicketSDKError
try:
booking = ETicketParser().parse("ticket.pdf")
except ETicketSDKError as exc:
print(f"Failed: {exc}")
Validation (core/validator.py) checks ticket-number format (13 digits), PNR
format (6 alphanumerics), currency code, flight-number format and segment
presence. In non-strict mode, issues are returned as result.warnings; in
strict_validation=True mode a ValidationError is raised.
Development Guide
git clone https://github.com/your-org/eticket-document-sdk
cd eticket-document-sdk
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# Run the test suite with coverage (target: 90%+)
pytest --cov=eticket_document_sdk --cov-report=term-missing
# Lint / format
ruff check .
ruff format .
Tests use a bundled real Vietnam Airlines fixture (tests/fixtures/). OCR-path
tests use a fake engine, so PaddleOCR is not required to run the suite.
Project layout mirrors the pipeline: core/ (orchestration), pdf/
(extraction), ocr/ (fallback), parsers/ (plugins), models/ + schemas/
(Pydantic), utils/, exceptions/.
Release Guide
- Update the version in
pyproject.tomlandeticket_document_sdk/__init__.py(__version__). - Update the changelog / release notes.
- Run the full test suite and linters; ensure coverage ≥ 90%.
- Build the distributions:
python -m build
This producesdist/eticket_document_sdk-<version>-py3-none-any.whland the sdist.tar.gz. - Smoke-test the wheel in a clean virtualenv:
pip install dist/eticket_document_sdk-*.whl
- Publish:
python -m twine upload dist/*
- Tag the release:
git tag v<version> && git push --tags.
Roadmap
The extractor/parser boundary is deliberately abstract so additional backends can be added without breaking the public API:
- Cloud OCR / Document AI: Azure Document Intelligence, AWS Textract,
Google Document AI — pluggable as alternative
OCREngineimplementations or text providers. - LLM structured outputs: OpenAI and Claude structured outputs as
alternative parser plugins behind the same
BaseParsercontract.
Because selection happens via the registry + classifier, these can be layered in
as new strategies while ETicketParser.parse(...) stays unchanged.
License
MIT — see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file eticket_document_sdk-1.5.0.tar.gz.
File metadata
- Download URL: eticket_document_sdk-1.5.0.tar.gz
- Upload date:
- Size: 285.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
817951d34fc33bcee53b3efd41a276a5d28e6d9358425536ddda5d73a5ef41bd
|
|
| MD5 |
7771b6e09d39d8645f301bb3dfe00346
|
|
| BLAKE2b-256 |
e51ff2a3c776fb5b07d7e3b686bf448a301bd8ca01a53bac9cc90729d5dfe9ac
|
Provenance
The following attestation bundles were made for eticket_document_sdk-1.5.0.tar.gz:
Publisher:
publish.yml on hailong2107/eticket-document-sdk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
eticket_document_sdk-1.5.0.tar.gz -
Subject digest:
817951d34fc33bcee53b3efd41a276a5d28e6d9358425536ddda5d73a5ef41bd - Sigstore transparency entry: 2204753306
- Sigstore integration time:
-
Permalink:
hailong2107/eticket-document-sdk@13ad6e22f04b97ac2a1cbfc67449645be6ef256a -
Branch / Tag:
refs/tags/v1.5.0 - Owner: https://github.com/hailong2107
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@13ad6e22f04b97ac2a1cbfc67449645be6ef256a -
Trigger Event:
push
-
Statement type:
File details
Details for the file eticket_document_sdk-1.5.0-py3-none-any.whl.
File metadata
- Download URL: eticket_document_sdk-1.5.0-py3-none-any.whl
- Upload date:
- Size: 53.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
31dd48b9ffc1a40f69b619d6a2b3479e4dd100eaeee8062be5e6844106981ee3
|
|
| MD5 |
d77179bf29a866204ebd73263621b18a
|
|
| BLAKE2b-256 |
9de3ff9189c1fe1a76c7b45099b110e7d2910c76fc4bb480a2efb39ff7ccd309
|
Provenance
The following attestation bundles were made for eticket_document_sdk-1.5.0-py3-none-any.whl:
Publisher:
publish.yml on hailong2107/eticket-document-sdk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
eticket_document_sdk-1.5.0-py3-none-any.whl -
Subject digest:
31dd48b9ffc1a40f69b619d6a2b3479e4dd100eaeee8062be5e6844106981ee3 - Sigstore transparency entry: 2204753315
- Sigstore integration time:
-
Permalink:
hailong2107/eticket-document-sdk@13ad6e22f04b97ac2a1cbfc67449645be6ef256a -
Branch / Tag:
refs/tags/v1.5.0 - Owner: https://github.com/hailong2107
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@13ad6e22f04b97ac2a1cbfc67449645be6ef256a -
Trigger Event:
push
-
Statement type: