fyron 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fyron/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """Fyron: interoperable healthcare data and AI workflows."""
2
+
3
+ from .fhir.auth import Auth
4
+
5
+ from .fhir.rest import FHIRRestClient
6
+ try:
7
+ from .fhir.sql import FHIRSQLClient
8
+ except Exception: # pragma: no cover
9
+ FHIRSQLClient = None # type: ignore
10
+ from .dicom.client import DICOMDownloader
11
+ from .llm.agent import LLMAgent
12
+ from .documents.client import DocumentDownloader
13
+ from .fhir.types import FHIRObj
14
+ from .fhir.utils import (
15
+ extract_condition,
16
+ extract_diagnostic_report,
17
+ extract_encounter,
18
+ extract_imaging_study,
19
+ extract_observation,
20
+ extract_patient,
21
+ extract_procedure,
22
+ process_condition_bundle,
23
+ process_diagnostic_report_bundle,
24
+ process_encounter_bundle,
25
+ process_imaging_study_bundle,
26
+ process_observation_bundle,
27
+ process_patient_bundle,
28
+ process_procedure_bundle,
29
+ safe_get,
30
+ )
31
+ from .core.io import DataIO, TeableClient, read_csv, read_excel, write_csv, write_excel
32
+ from .core.env import load_env
33
+
34
+ __all__ = [
35
+ "Auth",
36
+ "FHIRRestClient",
37
+ "FHIRSQLClient",
38
+ "DICOMDownloader",
39
+ "LLMAgent",
40
+ "DocumentDownloader",
41
+ "FHIRObj",
42
+ "extract_condition",
43
+ "extract_diagnostic_report",
44
+ "extract_encounter",
45
+ "extract_imaging_study",
46
+ "extract_observation",
47
+ "extract_patient",
48
+ "extract_procedure",
49
+ "process_condition_bundle",
50
+ "process_diagnostic_report_bundle",
51
+ "process_encounter_bundle",
52
+ "process_imaging_study_bundle",
53
+ "process_observation_bundle",
54
+ "process_patient_bundle",
55
+ "process_procedure_bundle",
56
+ "safe_get",
57
+ "DataIO",
58
+ "TeableClient",
59
+ "load_env",
60
+ "read_csv",
61
+ "read_excel",
62
+ "write_csv",
63
+ "write_excel",
64
+ ]
65
+ __version__ = "0.1.0"
fyron/core/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Core/shared utilities for Fyron."""
2
+
3
+ from .env import load_env
4
+ from .io import DataIO, TeableClient, read_csv, read_excel, write_csv, write_excel
5
+
6
+ __all__ = [
7
+ "DataIO",
8
+ "TeableClient",
9
+ "load_env",
10
+ "read_csv",
11
+ "read_excel",
12
+ "write_csv",
13
+ "write_excel",
14
+ ]
fyron/core/env.py ADDED
@@ -0,0 +1,37 @@
1
+ """Environment loading helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Optional
7
+
8
+ from dotenv import find_dotenv, load_dotenv
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def load_env(
14
+ path: Optional[str] = None,
15
+ *,
16
+ override: bool = False,
17
+ warn_if_missing: bool = True,
18
+ ) -> Optional[str]:
19
+ """Load environment variables from a .env file.
20
+
21
+ Parameters
22
+ ----------
23
+ path:
24
+ Optional path to a .env file. If None, searches from the current working directory.
25
+ override:
26
+ Whether to override existing environment variables.
27
+ warn_if_missing:
28
+ If True, logs a warning when no .env file is found.
29
+ """
30
+ env_path = path or find_dotenv(usecwd=True)
31
+ if env_path:
32
+ load_dotenv(env_path, override=override)
33
+ logger.info("Loaded environment from %s", env_path)
34
+ return env_path
35
+ if warn_if_missing:
36
+ logger.warning("No .env file found. Set environment variables or create a .env file.")
37
+ return None
fyron/core/io.py ADDED
@@ -0,0 +1,394 @@
1
+ """IO helpers for CSV/Excel and Teable tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, Iterable, List, Optional
9
+
10
+ import pandas as pd
11
+ import requests
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class DataIO:
17
+ """Convenience wrappers for CSV/Excel IO."""
18
+
19
+ @staticmethod
20
+ def read_csv(path: str, **kwargs: Any) -> pd.DataFrame:
21
+ return pd.read_csv(path, **kwargs)
22
+
23
+ @staticmethod
24
+ def write_csv(df: pd.DataFrame, path: str, **kwargs: Any) -> None:
25
+ df.to_csv(path, index=False, **kwargs)
26
+
27
+ @staticmethod
28
+ def read_excel(path: str, **kwargs: Any) -> pd.DataFrame:
29
+ return pd.read_excel(path, **kwargs)
30
+
31
+ @staticmethod
32
+ def write_excel(df: pd.DataFrame, path: str, **kwargs: Any) -> None:
33
+ df.to_excel(path, index=False, **kwargs)
34
+
35
+
36
+ @dataclass
37
+ class TeableConfig:
38
+ base_url: str
39
+ token: str
40
+
41
+
42
+ class TeableClient:
43
+ """Minimal Teable table client for reading/writing DataFrames.
44
+
45
+ Environment variables:
46
+ - TEABLE_BASE_URL
47
+ - TEABLE_TOKEN
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ base_url: Optional[str] = None,
53
+ token: Optional[str] = None,
54
+ timeout: int = 30,
55
+ ) -> None:
56
+ self.config = TeableConfig(
57
+ base_url=base_url or os.environ.get("TEABLE_BASE_URL", "https://app.teable.ai"),
58
+ token=token or os.environ.get("TEABLE_TOKEN", ""),
59
+ )
60
+ if not self.config.token:
61
+ raise ValueError("Teable token is required (set TEABLE_TOKEN or pass token).")
62
+ self.timeout = timeout
63
+ self.session = requests.Session()
64
+ self.session.headers.update(
65
+ {
66
+ "Authorization": f"Bearer {self.config.token}",
67
+ "Content-Type": "application/json",
68
+ }
69
+ )
70
+
71
+ def list_records(
72
+ self,
73
+ table_id: str,
74
+ *,
75
+ view_id: Optional[str] = None,
76
+ take: int = 1000,
77
+ skip: int = 0,
78
+ field_key_type: str = "name",
79
+ cell_format: str = "json",
80
+ projection: Optional[List[str]] = None,
81
+ **params: Any,
82
+ ) -> Dict[str, Any]:
83
+ url = self._url(f"/api/table/{table_id}/record")
84
+ query: Dict[str, Any] = {
85
+ "take": take,
86
+ "skip": skip,
87
+ "fieldKeyType": field_key_type,
88
+ "cellFormat": cell_format,
89
+ }
90
+ if view_id:
91
+ query["viewId"] = view_id
92
+ if projection:
93
+ query["projection"] = projection
94
+ query.update(params)
95
+ resp = self.session.get(url, params=query, timeout=self.timeout)
96
+ resp.raise_for_status()
97
+ return resp.json()
98
+
99
+ def list_spaces(self) -> List[Dict[str, Any]]:
100
+ """List spaces available to the current token."""
101
+ url = self._url("/api/space")
102
+ resp = self.session.get(url, timeout=self.timeout)
103
+ resp.raise_for_status()
104
+ return resp.json()
105
+
106
+ def list_bases(self, *, space_id: Optional[str] = None) -> List[Dict[str, Any]]:
107
+ """List bases for a space or all accessible bases."""
108
+ if space_id:
109
+ url = self._url(f"/api/space/{space_id}/base")
110
+ else:
111
+ url = self._url("/api/base/access/all")
112
+ resp = self.session.get(url, timeout=self.timeout)
113
+ resp.raise_for_status()
114
+ return resp.json()
115
+
116
+ def create_base(
117
+ self,
118
+ *,
119
+ space_id: str,
120
+ name: str,
121
+ icon: Optional[str] = None,
122
+ ) -> Dict[str, Any]:
123
+ """Create a base in a space."""
124
+ url = self._url("/api/base")
125
+ body: Dict[str, Any] = {"spaceId": space_id, "name": name}
126
+ if icon:
127
+ body["icon"] = icon
128
+ resp = self.session.post(url, json=body, timeout=self.timeout)
129
+ resp.raise_for_status()
130
+ return resp.json()
131
+
132
+ def get_or_create_base(
133
+ self,
134
+ *,
135
+ name: str,
136
+ space_id: Optional[str] = None,
137
+ space_name: Optional[str] = None,
138
+ icon: Optional[str] = None,
139
+ ) -> Dict[str, Any]:
140
+ """Return a base by name, creating it if it doesn't exist."""
141
+ resolved_space_id = space_id
142
+ if not resolved_space_id and space_name:
143
+ spaces = self.list_spaces()
144
+ matches = [s for s in spaces if s.get("name") == space_name]
145
+ if not matches:
146
+ raise ValueError(f"Space '{space_name}' not found.")
147
+ resolved_space_id = matches[0].get("id")
148
+ if not resolved_space_id:
149
+ spaces = self.list_spaces()
150
+ if len(spaces) == 1:
151
+ resolved_space_id = spaces[0].get("id")
152
+ else:
153
+ raise ValueError("space_id (or space_name) is required to create a base.")
154
+
155
+ bases = self.list_bases(space_id=resolved_space_id)
156
+ for base in bases:
157
+ if base.get("name") == name:
158
+ return base
159
+ return self.create_base(space_id=resolved_space_id, name=name, icon=icon)
160
+
161
+ def list_tables(self, base_id: str) -> List[Dict[str, Any]]:
162
+ """List tables in a base."""
163
+ url = self._url(f"/api/base/{base_id}/table")
164
+ resp = self.session.get(url, timeout=self.timeout)
165
+ resp.raise_for_status()
166
+ return resp.json()
167
+
168
+ def create_table(
169
+ self,
170
+ *,
171
+ base_id: str,
172
+ name: str,
173
+ db_table_name: Optional[str] = None,
174
+ description: Optional[str] = None,
175
+ icon: Optional[str] = None,
176
+ fields: Optional[List[Dict[str, Any]]] = None,
177
+ views: Optional[List[Dict[str, Any]]] = None,
178
+ records: Optional[List[Dict[str, Any]]] = None,
179
+ order: Optional[int] = None,
180
+ field_key_type: str = "name",
181
+ ) -> Dict[str, Any]:
182
+ """Create a table in a base."""
183
+ url = self._url(f"/api/base/{base_id}/table")
184
+ body: Dict[str, Any] = {"name": name, "fieldKeyType": field_key_type}
185
+ if db_table_name:
186
+ body["dbTableName"] = db_table_name
187
+ if description:
188
+ body["description"] = description
189
+ if icon:
190
+ body["icon"] = icon
191
+ if fields is not None:
192
+ body["fields"] = fields
193
+ if views is not None:
194
+ body["views"] = views
195
+ if records is not None:
196
+ body["records"] = records
197
+ if order is not None:
198
+ body["order"] = order
199
+ resp = self.session.post(url, json=body, timeout=self.timeout)
200
+ resp.raise_for_status()
201
+ return resp.json()
202
+
203
+ def get_or_create_table(
204
+ self,
205
+ *,
206
+ base_id: str,
207
+ name: str,
208
+ db_table_name: Optional[str] = None,
209
+ description: Optional[str] = None,
210
+ icon: Optional[str] = None,
211
+ fields: Optional[List[Dict[str, Any]]] = None,
212
+ views: Optional[List[Dict[str, Any]]] = None,
213
+ records: Optional[List[Dict[str, Any]]] = None,
214
+ order: Optional[int] = None,
215
+ field_key_type: str = "name",
216
+ ) -> Dict[str, Any]:
217
+ """Return a table by name (or db_table_name), creating it if missing."""
218
+ tables = self.list_tables(base_id)
219
+ for table in tables:
220
+ if table.get("name") == name:
221
+ return table
222
+ if db_table_name and table.get("dbTableName") == db_table_name:
223
+ return table
224
+ return self.create_table(
225
+ base_id=base_id,
226
+ name=name,
227
+ db_table_name=db_table_name,
228
+ description=description,
229
+ icon=icon,
230
+ fields=fields,
231
+ views=views,
232
+ records=records,
233
+ order=order,
234
+ field_key_type=field_key_type,
235
+ )
236
+
237
+ def read_table(
238
+ self,
239
+ table_id: str,
240
+ *,
241
+ view_id: Optional[str] = None,
242
+ field_key_type: str = "name",
243
+ cell_format: str = "json",
244
+ projection: Optional[List[str]] = None,
245
+ take: int = 1000,
246
+ max_records: Optional[int] = None,
247
+ ) -> pd.DataFrame:
248
+ records: List[Dict[str, Any]] = []
249
+ skip = 0
250
+ while True:
251
+ payload = self.list_records(
252
+ table_id,
253
+ view_id=view_id,
254
+ take=take,
255
+ skip=skip,
256
+ field_key_type=field_key_type,
257
+ cell_format=cell_format,
258
+ projection=projection,
259
+ )
260
+ batch = payload.get("records", [])
261
+ records.extend(batch)
262
+ if max_records is not None and len(records) >= max_records:
263
+ records = records[:max_records]
264
+ break
265
+ if len(batch) < take:
266
+ break
267
+ skip += take
268
+ rows = [record.get("fields", {}) for record in records]
269
+ df = pd.DataFrame(rows)
270
+ if "id" not in df.columns and records:
271
+ df.insert(0, "record_id", [r.get("id") for r in records])
272
+ return df
273
+
274
+ def create_records(
275
+ self,
276
+ table_id: str,
277
+ records: Iterable[Dict[str, Any]],
278
+ *,
279
+ field_key_type: str = "name",
280
+ typecast: bool = True,
281
+ order: Optional[Dict[str, Any]] = None,
282
+ ) -> Dict[str, Any]:
283
+ url = self._url(f"/api/table/{table_id}/record")
284
+ body: Dict[str, Any] = {
285
+ "records": [{"fields": r} for r in records],
286
+ "fieldKeyType": field_key_type,
287
+ "typecast": typecast,
288
+ }
289
+ if order:
290
+ body["order"] = order
291
+ resp = self.session.post(url, json=body, timeout=self.timeout)
292
+ resp.raise_for_status()
293
+ return resp.json()
294
+
295
+ def delete_records(
296
+ self,
297
+ table_id: str,
298
+ record_ids: List[str],
299
+ ) -> None:
300
+ """Delete records by ID."""
301
+ if not record_ids:
302
+ return
303
+ url = self._url(f"/api/table/{table_id}/record")
304
+ resp = self.session.delete(url, params={"recordIds": record_ids}, timeout=self.timeout)
305
+ resp.raise_for_status()
306
+
307
+ def clear_table(
308
+ self,
309
+ table_id: str,
310
+ *,
311
+ batch_size: int = 200,
312
+ take: int = 1000,
313
+ ) -> int:
314
+ """Delete all records in a table and return deleted count."""
315
+ deleted = 0
316
+ skip = 0
317
+ while True:
318
+ payload = self.list_records(table_id, take=take, skip=skip)
319
+ records = payload.get("records", [])
320
+ record_ids = [r.get("id") for r in records if r.get("id")]
321
+ if not record_ids:
322
+ break
323
+ for idx in range(0, len(record_ids), batch_size):
324
+ self.delete_records(table_id, record_ids[idx : idx + batch_size])
325
+ deleted += len(record_ids[idx : idx + batch_size])
326
+ if len(records) < take:
327
+ break
328
+ skip += take
329
+ return deleted
330
+
331
+ def write_dataframe(
332
+ self,
333
+ table_id: str,
334
+ df: pd.DataFrame,
335
+ *,
336
+ field_key_type: str = "name",
337
+ typecast: bool = True,
338
+ chunk_size: int = 200,
339
+ ) -> List[str]:
340
+ record_ids: List[str] = []
341
+ rows = df.to_dict(orient="records")
342
+ for idx in range(0, len(rows), chunk_size):
343
+ batch = rows[idx : idx + chunk_size]
344
+ payload = self.create_records(
345
+ table_id,
346
+ batch,
347
+ field_key_type=field_key_type,
348
+ typecast=typecast,
349
+ )
350
+ record_ids.extend([r.get("id") for r in payload.get("records", [])])
351
+ return record_ids
352
+
353
+ def overwrite_table(
354
+ self,
355
+ table_id: str,
356
+ df: pd.DataFrame,
357
+ *,
358
+ delete_batch_size: int = 200,
359
+ create_chunk_size: int = 200,
360
+ field_key_type: str = "name",
361
+ typecast: bool = True,
362
+ ) -> List[str]:
363
+ """Delete all records in a table, then write the DataFrame."""
364
+ self.clear_table(table_id, batch_size=delete_batch_size)
365
+ return self.write_dataframe(
366
+ table_id,
367
+ df,
368
+ field_key_type=field_key_type,
369
+ typecast=typecast,
370
+ chunk_size=create_chunk_size,
371
+ )
372
+
373
+ def _url(self, path: str) -> str:
374
+ return self.config.base_url.rstrip("/") + path
375
+
376
+
377
+ def read_csv(path: str, **kwargs: Any) -> pd.DataFrame:
378
+ """Read a CSV file (shortcut for DataIO.read_csv)."""
379
+ return DataIO.read_csv(path, **kwargs)
380
+
381
+
382
+ def write_csv(df: pd.DataFrame, path: str, **kwargs: Any) -> None:
383
+ """Write a DataFrame to CSV (shortcut for DataIO.write_csv)."""
384
+ DataIO.write_csv(df, path, **kwargs)
385
+
386
+
387
+ def read_excel(path: str, **kwargs: Any) -> pd.DataFrame:
388
+ """Read an Excel file (shortcut for DataIO.read_excel)."""
389
+ return DataIO.read_excel(path, **kwargs)
390
+
391
+
392
+ def write_excel(df: pd.DataFrame, path: str, **kwargs: Any) -> None:
393
+ """Write a DataFrame to Excel (shortcut for DataIO.write_excel)."""
394
+ DataIO.write_excel(df, path, **kwargs)
@@ -0,0 +1,5 @@
1
+ """DICOM functionality."""
2
+
3
+ from .client import DICOMDownloader
4
+
5
+ __all__ = ["DICOMDownloader"]