Architecture¶
How a call travels through the package, why each module exists, and the decisions behind them. Read this before changing the code.
The pipeline¶
Every data method follows the same four steps:
FC27API.get_member_stats(1001, output=...)
│
├─ 1. resolve output format _output.resolve_output (fails early if pandas is missing)
├─ 2. GET members/stats _http.request_json (urllib + browser headers)
│ └─ output="raw" ──────────► return EA's JSON untouched
├─ 3. normalize _normalize (flatten, numbers, timestamps, columns)
│ └─ output="records" ───────► return list[dict]
└─ 4. to_dataframe _output.to_dataframe (pd.DataFrame.from_records)
└─ output="dataframe" ─────► return DataFrame
Because the DataFrame is built from the records, the two formats always have the same columns and values. There is no separate pandas code path to drift out of sync.
Modules¶
| Module | Public? | Responsibility |
|---|---|---|
client.py |
yes (FC27API) |
One method per endpoint; shapes EA's response into rows; derives result, dnf, crest URLs |
_http.py |
BASE_URL, HEADERS |
The only code that talks to EA. Builds the URL, sends the request, maps failures to exceptions |
_normalize.py |
no | Pure-Python cleaning: flatten, coerce_numbers, select_columns, convert_timestamps |
_columns.py |
no | The curated column set and camelCase names per method |
_output.py |
OutputFormat |
Output format enum, validation, lazy pandas import |
events.py |
yes | match_event_aggregate_* parser and the named-stat table |
assets.py |
yes | Crest, division and reputation image URLs; kit colours |
enums.py |
yes | MatchType, LeaderboardScope, Platform |
exceptions.py |
yes | Error hierarchy |
__main__.py |
CLI | fc-clubs / python -m fc_clubs_api |
Modules starting with _ are internal: they can change in any release. Everything
importable from fc_clubs_api directly (see __all__) is the public, versioned API.
Talking to EA (_http.py)¶
Why the browser headers¶
EA's servers sit behind Akamai, which blocks requests that don't look like they come
from the proclubs.ea.com website. Without the headers in HEADERS, EA answers
403 Forbidden or never answers at all (the request hangs until the timeout).
The important one is "sec-fetch-site": "same-origin"; user-agent and sec-ch-ua
identify as Chrome on Windows. Plain curl is blocked even with these headers, while
Python's urllib gets through.
FC27API(headers={...}) merges your headers over these defaults, so you can change
the user agent without losing the rest.
Why urllib and not requests¶
The client sends a handful of simple GET requests. requests earns its place with
connection pooling, retries, cookies or uploads, none of which are needed here. Using
the standard library keeps the base install dependency-free (only tzdata on Windows,
which has no system timezone database).
Error mapping¶
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
data = json.load(response)
except urllib.error.HTTPError as error: # EA answered 4xx/5xx
raise FC27HTTPError(error.code, url) from None
except OSError as error: # no network, DNS, timeout
raise FC27ConnectionError(...) from None
except json.JSONDecodeError: # HTML block page, empty body
raise FC27ResponseError(...) from None
The order matters: HTTPError is a subclass of OSError, so it must be caught
first. from None keeps tracebacks to one short message. All three subclass
FC27APIError, so except FC27APIError still catches everything, as it did in 0.1.
EA sometimes answers JSON null instead of []; request_json normalizes that to
[] so no caller has to check for None. This is the only change raw output
ever makes.
Cleaning the data (_normalize.py)¶
EA's JSON has three problems for analysis, each fixed by one function.
Nested objects → flatten. {"clubInfo": {"name": "X"}} becomes
{"clubInfo.name": "X"}, the same dotted names pandas.json_normalize would produce.
Numbers sent as text → coerce_numbers. EA sends "25" and "7.4". Text
can't be summed and sorts wrongly ("9" > "25"). A field is converted only if
every non-null value in it is numeric, so a column of player names is never
half-converted, and an empty string ("", which EA uses for "unknown") keeps the
column as text. Fields containing any decimal become float, the rest int. The
same function makes the records rectangular: a field missing from one record is
filled with None, so every dict has the same keys.
Unix timestamps → convert_timestamps. 1767297600 becomes a timezone-aware
datetime (2026-01-01 20:00:00+00:00), converted to the client's timezone.
select_columns then keeps the curated set from _columns.py and renames it
(passesmade → passesMade, mom → manOfTheMatch). all_columns=True skips
this step and keeps every field under EA's name.
Shaping each endpoint (client.py)¶
Most methods are three lines: request, pick the list of rows out of EA's shape, deliver. The shapes differ:
| Method | EA's shape | Rows taken from |
|---|---|---|
search_club_by_name |
list of clubs | the list (+ crestUrl) |
get_club_details |
{"1001": {...}} |
the dict's values (+ crestUrl) |
get_club_overall_stats |
list with one item | the list |
get_member_stats / get_member_career_stats |
{"members": [...], "positionCount": {...}} |
members |
get_club_matches |
list of matches | one derived row per match |
get_match_players |
list of matches | players → club id → player id → stats |
Match results¶
get_club_matches looks at each match from the requested club's side. EA keys
clubs by club id as text, so the id is converted with str() first; the other
key is the opponent. In league matches EA fills wins/losses/ties; in friendlies
all three are "0" for both clubs, so the score decides. dnf is true when either
club has winnerByDnf == "1" (someone quit and the other club was awarded the win).
Event stats¶
With include_events=True, each player row gets events.decode_events(stats)
merged in with setdefault, so EA's own named fields (goals, assists, shots)
are never overwritten by a decoded value. See Match events.
Output formats (_output.py)¶
OutputFormat is a str enum, so "records" and OutputFormat.RECORDS are
interchangeable. The client stores a default (records, so a plain install never
needs pandas); each method's output=None means "use the default". When the result
will be a DataFrame, pandas is imported before the request is sent, so a missing
install fails immediately with an install hint instead of after a network round trip.
Tests¶
The tests never contact EA. tests/conftest.py's fake_ea patches
urllib.request.urlopen to return a saved response from tests/fixtures/. The
fixtures have the exact shape of real responses, but every name, id and date is a
placeholder. Client tests run on records so the whole suite passes without
pandas; test_output.py then checks that the DataFrame matches the records for
every method. CI runs the suite on Python 3.9–3.14, and once more without pandas.
Decisions worth keeping¶
- No runtime dependencies beyond the standard library. pandas is an extra.
- Raw means raw. Never "fix" EA's data in the
rawpath; do it in_normalize. - One request per call. No hidden caching, retries or rate limiting: callers decide their own policy (and should pause between requests when looping over clubs).
- Observed, not assumed. Endpoint behaviour in the docs is dated and checked against the live API; guesses are labelled as such.