
![Fyron Banner](images/BF-Fyron.png)

<div align="center">
  <img alt="Python" src="https://img.shields.io/badge/python-3.10%2B-blue" />
  <img alt="License" src="https://img.shields.io/badge/license-MIT-green" />
  <img alt="FHIR" src="https://img.shields.io/badge/FHIR-enabled-0b7dda" />
  <img alt="DICOM" src="https://img.shields.io/badge/DICOM-supported-2e7d32" />
  <img alt="Documents" src="https://img.shields.io/badge/Documents-supported-6a1b9a" />
</div>

Fyron is a pragmatic Python toolkit for interoperable healthcare data and AI workflows. It gives developers clean, testable primitives for FHIR (REST + SQL), DICOM imaging, document downloads, Teable integration, and LLM-assisted analysis without hiding the underlying protocols.

**Features:** FHIR REST + SQL with pagination and caching · DICOMweb with NIfTI export · Document download with auth · Teable read/write · LLM prompts over DataFrames and files · Optional Excel support via `fyron[excel]`

## Table of Contents
- [Quickstart](#quickstart)
- [Common tasks](#common-tasks)
- [Requirements](#requirements)
- [Who It's For](#who-its-for)
- [Package Layout](#package-layout)
- [Capabilities](#capabilities)
- [Setup](#setup)
- [Core Workflows](#core-workflows)
  - [FHIR REST](#fhir-rest)
  - [FHIR SQL](#fhir-sql)
  - [FHIR Utilities](#fhir-utilities)
  - [DICOM](#dicom)
  - [Documents](#documents)
  - [LLM Agent](#llm-agent)
  - [Data IO](#data-io)
  - [Teable](#teable)
- [Caching](#caching)
- [Logging](#logging)
- [Development](#development)
- [Troubleshooting](#troubleshooting)
- [License](#license)

## Quickstart
Get a first FHIR query running in a few steps.

**1. Install** (Python 3.10+)

```bash
# pip
pip install fyron

# Poetry (add to your project)
poetry add fyron

# uv
uv pip install fyron
# or, in a project with pyproject.toml: uv add fyron
```

With Excel support: `pip install fyron[excel]`, `poetry add fyron[excel]`, or `uv pip install "fyron[excel]"`.

**2. Configure environment**  
If you use a repo clone: `cp .example.env .env` and edit. If you installed from PyPI, copy [.example.env](https://github.com/bitsandflames/fyron/blob/main/.example.env) from the repo or set variables from [Setup](#setup) in your shell or `.env`.

**3. Load env and run a query**
```python
from fyron import load_env, FHIRRestClient

load_env()  # optional: path=".env", override=False, warn_if_missing=True

client = FHIRRestClient()
patients = client.query_df(
    resource_type="Patient",
    params={"_count": 25, "_sort": "_id"},
    max_pages=1,
)
print(patients.head())
```

To try without any server: use `base_url="https://hapi.fhir.org/baseR4"` (public HAPI server; no auth needed for read-only).

## Common tasks
| Goal | Section / hint |
|------|----------------|
| First FHIR query | [Quickstart](#quickstart) |
| Use a `.env` file | `load_env()` — [Quickstart](#quickstart), [Setup](#setup) |
| Auth with token endpoint | `Auth.token_env(auth_url=..., refresh_url=...)` — [FHIR REST](#fhir-rest) |
| Export to Excel | `write_excel(df, "out.xlsx")` — install `fyron[excel]` — [Data IO](#data-io) |
| FHIRPath extraction | `query_df(..., fhir_paths=[("col", "path")])` — [FHIR REST](#fhir-rest) |
| Query from a DataFrame | `query_df_from(df, ...)` (REST or SQL) — [FHIR REST](#fhir-rest), [FHIR SQL](#fhir-sql) |
| DICOM from a table | `download_from_df(df, output_dir=...)` — columns: `study_instance_uid`, `series_instance_uid` — [DICOM](#dicom) |
| LLM over documents | `agent.prompt_documents(documents=..., prompt=...)` — [LLM Agent](#llm-agent) |

## Requirements
- **Python** 3.10 or newer.
- **Optional:** `fhirpathpy` for FHIRPath in `query_df` — `pip install fhirpathpy`.
- **Optional:** Excel (`.xlsx`) — `pip install fyron[excel]`.
- **Optional:** `FHIRSQLClient` (Postgres) is included if `psycopg` is available; otherwise it is `None` and only REST is used.

## Who It’s For
- Data scientists and ML engineers building clinical datasets and cohorts.
- Clinical informatics and analytics teams working across FHIR, SQL, and imaging.
- Researchers who need reproducible pipelines and methods-ready documentation.
- Engineers integrating healthcare data into apps, dashboards, or LLM workflows.

## Package Layout
```
fyron/
  fhir/        # REST + SQL clients, auth, types, utilities
  dicom/       # DICOM downloader
  documents/   # Document downloader
  llm/         # LLM agent
  core/        # IO + integrations
```

## Capabilities
Expand the sections below to see what Fyron supports in each area.

<details open>
  <summary><strong>FHIR</strong></summary>
  REST client with pagination, caching, multiprocessing, and FHIRPath extraction. SQL client for Postgres-backed FHIR stores.
</details>

<details>
  <summary><strong>DICOM</strong></summary>
  DICOMweb downloads with optional NIfTI conversion and per-series/study manifests.
</details>

<details>
  <summary><strong>Documents</strong></summary>
  Deterministic URL downloads with metadata, hashes, and DataFrame helpers.
</details>

<details>
  <summary><strong>Teable</strong></summary>
  Read/write DataFrames to Teable tables with pagination, overwrite support, and base/table creation.
</details>

<details>
  <summary><strong>LLM</strong></summary>
  Prompting utilities for text, documents, images, and PDFs via configurable providers.
</details>

## Setup
Fyron uses environment variables for endpoints and credentials. You can set them in your shell or in a `.env` file (use [.example.env](https://github.com/bitsandflames/fyron/blob/main/.example.env) as a template).

**Minimum for Quickstart (FHIR REST only)**  
Set `FHIR_BASE_URL`. For token auth, also set `FHIR_AUTH_URL`, `FHIR_USER`, and `FHIR_PASSWORD`. Call `load_env()` if you use a `.env` file.

**Full reference** — all optional, depending on which features you use:
```bash
# FHIR REST
export FHIR_BASE_URL="https://example.org/fhir"
export FHIR_AUTH_URL="https://example.org/auth/token"
export FHIR_REFRESH_URL="https://example.org/auth/refresh"
export FHIR_USER="alice"
export FHIR_PASSWORD="secret"

# DICOMweb
export DICOM_WEB_URL="https://pacs.example.org/dicomweb"
export DICOM_USER="alice"
export DICOM_PASSWORD="secret"

# FHIR SQL (optional; requires psycopg)
export FHIR_DB_HOST="localhost"
export FHIR_DB_PORT="5432"
export FHIR_DB_NAME="fhir"
export FHIR_DB_USER="postgres"
export FHIR_DB_PASSWORD="secret"

# LLM
export LLM_PROVIDER="openai"
export LLM_BASE_URL="https://api.openai.com"
export LLM_API_KEY="your_api_key"
export LLM_MODEL="gpt-4.1-mini"

# Teable
export TEABLE_BASE_URL="https://app.teable.ai"
export TEABLE_TOKEN="your_teable_token"
```

**Notes**
- **FHIR auth:** Token auth uses `FHIR_USER`, `FHIR_PASSWORD`, and `FHIR_AUTH_URL`. Use `Auth.token_env(auth_url=..., refresh_url=...)` to build an auth object from env.
- **`.env` loading:** `load_env()` looks for a `.env` in the current directory. Options: `path` (explicit file), `override` (overwrite existing env vars), `warn_if_missing`.
- **FHIRPath:** For `fhir_paths` in `query_df`, install `fhirpathpy`: `pip install fhirpathpy`.

## Core Workflows

### FHIR REST
Query any FHIR resource type; results are returned as a pandas DataFrame. Supports pagination, optional FHIRPath extraction, custom bundle processors, and parallel fetch from a DataFrame.
```python
from fyron import FHIRRestClient

client = FHIRRestClient()
patients = client.query_df(
    resource_type="Patient",
    params={"_count": 25, "_sort": "_id"},
    max_pages=1,
)
print(patients.head())
```

`FHIRRestClient` arguments:

| Argument | Description | Values/Defaults |
| --- | --- | --- |
| `base_url` | FHIR server base URL | Defaults to `FHIR_BASE_URL` |
| `auth` | Reuse `Auth` or `requests.Session` | Optional |
| `num_processes` | Parallel worker count | Default `4` |
| `request_timeout` | Per-request timeout in seconds | Default `30` |
| `log_requests` | Log timing and summaries | `True`/`False` |
| `return_fhir_obj` | Wrap bundles as `FHIRObj` | `True`/`False` |

This uses FHIRPath expressions to extract specific fields.
```python
from fyron import FHIRRestClient

client = FHIRRestClient()

df = client.query_df(
    resource_type="Observation",
    params={"_count": 25},
    fhir_paths=[
        ("patient_id", "subject.reference.replace('Patient/', '')"),
        ("code", "code.coding.code"),
        ("value", "valueQuantity.value"),
    ],
    max_pages=1,
)
```

This applies a custom bundle processor with safe FHIR extraction.
`safe_get` supports dotted paths and list indexes (for example, `"code.coding[0].code"`).
```python
from fyron import FHIRObj, FHIRRestClient, safe_get

client = FHIRRestClient(return_fhir_obj=True)


def process_bundle(bundle):
    bundle = FHIRObj(**bundle) if isinstance(bundle, dict) else bundle
    rows = []
    for entry in bundle.entry or []:
        resource = entry.resource
        rows.append({
            "resourceType": safe_get(resource, "resourceType"),
            "id": safe_get(resource, "id"),
            "subject": safe_get(resource, "subject.reference"),
            "code": safe_get(resource, "code.coding[0].code"),
        })
    return {"Resource": rows}

custom_df = client.query_df(
    resource_type="Patient",
    params={"_count": 10},
    mode="custom",
    process_function=process_bundle,
    max_pages=1,
)
```

This runs one query per row in a DataFrame and fetches in parallel.
```python
from fyron import Auth, FHIRRestClient

auth = Auth.token_env(
    auth_url="https://example.org/auth/token",
    refresh_url="https://example.org/auth/refresh",
)

client = FHIRRestClient(auth=auth, num_processes=4)

result_df = client.query_df_from(
    df=patients_df,
    resource_type="Observation",
    column_map={"subject": "patient_id"},
    params={"_count": 50},
    parallel_fetch=True,
)
```

`Auth.token_env` arguments:

| Argument | Description | Values/Defaults |
| --- | --- | --- |
| `auth_url` | Token endpoint URL | Required |
| `refresh_url` | Refresh endpoint | Optional |
| `token` | Pre-issued bearer token | Optional |

### FHIR SQL
Run parameterized SQL against a Postgres-backed FHIR store and get DataFrames. Requires `psycopg`; if it is not installed, `FHIRSQLClient` is `None` (REST-only installs still work).
```python
from fyron import FHIRSQLClient

sql_client = FHIRSQLClient()

patients = sql_client.query_df(
    "SELECT id, resource_type FROM fhir_resources WHERE resource_type = %s",
    params=["Patient"],
)

sql = """
SELECT id, resource_type, subject_id
FROM observation
WHERE subject_id IN ({patient_ids})
"""

obs = sql_client.query_df_from(
    df=patients_df,
    sql=sql,
    column_map={"patient_ids": "patient_id"},
    chunk_size=500,
    parallel=True,
)
```

`FHIRSQLClient` arguments:

| Argument | Description | Values/Defaults |
| --- | --- | --- |
| `dsn` | Full connection string | Optional |
| `host` | DB host | Optional |
| `port` | DB port | Optional |
| `dbname` | Database name | Optional |
| `user` | DB user | Optional |
| `password` | DB password | Optional |
| `connect_timeout` | Connect timeout (seconds) | Default `10` |
| `log_queries` | Log SQL queries | `True`/`False` |

### FHIR Utilities
This uses built-in bundle processors to standardize common resources.
```python
from fyron import (
    FHIRRestClient,
    process_patient_bundle,
    process_encounter_bundle,
    process_observation_bundle,
    process_condition_bundle,
    process_procedure_bundle,
    process_imaging_study_bundle,
    process_diagnostic_report_bundle,
)

client = FHIRRestClient(return_fhir_obj=True)

patients = client.query_df(
    resource_type="Patient",
    params={"_count": 25},
    mode="custom",
    process_function=process_patient_bundle,
)
```

### DICOM
Download DICOM series via DICOMweb; optionally convert to NIfTI. Supports single series, batch from a DataFrame, and a CLI (`fyron-dicom`).
```python
from fyron import DICOMDownloader

loader = DICOMDownloader(output_format="nifti", num_processes=2)

results = loader.download_series(
    study_uid="1.2.3.4.5",
    series_uid="1.2.3.4.5.6",
    output_dir="dicom_out",
)
print(results)
```
Example auth options:

```python
# Reuse an Auth object
from fyron import Auth, DICOMDownloader

auth = Auth.token_env(auth_url="https://example.org/auth/token")
loader = DICOMDownloader(auth=auth)

# Standalone basic auth
loader = DICOMDownloader(basic_auth=("user", "pass"))
```

`DICOMDownloader` arguments:

| Argument | Description | Values/Defaults |
| --- | --- | --- |
| `dicom_web_url` | DICOMweb endpoint | Defaults to `DICOM_WEB_URL` |
| `auth` | Reuse `Auth` or `requests.Session` | Optional |
| `basic_auth` | Standalone basic auth tuple | `(user, password)` |
| `output_format` | Output format | `"dicom"` or `"nifti"` |
| `num_processes` | Parallel workers for DataFrame downloads | Default `1` |
| `use_compression` | Compress NIfTI output | `True`/`False` |

Download studies or series from a DataFrame. The DataFrame must have columns **`study_instance_uid`** and **`series_instance_uid`** (or configurable via the method).
```python
from fyron import DICOMDownloader

loader = DICOMDownloader(output_format="nifti", num_processes=2)

downloads = loader.download_from_df(
    df=imaging_df,
    output_dir="dicom_out",
)
print(downloads.head())
```

Also there is the option to download single series/studies using an CLI:

```bash
fyron-dicom --dicom-web-url https://pacs.example.org/dicomweb \
  --study-uid 1.2.3.4.5 --series-uid 1.2.3.4.5.6 --output-dir dicom_out
```


### Documents
Download documents from URLs (or from a DataFrame column) into a deterministic folder layout, with optional auth and metadata/hashes.
```python
from fyron import DocumentDownloader

urls = [
    "https://example.org/reports/report1.pdf",
    "https://example.org/reports/report2.pdf",
]

loader = DocumentDownloader(output_dir="docs_out")
results = loader.download_urls(urls)
print(results.head())
```
Example auth options:

```python
# Reuse an Auth object
from fyron import Auth, DocumentDownloader

auth = Auth.token_env(auth_url="https://example.org/auth/token")
loader = DocumentDownloader(auth=auth)

# Standalone basic auth
loader = DocumentDownloader(basic_auth=("user", "pass"))
```

`DocumentDownloader` arguments:

| Argument | Description | Values/Defaults |
| --- | --- | --- |
| `base_url` | Prefix for relative URLs | Defaults to `FHIR_BASE_URL` |
| `output_dir` | Download folder | Default `documents_out` |
| `timeout` | Request timeout (seconds) | Default `30` |
| `skip_existing` | Skip if file exists | `True`/`False` |
| `max_workers` | Thread pool size | Default `4` |
| `save_mode` | File mode | `"auto"`, `"txt"`, `"pdf"` |
| `force_extension` | Force file extension | Optional |
| `auth` | Reuse `Auth` or `requests.Session` | Optional |
| `basic_auth` | Standalone basic auth tuple | `(user, password)` |

This downloads documents referenced by a DataFrame column.
```python
from fyron import DocumentDownloader

loader = DocumentDownloader(output_dir="docs_out")
results = loader.download_from_df(df=docs_df, url_col="document_url")
print(results.head())
```

### LLM Agent
Send prompts to OpenAI or compatible APIs; run over a list of documents, a DataFrame column, or a single prompt. Supports text, images, and file inputs.
```python
from fyron import LLMAgent

agent = LLMAgent(provider="openai")
response = agent.prompt("Summarize the key findings in this dataset")
print(response)
```

`LLMAgent` arguments:

| Argument | Description | Values/Defaults |
| --- | --- | --- |
| `provider` | Provider type | `"openai"`, `"anyllm"`, `"custom"` |
| `base_url` | API base or custom endpoint | Defaults to `LLM_BASE_URL` |
| `api_key` | API key | Optional (required by some providers) |
| `model` | Model name | Defaults to `LLM_MODEL` |
| `timeout` | Request timeout (seconds) | Default `30` |
| `verify_ssl` | Verify TLS | `True`/`False` |

This runs a prompt across a list of documents or file paths.
```python
from fyron import LLMAgent

agent = LLMAgent(provider="openai")

docs = ["doc1 text", "doc2 text", "/path/to/file.txt"]
summary_df = agent.prompt_documents(
    documents=docs,
    prompt="Summarize clinically relevant findings.",
    output_csv="doc_summaries.csv",
)
print(summary_df.head())
```

This runs a prompt across a DataFrame column and appends results.
```python
from fyron import LLMAgent

agent = LLMAgent(provider="openai")

out = agent.prompt_dataframe(
    df=notes_df,
    text_col="note_text",
    prompt="Extract key diagnoses.",
    output_col="diagnoses",
    output_csv="notes_with_diagnoses.csv",
)
print(out.head())
```

This generates a Methods-style description of a Python module.
```python
from fyron import LLMAgent

agent = LLMAgent(provider="openai")
md = agent.describe_python_file(
    file_path="src/project/pipeline.py",
    output_md="docs/methods_pipeline.md",
)
print(md[:400])
```

### Data IO
Shortcut helpers for CSV and Excel: `read_csv`, `write_csv`, `read_excel`, `write_excel`. For `.xlsx` files use the Excel extra: `pip install fyron[excel]`.
```python
from fyron import read_csv, write_excel

df = read_csv("patients.csv")
write_excel(df, "patients.xlsx")
```

### Teable
Read and write [Teable](https://teable.io) tables as DataFrames: list spaces/bases/tables, get-or-create base/table, overwrite table.
```python
from fyron import TeableClient

teable = TeableClient(base_url="https://app.teable.ai", token="YOUR_TOKEN")

df = teable.read_table("tblXXXX")
record_ids = teable.write_dataframe("tblXXXX", df)
```

`TeableClient` arguments:

| Argument | Description | Values/Defaults |
| --- | --- | --- |
| `base_url` | Teable API base | Defaults to `TEABLE_BASE_URL` |
| `token` | Teable API token | Required |
| `timeout` | Request timeout (seconds) | Default `30` |

This lists spaces, bases, and tables for discovery.
```python
from fyron import TeableClient

teable = TeableClient(base_url="https://app.teable.ai", token="YOUR_TOKEN")

spaces = teable.list_spaces()
space_id = spaces[0]["id"] if spaces else None
bases = teable.list_bases(space_id=space_id) if space_id else []
tables = teable.list_tables(bases[0]["id"]) if bases else []
```

This ensures a base and table exist (creating them if needed).
```python
from fyron import TeableClient

teable = TeableClient(base_url="https://app.teable.ai", token="YOUR_TOKEN")

base = teable.get_or_create_base(name="Clinical", space_name="My Workspace")
base_id = base["id"]

table = teable.get_or_create_table(
    base_id=base_id,
    name="Observations",
)
```

This fully replaces a table by deleting then inserting all rows.
```python
from fyron import TeableClient

teable = TeableClient(base_url="https://app.teable.ai", token="YOUR_TOKEN")
record_ids = teable.overwrite_table("tblXXXX", df)
```

## Caching
FHIR REST GET responses can be cached in memory to avoid repeated requests. Control TTL and size (or disable).
```python
from fyron import FHIRRestClient

client = FHIRRestClient(
    cache_ttl_seconds=300,   # default 5 minutes
    cache_max_entries=1000,  # default 1000 responses
)

# Disable caching
client = FHIRRestClient(cache_ttl_seconds=0, cache_max_entries=0)
```

## Logging
Set `logging` to INFO and use `FHIRRestClient(log_requests=True)` (or other clients’ logging options) to log request timing and summaries.
```python
import logging
from fyron import FHIRRestClient

logging.basicConfig(level=logging.INFO)
client = FHIRRestClient(log_requests=True)
```

## Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Install dependencies with Poetry and run tests:
```bash
poetry install
poetry run pytest
```
Optional integration tests (require a reachable FHIR server) run when `FYRON_INTEGRATION=1` is set; see [tests/test_fhir_rest_integration.py](tests/test_fhir_rest_integration.py).

## Troubleshooting
| Issue | What to do |
|-------|------------|
| `No module named 'fyron'` | Install the package: `pip install fyron` or, for development, `poetry install` and run with `poetry run pytest` / `poetry run python`. |
| `FHIRSQLClient` is `None` | The SQL client is optional. Install `psycopg` (included in default deps) or use REST-only. |
| Excel: missing engine / openpyxl | Install the Excel extra: `pip install fyron[excel]`. |
| No .env found / credentials not picked up | Call `load_env()` before creating clients, or set variables in the shell. Use `load_env(path=".env")` if the file is not in the current directory. |
| FHIRPath errors or missing columns | Install the optional dependency: `pip install fhirpathpy`. |

For bugs and feature requests, open an issue on the [repository](https://github.com/bitsandflames/fyron).

## License
MIT
