Python module guide
CMS Marketplace client
Build households and places, search and cache plan records, inspect benefits, and understand the calculations implemented in this module.
Configuration and imports
This guide describes the checked-in Python implementation, including its incomplete paths. Its calculations and datasets are code behavior; their constants do not establish current eligibility rules, premiums, or legal requirements.
Set the module-level API_KEY before making API calls. The module uses toolbox.AiohttpResponse and aiosqliteObj; install their aiohttp, requests, and aiosqlite dependencies and provision pmblue_update. At import time it calls pmblue_update.maintenance("toolbox", "aiosqliteObj"), imports those dependencies, and calls self_update(primary_mod=True). Importing can therefore perform network requests, create/update local module and cache files, or prompt to publish a missing/newer local module. See updater configuration.
DEFAULT_YEAR is set once during import to the current calendar year, advancing to the next year in November and December. Pass year explicitly when results must be tied to a particular year.
import asyncio
import os
import cms_marketplace as cms
cms.API_KEY = os.environ["CMS_MARKETPLACE_API_KEY"]
async def main():
# Explicit place data avoids an additional ZIP-to-county lookup.
place = cms.Place("33101", state="FL", countyfips="12086")
household = cms.Household(income=42000)
household.add_person(cms.Person("1990-01-15"))
request = cms.PlanSearchRequest(place, household, year=2026)
response = await request
# Synchronous iteration covers the already-loaded first page.
for plan in response:
print(plan.id, plan.name, plan.premium)
asyncio.run(main())People and households
Person(date_of_birth, is_pregnant=False, is_parent=False, uses_tobacco=False, aptc_eligible=True, gender=None, relationship=None, has_mec=None) accepts a date/datetime, a date string (YYYY-MM-DD, MM/DD/YYYY, or MM-DD-YYYY), or an integer age. dict() serializes a date as dob and an integer as age, including optional fields only when supplied.
set_gender() normalizes Male/Female and their lowercase or one-letter forms; reading gender while unset raises ValueError. The constructor itself stores a supplied gender without normalization. age computes age from a date, but fails for integer-age inputs because it expects .year, .month, and .day. Use person.dict()["age"] for integer input. Pass actual dates or small age integers; the apparent birth-year conversion branch restores the original integer and is not reliable.
Household(income=0, has_married_couple=False) stores annual income and a list of people. Numeric income strings may contain commas. add_person(person) requires Person and assigns Self to the first person; when the married flag is set it assigns Spouse to the second person if their relationship is unset. create_and_add_person(...) accepts the same person fields and returns None. dict() returns the request object and len(household) counts people.
Places and county lookup
Place(zipcode, state=None, countyfips=None, countyname=None) describes a location. await place.full_dict() returns zipcode, state, and countyfips; it looks up missing state/FIPS values on demand. Keep ZIP and FIPS values as strings to preserve leading zeroes.
| Place setting | Current values and effect |
|---|---|
FIPS_RETRIEVAL_METHOD | 0 (default): HUD-derived CSV from /resources/zip_county, labeled June 2025 in source. 1: CMS county-ZIP dataset. 2: CMS ZIP lookup endpoint. 3: combined datasets. |
MULTIPLE_RESULT_BEHAVIOR | 0: select a result (HUD-based paths use weighting; other paths may use the first). 1: raise on ambiguity. 2: return all matches. |
DATA_FILE_NAME | Defaults to countyfips; dataset methods write countyfips.csv and/or countyfips.json in the working directory and cache data in memory. |
cms.Place.FIPS_RETRIEVAL_METHOD = 2
cms.Place.MULTIPLE_RESULT_BEHAVIOR = 2
matches = await cms.Place.retrieve_county_fips("33101")
if matches is None or matches == []:
raise ValueError("No county found")
if isinstance(matches, list):
# Present matches to your user; use their selected Place.
print([(p.countyfips, p.countyname) for p in matches])
else:
print(matches.countyfips, matches.countyname)
Choose an ambiguous result before building the request: full_dict() silently selects the first element of a returned list. Empty/no matches can produce IndexError or AttributeError. County-name lookup is unavailable for method 0 (NotImplementedError). Dataset files have no automatic freshness refresh.
Plan search and pagination
PlanSearchRequest(place, household, market="Individual", year=None, filter=None, sort=None, csr_override=None) builds the request. await request.dict() exposes the serialized request; await request.search() and await request return a PlanSearchResponse. The first response is cached on the request object, so create a new request when inputs change.
issuer_filter = cms.Filter()
issuer_filter.add_issuer("Example Issuer")
request = cms.PlanSearchRequest(
place, household, year=2026, filter=issuer_filter
)
response = await request
async for plan in response:
print(plan.name, plan.metal_level)
# Or collect all pages into a list (can make multiple requests):
all_plans = await response
| Expression | Behavior |
|---|---|
for plan in response | Iterates only the currently loaded response.data list. |
async for plan in response | Reads stored objects and fetches additional pages with offsets increasing by 10. Attempts cache writes when caching is enabled. |
await response | Collects asynchronous iteration into a list of Plan. |
len(response), response.total | Reported total, which can exceed the loaded list length. |
Filter | add_issuer(name) appends an issuer name; dict() emits the issuer list when nonempty. |
Await a new request before iterating it asynchronously: direct async for plan in request on an unfetched request incorrectly awaits a synchronous __aiter__() result and can raise TypeError. The paginator assumes pages of 10, can make an extra request at the end, and does not reset its offset when iteration restarts; use a fresh request for a fresh traversal. loaded_all is advisory and is not always set at termination.
Plans, benefits, and costs
await get_plan_by_id(plan_id, year=None) (alias get_plan_from_id) checks the optional local cache, then requests a plan and returns Plan. A plan exposes id/plan_id, name, type, premium, metal_level, state, benefits_url, raw data, an Issuer, and a list of Benefit objects. Issuer provides id, name, phone, and individual_url.
Benefit exposes its name, type, covered, has_limits, limit_units, limit_quantity, raw data, and cost_sharings. Each CostSharing contains network tier, copay/coinsurance amounts and options, display_string, CSR data, benefit_before_deductible, and the derived booleans is_copay, is_coinsurance, and is_valid.
plan = await cms.get_plan_by_id("EXAMPLE_PLAN_ID", year=2026)
for benefit in plan.benefits:
for share in benefit.cost_sharings:
if share.network_tier == "In-Network" and share.is_valid:
print(benefit.name, share.display_string)
primary_copay, specialist_copay, and generic_drugs_copay select the highest parsed copay across matching benefit entries. They do not limit themselves to one network tier. Display properties are primary_display, specialist_display, and generic_drugs_display.
individual_deductibles and family_deductibles return three-tuples (combined, medical, drug), with missing values represented by None. Boolean has_individual_deductible/has_family_deductible and individual/family maximum-out-of-pocket properties are also available. Parsed values follow this implementation's matching rules; retain plan.data to inspect the full response.
CostSharing.get_relevant_cost_sharing() is currently placed on a class that does not have cost_sharings and raises AttributeError for an ordinary instance. Iterate benefit.cost_sharings explicitly, as above. get_plan_from_text() also has inconsistent results: a 14-character string returns an unawaited coroutine, while a name returns only a cached plan ID or None. Prefer get_plan_by_id() when you have an ID.
Optional SQLite plan cache
PlanCache.ENABLED defaults to False. Enable and configure it before the first cache access. Cache operations use aiosqliteObj and an on-demand plans table containing plan ID, name, update timestamp, and compressed JSON bytes.
cms.PlanCache.CACHE_FILE = "marketplace-plans.sqlite3"
cms.PlanCache.COMPRESSION_METHOD = 0 # zlib; 1 selects lzma
cms.PlanCache.COMPRESSION_LEVEL = 6
cms.PlanCache.UPDATE_EVERY = 86400
cms.PlanCache.ENABLED = True
cache_db = await cms.PlanCache.db # Initializes schema and name crosswalk.
plan = await cms.get_plan_by_id("EXAMPLE_PLAN_ID", year=2026)
cached = await cms.PlanCache.get_plan_from_id(plan.id)
# At application shutdown, after all cache users are finished:
await cache_db.close()
UPDATE_EVERY accepts seconds or a datetime.timedelta. compress_json() and decompress_json() use the globally selected algorithm; choosing LZMA without the lzma module raises ImportError. Use a separate cache file when changing compression because existing rows do not record their compression algorithm.
get_plan_id_from_name(name, fuzzy=False) looks up uppercase names in the in-memory crosswalk, optionally using toolbox.Matching.fuzzy_within(..., threshold=.85). The crosswalk is populated when the database opens and is not updated by new inserts. Name lookup does not itself initialize the database.
The cache is keyed only by plan ID, without year or household context. Reusing a file across years/households may reuse a plan or premium from a different request. Expiration is checked against the in-memory timestamps before the first database initialization, so preexisting stale rows may be returned on the first lookup. Expired rows are treated as misses after loading; no active deletion occurs. A write within one tenth of UPDATE_EVERY of the previous write is skipped. These behaviors mean the cache should not be treated as an authoritative plan store.
Eligibility and calculation helpers
await get_household_eligibility(place, household, year=None) posts to the eligibility estimates endpoint and returns decoded JSON. await get_slcsp(...) posts the household and place with market="Individual" and returns decoded JSON. await request.get_slcsp() uses the request's place, household, and year.
PovertyGuidelines wraps a returned guideline table. Call await PovertyGuidelines.get_guidelines(year) or await PovertyGuidelines.calculate_guideline_for_year(household_size, year). Instances support guidelines[household_size] and the intentionally misspelled public method calculate_guidline(household_size).
_year value, so requesting an earlier available property can fetch DEFAULT_YEAR instead. Requests also hardcode states/FL. Missing year attributes can raise AttributeError, and an unset API key raises ValueError.interpolate_applicable_percentage(fpl_ratio) uses the static APPLICABLE_PERCENTAGE_BRACKETS table below. These are the literal values in version 0.4.1, not verified rates for any current coverage year. Interpolation is linear within each listed interval; values outside all intervals return None.
| FPL ratio interval | Lower to upper percentage in source |
|---|---|
| 0–1.33 | 2.10%–2.10% |
| 1.33–1.50 | 3.14%–4.19% |
| 1.50–2.00 | 4.19%–6.60% |
| 2.00–2.50 | 6.60%–8.44% |
| 2.50–3.00 | 8.44%–9.96% |
| 3.00–4.00 | 9.96%–9.96% |
await calculate_aptc(place, household, year=None) divides household income by the retrieved guideline, interpolates that table, and subtracts the calculated annual contribution from 12 times the returned SLCSP premium, floored at zero. The result includes annual_aptc, monthly_aptc, and calculation inputs. A ratio outside the table returns zero with the code's fixed reason string Income above 400% FPL. Passing a different year changes API requests, not the static percentage table.
plan.spending_estimate(primary_visits=0, specialist_visits=0, generic_drugs=0, emergency_room_visits=0) returns a rough individual cost total, excluding premiums. It uses fixed assumed uninsured costs of 110, 180, 25, and 1500 respectively, iterates service counts against deductible logic, and caps the result at the parsed individual maximum-out-of-pocket value when present. Counts should be nonnegative integers. Its separate medical/drug deductible branch assigns some spending to the wrong accumulator; its result is an implementation estimate, not a benefits determination. Family deductible fields are not used by this method.
Errors and operational limits
- HTTP results are generally decoded directly without checking status, so authentication, schema, network, and JSON failures can surface as
KeyError,AttributeError, transport exceptions, or incomplete result objects. The module has no common API-error type. - Invalid person dates/gender or lookup settings raise
ValueError; non-Personhousehold entries raiseTypeError. Date-based age calculation can also fail for a February 29 birthday during a non-leap year. - Several model constructors assume complete response dictionaries. For example,
PlanconstructsIssuereven if issuer data is absent, which can raiseAttributeError. - File paths are relative to the process working directory unless configured otherwise; downloads, compression, and SQLite caching need writable storage. Synchronous file reads/writes and compression occur inside otherwise asynchronous flows.
- The guide describes the current module and explicitly identifies incomplete helpers. Examples do not contact live services unless executed with valid credentials; verify returned data and the desired year in your application.
Object handbook
Request-side objects (Person, Household, Place, and Filter) are assembled by the application. A PlanSearchRequest turns them into API JSON and returns a PlanSearchResponse, which creates Plan objects. Each plan owns an Issuer and a list of Benefit objects; benefits own CostSharing objects. Except for the optional cache, these are in-memory wrappers around request or response data.
Person and Household
A Person stores date_of_birth, is_pregnant, is_parent, uses_tobacco, aptc_eligible, relationship, and has_mec. These fields are included by dict() according to the rules in the household guide above. It has no link to a particular household and makes no API requests. set_gender(value) changes normalized gender locally and returns None. age and gender are synchronous properties with the documented missing/integer/leap-date limitations.
Household(income=0, has_married_couple=False) owns mutable income, has_married_couple, and people. add_person(person) appends the supplied object rather than copying it and may fill in its relationship. create_and_add_person(...) constructs and appends a person but returns no object; use household.people[-1] afterward if needed. dict() builds a fresh request dictionary from the current people. Changing a person therefore affects subsequent household serialization, but it does not refresh a search request whose response was already fetched.
Place and Filter
Place(zipcode, state=None, countyfips=None, countyname=None) stores the supplied attributes. await full_dict() may do network or dataset I/O when state/FIPS is missing. Resolved state and FIPS are used in the returned dictionary but are not written back to the corresponding instance attributes; county name may be filled on the instance. Pass known location data at construction when you want to avoid repeated resolution logic.
Place.retrieve_county_fips(zipcode) and retrieve_county_name(countyfips) are class-level asynchronous functions, selected by the shared class settings. A FIPS lookup's result may be a Place, list, or no result depending on the selected backend and ambiguity setting. Do not assume a consistent list shape without normalizing it yourself. Filter() initializes an empty issuers list; add_issuer(name) appends without deduplication and dict() returns either an issuer filter or an empty dictionary.
PlanSearchRequest
The constructor retains place, household, market, year, filter, sort, and csr_override. When year=None, it captures DEFAULT_YEAR at construction. await dict() serializes current arguments and may resolve the place. The optional sort and csr_override values pass through without local validation.
await search() performs the initial POST only if the request has no stored response; it then returns its PlanSearchResponse. await request is the same operation. await get_slcsp() calls the module-level SLCSP helper with this request's place, household, and year, returning decoded JSON without storing it as the search response. A new request object is the straightforward way to run a fresh query after changing inputs.
PlanSearchResponse
PlanSearchResponse(data, request) parses data.get("plans", []) into Plan instances, sets request to the originating request, and sets total to the returned total or the initial loaded count. data on this object is a list of plans, unlike the raw mapping stored as plan.data. loaded_all begins false and is advisory.
Synchronous iteration returns the currently loaded list without I/O; asynchronous iteration may POST further pages and append to that same list. len(response) uses the reported total. await response collects asynchronous iteration into a list. Reusing the same response concurrently shares mutable iteration state, and restarting iteration does not reset its pagination offset. Create a new request for independent traversals.
Plan, Issuer, Benefit, and CostSharing
Plan(data) expects one plan mapping. It parses identifying/display fields immediately, creates Issuer(data.get("issuer")), and creates one Benefit for each benefits entry. It retains the original mapping as data. Construction does not fetch more data. Copay/deductible/out-of-pocket properties are computed from that mapping and many are cached; mutating raw data after first access may leave derived values stale. Reconstruct a plan from replacement data when you need a consistent new snapshot.
Issuer(data) stores id, name, phone, individual_url, and raw data. It has no API methods. plan.issuer_name copies plan.issuer.name at construction and will not automatically follow later manual changes to the issuer.
Benefit(data) stores name, type, covered (default false), has_limits (default false), limit_units, limit_quantity, and raw data. It builds cost_sharings from the response list and does not select a preferred tier for the caller. Coverage and limits remain separate from the derived validity of any cost-sharing entry.
CostSharing(data) stores network_tier, copay_amount, copay_options, coinsurance_rate, coinsurance_options, display_string, csr, and raw data. The constructor maps only the literal BBD to true for benefit_before_deductible; all other values become false. It computes is_coinsurance, is_copay, and is_valid once, excluding display values Not Applicable/Not Covered and certain absent/full-rate amounts. These flags are this parser's heuristics, not a replacement for the complete benefit response. The misplaced get_relevant_cost_sharing() method remains unusable on ordinary instances.
Plan.spending_estimate(...) is synchronous, returns a number, and uses the fixed cost/deductible algorithm documented above. It does not make a request, include premiums, or incorporate the household's family utilization. Individual/family deductible properties each return three-tuples; absent values are None, not guaranteed zeroes.
PlanCache
PlanCache is configured and called at class level; separate instances do not provide independent files or settings. await PlanCache.db creates/opens the shared aiosqliteObj.Database, initializes its schema/indexes, and loads timestamp/name crosswalks. This property is awaitable and has no parentheses. Its database object can be closed at application shutdown after all cache work has stopped.
await get_plan_from_id(plan_id) returns a cached Plan or None. await get_plan_id_from_name(plan_name, fuzzy=False) returns only an ID or None; it does not fetch a plan. await add_plan_to_cache(plan) inserts/updates raw plan data and commits, or returns immediately when caching is disabled/recently written. compress_json(data) and decompress_json(blob) are synchronous byte conversion helpers using the selected global compression method. Cache keys and freshness limitations remain as described above.
PovertyGuidelines and top-level functions
PovertyGuidelines(data) stores raw data, per_person_after_eight, and a guidelines dictionary keyed by household size. calculate_guidline(size) and object[size] return a directly stored amount or, for sizes above eight, the size-eight value plus the extra-person amount. Unsupported smaller sizes raise ValueError; a missing size-eight row can raise KeyError. Constructing an object from supplied data requires no request.
Class-level await get_guidelines(year=None) returns a guideline object and await calculate_guideline_for_year(size, year=None) returns its calculated number, subject to the existing year/state bug. Top-level eligibility/SLCSP helpers return remote JSON; get_plan_by_id()/get_plan_from_id() return Plan; get_plan_from_text() currently has inconsistent return types. interpolate_applicable_percentage(ratio) is a synchronous numeric helper; calculate_aptc() combines remote JSON with the static table and returns a calculation dictionary. None of these helpers persists household data.
Worked examples
Construct and inspect a household request before fetching
import datetime
import cms_marketplace as cms
household = cms.Household(income="42,000", has_married_couple=True)
first = cms.Person(
datetime.date(1990, 1, 15),
uses_tobacco=False,
aptc_eligible=True,
has_mec=False,
)
first.set_gender("f")
household.add_person(first)
household.create_and_add_person("1992-06-20", has_mec=False)
assert first.relationship == "Self"
assert household.people[1].relationship == "Spouse"
payload = household.dict()
print(payload["income"], len(payload["people"]))
# In an async function, inspect the complete request before sending:
# place = cms.Place("33101", state="FL", countyfips="12086")
# request = cms.PlanSearchRequest(place, household, year=2026)
# request_payload = await request.dict()
The constructor methods make local objects. The person defaults and relationship assignments are explicit here; choose flags from the application's actual inputs rather than inferring them from this example. Serialization with an explicit place avoids a county lookup.
Choose a display entry explicitly from each benefit
def preferred_cost_sharing(benefit):
for tier in ("In-Network", "In-Network Tier 2"):
for sharing in benefit.cost_sharings:
if sharing.network_tier == tier and sharing.is_valid:
return sharing
return None
def benefit_rows(plan):
rows = []
for benefit in plan.benefits:
sharing = preferred_cost_sharing(benefit)
rows.append({
"name": benefit.name,
"covered": benefit.covered,
"has_limits": benefit.has_limits,
"limit_quantity": benefit.limit_quantity,
"limit_units": benefit.limit_units,
"network_tier": sharing.network_tier if sharing else None,
"display": sharing.display_string if sharing else None,
})
return rows
This is application selection logic over the parsed object tree. It makes the tier preference visible and handles an absent usable entry. It deliberately preserves coverage and limit fields alongside display text; a missing selected entry does not establish that the benefit is uncovered.
Produce a plain-data plan report from a fresh search
async def plan_report(place, household, year):
request = cms.PlanSearchRequest(place, household, year=year)
response = await request
report = []
async for plan in response:
report.append({
"requested_year": year,
"id": plan.id,
"name": plan.name,
"issuer": plan.issuer.name,
"metal_level": plan.metal_level,
"premium": plan.premium,
"deductibles": plan.individual_deductibles,
"maximum_out_of_pocket": plan.individual_max_out_of_pocket,
"benefits": benefit_rows(plan),
})
return report
This consumes the response asynchronously, so it can make several requests. Each returned record retains the requested year as application context; the module's plan cache itself does not use year or household in its key. Keep caching disabled for requests that must not share cached household/year-specific values.
Resolve a cached name to a plan in two explicit steps
async def cached_plan_by_name(name, year):
if not cms.PlanCache.ENABLED:
raise RuntimeError("Enable and configure the plan cache first")
await cms.PlanCache.db # Opens the DB and loads its name crosswalk.
plan_id = await cms.PlanCache.get_plan_id_from_name(name, fuzzy=False)
if plan_id is None:
return None
return await cms.get_plan_by_id(plan_id, year=year)
This avoids get_plan_from_text()'s coroutine/ID ambiguity. It searches only names loaded into the cache's current crosswalk, not the live Marketplace API. New inserts do not refresh that crosswalk, and the cache's year/freshness caveats still apply. Close the shared cache database once at application shutdown, not after every lookup.