Module Reference¶
This section documents every module in src/ in enough detail that any engineer can understand what it does, why it exists as a separate module, its key behaviors, and its error handling.
src/config.py — Configuration & Validation¶
What it does: Loads all environment variables at import time and validates every required variable is present and non-empty. If anything is missing, the program fails immediately with a clear error before making any API call.
Why fail-fast matters: Without this, a missing env var causes a confusing error deep inside a CLOSRTECH or Facebook call — something like NoneType is not iterable with no obvious cause. With fail-fast: ValueError: Missing required env var: FB_ACCESS_TOKEN at startup, before any network call.
Key behaviors:
- Calls load_dotenv() automatically on import — no other module needs to call it
- _require(name): raises ValueError if the variable is absent or empty string
- DRY_RUN parsed as bool: accepts "true", "1", "yes" (case-insensitive). Defaults to True if unset — always safe by default
- Logs a prominent warning at startup when DRY_RUN=True so it's always visible in the logs
Required vars: CLOSRTECH_EMAIL, CLOSRTECH_PASSWORD, CLOSRTECH_CAMPAIGN, FB_ACCESS_TOKEN, FB_AD_ACCOUNT_ID, FB_CAMPAIGN_ID
Optional vars: DRY_RUN (defaults true), SLACK_WEBHOOK_URL (defaults empty, disables Slack)
src/closrtech_client.py — CLOSRTECH API Client¶
What it does: All communication with CLOSRTECH lives here. Nothing outside this module knows the URL, parameters, auth, retry logic, or error types.
get_demand() -> dict[str, int]¶
The only function called by the orchestrator. Fetches active state demand.
- Request:
GET demand.php?campaign=VND_VETERAN_LEADS&email=...&pass=... - Timeout: 30 seconds per attempt
- Retry: 3 attempts via
tenacity, exponential backoff at 4s, 8s, 16s. Only retriesClosrtechError(network/HTTP failures). Does NOT retryClosrtechDataError(bad response data) — retrying a bad response won't fix it - Filtering: Returns only states where
demand > 0. Zero-demand states are excluded from the output - Fail-safe: If after filtering the result is empty AND the original response was non-empty (meaning every state had
demand == 0), raisesClosrtechDataErrorand aborts
get_orders() — Disabled (v2)¶
Planned endpoint (orders.php) returns 404. Exists as a placeholder. Mike must escalate to the CLOSRTECH developer. Not called anywhere in v1.
Error types:
- ClosrtechError — network failure, timeout, HTTP error (retriable)
- ClosrtechDataError — invalid response or all-zeros fail-safe triggered (not retriable)
src/state_mapper.py — USPS to Facebook Translation¶
What it does: Converts CLOSRTECH's format (USPS 2-letter codes) to the exact format Facebook expects for geographic targeting (list of region objects with numeric keys).
Why its own module: CLOSRTECH client knows how to talk to CLOSRTECH. Facebook client knows how to talk to Facebook. The translation between their formats is a third responsibility that belongs to neither.
usps_to_fb_region(usps_code) -> dict | None¶
- Loads
data/fb_region_keys.jsonon first call and caches it in_MAPPING(module-level). Subsequent calls read from memory, never from disk again - Normalizes input to uppercase:
"oh","OH"," OH "all resolve correctly - Unknown code: logs a warning, returns
None. Does not raise — one unmapped state should not abort the entire sync - Returns:
{"key": "3878", "name": "Ohio", "country": "US"}
build_fb_regions(active_states) -> list[dict]¶
- Takes the filtered CLOSRTECH dict (
{"OH": 15, "TX": 4, ...}) and converts it to the list Facebook expects - States that return
Nonefromusps_to_fb_region()are omitted (warning already logged) - Returns the full list ready to be passed directly into the targeting update
- Edge case: if every state fails to map (corrupted or missing
fb_region_keys.json), returns empty list.sync.pydetects this and aborts before touching Facebook
src/facebook_client.py — Facebook Graph API Wrapper¶
What it does: All communication with Facebook lives here. No other module imports the facebook-business SDK or calls the Graph API directly.
init_api(access_token, ad_account_id) -> AdAccount¶
- Initializes the Facebook SDK session with the System User token
- Configures the
FacebookAdsApisingleton (SDK requirement before any API call) - Returns an
AdAccountobject bound toact_XXXXXXXXXXfor subsequent calls - Failure: If the token is invalid, the SDK raises on the first actual API call (
get_active_adsets), not here
get_active_adsets(ad_account, campaign_id) -> list[AdSet]¶
- Queries the Graph API for all adsets in the specified campaign
- Filters client-side for
effective_status == "ACTIVE"— paused and archived adsets are excluded - Requests only the fields needed:
id,name,targeting,effective_status - Why
effective_statusnotstatus:effective_statusreflects the combined state including whether the parent campaign is active
update_adset_geo(adset, fb_regions, dry_run) -> bool¶
- Idempotency check: Compares
current_keysvsdesired_keysas sets. If equal, skips the write and returnsFalse - deepcopy pattern: Reads the full current targeting object, makes a
copy.deepcopy()of it, replaces onlygeo_locations.regionsin the copy, then sends the copy as the update payload. Every other targeting field is preserved exactly as-is - DRY_RUN guard: If
dry_run=True, logs what would be done but makes no API call. ReturnsTrue(would have updated) so SyncReport shows correct count - Retry: 3 attempts, exponential backoff at 5s, 10s, 20s via
tenacity - Returns
Trueif an update was made (or would have been made in dry-run),Falseif skipped
_build_new_targeting(current_targeting, fb_regions) -> dict¶
- Internal helper (underscore prefix — not called outside this module)
- Encapsulates the deepcopy + replace pattern so it can be unit-tested in isolation
src/sync.py — Orchestrator¶
What it does: The single function run_sync() calls every other module in the correct order, handles errors at each step, accumulates a SyncReport, and returns it.
run_sync() -> SyncReport¶
- Early abort: Steps 1–4 raise on failure and propagate to
main.py, which logs and exits with code 1 - Per-adset error isolation: Step 5 wraps each
update_adset_geo()call in try/except. One failed adset is logged inreport.errorsand the loop continues - Empty fb_regions abort: If
build_fb_regions()returns an empty list,run_sync()raises immediately — writing no states to Facebook would disable all geographic targeting
SyncReport (dataclass)¶
| Field | Type | Meaning |
|---|---|---|
adsets_processed |
int |
Total active adsets found |
adsets_updated |
int |
Adsets where targeting was changed |
adsets_skipped |
int |
Adsets where targeting was already correct |
active_states |
list[str] |
USPS codes of states with demand > 0 |
errors |
list[str] |
Per-adset error messages (empty if all succeeded) |
dry_run |
bool |
Whether this was a dry run |
success |
bool |
True if no errors in errors list |
The success field determines exit code: sys.exit(0) if True, sys.exit(1) if False.
src/notifier.py — Slack or stdout Reporting¶
What it does: Takes a completed SyncReport and delivers it. If SLACK_WEBHOOK_URL is configured, sends a structured message to Slack. If not, prints to stdout.
Report format:
CLOSRADS Sync — [DRY RUN] | 2026-04-15 13:01:22 UTC
Status: SUCCESS
Active states (35): AK, AR, AZ, CA, CO, CT, DE, FL, GA, HI...
Adsets processed: 5 | Updated: 5 | Skipped: 0
Errors: none
- If Slack fails (timeout, bad status code), logs a warning but does NOT raise — a notification failure should never mask the actual sync result
- Always called after sync completes, regardless of success/failure
main.py — Entry Point¶
What it does: Minimal entry point. Configures logging, runs the sync, handles the exit code.
Why the UTF-8 fix: Python 3 on Windows defaults stdout to the system code page (often cp1252), which crashes when printing characters outside that range. The fix sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") forces UTF-8 regardless of OS. GitHub Actions runs on Linux (UTF-8 by default) so this is a no-op there.
Logging config:
- Level: INFO
- Format: %(asctime)s [%(levelname)s] %(name)s: %(message)s
Execution flow:
if __name__ == "__main__":
# 1. UTF-8 fix
# 2. logging.basicConfig
# 3. report = run_sync()
# 4. sys.exit(0 if report.success else 1)
The exit code is the contract with GitHub Actions. Exit 0 = success (green check). Exit 1 = failure (red X, notification sent to team if configured).