dic-helper-toolkit 1.0.2__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.
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: dic-helper-toolkit
3
+ Version: 1.0.2
4
+ Summary: Helper libraries for data integration centers' daily tasks
5
+ Project-URL: Repository, https://github.com/haemka/dic-helper-toolkit
6
+ Project-URL: Issues, https://github.com/haemka/dic-helper-toolkit/issues
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests~=2.34.2
10
+ Requires-Dist: requests-oauthlib~=2.0.0
11
+ Requires-Dist: oauthlib~=3.3.1
12
+ Requires-Dist: fhir.resources~=8.2.0
13
+ Requires-Dist: pytest~=9.0.3
14
+ Requires-Dist: certifi~=2026.5.20
15
+ Requires-Dist: charset-normalizer~=3.4.7
16
+ Requires-Dist: pydantic~=2.13.4
17
+ Requires-Dist: typing-extensions~=4.15.0
18
+ Requires-Dist: typing-inspection~=0.4.2
19
+ Requires-Dist: responses~=0.26.1
20
+ Provides-Extra: xml
21
+ Requires-Dist: lxml~=6.1; extra == "xml"
22
+ Provides-Extra: tests
23
+ Requires-Dist: pytest; extra == "tests"
24
+ Requires-Dist: responses; extra == "tests"
25
+ Requires-Dist: lxml~=6.1; extra == "tests"
26
+
27
+ # Python DIC Helper Toolkit
28
+
29
+ This Toolkit contains helper libraries for the data integration centers daily tasks.
30
+
31
+ ## Installation
32
+
33
+ ```shell
34
+ pip install dic-helper-toolkit
35
+ ```
36
+
37
+ The package is published on [PyPI](https://pypi.org/project/dic-helper-toolkit/). To use the FHIR XML reader, install
38
+ the `xml` extra: `pip install dic-helper-toolkit[xml]`.
39
+
40
+ ## Contents
41
+
42
+ ### HTTP client
43
+
44
+ A wrapper around the `requests` library which allows to create an object for an endpoint holding all needed
45
+ configuration including authentication and certificate verification. It supports Basic Auth and OAuth 2.0.
46
+
47
+ #### Usage:
48
+
49
+ ```python
50
+ from dic_helpers.http_client import HTTPClient
51
+
52
+ ## No authentication or CA certificate at all
53
+ client = HTTPClient(base_url="https://example.com")
54
+
55
+ ## Basic Auth with CA certificate
56
+ #client = HTTPClient(base_url="https://example.com", username="user", password="P4SSW0RD", ca_file="MyCA.crt")
57
+
58
+ ## OAuth 2.0 with username and password
59
+ #client = HTTPClient(base_url="https://example.com", username="user", password="P4SSW0RD",
60
+ # auth_url="https://keycloak.example.com", realm="MyRealm", client_id="MyClient",
61
+ # client_secret="00000000-00000000-00000000-00000000", ca_file="MyCA.crt")
62
+
63
+ with client.get("index.html") as result:
64
+ print(result)
65
+ ```
66
+
67
+
68
+ ### FHIR client
69
+
70
+ A simple FHIR client library which uses the HTTP client from above and handles resources directly as `fhir.resources`
71
+ (https://github.com/nazrulworld/fhir.resources) object.
72
+
73
+ #### Usage:
74
+
75
+ ```python
76
+ from dic_helpers.fhir_client import FHIRClient
77
+
78
+ client = FHIRClient(base_url="https://example.com", username="user", password="P4SSW0RD",
79
+ auth_url="https://keycloak.example.com", realm="MyRealm", client_id="MyClient",
80
+ client_secret="00000000-00000000-00000000-00000000", ca_file="MyCA.crt")
81
+
82
+ # Fetch a patient
83
+ patients = client.search("Patient", "identifier=0123456789&_lastUpdated=ge2025-01-01"):
84
+ for patient in patients:
85
+ print(patient)
86
+ ```
87
+ *Keep in mind, that this returns an object.*
88
+
89
+ ### Directory reader
90
+
91
+ Lists the files of a directory, optionally including all subdirectories, whose names match shell-style patterns.
92
+
93
+ #### Usage:
94
+
95
+ ```python
96
+ from dic_helpers.directory_reader import DirectoryReader
97
+
98
+ reader = DirectoryReader("/data/export", patterns=["*.json", "*.ndjson"], recursive=True)
99
+ for path in reader.list_files():
100
+ print(path)
101
+ ```
102
+
103
+ - **Only regular files** are listed, sorted by path, as `pathlib.Path` objects that start with the directory as given.
104
+ - **`patterns`** (default `"*"`) are matched case-sensitively against the file names (`*`, `?`, `[seq]`). A single
105
+ pattern can be a string; a file is listed if it matches any pattern. Special characters in the directory's own name
106
+ don't matter.
107
+ - **`recursive`** (default `False`) also lists the files of all subdirectories.
108
+ - **`include_hidden`** (default `False`): hidden files (names starting with `.`) are skipped and hidden directories
109
+ are not read, unless this is `True`.
110
+ - **`follow_symlinks`** (default `False`): symbolic links to directories are only read if this is `True`. Every
111
+ directory is read once at most, so symlink loops end. Symbolic links to files are always listed, broken links never.
112
+ - **Errors:** a missing directory raises `FileNotFoundError`, a file instead of a directory `NotADirectoryError`, and
113
+ an unreadable directory `PermissionError`. An empty list therefore always means that no file matched.
114
+
115
+
116
+ ### FHIR file reader
117
+
118
+ Reads FHIR R4B resources from files as `fhir.resources` objects, together with the file and line they were read from.
119
+ Combined with the directory reader:
120
+
121
+ ```python
122
+ from dic_helpers.directory_reader import DirectoryReader
123
+ from dic_helpers.fhir_file_reader import FHIRFileReader
124
+
125
+ paths = DirectoryReader("/data/export", patterns=["*.json", "*.ndjson", "*.xml", "*.gz"], recursive=True).list_files()
126
+ for item in FHIRFileReader(paths).resources():
127
+ print(item.path, item.line, item.resource.get_resource_type(), item.resource.id)
128
+ ```
129
+
130
+ **Formats**, by file extension (case-insensitive):
131
+
132
+ | Extension | Content | `item.line` |
133
+ |---|---|---|
134
+ | `.json` | one resource | `None` |
135
+ | `.ndjson` | one resource per line (FHIR bulk data), empty lines are skipped | line number, from 1 |
136
+ | `.xml` | one resource in FHIR XML, the resource type comes from the root element | `None` |
137
+ | `.json.gz`, `.ndjson.gz`, `.xml.gz` | the same, gzip compressed | as above |
138
+
139
+ Other extensions are an error, the format is not guessed. Text is UTF-8, a byte order mark is allowed. Reading XML
140
+ needs `lxml`: install `dic-helper-toolkit[xml]`. External entities are not resolved, an XML file that references one is
141
+ invalid.
142
+
143
+ **Parameters:**
144
+ - **`paths`**: the files, read in this order; a single path can be given on its own. Resources come in the order they
145
+ appear in each file. NDJSON files are read line by line, also compressed ones; JSON and XML files as a whole.
146
+ - **`unpack_bundles`** (default `True`): a Bundle gives the resources of its entries, of any Bundle type. Only the
147
+ outermost Bundle is unpacked, entries without a resource are left out. With `False` the Bundle is returned.
148
+ - **`on_error`** (default `"raise"`): an unreadable or invalid file, NDJSON line or resource raises `FHIRFileError`,
149
+ whose message starts with the file and line, e.g. `export/Patient.ndjson:17: invalid JSON: …` or
150
+ `patient.json: invalid Patient: birthDate: Date value string does not match spec regex.`. With `"skip"` the message
151
+ is logged as a warning, and reading continues with the next line (NDJSON) or the next file. A missing `lxml` is
152
+ always an error.
@@ -0,0 +1,9 @@
1
+ dic_helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ dic_helpers/directory_reader.py,sha256=JQQMaBfoORzOwXAWCNJvcJiHJERe8F_p69yKXP_5uKg,3997
3
+ dic_helpers/fhir_client.py,sha256=yYuo2hPQGa2F11eiBaOMuKfjrXb0DYTOHCOcqom6LOM,14050
4
+ dic_helpers/fhir_file_reader.py,sha256=8Y43J-2cc3X79iuQpupRV08JsgxhprkwnihfvpEj11A,13315
5
+ dic_helpers/http_client.py,sha256=tej3q1BsDTRQ3t8Iw8ISuElVuV1tUYEtX1YAA-KrIxU,11759
6
+ dic_helper_toolkit-1.0.2.dist-info/METADATA,sha256=gyZKqm22Wq-WWgBAn2cMfAiO1pysGjVgE3xcYTStx0U,6569
7
+ dic_helper_toolkit-1.0.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ dic_helper_toolkit-1.0.2.dist-info/top_level.txt,sha256=BNkYErUWeTgH4-0z0EnR2h1rzj5-JCq_tw14_q53f_w,12
9
+ dic_helper_toolkit-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dic_helpers
File without changes
@@ -0,0 +1,80 @@
1
+ """Listing the files of a directory, optionally including its subdirectories."""
2
+ import fnmatch
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Iterable, List, Set, Tuple, Union
6
+
7
+
8
+ class DirectoryReader:
9
+ """
10
+ Lists the files of a directory whose names match one of the given patterns.
11
+ """
12
+
13
+ def __init__(self, directory: Union[str, os.PathLike], patterns: Union[str, Iterable[str]] = ("*",),
14
+ recursive: bool = False, include_hidden: bool = False, follow_symlinks: bool = False) -> None:
15
+ """
16
+ Initialize the DirectoryReader. The directory is only read by list_files().
17
+
18
+ :param directory: The directory to read.
19
+ :type directory: Union[str, os.PathLike]
20
+ :param patterns: Shell-style patterns (fnmatch: *, ?, [seq]) matched case-sensitively against the file names,
21
+ e.g. ("*.json", "*.ndjson"). A single pattern can be given as a string. A file is listed if it matches any
22
+ pattern.
23
+ :type patterns: Union[str, Iterable[str]]
24
+ :param recursive: Also list the files of all subdirectories.
25
+ :type recursive: bool
26
+ :param include_hidden: Also list hidden files (names starting with '.') and read hidden subdirectories.
27
+ :type include_hidden: bool
28
+ :param follow_symlinks: Also read subdirectories that are symbolic links. Every directory is read once at most,
29
+ so symlink loops end. Symbolic links to files are always listed.
30
+ :type follow_symlinks: bool
31
+ """
32
+ self.directory = Path(directory)
33
+ self.patterns: Tuple[str, ...] = (patterns,) if isinstance(patterns, str) else tuple(patterns)
34
+ self.recursive = recursive
35
+ self.include_hidden = include_hidden
36
+ self.follow_symlinks = follow_symlinks
37
+
38
+ def list_files(self) -> List[Path]:
39
+ """
40
+ List the matching files.
41
+
42
+ :return: The paths of the matching regular files, sorted. Each path starts with the directory as given.
43
+ :rtype: List[Path]
44
+ :raises FileNotFoundError: If the directory doesn't exist.
45
+ :raises NotADirectoryError: If the path is not a directory.
46
+ :raises PermissionError: If the directory or one of the subdirectories to read can't be read.
47
+ """
48
+ if not self.directory.exists():
49
+ raise FileNotFoundError(f"Directory not found: {self.directory}")
50
+ if not self.directory.is_dir():
51
+ raise NotADirectoryError(f"Not a directory: {self.directory}")
52
+ files: List[Path] = []
53
+ self._read(self.directory, files, visited=set())
54
+ return sorted(files)
55
+
56
+ def _read(self, directory: Path, files: List[Path], visited: Set[Tuple[int, int]]) -> None:
57
+ """
58
+ Add the matching files of a directory to files, and read its subdirectories if recursive.
59
+
60
+ :param directory: The directory to read.
61
+ :type directory: Path
62
+ :param files: The list the matching files are added to.
63
+ :type files: List[Path]
64
+ :param visited: Device and inode of the directories read so far; directories in it are not read again.
65
+ :type visited: Set[Tuple[int, int]]
66
+ """
67
+ status = directory.stat()
68
+ visited.add((status.st_dev, status.st_ino))
69
+ with os.scandir(directory) as entries:
70
+ for entry in entries:
71
+ if not self.include_hidden and entry.name.startswith("."):
72
+ continue
73
+ # is_file() follows symbolic links: links to files are listed, broken links are not
74
+ if entry.is_file():
75
+ if any(fnmatch.fnmatchcase(entry.name, pattern) for pattern in self.patterns):
76
+ files.append(Path(entry.path))
77
+ elif self.recursive and entry.is_dir(follow_symlinks=self.follow_symlinks):
78
+ status = entry.stat()
79
+ if (status.st_dev, status.st_ino) not in visited:
80
+ self._read(Path(entry.path), files, visited)
@@ -0,0 +1,314 @@
1
+ import logging
2
+ import json
3
+ from typing import Dict, Any, Generator, Optional
4
+ from urllib.parse import urlparse, parse_qs, urlencode, urljoin
5
+ from fhir.resources.R4B import get_fhir_model_class
6
+ from fhir.resources.R4B.bundle import Bundle
7
+ from dic_helpers.http_client import HTTPClient
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class FHIRClient(HTTPClient):
14
+ """
15
+ A FHIR API client that abstracts an advanced HTTP client.
16
+
17
+ This client uses the custom implementation for a HTTP client from the same package (dic-helper-toolkit). It offers
18
+ easy to use function for FHIR requests and uses fhir.resources as object representations of resources.
19
+ """
20
+ def __init__(self, page_size=50, next_param: Optional[str] = None, *args, **kwargs) -> None:
21
+ """Initialize the FHIRClient
22
+
23
+ :param page_size: Number of entries to request per search page.
24
+ :param next_param: Query parameter for offset paging, for servers that don't provide standard FHIR paging
25
+ links (e.g. '_offset'). Defaults to None, which uses standard FHIR paging only.
26
+ :param args: Positional arguments to pass to the parent HTTPClient.
27
+ :param kwargs: Keyword arguments to pass to the parent HTTPClient.
28
+ """
29
+ super().__init__(*args, **kwargs)
30
+ self.page_size = page_size
31
+ # An empty string counts as not configured
32
+ self.next_param: Optional[str] = next_param or None
33
+
34
+ def _is_standard_paging(self, bundle: Dict[str, Any]) -> bool:
35
+ """Check if the bundle follows standard FHIR paging
36
+
37
+ :param bundle: A dictionary representing a FHIR Bundle.
38
+ :type bundle: Dict[str, Any]
39
+ :return: True if the bundle uses standard paging, False otherwise.
40
+ :rtype: bool
41
+ """
42
+ if 'link' in bundle:
43
+ return any(link['relation'] in ['next', 'previous'] for link in bundle['link'])
44
+ return False
45
+
46
+ def _get_next_url(self, bundle: Dict[str, Any]) -> Optional[str]:
47
+ """Get the 'next' URL from the bundle
48
+
49
+ :param bundle: A dictionary representing a FHIR Bundle.
50
+ :type bundle: Dict[str, Any]
51
+ :return: The next URL from the bundle, None if it has no 'next' link.
52
+ :rtype: Optional[str]
53
+ """
54
+ if 'link' in bundle:
55
+ next_link = next((link['url'] for link in bundle['link'] if link['relation'] == 'next'), None)
56
+ return next_link
57
+ return None
58
+
59
+ def _construct_next_params(self, original_params: Dict[str, Any], offset: int) -> Dict[str, Any]:
60
+ """Construct the search parameters of the next page for non-standard (offset) paging
61
+
62
+ :param original_params: The search parameters of the current page; they are changed and returned.
63
+ :type original_params: Dict[str, Any]
64
+ :param offset: The offset of the next page, set as the next_param parameter.
65
+ :type offset: int
66
+ :return: The search parameters of the next page, _count unchanged.
67
+ :rtype: Dict[str, Any]
68
+ """
69
+ # _count stays as requested, the page size of the whole search
70
+ query_params = original_params
71
+ query_params[self.next_param] = str(offset)
72
+ return query_params
73
+
74
+ @staticmethod
75
+ def _parse_params(params: str) -> Dict[str, Any]:
76
+ """Parse search parameters given as a query string into a dictionary
77
+
78
+ :param params: The parameters, e.g. 'identifier=0123456789&_lastUpdated=ge2025-01-01', not URL encoded.
79
+ :type params: str
80
+ :return: The parameters by name; a repeated parameter keeps its last value.
81
+ :rtype: Dict[str, Any]
82
+ """
83
+ return dict(param.split('=', 1) for param in params.split('&'))
84
+
85
+ def search(self, resource_type: str, params: Optional[Any] = None) -> Generator[Bundle, None, None]:
86
+ """Perform a FHIR search and yield every result page as a Bundle
87
+
88
+ Pages are followed by the Bundle's 'next' link (standard FHIR paging), or, if next_param is configured and a
89
+ page is full, by requesting the next offset. A server that returns the same page again for the offset stops
90
+ the search with a warning. Without next_param, a warning is logged if the Bundle's total shows that results
91
+ are missing.
92
+
93
+ :param resource_type: The resource type to search for.
94
+ :type resource_type: str
95
+ :param params: Search parameters as a dictionary (a list value repeats the parameter) or as a query string.
96
+ '_count' defaults to the client's page_size.
97
+ :type params: Dict[str, Any] or str, optional
98
+ :return: A generator that yields the result pages.
99
+ :rtype: Generator[Bundle, None, None]
100
+ """
101
+ if params is None:
102
+ params = {}
103
+ elif isinstance(params, str):
104
+ params = self._parse_params(params)
105
+ params['_count'] = params.get('_count', self.page_size)
106
+ # The page size actually requested: a _count given by the caller overrides the client's page_size
107
+ count = params['_count'][0] if isinstance(params['_count'], list) else params['_count']
108
+ page_size = int(count)
109
+
110
+ url = urljoin(self.base_url, resource_type)
111
+ path = resource_type
112
+ logger.debug(f"Search request with path {path} and params {params}")
113
+
114
+ previous_entries = None
115
+ received = 0
116
+ while path:
117
+ response = self.get(path, params=params)
118
+ bundle_dict = response.json()
119
+ entries = bundle_dict.get('entry') or []
120
+ # A server ignoring the offset parameter returns the same page again, which would otherwise loop forever
121
+ if entries and entries == previous_entries:
122
+ logger.warning(f"Server returned the same page again for paging parameter '{self.next_param}'. It "
123
+ f"probably does not support this parameter. Stopping the search.")
124
+ break
125
+ previous_entries = entries
126
+ received += len(entries)
127
+ resource_class = get_fhir_model_class('Bundle')
128
+ bundle = resource_class.model_validate_json(json.dumps(bundle_dict))
129
+
130
+ yield bundle
131
+
132
+ if self._is_standard_paging(bundle_dict):
133
+ # Standard FHIR paging
134
+ next_url = self._get_next_url(bundle_dict)
135
+ if next_url:
136
+ parsed_next_url = urlparse(next_url)
137
+ path = parsed_next_url.path
138
+ params = parse_qs(parsed_next_url.query) # Correctly parse query parameters
139
+ else:
140
+ path = None
141
+ elif self.next_param and len(entries) == page_size:
142
+ # Non-standard offset paging, only if configured
143
+ current_offset = int(params.get(self.next_param, 0))
144
+ next_offset = current_offset + page_size
145
+
146
+ params = self._construct_next_params(params, next_offset)
147
+ else:
148
+ total = bundle_dict.get('total')
149
+ if self.next_param is None and isinstance(total, int) and total > received:
150
+ logger.warning(f"Search for {resource_type} returned {received} of {total} results without a next "
151
+ f"link. The server probably doesn't support standard FHIR paging, configure "
152
+ f"next_param to use offset paging.")
153
+ path = None
154
+ if path:
155
+ logger.debug(f"Next URL has path '{path}' and params '{params}'")
156
+
157
+ def search_all(self, resource_type: str, params: Optional[Dict[str, Any]] = None) -> Bundle:
158
+ """Perform a FHIR search and return a single Bundle containing all entries.
159
+
160
+ :param resource_type: The resource type to search for.
161
+ :type resource_type: str
162
+ :param params: Search parameters, as for search().
163
+ :type params: Dict[str, Any], optional
164
+ :return: A searchset Bundle with the entries of all result pages.
165
+ :rtype: Bundle
166
+ """
167
+ # Initialize an empty Bundle to store all entries
168
+ all_entries_bundle = Bundle(
169
+ type='searchset',
170
+ total=0,
171
+ entry=[]
172
+ )
173
+
174
+ # Call the original search function
175
+ for page_bundle in self.search(resource_type, params):
176
+ # Add entries from each page to the all_entries_bundle
177
+ if page_bundle.entry:
178
+ all_entries_bundle.entry.extend(page_bundle.entry)
179
+
180
+ # Copy metadata from the last page
181
+ all_entries_bundle.type = page_bundle.type
182
+ all_entries_bundle.total = page_bundle.total
183
+
184
+ # Update other relevant fields
185
+ if hasattr(page_bundle, 'meta'):
186
+ all_entries_bundle.meta = page_bundle.meta
187
+ if hasattr(page_bundle, 'link'):
188
+ all_entries_bundle.link = page_bundle.link
189
+
190
+ # If total wasn't set in the original bundles, use the length of entries
191
+ if all_entries_bundle.total is None or all_entries_bundle.total == 0:
192
+ all_entries_bundle.total = len(all_entries_bundle.entry)
193
+
194
+ return all_entries_bundle
195
+
196
+ def get_resource(self, resource_type: str, id: str, **kwargs: Any) -> Any:
197
+ """Retrieve a single resource
198
+
199
+ :param resource_type: The resource type to search for.
200
+ :type resource_type: str
201
+ :param id: The id of the resource to retrieve.
202
+ :type id: str
203
+ :param kwargs: Additional arguments to pass to the request.
204
+ :return: The resource retrieved.
205
+ :rtype: Any
206
+ """
207
+ response = self.get(f"{resource_type}/{id}", **kwargs)
208
+ resource_dict = response.json()
209
+ resource_class = get_fhir_model_class(resource_type)
210
+ return resource_class.model_validate_json(json.dumps(resource_dict))
211
+
212
+ def post_resource(self, resource: Any, **kwargs: Any) -> Any:
213
+ """Create a new resource
214
+
215
+ :param resource: The resource to create.
216
+ :type resource: Any
217
+ :param kwargs: Additional arguments to pass to the request.
218
+ :return: The created resource.
219
+ :rtype: Any
220
+ """
221
+ resource_type = resource.get_resource_type()
222
+ path = None if resource_type == "Bundle" else resource_type
223
+ response = self.post(path=path, json=json.loads(resource.model_dump_json()), **kwargs)
224
+ created_resource_dict = response.json()
225
+ resource_class = get_fhir_model_class(resource_type)
226
+ return resource_class.model_validate_json(json.dumps(created_resource_dict))
227
+
228
+ # Alias for post_resource()
229
+ create_resource = post_resource
230
+
231
+ def put_resource(self, resource: Any, **kwargs: Any) -> Any:
232
+ """Update an existing resource
233
+
234
+ :param resource: The resource to update.
235
+ :type resource: Any
236
+ :param kwargs: Additional arguments to pass to the request.
237
+ :return: The updated resource.
238
+ :rtype: Any
239
+ """
240
+ resource_type = resource.get_resource_type()
241
+ resource_id = resource.id
242
+ response = self.put(f"{resource_type}/{resource_id}",
243
+ json=json.loads(resource.model_dump_json()), **kwargs)
244
+ updated_resource_dict = response.json()
245
+ resource_class = get_fhir_model_class(resource_type)
246
+ return resource_class.model_validate_json(json.dumps(updated_resource_dict))
247
+
248
+ # Alias for put_resource()
249
+ update_resource = put_resource
250
+
251
+ def delete_resource(self, resource_type: str, id: str, **kwargs: Any) -> None:
252
+ """Delete a resource
253
+
254
+ :param resource_type: The resource type to search for.
255
+ :type resource_type: str
256
+ :param kwargs: Additional arguments to pass to the request.
257
+ :param id: The id of the resource to delete.
258
+ :type id: str
259
+ """
260
+ self.delete(f"{resource_type}/{id}", **kwargs)
261
+
262
+ def post_bundle(self, bundle: Bundle, **kwargs: Any) -> Bundle:
263
+ """Execute a transaction or batch Bundle
264
+
265
+ :param bundle: The Bundle to execute.
266
+ :type bundle: Bundle
267
+ :param kwargs: Additional arguments to pass to the request.
268
+ :return: The executed Bundle.
269
+ :rtype: Bundle
270
+ """
271
+ response = self.post("", json=json.loads(bundle.model_dump_json()), **kwargs)
272
+ result_bundle_dict = response.json()
273
+ resource_class = get_fhir_model_class('Bundle')
274
+ return resource_class.model_validate_json(json.dumps(result_bundle_dict))
275
+
276
+ # Alias for post_bundle()
277
+ execute_bundle = post_bundle
278
+
279
+ def get_capability_statement(self) -> Any:
280
+ """Retrieve the server's capability statement
281
+
282
+ :return: The server's capability statement.
283
+ :rtype: Any
284
+ """
285
+ response = self.get("metadata")
286
+ capability_statement_dict = response.json()
287
+ resource_class = get_fhir_model_class('CapabilityStatement')
288
+ return resource_class.model_validate_json(json.dumps(capability_statement_dict))
289
+
290
+ def execute_operation(self, operation_name: str, resource_type: Optional[str] = None,
291
+ id: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None) -> Any:
292
+ """Execute a FHIR operation
293
+
294
+ :param operation_name: The operation to execute.
295
+ :type operation_name: str
296
+ :param resource_type: The resource type to search for.
297
+ :type resource_type: str
298
+ :param id: The id of the resource to execute.
299
+ :type id: str
300
+ :param parameters: Optional parameters to pass to the parent HTTPClient.
301
+ :type parameters: Dict[str, Any]
302
+ :return: The executed operation.
303
+ :rtype: Any
304
+ """
305
+ path = f"${operation_name}"
306
+ if resource_type and id:
307
+ path = f"{resource_type}/{id}/" + path
308
+ elif resource_type:
309
+ path = f"{resource_type}/" + path
310
+
311
+ response = self.post(path, json=parameters)
312
+ result_dict = response.json()
313
+ resource_class = get_fhir_model_class('Parameters')
314
+ return resource_class.model_validate_json(json.dumps(result_dict))
@@ -0,0 +1,307 @@
1
+ """Reading FHIR resources from files: JSON, NDJSON and FHIR XML, each optionally gzip compressed."""
2
+ import gzip
3
+ import json
4
+ import logging
5
+ import os
6
+ import zlib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import IO, Iterable, Iterator, Literal, Optional, Tuple, Union
10
+
11
+ from fhir.resources.R4B import get_fhir_model_class
12
+ from fhir.resources.R4B.resource import Resource
13
+ from pydantic import ValidationError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # File formats by extension, before an optional .gz
18
+ FORMATS = {".ndjson": "ndjson", ".json": "json", ".xml": "xml"}
19
+ FHIR_NAMESPACE = "http://hl7.org/fhir"
20
+
21
+
22
+ class FHIRFileError(Exception):
23
+ """An unreadable or invalid file, NDJSON line or resource. The message starts with the path (and line)."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class FHIRFileItem:
28
+ """A resource and where it was read: the file, and for NDJSON the line (from 1), otherwise None."""
29
+ path: Path
30
+ line: Optional[int]
31
+ resource: Resource
32
+
33
+
34
+ class FHIRFileReader:
35
+ """
36
+ Reads FHIR R4B resources from files. The format comes from the file extension (case-insensitive): .json (a
37
+ resource), .ndjson (a resource per line, empty lines are skipped) or .xml (a resource in FHIR XML, needs the extra
38
+ dic-helper-toolkit[xml]), each optionally followed by .gz for gzip compression. Text is UTF-8, optionally with BOM.
39
+ """
40
+
41
+ def __init__(self, paths: Union[str, os.PathLike, Iterable[Union[str, os.PathLike]]], unpack_bundles: bool = True,
42
+ on_error: Literal["raise", "skip"] = "raise") -> None:
43
+ """
44
+ Initialize the FHIRFileReader. The files are only read by resources().
45
+
46
+ :param paths: The files to read, in this order. A single path can be given on its own.
47
+ :type paths: Union[str, os.PathLike, Iterable[Union[str, os.PathLike]]]
48
+ :param unpack_bundles: Return the resources of a Bundle's entries instead of the Bundle (only the outermost
49
+ Bundle; entries without a resource are left out).
50
+ :type unpack_bundles: bool
51
+ :param on_error: 'raise': an unreadable or invalid file, line or resource raises FHIRFileError. 'skip': it is
52
+ logged as a warning instead, and reading continues with the next line (NDJSON) or the next file.
53
+ :type on_error: Literal["raise", "skip"]
54
+ :raises ValueError: If on_error is neither 'raise' nor 'skip'.
55
+ """
56
+ if on_error not in ("raise", "skip"):
57
+ raise ValueError(f"on_error must be 'raise' or 'skip', not {on_error!r}")
58
+ if isinstance(paths, (str, os.PathLike)):
59
+ paths = [paths]
60
+ self.paths = [Path(path) for path in paths]
61
+ self.unpack_bundles = unpack_bundles
62
+ self.on_error = on_error
63
+
64
+ def resources(self) -> Iterator[FHIRFileItem]:
65
+ """
66
+ Read the files.
67
+
68
+ :return: The resources with their origin, in the order of the files and within each file.
69
+ :rtype: Iterator[FHIRFileItem]
70
+ :raises FHIRFileError: With on_error 'raise', for an unreadable or invalid file, line or resource.
71
+ :raises ImportError: For an XML file without lxml installed, also with on_error 'skip'.
72
+ """
73
+ for path in self.paths:
74
+ yield from self._read_file(path)
75
+
76
+ def _read_file(self, path: Path) -> Iterator[FHIRFileItem]:
77
+ """
78
+ Read the resources of one file in its format. Errors of the whole file go to _fail().
79
+
80
+ :param path: The file to read.
81
+ :type path: Path
82
+ :return: The resources of the file with their origin.
83
+ :rtype: Iterator[FHIRFileItem]
84
+ """
85
+ try:
86
+ file_format, compressed = self._format(path)
87
+ with self._open(path, compressed) as file:
88
+ if file_format == "ndjson":
89
+ yield from self._read_ndjson(path, file)
90
+ else:
91
+ content = file.read()
92
+ if file_format == "json":
93
+ resource = self._from_json(self._decode(content, str(path)), str(path))
94
+ else:
95
+ resource = self._from_xml(content, str(path))
96
+ yield from self._items(path, None, resource)
97
+ except FHIRFileError as error:
98
+ self._fail(error)
99
+ except (OSError, EOFError, zlib.error) as error:
100
+ # Can't be opened, or isn't valid gzip
101
+ self._fail(FHIRFileError(f"{path}: cannot read file: {error}"))
102
+
103
+ def _read_ndjson(self, path: Path, file: IO[bytes]) -> Iterator[FHIRFileItem]:
104
+ """
105
+ Read an NDJSON file line by line. Errors of a line go to _fail(), so with on_error 'skip' only that line is
106
+ left out.
107
+
108
+ :param path: The file, for the origin and for messages.
109
+ :type path: Path
110
+ :param file: The opened file, binary and already decompressed.
111
+ :type file: IO[bytes]
112
+ :return: The resources of the lines with their origin.
113
+ :rtype: Iterator[FHIRFileItem]
114
+ """
115
+ for number, raw in enumerate(file, start=1):
116
+ location = f"{path}:{number}"
117
+ try:
118
+ text = self._decode(raw, location).strip()
119
+ if not text:
120
+ continue
121
+ resource = self._from_json(text, location)
122
+ except FHIRFileError as error:
123
+ self._fail(error)
124
+ continue
125
+ yield from self._items(path, number, resource)
126
+
127
+ def _items(self, path: Path, line: Optional[int], resource: Resource) -> Iterator[FHIRFileItem]:
128
+ """
129
+ The results for a resource read from a file: the resources of its entries for a Bundle with unpack_bundles,
130
+ otherwise the resource itself.
131
+
132
+ :param path: The file the resource was read from.
133
+ :type path: Path
134
+ :param line: The NDJSON line the resource was read from, None for other formats.
135
+ :type line: Optional[int]
136
+ :param resource: The resource.
137
+ :type resource: Resource
138
+ :return: The resources with their origin.
139
+ :rtype: Iterator[FHIRFileItem]
140
+ """
141
+ if self.unpack_bundles and resource.get_resource_type() == "Bundle":
142
+ for entry in resource.entry or []:
143
+ if entry.resource is not None:
144
+ yield FHIRFileItem(path, line, entry.resource)
145
+ else:
146
+ yield FHIRFileItem(path, line, resource)
147
+
148
+ def _fail(self, error: FHIRFileError) -> None:
149
+ """
150
+ Handle an error according to on_error: raise it, or log it as a warning.
151
+
152
+ :param error: The error, its message names the file (and line).
153
+ :type error: FHIRFileError
154
+ :raises FHIRFileError: With on_error 'raise'.
155
+ """
156
+ if self.on_error == "raise":
157
+ raise error
158
+ logger.warning(str(error))
159
+
160
+ @staticmethod
161
+ def _format(path: Path) -> Tuple[str, bool]:
162
+ """
163
+ The format of a file and whether it is gzip compressed, from its extension (case-insensitive).
164
+
165
+ :param path: The file.
166
+ :type path: Path
167
+ :return: The format ('json', 'ndjson' or 'xml') and whether the file ends with .gz.
168
+ :rtype: Tuple[str, bool]
169
+ :raises FHIRFileError: For any other extension.
170
+ """
171
+ name = path.name.lower()
172
+ compressed = name.endswith(".gz")
173
+ if compressed:
174
+ name = name.removesuffix(".gz")
175
+ for extension, file_format in FORMATS.items():
176
+ if name.endswith(extension):
177
+ return file_format, compressed
178
+ raise FHIRFileError(f"{path}: unknown file type, expected .json, .ndjson or .xml, optionally with .gz")
179
+
180
+ @staticmethod
181
+ def _open(path: Path, compressed: bool) -> IO[bytes]:
182
+ """
183
+ Open a file for reading bytes, decompressing it if it is gzip compressed.
184
+
185
+ :param path: The file.
186
+ :type path: Path
187
+ :param compressed: Whether the file is gzip compressed.
188
+ :type compressed: bool
189
+ :return: The opened file.
190
+ :rtype: IO[bytes]
191
+ :raises OSError: If the file can't be opened.
192
+ """
193
+ return gzip.open(path, "rb") if compressed else open(path, "rb")
194
+
195
+ @staticmethod
196
+ def _decode(content: bytes, location: str) -> str:
197
+ """
198
+ Decode UTF-8 text, with or without a byte order mark.
199
+
200
+ :param content: The bytes of a file or an NDJSON line.
201
+ :type content: bytes
202
+ :param location: The file (and line) for the error message.
203
+ :type location: str
204
+ :return: The text, without byte order mark.
205
+ :rtype: str
206
+ :raises FHIRFileError: If the content is not UTF-8.
207
+ """
208
+ try:
209
+ # utf-8-sig removes a BOM, and reads text without BOM like utf-8
210
+ return content.decode("utf-8-sig")
211
+ except UnicodeDecodeError as error:
212
+ raise FHIRFileError(f"{location}: not UTF-8: {error}") from None
213
+
214
+ @staticmethod
215
+ def _model_class(resource_type: object, location: str) -> type:
216
+ """
217
+ The fhir.resources R4B model class of a resource type.
218
+
219
+ :param resource_type: The resource type, e.g. 'Patient'.
220
+ :type resource_type: object
221
+ :param location: The file (and line) for the error message.
222
+ :type location: str
223
+ :return: The model class.
224
+ :rtype: type
225
+ :raises FHIRFileError: If it is not a FHIR resource type, also for data types like 'HumanName'.
226
+ """
227
+ try:
228
+ model_class = get_fhir_model_class(str(resource_type))
229
+ except ValueError:
230
+ model_class = None
231
+ if model_class is None or not model_class.has_resource_base():
232
+ raise FHIRFileError(f"{location}: unknown resource type {resource_type!r}")
233
+ return model_class
234
+
235
+ def _from_json(self, text: str, location: str) -> Resource:
236
+ """
237
+ Parse and validate a resource in JSON.
238
+
239
+ :param text: The JSON text of a file or an NDJSON line.
240
+ :type text: str
241
+ :param location: The file (and line) for error messages.
242
+ :type location: str
243
+ :return: The resource.
244
+ :rtype: Resource
245
+ :raises FHIRFileError: For invalid JSON, a missing or unknown resourceType, or an invalid resource.
246
+ """
247
+ try:
248
+ data = json.loads(text)
249
+ except json.JSONDecodeError as error:
250
+ raise FHIRFileError(f"{location}: invalid JSON: {error}") from None
251
+ if not isinstance(data, dict) or "resourceType" not in data:
252
+ raise FHIRFileError(f"{location}: not a FHIR resource, resourceType is missing")
253
+ model_class = self._model_class(data["resourceType"], location)
254
+ try:
255
+ return model_class.model_validate(data)
256
+ except ValidationError as error:
257
+ raise FHIRFileError(f"{location}: invalid {data['resourceType']}: {_problems(error)}") from None
258
+
259
+ def _from_xml(self, content: bytes, location: str) -> Resource:
260
+ """
261
+ Parse and validate a resource in FHIR XML. The resource type is the name of the root element, which must be in
262
+ the FHIR namespace. External entities are not resolved.
263
+
264
+ :param content: The bytes of the file, XML parses the encoding itself.
265
+ :type content: bytes
266
+ :param location: The file for error messages.
267
+ :type location: str
268
+ :return: The resource.
269
+ :rtype: Resource
270
+ :raises FHIRFileError: For invalid XML, a root element outside the FHIR namespace, an unknown resource type or
271
+ an invalid resource.
272
+ :raises ImportError: If lxml is not installed.
273
+ """
274
+ try:
275
+ from lxml import etree
276
+ except ImportError:
277
+ raise ImportError("Reading FHIR XML needs lxml, install dic-helper-toolkit[xml]") from None
278
+ # No external entities and no network access while parsing
279
+ parser = etree.XMLParser(resolve_entities=False, no_network=True)
280
+ try:
281
+ root = etree.fromstring(content, parser)
282
+ except etree.XMLSyntaxError as error:
283
+ raise FHIRFileError(f"{location}: invalid XML: {error}") from None
284
+ name = etree.QName(root)
285
+ if name.namespace != FHIR_NAMESPACE:
286
+ raise FHIRFileError(f"{location}: not a FHIR resource, the root element is not in the namespace "
287
+ f"{FHIR_NAMESPACE}")
288
+ model_class = self._model_class(name.localname, location)
289
+ try:
290
+ return model_class.model_validate_xml(content, xmlparser=parser)
291
+ except ValidationError as error:
292
+ raise FHIRFileError(f"{location}: invalid {name.localname}: {_problems(error)}") from None
293
+ except ValueError as error:
294
+ raise FHIRFileError(f"{location}: invalid {name.localname}: {error}") from None
295
+
296
+
297
+ def _problems(error: ValidationError) -> str:
298
+ """
299
+ The problems of a validation error on one line, e.g. 'birthDate: Date value string does not match spec regex.'.
300
+
301
+ :param error: The validation error of a resource.
302
+ :type error: ValidationError
303
+ :return: Every problem as '<element path>: <message>', separated by '; '.
304
+ :rtype: str
305
+ """
306
+ return "; ".join(f"{'.'.join(str(part) for part in problem['loc'])}: {problem['msg'].removeprefix('Value error, ')}"
307
+ for problem in error.errors())
@@ -0,0 +1,300 @@
1
+ import requests
2
+ import ssl
3
+ import logging
4
+ from typing import Optional, Dict, Any
5
+ from requests.auth import HTTPBasicAuth
6
+ from requests_oauthlib import OAuth2Session
7
+ from oauthlib.oauth2 import LegacyApplicationClient, BackendApplicationClient, TokenExpiredError
8
+ from urllib.parse import urljoin
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class NoRFC5280HTTPAdapter(requests.adapters.HTTPAdapter):
15
+ """
16
+ HTTP adapter that verifies certificates without Python's strict RFC 5280 checks (``VERIFY_X509_STRICT``).
17
+
18
+ Certificate chain and hostname verification stay enabled; only the additional strictness checks enabled by
19
+ default since Python 3.13 are relaxed. Applies to direct connections as well as connections through proxies.
20
+ """
21
+
22
+ def __init__(self, *args, ca_file: Optional[str] = None, **kwargs):
23
+ """Initialize the adapter with an SSL context without the strict RFC 5280 checks.
24
+
25
+ :param args: Positional arguments to pass to requests' HTTPAdapter.
26
+ :param ca_file: Path to a CA certificate file; if given, only this file is trusted, otherwise the system's CAs.
27
+ :type ca_file: str, optional
28
+ :param kwargs: Keyword arguments to pass to requests' HTTPAdapter.
29
+ """
30
+ self._ssl_context = ssl.create_default_context(cafile=ca_file)
31
+ self._ssl_context.verify_flags &= ~ssl.VERIFY_X509_STRICT
32
+ super().__init__(*args, **kwargs)
33
+
34
+ def init_poolmanager(self, *args, **kwargs):
35
+ """Create the pool manager for direct connections with this adapter's SSL context.
36
+
37
+ :param args: Positional arguments to pass to requests' HTTPAdapter.init_poolmanager.
38
+ :param kwargs: Keyword arguments to pass to requests' HTTPAdapter.init_poolmanager.
39
+ """
40
+ kwargs["ssl_context"] = self._ssl_context
41
+ super().init_poolmanager(*args, **kwargs)
42
+
43
+ def proxy_manager_for(self, proxy, **proxy_kwargs):
44
+ """Return the pool manager for connections through a proxy, with this adapter's SSL context.
45
+
46
+ :param proxy: The proxy URL.
47
+ :type proxy: str
48
+ :param proxy_kwargs: Keyword arguments to pass to requests' HTTPAdapter.proxy_manager_for.
49
+ :return: The proxy manager.
50
+ :rtype: urllib3.ProxyManager
51
+ """
52
+ proxy_kwargs["ssl_context"] = self._ssl_context
53
+ return super().proxy_manager_for(proxy, **proxy_kwargs)
54
+
55
+
56
+ class HTTPClient:
57
+ """
58
+ A client for making HTTP requests with support for OAuth2 authentication.
59
+
60
+ This client can handle basic authentication, OAuth2 Resource Owner Password Credentials flow, and OAuth2 Client
61
+ Credentials flow.
62
+ """
63
+
64
+ def __init__(self, base_url: str, username: Optional[str] = None, password: Optional[str] = None,
65
+ auth_url: Optional[str] = None, realm: Optional[str] = None,
66
+ client_id: Optional[str] = None, client_secret: Optional[str] = None,
67
+ ca_file: Optional[str] = None, rfc5280: bool = True) -> None:
68
+ """Initialize the HTTPClient.
69
+
70
+ :param base_url: The base URL for API requests.
71
+ :type base_url: str
72
+ :param username: Username for authentication.
73
+ :type username: str, optional
74
+ :param password: Password for authentication.
75
+ :type password: str, optional
76
+ :param auth_url: URL for OAuth2 authentication.
77
+ :type auth_url: str, optional
78
+ :param realm: Realm for OAuth2 authentication.
79
+ :type realm: str, optional
80
+ :param client_id: Client ID for OAuth2 authentication.
81
+ :type client_id: str, optional
82
+ :param client_secret: Client secret for OAuth2 authentication.
83
+ :type client_secret: str, optional
84
+ :param ca_file: Path to CA certificate file for SSL verification.
85
+ :type ca_file: str, optional
86
+ :param rfc5280: Enforce Python's strict RFC 5280 certificate checks (``VERIFY_X509_STRICT``). Defaults to
87
+ True. False relaxes only these checks so that certificates from non-conforming CAs are accepted;
88
+ chain and hostname verification remain active either way.
89
+ :type rfc5280: bool, optional
90
+ """
91
+ self.base_url: str = self._normalize_url(base_url)
92
+ self.username: Optional[str] = username
93
+ self.password: Optional[str] = password
94
+ self.auth_url: Optional[str] = self._normalize_url(auth_url) if auth_url else None
95
+ self.realm: Optional[str] = realm
96
+ self.client_id: Optional[str] = client_id
97
+ self.client_secret: Optional[str] = client_secret
98
+ self.ca_file: Optional[str] = ca_file
99
+ self.rfc5280: bool = rfc5280
100
+ self.session: Optional[requests.Session] = None
101
+ self.token: Optional[Dict[str, Any]] = None
102
+ self.token_url: Optional[str] = None
103
+ self._create_session()
104
+
105
+ @staticmethod
106
+ def _normalize_url(url: str) -> str:
107
+ """
108
+ Ensure the URL ends with a forward slash.
109
+
110
+ :param url: The URL to normalize.
111
+ :type url: str
112
+ :return: The normalized URL.
113
+ :rtype: str
114
+ """
115
+ if url.endswith('/'):
116
+ return url
117
+ else:
118
+ return f"{url}/"
119
+
120
+ def _get_openid_configuration(self) -> Dict[str, Any]:
121
+ """
122
+ Retrieve the OpenID configuration from the authentication server.
123
+
124
+ :return: The OpenID configuration.
125
+ :rtype: dict
126
+ :raises requests.HTTPError: If the request fails.
127
+ """
128
+ config_url = urljoin(urljoin(self.auth_url, f"realms/{self.realm}/"),
129
+ ".well-known/openid-configuration")
130
+ response = self.session.get(config_url)
131
+ response.raise_for_status()
132
+ return response.json()
133
+
134
+ def _fetch_token(self) -> Dict[str, Any]:
135
+ """
136
+ Fetch an OAuth2 token from the authentication server.
137
+
138
+ :return: The OAuth2 token.
139
+ :rtype: dict
140
+ :raises oauthlib.oauth2.OAuth2Error: If token fetching fails.
141
+ """
142
+ if self.username and self.password:
143
+ # Resource Owner Password Credentials flow
144
+ return self.session.fetch_token(
145
+ token_url=self.token_url,
146
+ username=self.username,
147
+ password=self.password,
148
+ client_id=self.client_id,
149
+ client_secret=self.client_secret
150
+ )
151
+ else:
152
+ # Client Credentials flow
153
+ # TODO: untested, needs to be tested, after KeyCloak update
154
+ return self.session.fetch_token(
155
+ token_url=self.token_url,
156
+ client_id=self.client_id,
157
+ client_secret=self.client_secret
158
+ )
159
+
160
+ def _create_session(self) -> None:
161
+ """
162
+ Create and configure the HTTP session based on the authentication method.
163
+ """
164
+ oauth = self.auth_url and self.realm and self.client_id and self.client_secret
165
+ if oauth:
166
+ if self.username and self.password:
167
+ self.session = OAuth2Session(client=LegacyApplicationClient(client_id=self.client_id))
168
+ else:
169
+ self.session = OAuth2Session(client=BackendApplicationClient(client_id=self.client_id))
170
+ else:
171
+ self.session = requests.Session()
172
+ if self.username and self.password:
173
+ self.session.auth = HTTPBasicAuth(self.username, self.password)
174
+
175
+ if self.ca_file:
176
+ self.session.verify = self.ca_file
177
+
178
+ if not self.rfc5280:
179
+ self.session.mount('https://', NoRFC5280HTTPAdapter(ca_file=self.ca_file))
180
+
181
+ if oauth:
182
+ self.token_url = self._get_openid_configuration()['token_endpoint']
183
+ self.token = self._fetch_token()
184
+
185
+ def _token_updater(self, token: Dict[str, Any]) -> None:
186
+ """
187
+ Update the stored token when it's refreshed.
188
+
189
+ :param token: The new token.
190
+ :type token: dict
191
+ """
192
+ logger.debug(f"Token updater called with token {token}")
193
+ self.token = token
194
+
195
+ def _make_request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
196
+ """
197
+ Make an HTTP request, handling token expiration if necessary.
198
+
199
+ :param method: The HTTP method (GET, POST, etc.).
200
+ :type method: str
201
+ :param path: The path to request.
202
+ :type path: str
203
+ :param kwargs: Additional arguments to pass to the request.
204
+ :return: The HTTP response.
205
+ :rtype: requests.Response
206
+ :raises requests.HTTPError: If the request fails.
207
+ """
208
+ url = urljoin(self.base_url, path)
209
+ try:
210
+ response = self.session.request(method, url, **kwargs)
211
+ except TokenExpiredError:
212
+ logger.debug(f"Token expired for {url}, Re-fetching...")
213
+ self.token = self._fetch_token()
214
+ response = self.session.request(method, url, **kwargs)
215
+
216
+ if response.status_code == 401:
217
+ logger.debug(f"Token expired for {url}, Re-fetching...")
218
+ self.token = self._fetch_token()
219
+ response = self.session.request(method, url, **kwargs)
220
+
221
+ response.raise_for_status()
222
+ return response
223
+
224
+ def get(self, path: str, **kwargs: Any) -> requests.Response:
225
+ """
226
+ Send a GET request.
227
+
228
+ :param path: The path to request.
229
+ :type path: str
230
+ :param kwargs: Additional arguments to pass to the request.
231
+ :return: The HTTP response.
232
+ :rtype: requests.Response
233
+ """
234
+ return self._make_request('GET', path, **kwargs)
235
+
236
+ def post(self, path: str, **kwargs: Any) -> requests.Response:
237
+ """
238
+ Send a POST request.
239
+
240
+ :param path: The path to request.
241
+ :type path: str
242
+ :param kwargs: Additional arguments to pass to the request.
243
+ :return: The HTTP response.
244
+ :rtype: requests.Response
245
+ """
246
+ return self._make_request('POST', path, **kwargs)
247
+
248
+ def put(self, path: str, **kwargs: Any) -> requests.Response:
249
+ """
250
+ Send a PUT request.
251
+
252
+ :param path: The path to request.
253
+ :type path: str
254
+ :param kwargs: Additional arguments to pass to the request.
255
+ :return: The HTTP response.
256
+ :rtype: requests.Response
257
+ """
258
+ return self._make_request('PUT', path, **kwargs)
259
+
260
+ def delete(self, path: str, **kwargs: Any) -> requests.Response:
261
+ """
262
+ Send a DELETE request.
263
+
264
+ :param path: The path to request.
265
+ :type path: str
266
+ :param kwargs: Additional arguments to pass to the request.
267
+ :return: The HTTP response.
268
+ :rtype: requests.Response
269
+ """
270
+ return self._make_request('DELETE', path, **kwargs)
271
+
272
+ def patch(self, path: str, **kwargs: Any) -> requests.Response:
273
+ """
274
+ Send a PATCH request.
275
+
276
+ :param path: The path to request.
277
+ :type path: str
278
+ :param kwargs: Additional arguments to pass to the request.
279
+ :return: The HTTP response.
280
+ :rtype: requests.Response
281
+ """
282
+ return self._make_request('PATCH', path, **kwargs)
283
+
284
+ def request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
285
+ """
286
+ Send a custom HTTP request.
287
+
288
+ This method allows sending any HTTP request method and is used internally
289
+ by other convenience methods like get(), post(), etc.
290
+
291
+ :param method: The HTTP method (GET, POST, PUT, DELETE, etc.).
292
+ :type method: str
293
+ :param path: The path to request.
294
+ :type path: str
295
+ :param kwargs: Additional arguments to pass to the request.
296
+ :return: The HTTP response.
297
+ :rtype: requests.Response
298
+ :raises requests.HTTPError: If the request fails.
299
+ """
300
+ return self._make_request(method, path, **kwargs)