comb-utils 0.1.9__tar.gz → 0.2.0__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,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: comb_utils
3
- Version: 0.1.9
3
+ Version: 0.2.0
4
4
  Summary: Handy utils for Python projects.
5
5
  Author: Kaleb Coberly
6
6
  Maintainer-email: kaleb.coberly@gmail.com
@@ -40,13 +40,14 @@ Provides-Extra: test
40
40
  Requires-Dist: coverage[toml]; extra == "test"
41
41
  Requires-Dist: pytest; extra == "test"
42
42
  Requires-Dist: pytest-cov; extra == "test"
43
+ Dynamic: license-file
43
44
 
44
45
  # Comb Utils
45
46
 
46
- ## Summary
47
-
48
47
  Some handy utils for Python projects. Repo is made from the `reference_package` template repo: https://github.com/crickets-and-comb/reference_package. See the docs: https://crickets-and-comb.github.io/shared_utils/.
49
48
 
49
+ This is a [Crickets and Comb](https://cricketsandcomb.org) resource.
50
+
50
51
  ## Structure
51
52
 
52
53
  ```bash
@@ -122,6 +123,8 @@ When making smaller commits, you might just want to run some of the smaller comm
122
123
 
123
124
  $ make clean format full-qc full-test
124
125
 
126
+ #### Using act
127
+
125
128
  As a final step, it's good practice to test run the workflow before opening a PR or pushing to an open PR. We don't want to waste GitHub runtime on a glitch that we could have caught before. You can use a make target for that:
126
129
 
127
130
  $ make run-act
@@ -131,3 +134,5 @@ That will run `.github/workflows/CI_CD.yml`. But, you can also run any workflow
131
134
  To use this tool, you'll need to have Docker installed and running on your machine: https://www.docker.com/. You'll also need to install `act` in your terminal:
132
135
 
133
136
  $ brew install act
137
+
138
+ NOTE: To be more accurate, we've overridden `set-CI-CD-file` to create a local `CI_CD_act.yml` (which we ignore with Git) as a copy of `CI_CD.yml` and replace one of the workflow call URLs with a relative path. We use a relative path because otherwise `act` will not honor the overridden `full-test` make target and will run the shared version. That will fail because the shared `full-test` target includes running integration and e2e tests, which this repo does not include.
@@ -1,9 +1,9 @@
1
1
  # Comb Utils
2
2
 
3
- ## Summary
4
-
5
3
  Some handy utils for Python projects. Repo is made from the `reference_package` template repo: https://github.com/crickets-and-comb/reference_package. See the docs: https://crickets-and-comb.github.io/shared_utils/.
6
4
 
5
+ This is a [Crickets and Comb](https://cricketsandcomb.org) resource.
6
+
7
7
  ## Structure
8
8
 
9
9
  ```bash
@@ -79,6 +79,8 @@ When making smaller commits, you might just want to run some of the smaller comm
79
79
 
80
80
  $ make clean format full-qc full-test
81
81
 
82
+ #### Using act
83
+
82
84
  As a final step, it's good practice to test run the workflow before opening a PR or pushing to an open PR. We don't want to waste GitHub runtime on a glitch that we could have caught before. You can use a make target for that:
83
85
 
84
86
  $ make run-act
@@ -87,4 +89,6 @@ That will run `.github/workflows/CI_CD.yml`. But, you can also run any workflow
87
89
 
88
90
  To use this tool, you'll need to have Docker installed and running on your machine: https://www.docker.com/. You'll also need to install `act` in your terminal:
89
91
 
90
- $ brew install act
92
+ $ brew install act
93
+
94
+ NOTE: To be more accurate, we've overridden `set-CI-CD-file` to create a local `CI_CD_act.yml` (which we ignore with Git) as a copy of `CI_CD.yml` and replace one of the workflow call URLs with a relative path. We use a relative path because otherwise `act` will not honor the overridden `full-test` make target and will run the shared version. That will fail because the shared `full-test` target includes running integration and e2e tests, which this repo does not include.
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = comb_utils
3
- version = 0.1.9
3
+ version = 0.2.0
4
4
  description = Handy utils for Python projects.
5
5
  long_description = file: README.md
6
6
  long_description_content_type = text/markdown
@@ -0,0 +1,14 @@
1
+ """Top-level init."""
2
+
3
+ from comb_utils.lib import (
4
+ BaseCaller,
5
+ BaseDeleteCaller,
6
+ BaseGetCaller,
7
+ BasePagedResponseGetter,
8
+ BasePostCaller,
9
+ DocString,
10
+ ErrorDocString,
11
+ concat_response_pages,
12
+ get_response_dict,
13
+ get_responses,
14
+ )
@@ -0,0 +1,13 @@
1
+ """lib init."""
2
+
3
+ from comb_utils.lib.api_callers import (
4
+ BaseCaller,
5
+ BaseDeleteCaller,
6
+ BaseGetCaller,
7
+ BasePagedResponseGetter,
8
+ BasePostCaller,
9
+ concat_response_pages,
10
+ get_response_dict,
11
+ get_responses,
12
+ )
13
+ from comb_utils.lib.docs import DocString, ErrorDocString
@@ -0,0 +1,444 @@
1
+ """Classes for making API calls."""
2
+
3
+ import logging
4
+ from abc import abstractmethod
5
+ from collections.abc import Callable
6
+ from time import sleep
7
+ from typing import Any
8
+
9
+ import requests
10
+ from requests.auth import HTTPBasicAuth
11
+ from typeguard import typechecked
12
+
13
+ from comb_utils.lib.constants import RateLimits
14
+
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # TODO: https://github.com/crickets-and-comb/comb_utils/issues/38:
20
+ # Why are we using _set_url instead of the url property?
21
+ # Why are we using _set_request_call instead of the _request_call property?
22
+
23
+
24
+ class BaseCaller:
25
+ """An abstract class for making API calls.
26
+
27
+ See :doc:`api_callers`.
28
+
29
+ Example:
30
+ .. code:: python
31
+
32
+ class MyGetCaller(BaseCaller):
33
+ target_response_value
34
+
35
+ _min_wait_seconds: float = 0.1
36
+ # Initialize _wait_seconds and timeout as a class variable.
37
+ # Instances will adjust for class.
38
+ _wait_seconds: float = _min_wait_seconds
39
+ _timeout: float = 10
40
+
41
+ def _set_request_call(self):
42
+ self._request_call = requests.get
43
+
44
+ def _set_url(self):
45
+ self._url = "https://example.com/public/v0.2b/"
46
+
47
+ def _get_API_key(self) -> str | None:
48
+ # Optionally wrap your own API key retrieval function here.
49
+ return my_custom_key_retrieval_function()
50
+
51
+ def _handle_200(self):
52
+ super()._handle_200()
53
+ self.target_response_value = self.response_json["target_key"]
54
+
55
+ my_caller = MyCaller()
56
+ my_caller.call_api()
57
+ target_response_value = my_caller.target_response_value
58
+
59
+ .. important::
60
+ You must initialize _wait_seconds, _timeout, and _min_wait_seconds in child classes.
61
+ This allows child class instances to adjust the wait/timeout time for the child class.
62
+
63
+ .. warning::
64
+ There is a potential for this to run indefinitely for rate limiting and timeouts.
65
+ It handles them somewhat intelligently, but the assumption is that someone is watching
66
+ this run in the background and will stop it if it runs too long. It will eventually
67
+ at least crash the memory, depending on available memory, mean time to failure, and
68
+ time left in the universe.
69
+
70
+ .. note::
71
+ The `_set_request_call` and `_set_url` methods will be deprecated in favor of setting
72
+ the request call member at child class definition and passing the URL to `__init__`.
73
+ """
74
+
75
+ # Set by object:
76
+ #: The JSON from the response.
77
+ response_json: dict[str, Any]
78
+ #: The response from the API call.
79
+ _response: requests.Response
80
+
81
+ # Must set in child class with _set*:
82
+ #: The requests call method. (get, post, etc.)
83
+ _request_call: Callable
84
+ #: The URL for the API call.
85
+ _url: str
86
+
87
+ # Must set in child class:
88
+ #: The timeout for the API call.
89
+ _timeout: float
90
+ #: The minimum wait time between API calls.
91
+ _min_wait_seconds: float
92
+ #: The wait time between API calls. (Adjusted by instances, at class level.)
93
+ _wait_seconds: float
94
+
95
+ # Optionally set in child class, to pass to _request_call if needed:
96
+ #: The kwargs to pass to the requests call.
97
+ _call_kwargs: dict[str, Any] = {}
98
+ #: The scalar to increase wait time on rate limiting.
99
+ _wait_increase_scalar: float = RateLimits.WAIT_INCREASE_SCALAR
100
+ #: The scalar to decrease wait time on success.
101
+ _wait_decrease_scalar: float = RateLimits.WAIT_DECREASE_SECONDS
102
+
103
+ @typechecked
104
+ def __init__(self) -> None: # noqa: ANN401
105
+ """Initialize the BaseCaller object."""
106
+ self._set_request_call()
107
+ self._set_url()
108
+
109
+ @abstractmethod
110
+ @typechecked
111
+ def _set_request_call(self) -> None:
112
+ """Set the requests call method.
113
+
114
+ requests.get, requests.post, etc.
115
+
116
+ Raises:
117
+ NotImplementedError: If not implemented in child class.
118
+ """
119
+ raise NotImplementedError
120
+
121
+ @abstractmethod
122
+ @typechecked
123
+ def _set_url(self) -> None:
124
+ """Set the URL for the API call.
125
+
126
+ Raises:
127
+ NotImplementedError: If not implemented in child class.
128
+ """
129
+ raise NotImplementedError
130
+
131
+ @typechecked
132
+ def call_api(self) -> None:
133
+ """The main method for making the API call.
134
+
135
+ Handle errors, parse response, and decrease class wait time on success.
136
+
137
+ Raises:
138
+ ValueError: If the response status code is not expected.
139
+ requests.exceptions.HTTPError: For non-rate-limiting errors.
140
+ """
141
+ # Separated to allow for recursive calls on rate limiting.
142
+ self._call_api()
143
+ self._decrease_wait_time()
144
+
145
+ @typechecked
146
+ def _call_api(self) -> None:
147
+ """Wait and make and handle the API call.
148
+
149
+ Wrapped separately to allow for recursive calls on rate limiting and timeout.
150
+ """
151
+ sleep(type(self)._wait_seconds)
152
+ self._make_call()
153
+ self._raise_for_status()
154
+ self._parse_response()
155
+
156
+ @typechecked
157
+ def _make_call(self) -> None:
158
+ """Make the API call."""
159
+ self._response = self._request_call(
160
+ url=self._url,
161
+ auth=HTTPBasicAuth(self._get_API_key(), ""),
162
+ timeout=self._timeout,
163
+ **self._call_kwargs,
164
+ )
165
+
166
+ @typechecked
167
+ def _raise_for_status(self) -> None:
168
+ """Handle error responses.
169
+
170
+ For 429 (rate limiting), increases wait time and recursively calls the API.
171
+ For timeout, increases timeout and recursively calls the API.
172
+
173
+ Raises:
174
+ requests.exceptions.HTTPError: For non-rate-limiting errors.
175
+ requests.exceptions.Timeout: For timeouts.
176
+ """
177
+ try:
178
+ self._response.raise_for_status()
179
+ except requests.exceptions.HTTPError as http_e:
180
+ if self._response.status_code == 429:
181
+ self._handle_429()
182
+ else:
183
+ self._handle_unknown_error(e=http_e)
184
+ except requests.exceptions.Timeout:
185
+ self._handle_timeout()
186
+
187
+ @typechecked
188
+ def _parse_response(self) -> None:
189
+ """Parse the non-error reponse (200).
190
+
191
+ Raises:
192
+ ValueError: If the response status code is not expected.
193
+ """
194
+ if self._response.status_code == 200:
195
+ self._handle_200()
196
+ elif self._response.status_code == 204:
197
+ self._handle_204()
198
+ elif self._response.status_code == 429:
199
+ # This is here as well as in the _raise_for_status method because there was a case
200
+ # when the status code was 429 but the response didn't raise.
201
+ self._handle_429()
202
+ else:
203
+ response_dict = get_response_dict(response=self._response)
204
+ raise ValueError(
205
+ f"Unexpected response {self._response.status_code}:\n{response_dict}"
206
+ )
207
+
208
+ @typechecked
209
+ def _get_API_key(self) -> str | None:
210
+ """Get the API key.
211
+
212
+ Defaults to None, but can be overridden in child class.
213
+ """
214
+ return None
215
+
216
+ @typechecked
217
+ def _handle_429(self) -> None:
218
+ """Handle a 429 response.
219
+
220
+ Inreases the class wait time and recursively calls the API.
221
+ """
222
+ self._increase_wait_time()
223
+ logger.warning(f"Rate limited. Waiting {type(self)._wait_seconds} seconds to retry.")
224
+ self._call_api()
225
+
226
+ @typechecked
227
+ def _handle_timeout(self) -> None:
228
+ """Handle a timeout response.
229
+
230
+ Increases the class timeout and recursively calls the API.
231
+ """
232
+ self._increase_timeout()
233
+ response_dict = get_response_dict(response=self._response)
234
+ logger.warning(
235
+ f"Request timed out.\n{response_dict}"
236
+ f"\nTrying again with longer timeout: {type(self)._timeout} seconds."
237
+ )
238
+ self._call_api()
239
+
240
+ @typechecked
241
+ def _handle_200(self) -> None:
242
+ """Handle a 200 response.
243
+
244
+ Just gets the JSON from the response and sets it to `response_json`.
245
+ """
246
+ self.response_json = self._response.json()
247
+
248
+ @typechecked
249
+ def _handle_204(self) -> None:
250
+ """Handle a 204 response.
251
+
252
+ Just sets `response_json` to an empty dictionary.
253
+ """
254
+ self.response_json = {}
255
+
256
+ @typechecked
257
+ def _handle_unknown_error(self, e: Exception) -> None:
258
+ """Handle an unknown error response.
259
+
260
+ Raises:
261
+ Exception: The original error.
262
+ """
263
+ response_dict = get_response_dict(response=self._response)
264
+ err_msg = f"Got {self._response.status_code} response:\n{response_dict}"
265
+ raise requests.exceptions.HTTPError(err_msg) from e
266
+
267
+ @typechecked
268
+ def _decrease_wait_time(self) -> None:
269
+ """Decrease the wait time between API calls for whole class."""
270
+ cls = type(self)
271
+ cls._wait_seconds = max(
272
+ cls._wait_seconds * self._wait_decrease_scalar, cls._min_wait_seconds
273
+ )
274
+
275
+ @typechecked
276
+ def _increase_wait_time(self) -> None:
277
+ """Increase the wait time between API calls for whole class."""
278
+ cls = type(self)
279
+ cls._wait_seconds = cls._wait_seconds * self._wait_increase_scalar
280
+
281
+ @typechecked
282
+ def _increase_timeout(self) -> None:
283
+ """Increase the timeout for the API call for whole class."""
284
+ cls = type(self)
285
+ cls._timeout = cls._timeout * self._wait_increase_scalar
286
+
287
+
288
+ class BaseGetCaller(BaseCaller):
289
+ """A base class for making GET API calls.
290
+
291
+ Presets the timeout, initial wait time, and requests method.
292
+ """
293
+
294
+ _timeout: float = RateLimits.READ_TIMEOUT_SECONDS
295
+ _min_wait_seconds: float = RateLimits.READ_SECONDS
296
+ _wait_seconds: float = _min_wait_seconds
297
+
298
+ @typechecked
299
+ def _set_request_call(self) -> None:
300
+ """Set the requests call method to `requests.get`."""
301
+ self._request_call = requests.get
302
+
303
+
304
+ class BasePostCaller(BaseCaller):
305
+ """A base class for making POST API calls.
306
+
307
+ Presets the timeout, initial wait time, and requests method.
308
+ """
309
+
310
+ _timeout: float = RateLimits.WRITE_TIMEOUT_SECONDS
311
+ _min_wait_seconds: float = RateLimits.WRITE_SECONDS
312
+ _wait_seconds: float = _min_wait_seconds
313
+
314
+ @typechecked
315
+ def _set_request_call(self) -> None:
316
+ """Set the requests call method to `requests.post`."""
317
+ self._request_call = requests.post
318
+
319
+
320
+ class BaseDeleteCaller(BasePostCaller):
321
+ """A base class for making DELETE API calls.
322
+
323
+ Presets the timeout, initial wait time, and requests method.
324
+ """
325
+
326
+ @typechecked
327
+ def _set_request_call(self) -> None:
328
+ """Set the requests call method to `requests.delete`."""
329
+ self._request_call = requests.delete
330
+
331
+
332
+ class BasePagedResponseGetter(BaseGetCaller):
333
+ """Class for getting paged responses."""
334
+
335
+ #: The nextPageToken returned, but called salsa to avoid bandit.
336
+ next_page_salsa: str | None
337
+
338
+ #: The URL for the page.
339
+ _page_url: str
340
+
341
+ @typechecked
342
+ def __init__(self, page_url: str) -> None:
343
+ """Initialize the BasePagedResponseGetter object.
344
+
345
+ Args:
346
+ page_url: The URL for the page. (Optionally contains nextPageToken.)
347
+ """
348
+ self._page_url = page_url
349
+ super().__init__()
350
+
351
+ @typechecked
352
+ def _set_url(self) -> None:
353
+ """Set the URL for the API call to the `page_url`."""
354
+ self._url = self._page_url
355
+
356
+ @typechecked
357
+ def _handle_200(self) -> None:
358
+ """Handle a 200 response.
359
+
360
+ Sets `next_page_salsa` to the nextPageToken.
361
+ """
362
+ super()._handle_200()
363
+ self.next_page_salsa = self.response_json.get("nextPageToken", None)
364
+
365
+
366
+ @typechecked
367
+ def get_response_dict(response: requests.Response) -> dict[str, Any]:
368
+ """Safely handle a response that may not be JSON.
369
+
370
+ Args:
371
+ response: The response from the API call.
372
+
373
+ Returns:
374
+ A dictionary containing the response data.
375
+ """
376
+ try:
377
+ response_dict: dict = response.json()
378
+ except Exception as e:
379
+ response_dict = {
380
+ "reason": response.reason,
381
+ "additional_notes": "No-JSON response.",
382
+ "No-JSON response exception:": str(e),
383
+ }
384
+
385
+ return response_dict
386
+
387
+
388
+ # TODO: Pass params instead of forming URL first. ("params", not "json")
389
+ # (Would need to then grab params URL for next page, or just add nextpage to params?)
390
+ # https://github.com/crickets-and-comb/bfb_delivery/issues/61
391
+ # TODO: bfb_delivery issue 59, comb_utils issue 24: move above issue to comb_utils.
392
+ # TODO: bfb_delivery issue 59, comb_utils issue 24:
393
+ # Switch to default getter if key retriever can be empty.
394
+ @typechecked
395
+ def get_responses(
396
+ url: str, paged_response_class: type[BasePagedResponseGetter]
397
+ ) -> list[dict[str, Any]]:
398
+ """Get all responses from a paginated API endpoint.
399
+
400
+ Args:
401
+ url: The base URL of the API endpoint.
402
+ paged_response_class: The class used to get the paginated response.
403
+
404
+ Returns:
405
+ A list of dictionaries containing the responses from all pages.
406
+ """
407
+ # Calling the token salsa to trick bandit into ignoring what looks like a hardcoded token.
408
+ next_page_salsa = ""
409
+ next_page_cookie = ""
410
+ responses = []
411
+
412
+ while next_page_salsa is not None:
413
+ paged_response_getter = paged_response_class(page_url=url + str(next_page_cookie))
414
+ paged_response_getter.call_api()
415
+
416
+ stops = paged_response_getter._response.json()
417
+ responses.append(stops)
418
+ next_page_salsa = paged_response_getter.next_page_salsa
419
+
420
+ if next_page_salsa:
421
+ salsa_prefix = "?" if "?" not in url else "&"
422
+ next_page_cookie = f"{salsa_prefix}pageToken={next_page_salsa}"
423
+
424
+ return responses
425
+
426
+
427
+ @typechecked
428
+ def concat_response_pages(
429
+ page_list: list[dict[str, Any]], data_key: str
430
+ ) -> list[dict[str, Any]]:
431
+ """Extract and concatenate the data lists from response pages.
432
+
433
+ Args:
434
+ page_list: A list of response page dictionaries.
435
+ data_key: The key to extract the data from each page.
436
+
437
+ Returns:
438
+ A list of dictionaries containing the data from each page.
439
+ """
440
+ data_list = []
441
+ for page in page_list:
442
+ data_list += page[data_key]
443
+
444
+ return data_list
@@ -0,0 +1,14 @@
1
+ """Constants for the comb_utils library."""
2
+
3
+ from typing import Final
4
+
5
+
6
+ class RateLimits:
7
+ """Default rate limits for :doc:`api_callers`."""
8
+
9
+ READ_TIMEOUT_SECONDS: Final[float] = 10
10
+ READ_SECONDS: Final[float] = 0.1
11
+ WAIT_DECREASE_SECONDS: Final[float] = 0.6
12
+ WAIT_INCREASE_SCALAR: Final[float] = 2
13
+ WRITE_SECONDS: Final[float] = 0.2
14
+ WRITE_TIMEOUT_SECONDS: Final[float] = 10
@@ -27,7 +27,6 @@ class ErrorDocString:
27
27
  return
28
28
 
29
29
 
30
- # TODO: Move this to a central utils repo. (And combine with Defaults? Include types?)
31
30
  class DocString:
32
31
  """Class to format docstrings for public API `sphinx` docs and CLI `click` help.
33
32
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: comb_utils
3
- Version: 0.1.9
3
+ Version: 0.2.0
4
4
  Summary: Handy utils for Python projects.
5
5
  Author: Kaleb Coberly
6
6
  Maintainer-email: kaleb.coberly@gmail.com
@@ -40,13 +40,14 @@ Provides-Extra: test
40
40
  Requires-Dist: coverage[toml]; extra == "test"
41
41
  Requires-Dist: pytest; extra == "test"
42
42
  Requires-Dist: pytest-cov; extra == "test"
43
+ Dynamic: license-file
43
44
 
44
45
  # Comb Utils
45
46
 
46
- ## Summary
47
-
48
47
  Some handy utils for Python projects. Repo is made from the `reference_package` template repo: https://github.com/crickets-and-comb/reference_package. See the docs: https://crickets-and-comb.github.io/shared_utils/.
49
48
 
49
+ This is a [Crickets and Comb](https://cricketsandcomb.org) resource.
50
+
50
51
  ## Structure
51
52
 
52
53
  ```bash
@@ -122,6 +123,8 @@ When making smaller commits, you might just want to run some of the smaller comm
122
123
 
123
124
  $ make clean format full-qc full-test
124
125
 
126
+ #### Using act
127
+
125
128
  As a final step, it's good practice to test run the workflow before opening a PR or pushing to an open PR. We don't want to waste GitHub runtime on a glitch that we could have caught before. You can use a make target for that:
126
129
 
127
130
  $ make run-act
@@ -131,3 +134,5 @@ That will run `.github/workflows/CI_CD.yml`. But, you can also run any workflow
131
134
  To use this tool, you'll need to have Docker installed and running on your machine: https://www.docker.com/. You'll also need to install `act` in your terminal:
132
135
 
133
136
  $ brew install act
137
+
138
+ NOTE: To be more accurate, we've overridden `set-CI-CD-file` to create a local `CI_CD_act.yml` (which we ignore with Git) as a copy of `CI_CD.yml` and replace one of the workflow call URLs with a relative path. We use a relative path because otherwise `act` will not honor the overridden `full-test` make target and will run the shared version. That will fail because the shared `full-test` target includes running integration and e2e tests, which this repo does not include.
@@ -9,4 +9,6 @@ src/comb_utils.egg-info/dependency_links.txt
9
9
  src/comb_utils.egg-info/requires.txt
10
10
  src/comb_utils.egg-info/top_level.txt
11
11
  src/comb_utils/lib/__init__.py
12
+ src/comb_utils/lib/api_callers.py
13
+ src/comb_utils/lib/constants.py
12
14
  src/comb_utils/lib/docs.py
@@ -1,3 +0,0 @@
1
- """Top-level init."""
2
-
3
- from comb_utils.lib import DocString, ErrorDocString
@@ -1,3 +0,0 @@
1
- """lib init."""
2
-
3
- from comb_utils.lib.docs import DocString, ErrorDocString
File without changes
File without changes