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.
Files changed (43) hide show
  1. {domaintools_api-2.2.0/domaintools_api.egg-info → domaintools_api-2.3.0}/PKG-INFO +61 -3
  2. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/README.md +59 -1
  3. domaintools_api-2.3.0/VERSION +1 -0
  4. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/_version.py +1 -1
  5. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/api.py +65 -22
  6. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/base_results.py +67 -26
  7. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/api.py +50 -25
  8. domaintools_api-2.3.0/domaintools/cli/commands/feeds.py +316 -0
  9. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/constants.py +2 -0
  10. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/utils.py +2 -0
  11. domaintools_api-2.3.0/domaintools/constants.py +35 -0
  12. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/results.py +27 -0
  13. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/utils.py +21 -20
  14. {domaintools_api-2.2.0 → domaintools_api-2.3.0/domaintools_api.egg-info}/PKG-INFO +61 -3
  15. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/SOURCES.txt +1 -0
  16. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_async/__init__.py +10 -6
  17. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_api.py +148 -44
  18. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_utils.py +54 -52
  19. domaintools_api-2.2.0/VERSION +0 -1
  20. domaintools_api-2.2.0/domaintools/cli/commands/feeds.py +0 -138
  21. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/LICENSE +0 -0
  22. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/__init__.py +0 -0
  23. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/__init__.py +0 -0
  24. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/__init__.py +0 -0
  25. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/accounts.py +0 -0
  26. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/detects.py +0 -0
  27. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/domains.py +0 -0
  28. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/ips.py +0 -0
  29. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/iris.py +0 -0
  30. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/commands/phisheye.py +0 -0
  31. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/cli/main.py +0 -0
  32. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/exceptions.py +0 -0
  33. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools/filters.py +0 -0
  34. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/dependency_links.txt +0 -0
  35. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/entry_points.txt +0 -0
  36. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/requires.txt +0 -0
  37. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/domaintools_api.egg-info/top_level.txt +0 -0
  38. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/pyproject.toml +0 -0
  39. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/setup.cfg +0 -0
  40. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/setup.py +0 -0
  41. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_async.py +0 -0
  42. {domaintools_api-2.2.0 → domaintools_api-2.3.0}/tests/test_cli.py +0 -0
  43. {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
1
+ Metadata-Version: 2.2
2
2
  Name: domaintools_api
3
- Version: 2.2.0
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
@@ -20,4 +20,4 @@ OTHER DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  """
22
22
 
23
- current = "2.2.0"
23
+ current = "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
- if isinstance(proxy_url, str):
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
- self.handle_api_key(path, parameters)
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
- parameters["api_key"] = self.key
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
- sessionID = kwargs.get("sessionID")
1064
- after = kwargs.get("after")
1065
- if not (sessionID or after):
1066
- raise ValueError("sessionID or after (can be both) must be defined")
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-(api)",
1070
- "v1/feed/nod/",
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
- sessionID = kwargs.get("sessionID")
1078
- after = kwargs.get("after")
1079
- if not (sessionID or after):
1080
- raise ValueError("sessionID or after (can be both) must be defined")
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
- "newly-active-domains-feed-(api)",
1084
- "v1/feed/nad/",
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
- if limit_exceeded:
132
- self._limit_exceeded = True
133
- self._limit_exceeded_message = message
148
+ self.check_limit_exceeded()
134
149
 
135
- if self._limit_exceeded is True:
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
- if self.kwargs.get("format", "json") == "json":
142
- if "response" in self._data and "limit_exceeded" in self._data["response"] and self._data["response"]["limit_exceeded"] is True:
143
- return True, self._data["response"]["message"]
144
- # TODO: handle html, xml response errors better.
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
- return True, "limit exceeded"
147
- return False, ""
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 == 400:
185
+ if code in (400, 422):
171
186
  raise BadRequestException(code, reason)
172
- elif code == 403:
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.exceptions import ServiceException
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 = "python_wrapper_cli_2.0.0"
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
- f"{value} is not in available formats: {VALID_FORMATS}"
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
- return str(
89
- getattr(response, out_format)
90
- if out_format != "list"
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 = params.pop("format", "json")
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
- print(response)
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