Developer Guide - JaiminBrahmbhatt/Global-Entry-Appointment-Scanner GitHub Wiki
Developer Guide
Everything you need to work on global-entry-scanner itself.
Setup
git clone https://github.com/JaiminBrahmbhatt/Global-Entry-Appointment-Scanner
cd Global-Entry-Appointment-Scanner
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
Commands
# Run all tests
pytest
# Run a single test file
pytest tests/test_scanner.py -v
# Run a single test
pytest tests/test_scanner.py::test_check_once_deduplicates -v
# Run live API tests (hit the real CBP API — skipped in CI by default)
pytest -m integration
# Lint
ruff check global_entry_scanner/ tests/
# Type check
mypy global_entry_scanner/
Before every commit, all three must pass:
ruff check global_entry_scanner/ tests/
mypy global_entry_scanner/
pytest
Package layout
global_entry_scanner/
├── models.py — Location, Appointment (frozen dataclasses), ScanResult
├── config.py — Config dataclasses + load_config/save_config (TOML)
├── scanner.py — Scanner class: poll loop, API fetching, deduplication
├── notifications/
│ ├── base.py — Notifier protocol (send + validate)
│ ├── console.py — Prints to stdout, no credentials
│ ├── email.py — Gmail SMTP
│ ├── discord.py — Discord webhook
│ ├── slack.py — Slack webhook
│ └── sms.py — Twilio SMS
├── cli.py — Click CLI (setup, scan, locations, mcp commands)
└── mcp_server.py — FastMCP server
Key design decisions
Notifier is a structural Protocol (runtime_checkable) — notifiers do not inherit from a base class. Any object with send(subject, message) and validate() qualifies. Custom notifiers need zero imports from this package.
Deduplication — Scanner._seen is an in-memory dict[int, set[str]] (location_id → set of startTimestamp ISO strings). Resets on restart intentionally — slots change frequently and re-notifying after restart is acceptable.
Location cache — module-level TTLCache (15-day TTL, maxsize=1). Tests clear it via an autouse fixture in conftest.py.
Retry logic — Scanner._get() retries up to 3 times with exponential backoff on 5xx/network errors; raises immediately on 4xx. fetch_appointments only catches 404 (returns []); all other errors propagate to check_once which records them in ScanResult.error.
Concurrent notifications — _notify_all() uses ThreadPoolExecutor. Failures are logged per-channel and never re-raised.
Adding a new notification channel
- Create
global_entry_scanner/notifications/yournotifier.py:
from __future__ import annotations
class YourNotifier:
def __init__(self, api_key: str) -> None:
self._api_key = api_key
def validate(self) -> None:
if not self._api_key:
raise ValueError("YourNotifier: api_key is required")
def send(self, subject: str, message: str) -> None:
# call your service
...
- Export from
global_entry_scanner/notifications/__init__.py - Add credential prompt to
setupincli.py - Wire it up in the
scancommand incli.py - Add a config dataclass in
config.pyand handle loading/saving - Add optional dependency to
pyproject.tomlif needed - Write tests in
tests/test_yournotifier.py
CI
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
push / PR | ruff + mypy + pytest on Python 3.10, 3.11, 3.12 |
pylint.yml |
push / PR | ruff check only (legacy name) |
publish.yml |
GitHub Release created | Publish to PyPI via OIDC Trusted Publisher |
Releasing to PyPI
- Bump
versioninpyproject.toml - Commit and push to
main - Create a GitHub Release tagged
vX.Y.Z—publish.ymlfires automatically
Conventions
- Line length: 100 (
[tool.ruff]inpyproject.toml) - Python target: 3.10+ (no 3.11+ syntax in non-guarded blocks)
- Type checking:
strict = truemypy - No casts — use
-> Anywhere return type genuinely varies