mphapi 1.9.1__tar.gz → 1.12.1__tar.gz

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.
@@ -1,12 +1,13 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: mphapi
3
- Version: 1.9.1
3
+ Version: 1.12.1
4
4
  Summary: A Python interface to the MyPriceHealth API
5
5
  Author: David Archibald
6
6
  Author-email: davidarchibald@myprice.health
7
7
  Requires-Python: >=3.12,<4.0
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
10
11
  Requires-Dist: pydantic (>=2.6.4,<3.0.0)
11
- Requires-Dist: pytest (>=8.4.1,<9.0.0)
12
+ Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
12
13
  Requires-Dist: requests (>=2.31.0,<3.0.0)
@@ -5,6 +5,7 @@ import requests
5
5
  from pydantic import BaseModel, StrictBool, TypeAdapter
6
6
 
7
7
  from .claim import Claim, RateSheet
8
+ from .credentials import Credentials, get_credentials
8
9
  from .fields import camel_case_model_config, field_name
9
10
  from .response import Response, Responses
10
11
 
@@ -64,43 +65,89 @@ class PriceConfig(BaseModel):
64
65
  """set to true to return partially repriced claims. This can be useful to get pricing on non-erroring line items, but should be used with caution"""
65
66
 
66
67
 
67
- # It may be a bit jarring to see this import not at the top of the file. This is
68
+ # It may be a bit jarring to see these imports not at the top of the file. This is
68
69
  # intentional as `.pricing` depends on `PriceConfig`.
70
+ from .pricing import ClaimStatus # noqa: E402
69
71
  from .pricing import Pricing # noqa: E402
70
72
 
71
73
 
72
74
  class Client:
73
- url: str
75
+ api_url: str
74
76
  headers: Header
75
77
 
76
- def __init__(self, apiKey: str, isTest: bool = False, url: str | None = None):
77
- if url is None:
78
+ def __init__(
79
+ self,
80
+ apiKey: str,
81
+ isTest: bool = False,
82
+ api_url: str | None = None,
83
+ app_url: str | None = None,
84
+ app_api_key: str | None = None,
85
+ app_referer: str | None = None,
86
+ app_credentials: Credentials | None = None,
87
+ ):
88
+ if api_url is None:
89
+ if isTest:
90
+ self.api_url = "https://api-test.myprice.health"
91
+ else:
92
+ self.api_url = "https://api.myprice.health"
93
+ else:
94
+ self.api_url = api_url
95
+
96
+ if app_url is None:
78
97
  if isTest:
79
- self.url = "https://api-test.myprice.health"
98
+ self.app_url = "https://app-test.myprice.health"
80
99
  else:
81
- self.url = "https://api.myprice.health"
100
+ self.app_url = "https://app.myprice.health"
82
101
  else:
83
- self.url = url
102
+ self.app_url = app_url
103
+
104
+ if (app_url is None) != (app_api_key is None) or (app_api_key is None) != (
105
+ app_referer is None
106
+ ):
107
+ raise Exception(
108
+ "app_url, app_api_key, and app_referer must be set in tandem"
109
+ )
110
+
111
+ self.app_api_key = app_api_key
112
+ self.app_credentials = app_credentials
113
+ self.app_referer = app_referer
114
+
115
+ if (
116
+ self.app_credentials is None
117
+ and app_api_key is not None
118
+ and app_referer is not None
119
+ ):
120
+ self.app_credentials = get_credentials(app_api_key, app_referer)
84
121
 
85
122
  self.headers = {"x-api-key": apiKey}
86
123
 
124
+ def _get_id_token(self) -> str:
125
+ if self.app_credentials is None:
126
+ raise Exception("App credentials must be set to run this!")
127
+
128
+ refreshed = self.app_credentials.refresh_if_needed()
129
+ if refreshed is not None:
130
+ self.app_credentials = refreshed
131
+
132
+ return self.app_credentials.id_token
133
+
87
134
  def _do_request(
88
135
  self,
89
- path: str,
136
+ url: str,
90
137
  json: Any | None,
91
138
  method: str = "POST",
92
139
  headers: Header = {},
93
140
  ) -> requests.Response:
94
141
  return requests.request(
95
142
  method,
96
- urllib.parse.urljoin(self.url, path),
143
+ url,
97
144
  json=json,
98
145
  headers={**self.headers, **headers},
99
146
  )
100
147
 
101
148
  def _receive_response[Model: BaseModel](
102
149
  self,
103
- path: str,
150
+ url: str,
104
151
  body: BaseModel,
105
152
  response_model: type[Model],
106
153
  method: str = "POST",
@@ -115,7 +162,7 @@ class Client:
115
162
  """
116
163
 
117
164
  response = self._do_request(
118
- path,
165
+ url,
119
166
  body.model_dump(mode="json", by_alias=True, exclude_none=True),
120
167
  method,
121
168
  headers,
@@ -127,9 +174,44 @@ class Client:
127
174
  .result()
128
175
  )
129
176
 
177
+ def _receive_api_response[Model: BaseModel](
178
+ self,
179
+ url: str,
180
+ body: BaseModel,
181
+ response_model: type[Model],
182
+ method: str = "POST",
183
+ headers: Header = {},
184
+ ) -> Model:
185
+ return self._receive_response(
186
+ urllib.parse.urljoin(self.api_url, url),
187
+ body,
188
+ response_model,
189
+ method,
190
+ headers,
191
+ )
192
+
193
+ def _receive_app_response[Model: BaseModel](
194
+ self,
195
+ url: str,
196
+ body: BaseModel,
197
+ response_model: type[Model],
198
+ method: str = "POST",
199
+ headers: Header = {},
200
+ ) -> Model:
201
+ id_token = self._get_id_token()
202
+
203
+ # TODO: Handle refreshing revoked credentials. It's unclear what the error will be unfortunately.
204
+ return self._receive_response(
205
+ urllib.parse.urljoin(self.app_url, url),
206
+ body,
207
+ response_model,
208
+ method,
209
+ {"Authorization": f"Bearer {id_token}", **headers},
210
+ )
211
+
130
212
  def _receive_responses[Model: BaseModel](
131
213
  self,
132
- path: str,
214
+ url: str,
133
215
  body: Sequence[BaseModel],
134
216
  response_model: type[Model],
135
217
  method: str = "POST",
@@ -144,7 +226,7 @@ class Client:
144
226
  """
145
227
 
146
228
  response = self._do_request(
147
- path,
229
+ url,
148
230
  TypeAdapter(type(body)).dump_python(
149
231
  body, mode="json", by_alias=True, exclude_none=True
150
232
  ),
@@ -158,6 +240,41 @@ class Client:
158
240
  .results()
159
241
  )
160
242
 
243
+ def _receive_api_responses[Model: BaseModel](
244
+ self,
245
+ url: str,
246
+ body: Sequence[BaseModel],
247
+ response_model: type[Model],
248
+ method: str = "POST",
249
+ headers: Header = {},
250
+ ) -> list[Model]:
251
+ return self._receive_responses(
252
+ urllib.parse.urljoin(self.api_url, url),
253
+ body,
254
+ response_model,
255
+ method,
256
+ headers,
257
+ )
258
+
259
+ def _receive_app_responses[Model: BaseModel](
260
+ self,
261
+ url: str,
262
+ body: Sequence[BaseModel],
263
+ response_model: type[Model],
264
+ method: str = "POST",
265
+ headers: Header = {},
266
+ ) -> list[Model]:
267
+ id_token = self._get_id_token()
268
+
269
+ # TODO: Handle refreshing revoked credentials. It's unclear what the error will be unfortunately.
270
+ return self._receive_responses(
271
+ urllib.parse.urljoin(self.app_url, url),
272
+ body,
273
+ response_model,
274
+ method,
275
+ {"Authorization": f"Bearer {id_token}", **headers},
276
+ )
277
+
161
278
  def estimate_rate_sheet(self, *inputs: RateSheet) -> list[Pricing]:
162
279
  """
163
280
  Raises:
@@ -167,7 +284,7 @@ class Client:
167
284
  The error returned when the api returns an error.
168
285
  """
169
286
 
170
- return self._receive_responses(
287
+ return self._receive_api_responses(
171
288
  "/v1/medicare/estimate/rate-sheet",
172
289
  inputs,
173
290
  Pricing,
@@ -182,7 +299,7 @@ class Client:
182
299
  The error returned when the api returns an error.
183
300
  """
184
301
 
185
- return self._receive_responses(
302
+ return self._receive_api_responses(
186
303
  "/v1/medicare/estimate/claims",
187
304
  inputs,
188
305
  Pricing,
@@ -199,7 +316,7 @@ class Client:
199
316
  """
200
317
 
201
318
  return self._receive_response(
202
- "/v1/medicare/price/claim",
319
+ urllib.parse.urljoin(self.api_url, "/v1/medicare/price/claim"),
203
320
  input,
204
321
  Pricing,
205
322
  headers=self._get_price_headers(config),
@@ -214,7 +331,7 @@ class Client:
214
331
  The error returned when the api returns an error.
215
332
  """
216
333
 
217
- return self._receive_responses(
334
+ return self._receive_api_responses(
218
335
  "/v1/medicare/price/claims",
219
336
  input,
220
337
  Pricing,
@@ -257,3 +374,10 @@ class Client:
257
374
  headers["disable-machine-learning-estimates"] = "true"
258
375
 
259
376
  return headers
377
+
378
+ def insert_claim_status(self, claim_id: str, claim_status: ClaimStatus) -> None:
379
+ self._receive_app_response(
380
+ f"/v1/claim/{claim_id}/status",
381
+ claim_status,
382
+ BaseModel,
383
+ )
@@ -0,0 +1,114 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ # This import annoys Pylance for some reason.
9
+ from pytest_snapshot.plugin import Snapshot # type: ignore
10
+
11
+ from .client import Claim, Client, PriceConfig
12
+ from .credentials import Credentials, sign_in
13
+ from .env import load_env
14
+ from .pricing import ClaimStatus, PricedService, Pricing, status_new
15
+ from .response import ResponseError
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def run_around_tests():
20
+ load_env()
21
+
22
+
23
+ def test_client(snapshot: Snapshot):
24
+ api_key = os.getenv("MPH_API_KEY")
25
+ if api_key is None:
26
+ raise EnvironmentError("MPH_API_KEY must be set")
27
+
28
+ api_url = os.getenv("API_URL")
29
+ app_url = os.getenv("APP_URL")
30
+ app_api_key = os.getenv("FIREBASE_API_KEY")
31
+ if app_api_key is None:
32
+ raise Exception("FIREBASE_API_KEY must be set")
33
+
34
+ app_referer = os.getenv("FIREBASE_REFERER")
35
+ if app_referer is None:
36
+ raise Exception("FIREBASE_REFERER must be set")
37
+
38
+ app_credentials = Credentials(
39
+ api_key=app_api_key,
40
+ referer=app_referer,
41
+ credentials_path=Path("fake-credentials-path"),
42
+ email="test-user@mypricehealth.com",
43
+ # An id token with some minimal user info.
44
+ # Will not work if tested against a service that expects a real id token.
45
+ id_token="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImFhYSI6dHJ1ZX0.eyJpc3MiOiJ0ZXN0IGlzc3VlciIsImVtYWlsIjoidGVzdC11c2VyQG15cHJpY2VoZWFsdGguY29tIiwic3ViIjoidGVzdCBzdWJqZWN0IiwiYXVkIjoidGVzdCBhdWRpZW5jZSIsImV4cCI6MCwiaWF0IjowLCJsb2dpbl9pZCI6IjEyMzQ1Njc4OTAiLCJyb2xlcyI6WyJBZG1pbiJdfQ.iCx0YI9L9qo7KCkIq1w58C0mBdiCDhikPZsM3Mx78j11Ln3WxWpAnm0IomIRfrDVU1JczW8OjSj5os7igp9fYufZwObLV5N5eDd9LmYtXs2EcO8EFHcM7HeHx6lnkT1GxX-4J_946WbIdHYnpLyoXLZF_MZNfEwsN1UBG6YdaRcwfhF9vJHt6YM7-IoOcHvdLiEWv06Y9eC0v--_R_x8GwjSrE0Z9EyZw3hMz94QZ5VgUf8e89NexeVGIoD4pUOHuYmNIx1ca_UTIOg81exhhnh4g190jUSer5582YJc5Hx7gts3DyizRmAK8Glcwhnc7WKvZDQaSjtbHzIARnUKtw",
46
+ refresh_token="fake-refresh-token",
47
+ expires_at=sys.float_info.max,
48
+ )
49
+
50
+ client = Client(
51
+ api_key,
52
+ api_url=api_url,
53
+ app_url=app_url,
54
+ app_api_key=app_api_key,
55
+ app_referer=app_referer,
56
+ app_credentials=app_credentials,
57
+ )
58
+
59
+ config = PriceConfig(
60
+ is_commercial=True,
61
+ disable_cost_based_reimbursement=False,
62
+ use_commercial_synthetic_for_not_allowed=True,
63
+ use_drg_from_grouper=False,
64
+ use_best_drg_price=True,
65
+ override_threshold=300,
66
+ include_edits=True,
67
+ )
68
+
69
+ tests = ["hcfa", "inpatient", "outpatient"]
70
+
71
+ for test in tests:
72
+ with open(f"testdata/{test}.json", "r") as f:
73
+ data = json.load(f)
74
+
75
+ claim = Claim.model_validate(data)
76
+ pricing = client.price(config, claim)
77
+
78
+ snapshot.assert_match(pricing.model_dump_json(indent=4), f"{test}.json")
79
+
80
+ try:
81
+ client.insert_claim_status(
82
+ "123",
83
+ ClaimStatus(
84
+ step=status_new.step,
85
+ status=status_new.status,
86
+ pricing=Pricing(services=[PricedService(line_number="6789")]),
87
+ ),
88
+ )
89
+ except ResponseError as response_error:
90
+ # The claim and line item won't exist in the database
91
+ assert (
92
+ response_error.detail
93
+ == "expected to insert 1 line item repricing rows but inserted 0"
94
+ )
95
+
96
+ pass
97
+
98
+
99
+ def test_signin():
100
+ app_api_key = os.getenv("FIREBASE_API_KEY")
101
+ if app_api_key is None:
102
+ raise Exception("FIREBASE_API_KEY must be set")
103
+
104
+ test_user = os.getenv("FIREBASE_TEST_USER")
105
+ if test_user is None:
106
+ pytest.skip("Skipping because FIREBASE_TEST_USER is not set")
107
+
108
+ test_password = os.getenv("FIREBASE_TEST_PASSWORD")
109
+ if test_password is None:
110
+ pytest.skip("Skipping because FIREBASE_TEST_PASSWORD is not set")
111
+
112
+ credentials = sign_in(app_api_key, test_user, test_password)
113
+
114
+ assert credentials.email == test_user
@@ -0,0 +1,256 @@
1
+ import getpass
2
+ import json
3
+ import os
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import requests
8
+ from pydantic import BaseModel, Field, RootModel
9
+
10
+ from .fields import camel_case_model_config
11
+
12
+
13
+ class RawCredentials(BaseModel):
14
+ model_config = camel_case_model_config
15
+
16
+ email: str
17
+ id_token: str
18
+ refresh_token: str
19
+ expires_at: float
20
+
21
+ def store(
22
+ self, api_key: str, referer: str, credentials_path: Path
23
+ ) -> "Credentials":
24
+ stored_credentials = StoredCredentials(
25
+ api_key=api_key,
26
+ referer=referer,
27
+ email=self.email,
28
+ id_token=self.id_token,
29
+ refresh_token=self.refresh_token,
30
+ expires_at=self.expires_at,
31
+ )
32
+
33
+ os.makedirs(os.path.dirname(credentials_path), exist_ok=True)
34
+
35
+ with credentials_path.open("w+") as f:
36
+ json.dump(
37
+ stored_credentials.model_dump(
38
+ mode="json", by_alias=True, exclude_none=True
39
+ ),
40
+ f,
41
+ )
42
+
43
+ return Credentials(
44
+ referer=referer,
45
+ credentials_path=credentials_path,
46
+ api_key=api_key,
47
+ email=stored_credentials.email,
48
+ id_token=stored_credentials.id_token,
49
+ refresh_token=stored_credentials.refresh_token,
50
+ expires_at=stored_credentials.expires_at,
51
+ )
52
+
53
+
54
+ class StoredCredentials(RawCredentials):
55
+ api_key: str
56
+ referer: str
57
+
58
+
59
+ class Credentials(StoredCredentials):
60
+ credentials_path: Path = Field(..., exclude=True)
61
+
62
+ def refresh_if_needed(self) -> "Credentials | None":
63
+ # # There's more than 5 minutes until the credentials need refreshing. Don't bother.
64
+ if self.expires_at > time.time() + 5 * 60:
65
+ return None
66
+
67
+ new_credentials = refresh_token(self.api_key, self.referer, self.refresh_token)
68
+ return new_credentials.store(self.api_key, self.referer, self.credentials_path)
69
+
70
+
71
+ class GoogleException(Exception):
72
+ def __init__(self, message: str):
73
+ self.message = message
74
+
75
+
76
+ class GoogleError(BaseModel):
77
+ code: int
78
+ message: str
79
+ # errors omitted as unused
80
+
81
+
82
+ class GoogleResponseError(BaseModel):
83
+ model_config = camel_case_model_config
84
+
85
+ error: GoogleError
86
+
87
+ def to_exception(self) -> GoogleException:
88
+ return GoogleException(self.error.message)
89
+
90
+
91
+ class GoogleResponse[Result: BaseModel](RootModel[Result | GoogleResponseError]):
92
+ pass
93
+
94
+
95
+ class SignInResult(BaseModel):
96
+ """
97
+ The result of signing in.
98
+ See https://docs.cloud.google.com/identity-platform/docs/reference/rest/v1/accounts/signInWithPassword
99
+ """
100
+
101
+ model_config = camel_case_model_config
102
+
103
+ email: str
104
+ """The email of the authenticated user. Always present in the response."""
105
+
106
+ id_token: str
107
+ """An Identity Platform ID token for the authenticated user."""
108
+
109
+ refresh_token: str
110
+ """An Identity Platform refresh token for the authenticated user."""
111
+
112
+ expires_in: int
113
+ """The number of seconds until the Identity Platform ID token expires."""
114
+
115
+
116
+ class RefreshTokenResult(BaseModel):
117
+ """
118
+ The result of refreshing a refresh_token.
119
+ See https://cloud.google.com/identity-platform/docs/use-rest-api#section-refresh-token
120
+ """
121
+
122
+ # NOT camelCase
123
+
124
+ expires_in: int
125
+ """The number of seconds in which the ID token expires."""
126
+
127
+ token_type: str
128
+ """The type of the refresh token, always "Bearer"."""
129
+
130
+ refresh_token: str
131
+ """The Identity Platform refresh token provided in the request or a new refresh token."""
132
+
133
+ id_token: str
134
+ """An Identity Platform ID token."""
135
+
136
+ user_id: str
137
+ """The uid corresponding to the provided ID token."""
138
+
139
+ project_id: str
140
+ """Your Google Cloud project ID."""
141
+
142
+
143
+ def get_credentials(api_key: str, referer: str) -> Credentials:
144
+ credentials_path = get_credentials_path()
145
+
146
+ credentials = get_stored_credentials(credentials_path)
147
+ if credentials is not None:
148
+ try:
149
+ credentials.refresh_if_needed()
150
+ except GoogleException as e:
151
+ # `INVALID_ID_TOKEN` means the user must login again.
152
+ if e.message != "INVALID_ID_TOKEN":
153
+ raise e
154
+
155
+ return credentials
156
+
157
+ email = input("Email: ")
158
+ login_credentials: RawCredentials | None = None
159
+
160
+ while True:
161
+ try:
162
+ password = getpass.getpass("Password: ")
163
+ login_credentials = sign_in(api_key, email, password)
164
+ break
165
+ except GoogleException as e:
166
+ if e.message == "INVALID_PASSWORD":
167
+ continue
168
+
169
+ raise e
170
+
171
+ return login_credentials.store(api_key, referer, credentials_path)
172
+
173
+
174
+ def get_credentials_path() -> Path:
175
+ home = Path.home()
176
+ return home.joinpath(".mph", "credentials.json")
177
+
178
+
179
+ def get_stored_credentials(credentials_path: Path) -> Credentials | None:
180
+ try:
181
+ with credentials_path.open() as f:
182
+ credentials_json = json.load(f)
183
+ stored_credentials = StoredCredentials.model_validate(credentials_json)
184
+ except FileNotFoundError:
185
+ return None
186
+
187
+ credentials = Credentials(
188
+ email=stored_credentials.email,
189
+ id_token=stored_credentials.id_token,
190
+ refresh_token=stored_credentials.refresh_token,
191
+ expires_at=stored_credentials.expires_at,
192
+ api_key=stored_credentials.api_key,
193
+ referer=stored_credentials.referer,
194
+ credentials_path=credentials_path,
195
+ )
196
+
197
+ return credentials
198
+
199
+
200
+ def refresh_token(api_key: str, referer: str, refresh_token: str) -> RawCredentials:
201
+ response = requests.post(
202
+ "https://securetoken.googleapis.com/v1/token",
203
+ data={"grant_type": "refresh_token", "refresh_token": refresh_token},
204
+ params={
205
+ "key": api_key,
206
+ },
207
+ headers={
208
+ "Content-Type": "application/x-www-form-urlencoded",
209
+ "Referer": referer,
210
+ },
211
+ )
212
+
213
+ result = (
214
+ GoogleResponse[RefreshTokenResult].model_validate_json(response.content).root
215
+ )
216
+
217
+ if isinstance(result, GoogleResponseError):
218
+ raise result.to_exception()
219
+
220
+ return RawCredentials(
221
+ email="",
222
+ id_token=result.id_token,
223
+ refresh_token=result.refresh_token,
224
+ expires_at=time.time() + result.expires_in,
225
+ )
226
+
227
+
228
+ def sign_in(api_key: str, email: str, password: str) -> RawCredentials:
229
+ response = requests.post(
230
+ "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword",
231
+ json={
232
+ "email": email,
233
+ "password": password,
234
+ "returnSecureToken": "true",
235
+ },
236
+ params={
237
+ "key": api_key,
238
+ },
239
+ headers={"Referer": "http://myprice.health"},
240
+ )
241
+
242
+ result = GoogleResponse[SignInResult].model_validate_json(response.content).root
243
+
244
+ if isinstance(result, GoogleResponseError):
245
+ raise result.to_exception()
246
+
247
+ sign_in_result = result
248
+
249
+ credentials = RawCredentials(
250
+ email=sign_in_result.email,
251
+ id_token=sign_in_result.id_token,
252
+ refresh_token=sign_in_result.refresh_token,
253
+ expires_at=time.time() + sign_in_result.expires_in,
254
+ )
255
+
256
+ return credentials
@@ -0,0 +1,125 @@
1
+ from abc import abstractmethod
2
+ from datetime import datetime as dt
3
+ from datetime import tzinfo
4
+ from typing import Any, ClassVar, Self, SupportsIndex
5
+
6
+ from pydantic import GetCoreSchemaHandler
7
+ from pydantic_core import CoreSchema, core_schema
8
+
9
+
10
+ class AbstractDateTime:
11
+ format: ClassVar[str]
12
+ secondary_formats: ClassVar[list[str]] = []
13
+ datetime: dt
14
+
15
+ @classmethod
16
+ @abstractmethod
17
+ def from_datetime(cls, datetime: dt) -> Self:
18
+ pass
19
+
20
+ def __str__(self):
21
+ return self.datetime.strftime(self.format)
22
+
23
+ # Based off of this: https://docs.pydantic.dev/2.1/usage/types/custom/#handling-third-party-types
24
+ @classmethod
25
+ def __get_pydantic_core_schema__(
26
+ cls, source_type: Any, handler: GetCoreSchemaHandler
27
+ ) -> CoreSchema:
28
+ def to_datetime_cls(value: str) -> Self:
29
+ datetime: dt | None = None
30
+
31
+ if len(cls.secondary_formats) == 0:
32
+ datetime = dt.strptime(value, cls.format)
33
+ else:
34
+ formats = [cls.format, *cls.secondary_formats]
35
+ for format in formats:
36
+ try:
37
+ datetime = dt.strptime(value, format)
38
+ except ValueError:
39
+ continue
40
+
41
+ if datetime is None:
42
+ raise ValueError(
43
+ f"Could not parse date {repr(value)} with format {repr(cls.format)} or {repr(cls.secondary_formats)}"
44
+ )
45
+
46
+ return cls.from_datetime(datetime)
47
+
48
+ from_str = core_schema.chain_schema(
49
+ [
50
+ core_schema.str_schema(),
51
+ core_schema.no_info_plain_validator_function(to_datetime_cls),
52
+ ]
53
+ )
54
+
55
+ return core_schema.json_or_python_schema(
56
+ json_schema=from_str,
57
+ python_schema=core_schema.union_schema(
58
+ [
59
+ core_schema.is_instance_schema(cls),
60
+ from_str,
61
+ ]
62
+ ),
63
+ serialization=core_schema.plain_serializer_function_ser_schema(
64
+ lambda date: str(date)
65
+ ),
66
+ )
67
+
68
+
69
+ class Date(AbstractDateTime):
70
+ """Date is a custom type for representing dates in the format YYYYMMDD"""
71
+
72
+ format = "%Y%m%d"
73
+
74
+ year: int
75
+ month: int
76
+ day: int
77
+
78
+ def __init__(self, year: int, month: int, day: int):
79
+ self.year = year
80
+ self.month = month
81
+ self.day = day
82
+ self.datetime = dt(year, month, day)
83
+
84
+ @classmethod
85
+ def from_datetime(cls, datetime: dt):
86
+ return cls(datetime.year, datetime.month, datetime.day)
87
+
88
+
89
+ class DateTime(AbstractDateTime):
90
+ """DateTime is a custom type for representing dates"""
91
+
92
+ # `%f` is padded to 6 characters but should be 9 overall.
93
+ format = "%Y-%m-%d %H:%M:%S.000%f%z"
94
+
95
+ # Same constructor as datetime has.
96
+ def __init__(
97
+ self,
98
+ year: SupportsIndex,
99
+ month: SupportsIndex,
100
+ day: SupportsIndex,
101
+ hour: SupportsIndex = 0,
102
+ minute: SupportsIndex = 0,
103
+ second: SupportsIndex = 0,
104
+ microsecond: SupportsIndex = 0,
105
+ tzinfo: tzinfo | None = None,
106
+ *,
107
+ fold: int = 0,
108
+ ):
109
+ self.datetime = dt(
110
+ year, month, day, hour, minute, second, microsecond, tzinfo, fold=fold
111
+ )
112
+
113
+ @classmethod
114
+ def from_datetime(cls, datetime: dt):
115
+ return cls(
116
+ datetime.year,
117
+ datetime.month,
118
+ datetime.day,
119
+ datetime.hour,
120
+ datetime.minute,
121
+ datetime.second,
122
+ datetime.microsecond,
123
+ datetime.tzinfo,
124
+ fold=datetime.fold,
125
+ )
@@ -0,0 +1,24 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ from dotenv import load_dotenv
5
+
6
+
7
+ def load_env():
8
+ path = Path(__file__)
9
+ root_dir = None
10
+ env_file = None
11
+ for parent in path.parents:
12
+ root_dir = parent
13
+ env_file = root_dir.joinpath(".env")
14
+ if env_file.exists():
15
+ break
16
+
17
+ if root_dir is None:
18
+ print("No .env file found", file=sys.stderr)
19
+ return
20
+
21
+ print(f"Using .env from {repr(str(root_dir))}.")
22
+
23
+ load_dotenv(root_dir.joinpath(".env"))
24
+ load_dotenv(root_dir.joinpath(".env.local"))
@@ -4,6 +4,7 @@ from typing import Annotated, Optional
4
4
  from pydantic import BaseModel, Field
5
5
 
6
6
  from .client import PriceConfig
7
+ from .date import DateTime
7
8
  from .fields import camel_case_model_config, field_name
8
9
  from .response import ResponseError
9
10
 
@@ -73,6 +74,7 @@ class MedicareSource(str, Enum):
73
74
  ManualPricing = "Manual Pricing"
74
75
  SNF = "SNF PPS"
75
76
  Synthetic = "Synthetic Medicare"
77
+ DMEFS = "DMEFS"
76
78
 
77
79
 
78
80
  class InpatientPriceDetail(BaseModel):
@@ -420,3 +422,137 @@ class Pricing(BaseModel):
420
422
 
421
423
  edit_error: Optional[ResponseError] = None
422
424
  """An error that occurred during some step of the pricing process"""
425
+
426
+
427
+ class Step(str, Enum):
428
+ new = "New"
429
+ received = "Received"
430
+ pending = "Pending"
431
+ held = "Held"
432
+ error = "Error"
433
+ input_validated = "Input Validated"
434
+ provider_matched = "Provider Matched"
435
+ edit_complete = "Edit Complete"
436
+ medicare_priced = "Medicare Priced"
437
+ primary_allowed_priced = "Primary Allowed Priced"
438
+ network_allowed_priced = "Network Allowed Priced"
439
+ out_of_network = "Out of Network"
440
+ request_more_info = "Request More Info"
441
+ priced = "Priced"
442
+ returned = "Returned"
443
+
444
+
445
+ class Status(str, Enum):
446
+ pending_claim_input_validation = "Claim Input Validation"
447
+ pending_claim_edit_review = "Claim Edit Review"
448
+ pending_provider_matching = "Provider Matching"
449
+ pending_medicare_review = "Medicare Review"
450
+ pending_medicare_calculation = "Medicare Calculation"
451
+ pending_primary_allowed_review = "Primary Allowed Review"
452
+ pending_network_allowed_review = "Network Allowed Review"
453
+ pending_primary_allowed_determination = "Primary Allowed Determination"
454
+ pending_network_allowed_determination = "Network Allowed Determination"
455
+
456
+
457
+ class StepAndStatus(BaseModel):
458
+ model_config = camel_case_model_config
459
+
460
+ step: Step
461
+ status: Optional[Status] = None
462
+
463
+
464
+ class ClaimStatus(StepAndStatus):
465
+ model_config = camel_case_model_config
466
+
467
+ updated_by: Optional[str] = None
468
+ updated_at: Optional[DateTime] = None
469
+ pricing: Optional[Pricing] = None
470
+ error: Optional[ResponseError] = None
471
+
472
+
473
+ status_new = StepAndStatus(step=Step.new)
474
+ """created by TPA. We use the transaction date as a proxy for this date"""
475
+
476
+ status_received = StepAndStatus(step=Step.received)
477
+ """received and ready for processing. This is modified date of the file we get from SFTP"""
478
+
479
+ status_held = StepAndStatus(step=Step.held)
480
+ """held for various reasons"""
481
+
482
+ status_error = StepAndStatus(step=Step.error)
483
+ """claim encountered an error during processing"""
484
+
485
+ status_input_validated = StepAndStatus(step=Step.input_validated)
486
+ """claim input has been validated"""
487
+
488
+ status_provider_matched = StepAndStatus(step=Step.provider_matched)
489
+ """providers in the claim have been matched to the provider system of record"""
490
+
491
+ status_edit_complete = StepAndStatus(step=Step.edit_complete)
492
+ """claim has been edited and is ready for pricing"""
493
+
494
+ status_medicare_priced = StepAndStatus(step=Step.medicare_priced)
495
+ """claim has been priced according to Medicare"""
496
+
497
+ status_primary_allowed_priced = StepAndStatus(step=Step.primary_allowed_priced)
498
+ """claim has been priced according to the primary allowed amount (e.g. contract, RBP, etc.)"""
499
+
500
+ status_network_allowed_priced = StepAndStatus(step=Step.network_allowed_priced)
501
+ """claim has been priced according to the allowed amount of the network"""
502
+
503
+ status_out_of_network = StepAndStatus(step=Step.out_of_network)
504
+ """is out of network"""
505
+
506
+ status_request_more_info = StepAndStatus(step=Step.request_more_info)
507
+ """return claim to trading partner for more information to enable correct processing"""
508
+
509
+ status_priced = StepAndStatus(step=Step.priced)
510
+ """done pricing"""
511
+
512
+ status_returned = StepAndStatus(step=Step.returned)
513
+ """returned to TPA"""
514
+
515
+ status_pending_claim_input_validation = StepAndStatus(
516
+ step=Step.pending, status=Status.pending_claim_input_validation
517
+ )
518
+ """waiting for claim input validation"""
519
+
520
+ status_pending_claim_edit_review = StepAndStatus(
521
+ step=Step.pending, status=Status.pending_claim_edit_review
522
+ )
523
+ """waiting for claim edit review"""
524
+
525
+ status_pending_provider_matching = StepAndStatus(
526
+ step=Step.pending, status=Status.pending_provider_matching
527
+ )
528
+ """waiting for provider matching"""
529
+
530
+ status_pending_medicare_review = StepAndStatus(
531
+ step=Step.pending, status=Status.pending_medicare_review
532
+ )
533
+ """waiting for Medicare amount review"""
534
+
535
+ status_pending_medicare_calculation = StepAndStatus(
536
+ step=Step.pending, status=Status.pending_medicare_calculation
537
+ )
538
+ """waiting for Medicare amount calculation"""
539
+
540
+ status_pending_primary_allowed_review = StepAndStatus(
541
+ step=Step.pending, status=Status.pending_primary_allowed_review
542
+ )
543
+ """waiting for primary allowed amount review"""
544
+
545
+ status_pending_network_allowed_review = StepAndStatus(
546
+ step=Step.pending, status=Status.pending_network_allowed_review
547
+ )
548
+ """waiting for network allowed amount review"""
549
+
550
+ status_pending_primary_allowed_determination = StepAndStatus(
551
+ step=Step.pending, status=Status.pending_primary_allowed_determination
552
+ )
553
+ """waiting for the primary allowed amount (e.g. contract, RBP rate, etc.) to be determined"""
554
+
555
+ status_pending_network_allowed_determination = StepAndStatus(
556
+ step=Step.pending, status=Status.pending_network_allowed_determination
557
+ )
558
+ """waiting for allowed amount from the network"""
File without changes
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "mphapi"
3
- version = "1.9.1"
3
+ version = "1.12.1"
4
4
  description = "A Python interface to the MyPriceHealth API"
5
5
  authors = ["David Archibald <davidarchibald@myprice.health>"]
6
6
 
@@ -8,10 +8,16 @@ authors = ["David Archibald <davidarchibald@myprice.health>"]
8
8
  python = "^3.12"
9
9
  pydantic = "^2.6.4"
10
10
  requests = "^2.31.0"
11
- pytest = "^8.4.1"
11
+ python-dotenv = "^1.1.1"
12
12
 
13
13
  [tool.poetry.group.dev.dependencies]
14
+ pytest = "^8.4.1"
14
15
  pytest-snapshot = "^0.9.0"
16
+ coverage = "^7.10.4"
17
+ black = "^25.1.0"
18
+ isort = "^6.0.1"
19
+ flake8 = "^7.3.0"
20
+ pyright = "^1.1.403"
15
21
 
16
22
  [build-system]
17
23
  requires = ["poetry-core"]
@@ -1,38 +0,0 @@
1
- import json
2
- import os
3
-
4
- # This import annoys Pylance for some reason.
5
- from pytest_snapshot.plugin import Snapshot # type: ignore
6
-
7
- from . import Claim, Client, PriceConfig
8
-
9
-
10
- def test_client(snapshot: Snapshot):
11
- api_key = os.getenv("API_KEY")
12
- if api_key is None:
13
- raise EnvironmentError("API_KEY must be set")
14
-
15
- api_url = os.getenv("API_URL")
16
-
17
- client = Client(api_key, url=api_url)
18
-
19
- config = PriceConfig(
20
- is_commercial=True,
21
- disable_cost_based_reimbursement=False,
22
- use_commercial_synthetic_for_not_allowed=True,
23
- use_drg_from_grouper=False,
24
- use_best_drg_price=True,
25
- override_threshold=300,
26
- include_edits=True,
27
- )
28
-
29
- tests = ["hcfa", "inpatient", "outpatient"]
30
-
31
- for test in tests:
32
- with open(f"testdata/{test}.json", "r") as f:
33
- data = json.load(f)
34
-
35
- claim = Claim.model_validate(data)
36
- pricing = client.price(config, claim)
37
-
38
- snapshot.assert_match(pricing.model_dump_json(indent=4), f"{test}.json")
@@ -1,51 +0,0 @@
1
- from datetime import datetime
2
- from typing import Any
3
-
4
- from pydantic import GetCoreSchemaHandler
5
- from pydantic_core import CoreSchema, core_schema
6
-
7
-
8
- class Date:
9
- """Date is a custom type for representing dates in the format YYYYMMDD"""
10
-
11
- year: int
12
- month: int
13
- day: int
14
-
15
- def __init__(self, year: int, month: int, day: int):
16
- self.year = year
17
- self.month = month
18
- self.day = day
19
-
20
- def __str__(self):
21
- return f"{self.year}{self.month:02d}{self.day:02d}"
22
-
23
- # Based off of this: https://docs.pydantic.dev/2.1/usage/types/custom/#handling-third-party-types
24
- @classmethod
25
- def __get_pydantic_core_schema__(
26
- cls, source_type: Any, handler: GetCoreSchemaHandler
27
- ) -> CoreSchema:
28
- def to_date(value: str) -> Date:
29
- time = datetime.strptime(value, "%Y%m%d")
30
-
31
- return Date(time.year, time.month, time.day)
32
-
33
- from_str = core_schema.chain_schema(
34
- [
35
- core_schema.str_schema(),
36
- core_schema.no_info_plain_validator_function(to_date),
37
- ]
38
- )
39
-
40
- return core_schema.json_or_python_schema(
41
- json_schema=from_str,
42
- python_schema=core_schema.union_schema(
43
- [
44
- core_schema.is_instance_schema(Date),
45
- from_str,
46
- ]
47
- ),
48
- serialization=core_schema.plain_serializer_function_ser_schema(
49
- lambda date: str(date)
50
- ),
51
- )
File without changes
File without changes
File without changes
File without changes