domaintools-api 2.2.0__tar.gz → 2.3.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.
- {domaintools_api-2.2.0/domaintools_api.egg-info → domaintools_api-2.3.0}/PKG-INFO +61 -3
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/README.md +59 -1
- domaintools_api-2.3.0/VERSION +1 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/_version.py +1 -1
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/api.py +65 -22
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/base_results.py +67 -26
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/api.py +50 -25
- domaintools_api-2.3.0/domaintools/cli/commands/feeds.py +316 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/constants.py +2 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/utils.py +2 -0
- domaintools_api-2.3.0/domaintools/constants.py +35 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/results.py +27 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/utils.py +21 -20
- {domaintools_api-2.2.0 → domaintools_api-2.3.0/domaintools_api.egg-info}/PKG-INFO +61 -3
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/SOURCES.txt +1 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_async/__init__.py +10 -6
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_api.py +148 -44
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_utils.py +54 -52
- domaintools_api-2.2.0/VERSION +0 -1
- domaintools_api-2.2.0/domaintools/cli/commands/feeds.py +0 -138
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/LICENSE +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/__init__.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/__init__.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/__init__.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/accounts.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/detects.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/domains.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/ips.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/iris.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/phisheye.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/main.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/exceptions.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/filters.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/dependency_links.txt +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/entry_points.txt +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/requires.txt +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/top_level.txt +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/pyproject.toml +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/setup.cfg +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/setup.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_async.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_cli.py +0 -0
- {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_filters.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: domaintools_api
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: DomainTools Official Python API
|
|
5
5
|
Author-email: DomainTools <integrations@domaintools.com>
|
|
6
6
|
License: The MIT License (MIT)
|
|
@@ -260,5 +260,63 @@ API_KEY
|
|
|
260
260
|
Python Version Support Policy
|
|
261
261
|
===================
|
|
262
262
|
|
|
263
|
-
Please see the [supported versions](https://github.com/DomainTools/python_api/raw/main/PYTHON_SUPPORT.md) document
|
|
263
|
+
Please see the [supported versions](https://github.com/DomainTools/python_api/raw/main/PYTHON_SUPPORT.md) document
|
|
264
264
|
for the DomainTools Python support policy.
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
Real-Time Threat Intelligence Feeds
|
|
268
|
+
===================
|
|
269
|
+
|
|
270
|
+
Real-Time Threat Intelligence Feeds provide data on the different stages of the domain lifecycle: from first-observed in the wild, to newly re-activated after a period of quiet. Access current feed data in real-time or retrieve historical feed data through separate APIs.
|
|
271
|
+
|
|
272
|
+
Custom parameters aside from the common `GET` Request parameters:
|
|
273
|
+
- `endpoint` (choose either `download` or `feed` API endpoint - default is `feed`)
|
|
274
|
+
```python
|
|
275
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
276
|
+
api.nod(endpoint="feed", **kwargs)
|
|
277
|
+
```
|
|
278
|
+
- `header_authentication`: by default, we're using API Header Authentication. Set this False if you want to use API Key and Secret Authentication. Apparently, you can't use API Header Authentication for `download` endpoints so this will be defaulted to `False` even without explicitly setting it.
|
|
279
|
+
```python
|
|
280
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
281
|
+
api.nod(header_authentication=False, **kwargs)
|
|
282
|
+
```
|
|
283
|
+
- `output_format`: (choose either `csv` or `jsonl` - default is `jsonl`). Cannot be used in `domainrdap` feeds. Additionally, `csv` is not available for `download` endpoints.
|
|
284
|
+
```python
|
|
285
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
286
|
+
api.nod(output_format="csv", **kwargs)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The Feed API standard access pattern is to periodically request the most recent feed data, as often as every 60 seconds. Specify the range of data you receive in one of two ways:
|
|
290
|
+
|
|
291
|
+
1. With `sessionID`: Make a call and provide a new `sessionID` parameter of your choosing. The API will return the last hour of data by default.
|
|
292
|
+
- Each subsequent call to the API using your `sessionID` will return all data since the last.
|
|
293
|
+
- Any single request returns a maximum of 10M results. Requests that exceed 10M results will return a HTTP 206 response code; repeat the same request (with the same `sessionID`) to receive the next tranche of data until receiving a HTTP 200 response code.
|
|
294
|
+
2. Or, specify the time range in one of two ways:
|
|
295
|
+
- Either an `after=-60` query parameter, where (in this example) -60 indicates the previous 60 seconds.
|
|
296
|
+
- Or `after` and `before` query parameters for a time range, with each parameter accepting an ISO-8601 UTC formatted timestamp (a UTC date and time of the format YYYY-MM-DDThh:mm:ssZ)
|
|
297
|
+
|
|
298
|
+
## Handling iterative response from RTUF endpoints:
|
|
299
|
+
|
|
300
|
+
Since we may dealing with large feeds datasets, the python wrapper uses `generator` for efficient memory handling. Therefore, we need to iterate through the `generator` if we're accessing the partial results of the feeds data.
|
|
301
|
+
|
|
302
|
+
### Single request because the requested data is within the maximum result:
|
|
303
|
+
```python
|
|
304
|
+
from domaintools import API
|
|
305
|
+
|
|
306
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
307
|
+
results = api.nod(sessionID="my-session-id", after=-60)
|
|
308
|
+
|
|
309
|
+
for result in results.response() # generator that holds NOD feeds data for the past 60 seconds and is expected to request only once
|
|
310
|
+
# do things to result
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Multiple requests because the requested data is more than the maximum result per request:
|
|
314
|
+
```python
|
|
315
|
+
from domaintools import API
|
|
316
|
+
|
|
317
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
318
|
+
results = api.nod(sessionID="my-session-id", after=-7200)
|
|
319
|
+
|
|
320
|
+
for partial_result in results.response() # generator that holds NOD feeds data for the past 2 hours and is expected to request multiple times
|
|
321
|
+
# do things to partial_result
|
|
322
|
+
```
|
|
@@ -211,5 +211,63 @@ API_KEY
|
|
|
211
211
|
Python Version Support Policy
|
|
212
212
|
===================
|
|
213
213
|
|
|
214
|
-
Please see the [supported versions](https://github.com/DomainTools/python_api/raw/main/PYTHON_SUPPORT.md) document
|
|
214
|
+
Please see the [supported versions](https://github.com/DomainTools/python_api/raw/main/PYTHON_SUPPORT.md) document
|
|
215
215
|
for the DomainTools Python support policy.
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
Real-Time Threat Intelligence Feeds
|
|
219
|
+
===================
|
|
220
|
+
|
|
221
|
+
Real-Time Threat Intelligence Feeds provide data on the different stages of the domain lifecycle: from first-observed in the wild, to newly re-activated after a period of quiet. Access current feed data in real-time or retrieve historical feed data through separate APIs.
|
|
222
|
+
|
|
223
|
+
Custom parameters aside from the common `GET` Request parameters:
|
|
224
|
+
- `endpoint` (choose either `download` or `feed` API endpoint - default is `feed`)
|
|
225
|
+
```python
|
|
226
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
227
|
+
api.nod(endpoint="feed", **kwargs)
|
|
228
|
+
```
|
|
229
|
+
- `header_authentication`: by default, we're using API Header Authentication. Set this False if you want to use API Key and Secret Authentication. Apparently, you can't use API Header Authentication for `download` endpoints so this will be defaulted to `False` even without explicitly setting it.
|
|
230
|
+
```python
|
|
231
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
232
|
+
api.nod(header_authentication=False, **kwargs)
|
|
233
|
+
```
|
|
234
|
+
- `output_format`: (choose either `csv` or `jsonl` - default is `jsonl`). Cannot be used in `domainrdap` feeds. Additionally, `csv` is not available for `download` endpoints.
|
|
235
|
+
```python
|
|
236
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
237
|
+
api.nod(output_format="csv", **kwargs)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The Feed API standard access pattern is to periodically request the most recent feed data, as often as every 60 seconds. Specify the range of data you receive in one of two ways:
|
|
241
|
+
|
|
242
|
+
1. With `sessionID`: Make a call and provide a new `sessionID` parameter of your choosing. The API will return the last hour of data by default.
|
|
243
|
+
- Each subsequent call to the API using your `sessionID` will return all data since the last.
|
|
244
|
+
- Any single request returns a maximum of 10M results. Requests that exceed 10M results will return a HTTP 206 response code; repeat the same request (with the same `sessionID`) to receive the next tranche of data until receiving a HTTP 200 response code.
|
|
245
|
+
2. Or, specify the time range in one of two ways:
|
|
246
|
+
- Either an `after=-60` query parameter, where (in this example) -60 indicates the previous 60 seconds.
|
|
247
|
+
- Or `after` and `before` query parameters for a time range, with each parameter accepting an ISO-8601 UTC formatted timestamp (a UTC date and time of the format YYYY-MM-DDThh:mm:ssZ)
|
|
248
|
+
|
|
249
|
+
## Handling iterative response from RTUF endpoints:
|
|
250
|
+
|
|
251
|
+
Since we may dealing with large feeds datasets, the python wrapper uses `generator` for efficient memory handling. Therefore, we need to iterate through the `generator` if we're accessing the partial results of the feeds data.
|
|
252
|
+
|
|
253
|
+
### Single request because the requested data is within the maximum result:
|
|
254
|
+
```python
|
|
255
|
+
from domaintools import API
|
|
256
|
+
|
|
257
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
258
|
+
results = api.nod(sessionID="my-session-id", after=-60)
|
|
259
|
+
|
|
260
|
+
for result in results.response() # generator that holds NOD feeds data for the past 60 seconds and is expected to request only once
|
|
261
|
+
# do things to result
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Multiple requests because the requested data is more than the maximum result per request:
|
|
265
|
+
```python
|
|
266
|
+
from domaintools import API
|
|
267
|
+
|
|
268
|
+
api = API(USERNAME, KEY, always_sign_api_key=False)
|
|
269
|
+
results = api.nod(sessionID="my-session-id", after=-7200)
|
|
270
|
+
|
|
271
|
+
for partial_result in results.response() # generator that holds NOD feeds data for the past 2 hours and is expected to request multiple times
|
|
272
|
+
# do things to partial_result
|
|
273
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.3.0
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from datetime import datetime, timedelta, timezone
|
|
2
2
|
from hashlib import sha1, sha256, md5
|
|
3
3
|
from hmac import new as hmac
|
|
4
|
+
|
|
4
5
|
import re
|
|
5
6
|
|
|
7
|
+
from domaintools.constants import Endpoint, ENDPOINT_TO_SOURCE_MAP, FEEDS_PRODUCTS_LIST, OutputFormat
|
|
6
8
|
from domaintools._version import current as version
|
|
7
9
|
from domaintools.results import (
|
|
8
10
|
GroupedIterable,
|
|
@@ -10,6 +12,7 @@ from domaintools.results import (
|
|
|
10
12
|
ParsedDomainRdap,
|
|
11
13
|
Reputation,
|
|
12
14
|
Results,
|
|
15
|
+
FeedsResults,
|
|
13
16
|
)
|
|
14
17
|
from domaintools.filters import (
|
|
15
18
|
filter_by_riskscore,
|
|
@@ -18,6 +21,8 @@ from domaintools.filters import (
|
|
|
18
21
|
filter_by_field,
|
|
19
22
|
DTResultFilter,
|
|
20
23
|
)
|
|
24
|
+
from domaintools.utils import validate_feeds_parameters
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
AVAILABLE_KEY_SIGN_HASHES = ["sha1", "sha256", "md5"]
|
|
23
28
|
|
|
@@ -84,11 +89,8 @@ class API(object):
|
|
|
84
89
|
|
|
85
90
|
if not https:
|
|
86
91
|
raise Exception("The DomainTools API endpoints no longer support http traffic. Please make sure https=True.")
|
|
87
|
-
if proxy_url:
|
|
88
|
-
|
|
89
|
-
self.proxy_url = {"http://": proxy_url, "https://": proxy_url}
|
|
90
|
-
else:
|
|
91
|
-
raise Exception("Proxy URL must be a string. For example: '127.0.0.1:8888'")
|
|
92
|
+
if proxy_url and not isinstance(proxy_url, str):
|
|
93
|
+
raise Exception("Proxy URL must be a string. For example: '127.0.0.1:8888'")
|
|
92
94
|
|
|
93
95
|
def _build_api_url(self, api_url=None, api_port=None):
|
|
94
96
|
"""Build the API url based on the given url and port. Defaults to `https://api.domaintools.com`"""
|
|
@@ -122,14 +124,18 @@ class API(object):
|
|
|
122
124
|
uri = "/".join((self._rest_api_url, path.lstrip("/")))
|
|
123
125
|
parameters = self.default_parameters.copy()
|
|
124
126
|
parameters["api_username"] = self.username
|
|
125
|
-
|
|
127
|
+
header_authentication = kwargs.pop("header_authentication", True) # Used only by Real-Time Threat Intelligence Feeds endpoints for now
|
|
128
|
+
self.handle_api_key(product, path, parameters, header_authentication)
|
|
126
129
|
parameters.update({key: str(value).lower() if value in (True, False) else value for key, value in kwargs.items() if value is not None})
|
|
127
130
|
|
|
128
131
|
return cls(self, product, uri, **parameters)
|
|
129
132
|
|
|
130
|
-
def handle_api_key(self, path, parameters):
|
|
133
|
+
def handle_api_key(self, product, path, parameters, header_authentication):
|
|
131
134
|
if self.https and not self.always_sign_api_key:
|
|
132
|
-
|
|
135
|
+
if product in FEEDS_PRODUCTS_LIST and header_authentication:
|
|
136
|
+
parameters["X-Api-Key"] = self.key
|
|
137
|
+
else:
|
|
138
|
+
parameters["api_key"] = self.key
|
|
133
139
|
else:
|
|
134
140
|
if self.key_sign_hash and self.key_sign_hash in AVAILABLE_KEY_SIGN_HASHES:
|
|
135
141
|
signing_hash = eval(self.key_sign_hash)
|
|
@@ -1058,30 +1064,67 @@ class API(object):
|
|
|
1058
1064
|
**kwargs,
|
|
1059
1065
|
)
|
|
1060
1066
|
|
|
1061
|
-
def nod(self, **kwargs):
|
|
1067
|
+
def nod(self, **kwargs) -> FeedsResults:
|
|
1062
1068
|
"""Returns back list of the newly observed domains feed"""
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1069
|
+
validate_feeds_parameters(kwargs)
|
|
1070
|
+
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
|
|
1071
|
+
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint)
|
|
1072
|
+
if endpoint == Endpoint.DOWNLOAD.value or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
|
|
1073
|
+
# headers param is allowed only in Feed API and CSV format
|
|
1074
|
+
kwargs.pop("headers", None)
|
|
1067
1075
|
|
|
1068
1076
|
return self._results(
|
|
1069
|
-
"newly-observed-domains-feed-(
|
|
1070
|
-
"v1/
|
|
1077
|
+
f"newly-observed-domains-feed-({source.value})",
|
|
1078
|
+
f"v1/{endpoint}/nod/",
|
|
1071
1079
|
response_path=(),
|
|
1080
|
+
cls=FeedsResults,
|
|
1072
1081
|
**kwargs,
|
|
1073
1082
|
)
|
|
1074
1083
|
|
|
1075
|
-
def nad(self, **kwargs):
|
|
1084
|
+
def nad(self, **kwargs) -> FeedsResults:
|
|
1076
1085
|
"""Returns back list of the newly active domains feed"""
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1086
|
+
validate_feeds_parameters(kwargs)
|
|
1087
|
+
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
|
|
1088
|
+
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
|
|
1089
|
+
if endpoint == Endpoint.DOWNLOAD.value or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
|
|
1090
|
+
# headers param is allowed only in Feed API and CSV format
|
|
1091
|
+
kwargs.pop("headers", None)
|
|
1092
|
+
|
|
1093
|
+
return self._results(
|
|
1094
|
+
f"newly-active-domains-feed-({source})",
|
|
1095
|
+
f"v1/{endpoint}/nad/",
|
|
1096
|
+
response_path=(),
|
|
1097
|
+
cls=FeedsResults,
|
|
1098
|
+
**kwargs,
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
def domainrdap(self, **kwargs) -> FeedsResults:
|
|
1102
|
+
"""Returns changes to global domain registration information, populated by the Registration Data Access Protocol (RDAP)"""
|
|
1103
|
+
validate_feeds_parameters(kwargs)
|
|
1104
|
+
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
|
|
1105
|
+
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
|
|
1106
|
+
|
|
1107
|
+
return self._results(
|
|
1108
|
+
f"domain-registration-data-access-protocol-feed-({source})",
|
|
1109
|
+
f"v1/{endpoint}/domainrdap/",
|
|
1110
|
+
response_path=(),
|
|
1111
|
+
cls=FeedsResults,
|
|
1112
|
+
**kwargs,
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
def domaindiscovery(self, **kwargs) -> FeedsResults:
|
|
1116
|
+
"""Returns new domains as they are either discovered in domain registration information, observed by our global sensor network, or reported by trusted third parties"""
|
|
1117
|
+
validate_feeds_parameters(kwargs)
|
|
1118
|
+
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
|
|
1119
|
+
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
|
|
1120
|
+
if endpoint == Endpoint.DOWNLOAD.value or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
|
|
1121
|
+
# headers param is allowed only in Feed API and CSV format
|
|
1122
|
+
kwargs.pop("headers", None)
|
|
1081
1123
|
|
|
1082
1124
|
return self._results(
|
|
1083
|
-
"
|
|
1084
|
-
"v1/
|
|
1125
|
+
f"real-time-domain-discovery-feed-({source})",
|
|
1126
|
+
f"v1/{endpoint}/domaindiscovery/",
|
|
1085
1127
|
response_path=(),
|
|
1128
|
+
cls=FeedsResults,
|
|
1086
1129
|
**kwargs,
|
|
1087
1130
|
)
|
|
@@ -4,8 +4,12 @@ import json
|
|
|
4
4
|
import re
|
|
5
5
|
import time
|
|
6
6
|
import logging
|
|
7
|
+
|
|
8
|
+
from copy import deepcopy
|
|
7
9
|
from datetime import datetime
|
|
10
|
+
from httpx import Client
|
|
8
11
|
|
|
12
|
+
from domaintools.constants import FEEDS_PRODUCTS_LIST, OutputFormat, HEADER_ACCEPT_KEY_CSV_FORMAT
|
|
9
13
|
from domaintools.exceptions import (
|
|
10
14
|
BadRequestException,
|
|
11
15
|
InternalServerErrorException,
|
|
@@ -16,9 +20,7 @@ from domaintools.exceptions import (
|
|
|
16
20
|
IncompleteResponseException,
|
|
17
21
|
RequestUriTooLongException,
|
|
18
22
|
)
|
|
19
|
-
from domaintools.utils import get_feeds_products_list
|
|
20
23
|
|
|
21
|
-
from httpx import Client
|
|
22
24
|
|
|
23
25
|
try: # pragma: no cover
|
|
24
26
|
from collections.abc import MutableMapping, MutableSequence
|
|
@@ -51,8 +53,6 @@ class Results(MutableMapping, MutableSequence):
|
|
|
51
53
|
self._response = None
|
|
52
54
|
self._items_list = None
|
|
53
55
|
self._data = None
|
|
54
|
-
self._limit_exceeded = None
|
|
55
|
-
self._limit_exceeded_message = None
|
|
56
56
|
|
|
57
57
|
def _wait_time(self):
|
|
58
58
|
if not self.api.rate_limit or not self.product in self.api.limits:
|
|
@@ -75,6 +75,23 @@ class Results(MutableMapping, MutableSequence):
|
|
|
75
75
|
|
|
76
76
|
return wait_for
|
|
77
77
|
|
|
78
|
+
def _get_session_params(self):
|
|
79
|
+
parameters = deepcopy(self.kwargs)
|
|
80
|
+
parameters.pop("output_format", None)
|
|
81
|
+
parameters.pop(
|
|
82
|
+
"format", None
|
|
83
|
+
) # For some unknownn reasons, even if "format" is not included in the cli params for feeds endpoint, it is being populated thus we need to remove it. Happens only if using CLI.
|
|
84
|
+
headers = {}
|
|
85
|
+
if self.kwargs.get("output_format", OutputFormat.JSONL.value) == OutputFormat.CSV.value:
|
|
86
|
+
parameters["headers"] = int(bool(self.kwargs.get("headers", False)))
|
|
87
|
+
headers["accept"] = HEADER_ACCEPT_KEY_CSV_FORMAT
|
|
88
|
+
|
|
89
|
+
header_api_key = parameters.pop("X-Api-Key", None)
|
|
90
|
+
if header_api_key:
|
|
91
|
+
headers["X-Api-Key"] = header_api_key
|
|
92
|
+
|
|
93
|
+
return {"parameters": parameters, "headers": headers}
|
|
94
|
+
|
|
78
95
|
def _make_request(self):
|
|
79
96
|
|
|
80
97
|
with Client(verify=self.api.verify_ssl, proxy=self.api.proxy_url, timeout=None) as session:
|
|
@@ -90,6 +107,11 @@ class Results(MutableMapping, MutableSequence):
|
|
|
90
107
|
patch_data = self.kwargs.copy()
|
|
91
108
|
patch_data.update(self.api.extra_request_params)
|
|
92
109
|
return session.patch(url=self.url, json=patch_data)
|
|
110
|
+
elif self.product in FEEDS_PRODUCTS_LIST:
|
|
111
|
+
session_params = self._get_session_params()
|
|
112
|
+
parameters = session_params.get("parameters")
|
|
113
|
+
headers = session_params.get("headers")
|
|
114
|
+
return session.get(url=self.url, params=parameters, headers=headers, **self.api.extra_request_params)
|
|
93
115
|
else:
|
|
94
116
|
return session.get(url=self.url, params=self.kwargs, **self.api.extra_request_params)
|
|
95
117
|
|
|
@@ -118,33 +140,26 @@ class Results(MutableMapping, MutableSequence):
|
|
|
118
140
|
if self._data is None:
|
|
119
141
|
results = self._get_results()
|
|
120
142
|
self.setStatus(results.status_code, results)
|
|
121
|
-
if (
|
|
122
|
-
self.kwargs.get("format", "json") == "json"
|
|
123
|
-
and self.product
|
|
124
|
-
not in get_feeds_products_list() # Special handling of feeds products' data to preserve the result in jsonline format
|
|
125
|
-
):
|
|
143
|
+
if self.kwargs.get("format", "json") == "json":
|
|
126
144
|
self._data = results.json()
|
|
127
145
|
else:
|
|
128
146
|
self._data = results.text
|
|
129
|
-
limit_exceeded, message = self.check_limit_exceeded()
|
|
130
147
|
|
|
131
|
-
|
|
132
|
-
self._limit_exceeded = True
|
|
133
|
-
self._limit_exceeded_message = message
|
|
148
|
+
self.check_limit_exceeded()
|
|
134
149
|
|
|
135
|
-
|
|
136
|
-
raise ServiceException(503, "Limit Exceeded{}".format(self._limit_exceeded_message))
|
|
137
|
-
else:
|
|
138
|
-
return self._data
|
|
150
|
+
return self._data
|
|
139
151
|
|
|
140
152
|
def check_limit_exceeded(self):
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
limit_exceeded, reason = False, ""
|
|
154
|
+
if isinstance(self._data, dict) and (
|
|
155
|
+
"response" in self._data and "limit_exceeded" in self._data["response"] and self._data["response"]["limit_exceeded"] is True
|
|
156
|
+
):
|
|
157
|
+
limit_exceeded, reason = True, self._data["response"]["message"]
|
|
145
158
|
elif "response" in self._data and "limit_exceeded" in self._data:
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
limit_exceeded = True
|
|
160
|
+
|
|
161
|
+
if limit_exceeded:
|
|
162
|
+
raise ServiceException(503, f"Limit Exceeded {reason}")
|
|
148
163
|
|
|
149
164
|
@property
|
|
150
165
|
def status(self):
|
|
@@ -155,7 +170,7 @@ class Results(MutableMapping, MutableSequence):
|
|
|
155
170
|
|
|
156
171
|
def setStatus(self, code, response=None):
|
|
157
172
|
self._status = code
|
|
158
|
-
if code == 200:
|
|
173
|
+
if code == 200 or (self.product in FEEDS_PRODUCTS_LIST and code == 206):
|
|
159
174
|
return
|
|
160
175
|
|
|
161
176
|
reason = None
|
|
@@ -167,9 +182,9 @@ class Results(MutableMapping, MutableSequence):
|
|
|
167
182
|
if callable(reason):
|
|
168
183
|
reason = reason()
|
|
169
184
|
|
|
170
|
-
if code
|
|
185
|
+
if code in (400, 422):
|
|
171
186
|
raise BadRequestException(code, reason)
|
|
172
|
-
elif code
|
|
187
|
+
elif code in (401, 403):
|
|
173
188
|
raise NotAuthorizedException(code, reason)
|
|
174
189
|
elif code == 404:
|
|
175
190
|
raise NotFoundException(code, reason)
|
|
@@ -259,6 +274,32 @@ class Results(MutableMapping, MutableSequence):
|
|
|
259
274
|
**self.kwargs,
|
|
260
275
|
)
|
|
261
276
|
|
|
277
|
+
@property
|
|
278
|
+
def jsonl(self):
|
|
279
|
+
self.kwargs.pop("format", None)
|
|
280
|
+
return self.__class__(
|
|
281
|
+
format="jsonl",
|
|
282
|
+
product=self.product,
|
|
283
|
+
url=self.url,
|
|
284
|
+
items_path=self.items_path,
|
|
285
|
+
response_path=self.response_path,
|
|
286
|
+
api=self.api,
|
|
287
|
+
**self.kwargs,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def csv(self):
|
|
292
|
+
self.kwargs.pop("format", None)
|
|
293
|
+
return self.__class__(
|
|
294
|
+
format="csv",
|
|
295
|
+
product=self.product,
|
|
296
|
+
url=self.url,
|
|
297
|
+
items_path=self.items_path,
|
|
298
|
+
response_path=self.response_path,
|
|
299
|
+
api=self.api,
|
|
300
|
+
**self.kwargs,
|
|
301
|
+
)
|
|
302
|
+
|
|
262
303
|
@property
|
|
263
304
|
def xml(self):
|
|
264
305
|
self.kwargs.pop("format", None)
|
|
@@ -4,17 +4,20 @@ import sys
|
|
|
4
4
|
import os
|
|
5
5
|
import _io
|
|
6
6
|
|
|
7
|
+
from datetime import datetime
|
|
7
8
|
from typing import Optional, Dict, Tuple
|
|
8
9
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
9
10
|
|
|
10
11
|
from domaintools.api import API
|
|
11
|
-
from domaintools.
|
|
12
|
+
from domaintools.constants import Endpoint, FEEDS_PRODUCTS_LIST, OutputFormat
|
|
12
13
|
from domaintools.cli.utils import get_file_extension
|
|
14
|
+
from domaintools.exceptions import ServiceException
|
|
15
|
+
from domaintools._version import current as version
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
class DTCLICommand:
|
|
16
19
|
API_SUCCESS_STATUS = 200
|
|
17
|
-
APP_PARTNER_NAME = "
|
|
20
|
+
APP_PARTNER_NAME = f"python_wrapper_cli_{version}"
|
|
18
21
|
|
|
19
22
|
@staticmethod
|
|
20
23
|
def print_api_version(value: bool):
|
|
@@ -28,11 +31,35 @@ class DTCLICommand:
|
|
|
28
31
|
def validate_format_input(value: str):
|
|
29
32
|
VALID_FORMATS = ("list", "json", "xml", "html")
|
|
30
33
|
if value not in VALID_FORMATS:
|
|
31
|
-
raise typer.BadParameter(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
raise typer.BadParameter(f"{value} is not in available formats: {VALID_FORMATS}")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def validate_feeds_format_input(value: str):
|
|
39
|
+
VALID_FEEDS_FORMATS = ("jsonl", "csv")
|
|
40
|
+
if value not in VALID_FEEDS_FORMATS:
|
|
41
|
+
raise typer.BadParameter(f"{value} is not in available formats: {VALID_FEEDS_FORMATS}")
|
|
34
42
|
return value
|
|
35
43
|
|
|
44
|
+
@staticmethod
|
|
45
|
+
def validate_endpoint_input(value: str):
|
|
46
|
+
VALID_ENDPOINTS = (Endpoint.FEED.value, Endpoint.DOWNLOAD.value)
|
|
47
|
+
if value not in VALID_ENDPOINTS:
|
|
48
|
+
raise typer.BadParameter(f"{value} is not in available endpoints: {VALID_ENDPOINTS}")
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def validate_after_or_before_input(value: str):
|
|
53
|
+
if value is None or value.replace("-", "").isdigit():
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
# Checks if value is a valid ISO 8601 datetime string in UTC form
|
|
57
|
+
try:
|
|
58
|
+
datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
|
59
|
+
return value
|
|
60
|
+
except:
|
|
61
|
+
raise typer.BadParameter(f"{value} is neither an integer or a valid ISO 8601 datetime string in UTC form")
|
|
62
|
+
|
|
36
63
|
@staticmethod
|
|
37
64
|
def validate_source_file_extension(value: str):
|
|
38
65
|
"""Validates source file extension.
|
|
@@ -51,9 +78,7 @@ class DTCLICommand:
|
|
|
51
78
|
ext = get_file_extension(value)
|
|
52
79
|
|
|
53
80
|
if ext.lower() not in VALID_EXTENSIONS:
|
|
54
|
-
raise typer.BadParameter(
|
|
55
|
-
f"{value} is not in valid extensions. Valid file extensions: {VALID_EXTENSIONS}"
|
|
56
|
-
)
|
|
81
|
+
raise typer.BadParameter(f"{value} is not in valid extensions. Valid file extensions: {VALID_EXTENSIONS}")
|
|
57
82
|
|
|
58
83
|
return value
|
|
59
84
|
|
|
@@ -85,11 +110,9 @@ class DTCLICommand:
|
|
|
85
110
|
def _get_formatted_output(cls, cmd_name: str, response, out_format: str = "json"):
|
|
86
111
|
if cmd_name in ("available_api_calls",):
|
|
87
112
|
return "\n".join(response)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
else response.as_list()
|
|
92
|
-
)
|
|
113
|
+
if response.product in FEEDS_PRODUCTS_LIST:
|
|
114
|
+
return "\n".join([data for data in response.response()])
|
|
115
|
+
return str(getattr(response, out_format) if out_format != "list" else response.as_list())
|
|
93
116
|
|
|
94
117
|
@classmethod
|
|
95
118
|
def _get_credentials(cls, params: Optional[Dict] = {}) -> Tuple[str]:
|
|
@@ -106,9 +129,7 @@ class DTCLICommand:
|
|
|
106
129
|
with open(creds_file, "r") as cf:
|
|
107
130
|
user, key = cf.readline().strip(), cf.readline().strip()
|
|
108
131
|
except FileNotFoundError as e:
|
|
109
|
-
raise typer.BadParameter(
|
|
110
|
-
f"{str(e)}. Please create one first and try again."
|
|
111
|
-
)
|
|
132
|
+
raise typer.BadParameter(f"{str(e)}. Please create one first and try again.")
|
|
112
133
|
|
|
113
134
|
return user, key
|
|
114
135
|
|
|
@@ -149,7 +170,13 @@ class DTCLICommand:
|
|
|
149
170
|
"""
|
|
150
171
|
try:
|
|
151
172
|
rate_limit = params.pop("rate_limit", False)
|
|
152
|
-
response_format =
|
|
173
|
+
response_format = (
|
|
174
|
+
params.pop("format", "json")
|
|
175
|
+
if params.get("format", None)
|
|
176
|
+
else params.get(
|
|
177
|
+
"output_format", OutputFormat.JSONL.value
|
|
178
|
+
) # Using output_format for RTUF endpoints to separate from other endpoints. This will be needed further along the process
|
|
179
|
+
)
|
|
153
180
|
out_file = params.pop("out_file", sys.stdout)
|
|
154
181
|
verify_ssl = params.pop("no_verify_ssl", False)
|
|
155
182
|
always_sign_api_key = params.pop("no_sign_api_key", False)
|
|
@@ -198,13 +225,14 @@ class DTCLICommand:
|
|
|
198
225
|
total=None,
|
|
199
226
|
)
|
|
200
227
|
|
|
201
|
-
output = cls._get_formatted_output(
|
|
202
|
-
cmd_name=name, response=response, out_format=response_format
|
|
203
|
-
)
|
|
228
|
+
output = cls._get_formatted_output(cmd_name=name, response=response, out_format=response_format)
|
|
204
229
|
|
|
205
230
|
if isinstance(out_file, _io.TextIOWrapper):
|
|
206
231
|
# use rich `print` command to prettify the ouput in sys.stdout
|
|
207
|
-
|
|
232
|
+
if response.product in FEEDS_PRODUCTS_LIST:
|
|
233
|
+
print(output)
|
|
234
|
+
else:
|
|
235
|
+
print(response)
|
|
208
236
|
else:
|
|
209
237
|
# if it's a file then write
|
|
210
238
|
out_file.write(output if output.endswith("\n") else output + "\n")
|
|
@@ -215,10 +243,7 @@ class DTCLICommand:
|
|
|
215
243
|
_reason = getattr(e, "reason", {})
|
|
216
244
|
# check data type first as some of the reasons is just plain text
|
|
217
245
|
if isinstance(_reason, dict):
|
|
218
|
-
_reason = (
|
|
219
|
-
_reason.get("error", {}).get("message")
|
|
220
|
-
or "Unknown Error occured."
|
|
221
|
-
)
|
|
246
|
+
_reason = _reason.get("error", {}).get("message") or "Unknown Error occured."
|
|
222
247
|
|
|
223
248
|
reason = typer.style(_reason, bg=typer.colors.RED)
|
|
224
249
|
|