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.

DeduplicationScanner._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 logicScanner._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

  1. 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
        ...
  1. Export from global_entry_scanner/notifications/__init__.py
  2. Add credential prompt to setup in cli.py
  3. Wire it up in the scan command in cli.py
  4. Add a config dataclass in config.py and handle loading/saving
  5. Add optional dependency to pyproject.toml if needed
  6. 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

  1. Bump version in pyproject.toml
  2. Commit and push to main
  3. Create a GitHub Release tagged vX.Y.Zpublish.yml fires automatically

Conventions

  • Line length: 100 ([tool.ruff] in pyproject.toml)
  • Python target: 3.10+ (no 3.11+ syntax in non-guarded blocks)
  • Type checking: strict = true mypy
  • No casts — use -> Any where return type genuinely varies