Metadata-Version: 2.4
Name: open-epic
Version: 0.1.0
Summary: Python client for Epic MyChart via the SMART on FHIR patient-access API
Author: dynacylabs
Maintainer: dynacylabs
License: MIT
Project-URL: Homepage, https://github.com/dynacylabs/open_epic
Project-URL: Repository, https://github.com/dynacylabs/open_epic
Project-URL: Issues, https://github.com/dynacylabs/open_epic/issues
Keywords: epic,mychart,fhir,smart-on-fhir,healthcare,health,oauth2
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Healthcare Industry
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: fhir.resources>=7.1.0
Requires-Dist: pydantic>=2.6
Requires-Dist: cryptography>=42.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

# open-epic

A Python client for reading your own health data out of Epic **MyChart**,
built on Epic's official patient-access FHIR API (SMART on FHIR / OAuth2) --
not screen-scraping. You log in on your hospital's real MyChart page; the
app never sees your password, only a scoped access token.

## Installing

```bash
pip install open-epic
```

## Why FHIR instead of scraping MyChart directly

Under the 21st Century Cures Act, every Epic-powered health system exposes
a patient-facing FHIR R4 API. It's the sanctioned way to build exactly this
kind of tool: stable, structured JSON, and it doesn't violate MyChart's
terms of use the way automating the web UI would.

## No file I/O, anywhere in this library

This is a deliberate design constraint, not just an implementation detail:
`open_epic` never reads or writes config, credentials, or health data to
disk on its own. `OrgConfig` and `TokenSet` are plain dataclasses you
construct however you want (hardcoded, env vars, your own file/secrets-
manager read); `FHIRClient`/`AsyncFHIRClient` take them directly and
return typed FHIR objects in memory. If a token gets refreshed mid-session,
read the new one back via `client.credentials` -- nothing gets written
anywhere automatically. Persisting anything across runs -- org config,
credentials, downloaded data -- is entirely up to your own script; see
`examples/quickstart.py` for the minimal pattern (a `creds.json` the
*script* reads/writes with plain `json`, not the library) and
`examples/download_all.py` for a fuller one (dumping every resource
category to `./data/`).

## Read-only by design

`open_epic` only ever does `GET`/`search` -- it never calls a `Create`,
`Update`, or other write/interactive Epic API (booking or cancelling
appointments, sending MyChart messages, submitting forms, etc.), even
though Epic exposes some of those for patient apps. This is deliberate:
there's no code path in this library that can modify your actual medical
record or MyChart account, which also keeps it clear of Epic's stricter
review/approval requirements for write-capable apps.

## 1. Register a client ID for your hospital

1. Create a free account at [open.epic.com](https://open.epic.com/) and
   register a new app (SMART on FHIR, patient-facing / standalone launch).
   Add a redirect URI of `https://127.0.0.1:8765/callback` (or whatever you
   pass as `redirect_uri` below) and request the scopes you need, e.g.
   `openid fhirUser offline_access patient/*.read`. Epic requires `https`
   redirect_uris for production use -- plain `http` is only accepted for
   sandbox/dev testing. `SmartAuthFlow` handles this automatically: when
   `redirect_uri` is `https`, the local callback listener wraps itself in
   a freshly generated self-signed cert (see `localcert.py`, deleted again
   the moment login finishes), and the browser will show a one-time "not
   private" warning on the redirect -- click through it ("Advanced" ->
   "Proceed"), that's expected (and happens on every login, since nothing
   about the cert is cached between runs). If the redirect URI field
   splits scheme and host into separate controls, double check the scheme
   dropdown matches whatever you configure in `redirect_uri` exactly --
   Epic's match is exact, and a scheme-only mismatch produces a generic,
   hard-to-diagnose "OAuth2 Error" page with no useful detail.
2. Find your hospital's FHIR base URL. Either browse
   [open.epic.com/Endpoints/R4](https://open.epic.com/Endpoints/R4) yourself,
   or use `discovery.search_epic_endpoints("Your Hospital Name")`.
3. Ask your hospital's MyChart/Epic support to activate your client ID for
   patient use if it isn't already enabled for non-listed apps -- this step
   varies by health system.

Each health system you want to connect to needs its own registration --
build one `OrgConfig` per hospital.

## 2. Log in

```python
from open_epic import OrgConfig, SmartAuthFlow

org = OrgConfig(
    name="my-hospital",
    fhir_base_url="https://fhir.myhospital.org/api/FHIR/R4",
    client_id="your-client-id-from-open.epic.com",
)

tokens = SmartAuthFlow(org).login()  # opens your browser, you log into MyChart
```

`login()` opens the hospital's real MyChart login page, waits for you to
approve access, catches the redirect on a local server, and returns a
`TokenSet` -- access token, refresh token (if the org grants one), expiry,
patient ID. That's it; nothing gets written to disk. If you want to reuse
it later without logging in again, save it yourself:

```python
import json
from pathlib import Path

Path("creds.json").write_text(json.dumps(tokens.as_dict(), indent=2))

# ...later, a different run:
from open_epic import TokenSet
tokens = TokenSet.from_dict(json.loads(Path("creds.json").read_text()))
```

Note some Epic orgs cap the access token's lifetime (e.g. a fixed 1 hour)
with no refresh token issued at all -- MyChart's own consent screen ("How
long will the app have access to my information?") will tell you what your
org actually grants. If there's no refresh token, `login()` is the only way
to get a new one; the library can't work around an org's own policy.

## 2b. Or skip the object plumbing: `open_epic.init(...)`

A thin module-level session on top of everything above, for scripts that
just want "authenticate once, then go":

```python
import open_epic
from open_epic import OrgConfig

org = OrgConfig(name="my-hospital", fhir_base_url="...", client_id="...")

open_epic.init(org)  # opens your browser, logs in
# or, if you already have tokens from a previous run (see above):
open_epic.init(org, credentials=tokens)  # no browser, reuses them

if open_epic.is_initted:
    client = open_epic.get_client()
    print(client.patient.me().name[0].family)
    # save for next time, same pattern as above:
    creds_json = client.credentials.as_dict()
```

Pass `force_login=True` to always do a fresh interactive login even if
`credentials=` was given.

`OrgConfig.listen_host`/`listen_port` control what the local redirect-
catcher actually binds to, separate from `redirect_uri` (which is what
Epic and the browser see). Leave both unset for the common case where they
match; set them explicitly for port-forwarding/reverse-proxy/remote-dev
setups where the externally-visible address in `redirect_uri` isn't
something you can `bind()` to directly (e.g. binding `0.0.0.0` locally
while `redirect_uri` points at a dynamic-DNS hostname or forwarded public
port).

## Token validity, expiry, and introspection

`TokenSet.is_expired` is a local check against the `expires_at` timestamp
recorded at login/refresh -- cheap, no network call, and what the client
itself uses before every request to decide whether to silently refresh:

```python
if tokens.is_expired:
    tokens = SmartAuthFlow(org).refresh(tokens)
```

For a live, authoritative answer -- e.g. the patient revoked the app early
from MyChart's "Connected Apps" settings, which a local expiry check can't
see -- use RFC 7662 introspection instead:

```python
result = client.introspect()  # defaults to the session's own access token
# or: SmartAuthFlow(org).introspect(some_token)

print(result.active)          # bool -- authoritative right now
print(result.scope, result.exp, result.client_id)
print(result.raw)             # anything the org returned beyond the known fields
```

Raises `AuthenticationError` if the org doesn't advertise an
`introspection_endpoint` in its SMART configuration (optional, not every
org exposes it) or if the request itself fails. `active: False` in a
successful response isn't an error -- it's the org correctly telling you
the token no longer works.

If you requested the `openid` scope, `TokenSet.id_token` holds the signed
JWT Epic returns alongside the access token -- typically `sub`, `fhirUser`
(a reference to your Patient/RelatedPerson/Practitioner resource), `iss`,
`iat`, `exp`. Read its claims without verifying the signature (safe for a
token you just received directly from the org's token endpoint over TLS;
don't extend that trust to an id_token that arrived some other way):

```python
from open_epic import decode_id_token

claims = decode_id_token(tokens.id_token)
print(claims["fhirUser"])  # e.g. "Patient/eXYZ123"
```

## 3. Pull data -- scripts (sync)

```python
from open_epic import FHIRClient

with FHIRClient(org, tokens) as client:
    me = client.patient.me()
    print(me.name[0].family)

    for appt in client.appointments.list():
        print(appt.start, appt.status)

    for result in client.labs.results():
        print(result.code.text, result.valueQuantity)

    for med in client.medications.orders(status="active"):
        print(med.medicationCodeableConcept.text)

    for doc in client.documents.list():
        content = client.documents.content_of(doc)  # raw bytes (e.g. PDF)
```

## 4. Pull data -- async (e.g. inside FastAPI)

```python
from open_epic import AsyncFHIRClient

async def get_labs(org, tokens):
    async with AsyncFHIRClient(org, tokens) as client:
        return [r async for r in client.labs.results()]
```

The sync and async clients expose the identical shape (`.patient`,
`.appointments`, `.medications`, `.labs`, `.clinical`, `.documents`,
`.messages`) -- pick whichever fits where you're calling it from.

## What's available

Every FHIR resource type Epic exposes to patient-facing apps -- cross-checked
against the live, full API catalog at fhir.epic.com/Specifications (not just
a manual read of the Incoming APIs picker) -- is reachable here: via a
dedicated accessor, `client.resolve()`, or the generic `client.search()`
escape hatch below. This library is read-only by design (see below) -- Epic
also publishes write operations (e.g. `AllergyIntolerance.Create`,
`Observation.Create` for patient-entered vitals, `Appointment.$book`,
`QuestionnaireResponse.Create`) that intentionally aren't implemented here.
Not every org enables every API for patient use regardless of what Epic's
catalog lists -- worth spot-checking against your own org if you depend on
something specific.

| Accessor | FHIR resources |
|---|---|
| `client.patient` | `Patient` (your own demographics) |
| `client.appointments` | `Appointment` (scheduled visits), `Slot`/`Schedule` via `.slots()`/`.schedules()` (provider availability, not patient-scoped) |
| `client.encounters` | `Encounter` (visits that actually happened) |
| `client.medications` | `MedicationRequest`, `MedicationStatement`, `MedicationDispense` (refill history), `MedicationAdministration` (doses given via a line/drain/airway, via `.administrations()`), `Medication` via `.definitions()` (drug catalog entries) |
| `client.labs` | `Observation` -- `.results()` (labs), `.vitals()`, `.social_history()`, `.assessments()`, `.by_category(...)` for anything else (Epic has several more Observation categories than dedicated methods cover; see the module docstring) |
| `client.diagnostics` | `DiagnosticReport`, `Specimen`, `Media` (imaging-adjacent, "Study" category) |
| `client.clinical` | `Condition`, `AllergyIntolerance`, `Immunization`, `ImmunizationRecommendation` (vaccine forecast, via `.immunization_recommendations()`), `Procedure`, `Device`, `FamilyMemberHistory`, `Consent`, `RelatedPerson`, `AdverseEvent`, `BodyStructure`, `DeviceUseStatement`, `Flag`, `Substance` |
| `client.care` | `CarePlan`, `CareTeam`, `Goal`, `EpisodeOfCare` |
| `client.documents` | `DocumentReference` (+ binary content download) |
| `client.messages` | `Communication` (availability varies by health system) |
| `client.coverage` | `Coverage` (insurance) |
| `client.billing` | `ExplanationOfBenefit` (claims), `Account` (billing account status), `Contract` (reimbursement terms, via `.contracts()`) |
| `client.forms` | `QuestionnaireResponse` (MyChart intake/screening forms), `Questionnaire` (the form templates themselves, via `.definitions()`) |
| `client.orders` | `ServiceRequest`, `Task`, `NutritionOrder`, `DeviceRequest`, `RequestGroup` |
| `client.provenance` | `Provenance` (who/when a record was entered) |
| `client.directory` | `Practitioner`, `PractitionerRole`, `Organization`, `Location` -- searched directly (e.g. by name), not by reference |
| `client.research` | `ResearchStudy` (clinical trials/studies you're enrolled in), `ResearchSubject` (your enrollment status/arm, via `.subjects()`) |
| `client.lists` | `List` (Epic's curated summary lists -- active problem list, current med list, etc. -- distinct from an unfiltered resource search; see the module docstring on why there's no per-list-type convenience method) |

`client.resolve("Practitioner/abc123")` fetches a looked-up-by-id resource
(`Practitioner`, `PractitionerRole`, `Organization`, `Location`,
`Medication`) from a reference found on another resource. Use this when
you already have the reference (e.g. an Encounter's participant); use
`client.directory`/`client.medications.definitions()` instead when you
want to search independently (e.g. "find a practitioner named Smith").

`client.everything()` calls the `Patient/$everything` operation, and
`client.summary()` calls `Patient/$summary` (the International Patient
Summary -- a curated clinical document). Both return most/some of the
resource types above in one paginated Bundle instead of calling each
accessor separately, on orgs that support them. Neither is universally
enabled -- treat them as shortcuts to try, not something to depend on.

`client.expand_valueset(url)` calls `ValueSet/$expand` -- a terminology
operation (not patient data) for expanding a ValueSet's canonical URL into
its full list of codes, useful for building pick-lists or validating a
code against what the org actually supports.

Not every Epic org exposes every resource type to patients -- if a request
comes back empty, 403s, or 404s, that's the hospital's scope configuration,
not a bug in the client. Notably, raw imaging study/DICOM metadata
(`ImagingStudy`) generally isn't exposed to patient apps at all; imaging
results reach patients through `client.diagnostics.reports()`/`.media()`
or as PDFs via `client.documents`.

Every method returns a lazily-paginated iterator (or async iterator) of
typed `fhir.resources` model objects; results that don't validate against
the model fall back to plain `dict`s so an unexpected field never hard-fails
a request.

For anything not covered by the accessors above, drop to the generic API:

```python
client.search("Encounter", patient=client.patient_id, status="finished")
client.get_resource("Encounter", "some-id")
```

## Formatting helpers

FHIR's structured types (`HumanName`, `CodeableConcept`, `Quantity`, FHIR
date strings, `Address`, `ContactPoint`) are verbose to turn into plain
display strings. These are optional, dependency-free pure functions for
that -- every resource wrapper already returns full typed objects
regardless of whether you use them:

```python
from open_epic import (
    format_human_name, codeable_concept_text, format_quantity,
    parse_fhir_date, format_address, format_contact_point,
)

me = client.patient.me()
print(format_human_name(me.name))            # "Jane A Smith"
print(format_address(me.address[0]))          # "123 Main St, Cincinnati, OH 45202"
print(format_contact_point(me.telecom, system="phone"))

for obs in client.labs.results():
    print(codeable_concept_text(obs.code), format_quantity(obs.valueQuantity))
    # "Body temperature 98.6 degF"

for cond in client.clinical.conditions():
    onset = parse_fhir_date(cond.onsetDateTime)  # date or datetime, per precision
```

## Development

```bash
pip install -e ".[dev]"
pytest
```

## Publishing a release

```bash
rm -rf dist build
python -m build           # produces dist/*.whl and dist/*.tar.gz
twine check dist/*        # validate metadata before uploading
twine upload dist/*       # or: upload to https://test.pypi.org/legacy/ first
```

Bump `version` in [pyproject.toml](pyproject.toml) before building. CI
(`.github/workflows/publish.yml`) publishes automatically on a published
GitHub Release via PyPI's [trusted publishing](https://docs.pypi.org/trusted-publishers/)
(no stored API token) -- configure the `pypi` environment as a trusted
publisher on the PyPI project settings page first.
