domaintools-api 2.7.4__tar.gz → 2.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {domaintools_api-2.7.4/domaintools_api.egg-info → domaintools_api-2.8.1}/PKG-INFO +46 -5
  2. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/README.md +41 -4
  3. domaintools_api-2.8.1/VERSION +1 -0
  4. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/_version.py +1 -1
  5. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/api.py +79 -3
  6. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/base_results.py +25 -7
  7. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/api.py +10 -17
  8. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/commands/domains.py +46 -0
  9. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/commands/iris.py +5 -0
  10. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/constants.py +1 -0
  11. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/utils.py +1 -0
  12. {domaintools_api-2.7.4 → domaintools_api-2.8.1/domaintools_api.egg-info}/PKG-INFO +46 -5
  13. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools_api.egg-info/SOURCES.txt +1 -0
  14. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools_api.egg-info/requires.txt +4 -0
  15. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools_async/__init__.py +15 -3
  16. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/pyproject.toml +4 -1
  17. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/tests/test_api.py +113 -0
  18. domaintools_api-2.8.1/tests/test_async.py +91 -0
  19. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/tests/test_cli.py +8 -1
  20. domaintools_api-2.8.1/tests/test_iris_enrich_account_info.py +142 -0
  21. domaintools_api-2.7.4/VERSION +0 -1
  22. domaintools_api-2.7.4/tests/test_async.py +0 -50
  23. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/LICENSE +0 -0
  24. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/__init__.py +0 -0
  25. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/__init__.py +0 -0
  26. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/commands/__init__.py +0 -0
  27. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/commands/accounts.py +0 -0
  28. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/commands/detects.py +0 -0
  29. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/commands/feeds.py +0 -0
  30. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/commands/ips.py +0 -0
  31. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/cli/main.py +0 -0
  32. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/constants.py +0 -0
  33. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/decorators.py +0 -0
  34. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/docstring_patcher.py +0 -0
  35. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/exceptions.py +0 -0
  36. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/filters.py +0 -0
  37. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/request_validator.py +0 -0
  38. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/results.py +0 -0
  39. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/specs/iris-openapi.yaml +0 -0
  40. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools/utils.py +0 -0
  41. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools_api.egg-info/dependency_links.txt +0 -0
  42. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools_api.egg-info/entry_points.txt +0 -0
  43. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/domaintools_api.egg-info/top_level.txt +0 -0
  44. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/setup.cfg +0 -0
  45. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/setup.py +0 -0
  46. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/tests/test_decorators.py +0 -0
  47. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/tests/test_docstring_patcher.py +0 -0
  48. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/tests/test_filters.py +0 -0
  49. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/tests/test_request_validator.py +0 -0
  50. {domaintools_api-2.7.4 → domaintools_api-2.8.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: domaintools_api
3
- Version: 2.7.4
3
+ Version: 2.8.1
4
4
  Summary: DomainTools Official Python API
5
5
  Author-email: DomainTools <integrations@domaintools.com>
6
6
  License: The MIT License (MIT)
@@ -50,6 +50,10 @@ Requires-Dist: typer
50
50
  Provides-Extra: test
51
51
  Requires-Dist: pytest; extra == "test"
52
52
  Requires-Dist: mock; extra == "test"
53
+ Requires-Dist: vcrpy; extra == "test"
54
+ Requires-Dist: pytest-asyncio; extra == "test"
55
+ Requires-Dist: pytest-cov; extra == "test"
56
+ Requires-Dist: yarl; extra == "test"
53
57
  Dynamic: license-file
54
58
 
55
59
  ![domaintools](https://github.com/DomainTools/python_api/raw/main/artwork/logo.png)
@@ -199,6 +203,32 @@ You can get the status code of a response outside of exception handling by doing
199
203
  api.domain_profile('google.com').status == 200
200
204
  ```
201
205
 
206
+ IrisQL
207
+ ===================
208
+
209
+ IrisQL is a query language for Iris Investigate that lets you express complex, multi-field searches in a single request. Pass the query as a raw string via the `irisql` parameter. The query must begin with `# IrisQL-1.0`.
210
+
211
+ ```python
212
+ query = """# IrisQL-1.0
213
+ DOMAIN CONTAINS "phishing"
214
+ AND
215
+ RISK_SCORE GREATER_THAN 85
216
+ """
217
+
218
+ results = api.iris_investigate(irisql=query)
219
+ print(results["results_count"])
220
+ for domain in results:
221
+ print(domain["domain"])
222
+ ```
223
+
224
+ Pagination parameters (`page_size`, `sort_by`, `position`) are supported alongside IrisQL via `**kwargs`:
225
+
226
+ ```python
227
+ results = api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score", position=0)
228
+ ```
229
+
230
+ When `irisql` is set, any domain or filter parameters passed alongside it are silently ignored. IrisQL uses header-based authentication (`X-Api-Key`) automatically.
231
+
202
232
  Using the API Asynchronously
203
233
  ===================
204
234
 
@@ -255,6 +285,18 @@ Optionally, you can specify the desired format (html, xml, json, or list) of the
255
285
  domaintools domain_search google --max_length 10 -u $TEST_USER -k $TEST_KEY -f html
256
286
  ```
257
287
 
288
+ IrisQL queries are supported via the `--irisql` flag on `iris_investigate`. The query must begin with `# IrisQL-1.0` on its own line:
289
+
290
+ ```bash
291
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' -u $TEST_USER -k $TEST_KEY
292
+ ```
293
+
294
+ Pagination parameters can be passed alongside the IrisQL query:
295
+
296
+ ```bash
297
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' --page-size 50 --sort-by risk_score -u $TEST_USER -k $TEST_KEY
298
+ ```
299
+
258
300
  To avoid having to type in your API key repeatedly, you can specify them in `~/.dtapi` separated by a new line:
259
301
 
260
302
  ```bash
@@ -343,12 +385,11 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
343
385
  source venv/bin/activate
344
386
  ```
345
387
 
346
- - Install dependencies.
388
+ - Install dependencies (with test extras):
347
389
  ```bash
348
- pip install -r requirements/development.txt
390
+ pip install -e ".[test]"
349
391
  ```
350
-
351
- - From the python_api project root directory, install the package.
392
+ Or without test dependencies:
352
393
  ```bash
353
394
  pip install -e .
354
395
  ```
@@ -145,6 +145,32 @@ You can get the status code of a response outside of exception handling by doing
145
145
  api.domain_profile('google.com').status == 200
146
146
  ```
147
147
 
148
+ IrisQL
149
+ ===================
150
+
151
+ IrisQL is a query language for Iris Investigate that lets you express complex, multi-field searches in a single request. Pass the query as a raw string via the `irisql` parameter. The query must begin with `# IrisQL-1.0`.
152
+
153
+ ```python
154
+ query = """# IrisQL-1.0
155
+ DOMAIN CONTAINS "phishing"
156
+ AND
157
+ RISK_SCORE GREATER_THAN 85
158
+ """
159
+
160
+ results = api.iris_investigate(irisql=query)
161
+ print(results["results_count"])
162
+ for domain in results:
163
+ print(domain["domain"])
164
+ ```
165
+
166
+ Pagination parameters (`page_size`, `sort_by`, `position`) are supported alongside IrisQL via `**kwargs`:
167
+
168
+ ```python
169
+ results = api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score", position=0)
170
+ ```
171
+
172
+ When `irisql` is set, any domain or filter parameters passed alongside it are silently ignored. IrisQL uses header-based authentication (`X-Api-Key`) automatically.
173
+
148
174
  Using the API Asynchronously
149
175
  ===================
150
176
 
@@ -201,6 +227,18 @@ Optionally, you can specify the desired format (html, xml, json, or list) of the
201
227
  domaintools domain_search google --max_length 10 -u $TEST_USER -k $TEST_KEY -f html
202
228
  ```
203
229
 
230
+ IrisQL queries are supported via the `--irisql` flag on `iris_investigate`. The query must begin with `# IrisQL-1.0` on its own line:
231
+
232
+ ```bash
233
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' -u $TEST_USER -k $TEST_KEY
234
+ ```
235
+
236
+ Pagination parameters can be passed alongside the IrisQL query:
237
+
238
+ ```bash
239
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' --page-size 50 --sort-by risk_score -u $TEST_USER -k $TEST_KEY
240
+ ```
241
+
204
242
  To avoid having to type in your API key repeatedly, you can specify them in `~/.dtapi` separated by a new line:
205
243
 
206
244
  ```bash
@@ -289,12 +327,11 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
289
327
  source venv/bin/activate
290
328
  ```
291
329
 
292
- - Install dependencies.
330
+ - Install dependencies (with test extras):
293
331
  ```bash
294
- pip install -r requirements/development.txt
332
+ pip install -e ".[test]"
295
333
  ```
296
-
297
- - From the python_api project root directory, install the package.
334
+ Or without test dependencies:
298
335
  ```bash
299
336
  pip install -e .
300
337
  ```
@@ -0,0 +1 @@
1
+ 2.8.1
@@ -20,4 +20,4 @@ OTHER DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  """
22
22
 
23
- current = "2.7.4"
23
+ current = "2.8.1"
@@ -34,6 +34,7 @@ from domaintools.filters import (
34
34
  filter_by_field,
35
35
  DTResultFilter,
36
36
  )
37
+ from domaintools.exceptions import ServiceUnavailableException, ServiceException
37
38
  from domaintools.utils import validate_feeds_parameters
38
39
 
39
40
 
@@ -163,7 +164,12 @@ class API(object):
163
164
  if product != "account-information" and self.rate_limit and not self.limits_set and not self.limits:
164
165
  always_sign_api_key_previous_value = self.always_sign_api_key
165
166
  header_authentication_previous_value = self.header_authentication
166
- self._rate_limit(product)
167
+ try:
168
+ self._rate_limit(product)
169
+ except (ServiceUnavailableException, ServiceException):
170
+ # If account_information fails (e.g. rate limited itself),
171
+ # skip client-side rate limiting and proceed with the original call.
172
+ self.limits_set = True
167
173
  # Reset always_sign_api_key and header_authentication to its original
168
174
  # User-set values as these might be affected when self.account_information() was executed
169
175
  self.always_sign_api_key = always_sign_api_key_previous_value
@@ -275,6 +281,57 @@ class API(object):
275
281
  """Returns a profile for the specified domain name"""
276
282
  return self._results("domain-profile", "/v1/{0}".format(query))
277
283
 
284
+ def domain_history(
285
+ self,
286
+ query,
287
+ include_fields=None,
288
+ exclude_fields=None,
289
+ page_size=None,
290
+ offset=None,
291
+ next=None,
292
+ parsed_whois=None,
293
+ parsed_domain_rdap=None,
294
+ **kwargs,
295
+ ):
296
+ """Returns the history of changes for a given domain name.
297
+
298
+ Results are returned in reverse chronological order. Each change event includes
299
+ a timestamp, the field that changed, and the complete before/after domain state.
300
+
301
+ Args:
302
+ query: The apex domain name to retrieve history for (e.g. "domaintools.com").
303
+ include_fields: Comma-separated list of exact field names. Only change events
304
+ matching these fields appear in results. Cannot be combined with
305
+ exclude_fields. Supports aggregate prefixes (e.g. "all_ssl", "all_ip").
306
+ Example: "ip,registrar,all_ssl"
307
+ exclude_fields: Comma-separated list of exact field names. Change events
308
+ matching these fields are omitted. Cannot be combined with include_fields.
309
+ Example: "all_web_trackers,all_ssl"
310
+ page_size: Number of change events per page. Maximum and default is 100.
311
+ offset: 0-indexed starting point for pagination. Increment by page_size for
312
+ each subsequent page.
313
+ next: When True, includes a next URL in the response for cursor-based
314
+ pagination. Auth parameters must still be included when following it.
315
+ parsed_whois: When True, includes the full parsed WHOIS record in the
316
+ before/after objects of each change event.
317
+ parsed_domain_rdap: When True, includes the full parsed Domain RDAP record
318
+ in the before/after objects of each change event.
319
+ """
320
+ return self._results(
321
+ "domain-history",
322
+ "/v1/domain-history",
323
+ domain=query,
324
+ include_fields=include_fields,
325
+ exclude_fields=exclude_fields,
326
+ page_size=page_size,
327
+ offset=offset,
328
+ next=next,
329
+ parsed_whois=parsed_whois,
330
+ parsed_domain_rdap=parsed_domain_rdap,
331
+ items_path=("changes",),
332
+ **kwargs,
333
+ )
334
+
278
335
  def domain_search(
279
336
  self,
280
337
  query,
@@ -662,13 +719,14 @@ class API(object):
662
719
  updated_after=None,
663
720
  include_domains_with_missing_field=None,
664
721
  exclude_domains_with_missing_field=None,
722
+ irisql=None,
665
723
  **kwargs,
666
724
  ):
667
725
  """Returns back a list of domains based on the provided filters.
668
726
 
669
727
  You can loop over results of your investigation as if it was a native Python list:
670
728
 
671
- for result in api.iris_investigate(ip='199.30.228.112'): # Enables looping over all related results
729
+ for result in api.iris_investigate(ip='199.30.228.112'):
672
730
 
673
731
  api.iris_investigate(QUERY)['results_count'] Returns the number of results returned with this request
674
732
  api.iris_investigate(QUERY)['total_count'] Returns the number of results available within Iris
@@ -677,9 +735,27 @@ class API(object):
677
735
  api.iris_investigate(QUERY)['position'] Returns the position key that can be used to retrieve the next page:
678
736
  next_page = api.iris_investigate(QUERY, position=api.iris_investigate(QUERY)['position'])
679
737
 
680
- for enrichment in api.iris_enrich(i): # Enables looping over all returned enriched domains
738
+ IrisQL mode (mutually exclusive with all other search parameters):
739
+
740
+ irisql: str: A raw IrisQL query string. Must begin with '# IrisQL-1.0'.
741
+ Sent as a raw POST body (text/plain). When set, all domain/filter params are ignored.
742
+ Pagination params (page_size, sort_by, position) are still supported via **kwargs.
743
+
744
+ Example:
745
+ api.iris_investigate(irisql='# IrisQL-1.0\\nDOMAIN CONTAINS "phishing"', page_size=50, sort_by='risk_score')
681
746
 
682
747
  """
748
+ if irisql is not None:
749
+ if domains:
750
+ print("Warning: irisql is set — ignoring 'domains' and other search parameters. IrisQL query takes precedence.")
751
+ return self._results(
752
+ "iris-investigate",
753
+ "/v1/iris-investigate/",
754
+ items_path=("results",),
755
+ irisql=irisql,
756
+ **kwargs,
757
+ )
758
+
683
759
  # We put search_hash in the signature definition so the CLI can see it as a valid arg
684
760
  if search_hash:
685
761
  kwargs["search_hash"] = search_hash
@@ -25,7 +25,6 @@ from domaintools.exceptions import (
25
25
  RequestUriTooLongException,
26
26
  )
27
27
 
28
-
29
28
  try: # pragma: no cover
30
29
  from collections.abc import MutableMapping, MutableSequence
31
30
  except ImportError: # pragma: no cover
@@ -108,6 +107,19 @@ class Results(MutableMapping, MutableSequence):
108
107
  "iris-enrich",
109
108
  "iris-detect-escalate-domains",
110
109
  ]:
110
+ if self.product == "iris-investigate" and "irisql" in self.kwargs:
111
+ irisql_query = self.kwargs["irisql"]
112
+ auth_keys = {"api_username", "timestamp", "signature", "api_key"}
113
+ query_params = {
114
+ k: v for k, v in self.kwargs.items() if k != "irisql" and k not in auth_keys
115
+ }
116
+ query_params.update(self.api.extra_request_params)
117
+ return session.post(
118
+ url=self.url,
119
+ content=irisql_query,
120
+ params=query_params,
121
+ headers={**headers, "Content-Type": "text/plain", "X-Api-Key": self.api.key},
122
+ )
111
123
  post_data = self.kwargs.copy()
112
124
  post_data.update(self.api.extra_request_params)
113
125
  return session.post(url=self.url, data=post_data, headers=headers)
@@ -128,7 +140,7 @@ class Results(MutableMapping, MutableSequence):
128
140
  wait_for = self._wait_time()
129
141
  if self.api.rate_limit and (wait_for is None or self.product == "account-information"):
130
142
  data = self._make_request()
131
- if data.status_code == 503: # pragma: no cover
143
+ if data.status_code == 503 and self.product != "account-information": # pragma: no cover
132
144
  sleeptime = 60
133
145
  log.info(
134
146
  "503 encountered for [%s] - sleeping [%s] seconds before retrying request.",
@@ -153,13 +165,13 @@ class Results(MutableMapping, MutableSequence):
153
165
  self._data = results.json()
154
166
  else:
155
167
  self._data = results.text
156
-
157
168
  self.check_limit_exceeded()
158
169
 
159
170
  return self._data
160
171
 
161
172
  def check_limit_exceeded(self):
162
173
  limit_exceeded, reason = False, ""
174
+
163
175
  if isinstance(self._data, dict) and (
164
176
  "response" in self._data
165
177
  and "limit_exceeded" in self._data["response"]
@@ -167,7 +179,13 @@ class Results(MutableMapping, MutableSequence):
167
179
  ):
168
180
  limit_exceeded, reason = True, self._data["response"]["message"]
169
181
  elif "response" in self._data and "limit_exceeded" in self._data:
170
- limit_exceeded = True
182
+ # check for xml format, and return the actual error message
183
+ if self.kwargs.get("format") == "xml" and isinstance(self._data, str):
184
+ if re.search(r"<limit_exceeded>1</limit_exceeded>", self._data):
185
+ msg = re.search(r"<message>(.*?)</message>", self._data)
186
+ limit_exceeded, reason = True, msg.group(1) if msg else ""
187
+ else:
188
+ limit_exceeded = True
171
189
 
172
190
  if limit_exceeded:
173
191
  raise ServiceException(503, f"Limit Exceeded {reason}")
@@ -277,6 +295,8 @@ class Results(MutableMapping, MutableSequence):
277
295
 
278
296
  @property
279
297
  def json(self):
298
+ if self._data is not None:
299
+ return self
280
300
  self.kwargs.pop("format", None)
281
301
  return self.__class__(
282
302
  format="json",
@@ -341,9 +361,7 @@ class Results(MutableMapping, MutableSequence):
341
361
  )
342
362
 
343
363
  def as_list(self):
344
- return "\n".join(
345
- [json.dumps(item, indent=4, separators=(",", ": ")) for item in self._items()]
346
- )
364
+ return "\n".join([json.dumps(item, indent=4, separators=(",", ": ")) for item in self._items()])
347
365
 
348
366
  def __str__(self):
349
367
  return str(
@@ -230,28 +230,21 @@ class DTCLICommand:
230
230
  description=f"Preparing results with format of {response_format}...",
231
231
  )
232
232
 
233
+ if name not in ("available_api_calls",) and not getattr(response, "product", None) in RTTF_PRODUCTS_LIST:
234
+ response.data()
235
+
233
236
  output = cls._get_formatted_output(
234
237
  cmd_name=name, response=response, out_format=response_format
235
238
  )
236
239
 
237
- if isinstance(out_file, _io.TextIOWrapper):
238
- progress.update(
239
- task_id,
240
- description=f"Printing the results with format of {response_format}...",
241
- )
242
- # use rich `print` command to prettify the ouput in sys.stdout
243
- if name not in ("available_api_calls",) and response.product in RTTF_PRODUCTS_LIST:
244
- for feeds in response.response():
245
- print(feeds)
246
- else:
247
- print(response)
240
+ if isinstance(out_file, _io.TextIOWrapper):
241
+ if name not in ("available_api_calls",) and response.product in RTTF_PRODUCTS_LIST:
242
+ for feeds in response.response():
243
+ print(feeds)
248
244
  else:
249
- progress.update(
250
- task_id,
251
- description=f"Writing results to {out_file}",
252
- )
253
- # if it's a file then write
254
- out_file.write(output if output.endswith("\n") else output + "\n")
245
+ print(output)
246
+ else:
247
+ out_file.write(output if output.endswith("\n") else output + "\n")
255
248
  except Exception as e:
256
249
  if isinstance(e, ServiceException):
257
250
  code = typer.style(getattr(e, "code", 400), fg=typer.colors.BRIGHT_RED)
@@ -87,6 +87,51 @@ def domain_profile(
87
87
  DTCLICommand.run(name=c.DOMAIN_PROFILE, params=ctx.params)
88
88
 
89
89
 
90
+ @dt_cli.command(
91
+ name=c.DOMAIN_HISTORY,
92
+ help=get_cli_helptext_by_name(command_name=c.DOMAIN_HISTORY),
93
+ )
94
+ def domain_history(
95
+ ctx: typer.Context,
96
+ query: str = typer.Option(..., "-q", "--query", help="The apex domain name to retrieve history for (e.g. domaintools.com)."),
97
+ include_fields: str = typer.Option(None, "--include-fields", help="Comma-separated list of exact field names. Only change events matching these fields appear in results. Cannot be combined with --exclude-fields. Example: ip,registrar,all_ssl"),
98
+ exclude_fields: str = typer.Option(None, "--exclude-fields", help="Comma-separated list of exact field names. Change events matching these fields are omitted. Cannot be combined with --include-fields. Example: all_web_trackers,all_ssl"),
99
+ page_size: int = typer.Option(None, "--page-size", help="Number of change events per page. Maximum is 100 (default: 100)."),
100
+ offset: int = typer.Option(None, "--offset", help="0-indexed starting point for pagination. Increment by page-size for each subsequent page."),
101
+ next: bool = typer.Option(None, "--next", help="When true, includes a next URL in the response for cursor-based pagination."),
102
+ parsed_whois: bool = typer.Option(None, "--parsed-whois", help="When true, includes the full parsed WHOIS record in the before/after objects of each change event."),
103
+ parsed_domain_rdap: bool = typer.Option(None, "--parsed-domain-rdap", help="When true, includes the full parsed Domain RDAP record in the before/after objects of each change event."),
104
+ user: str = typer.Option(None, "-u", "--user", help="Domaintools API Username."),
105
+ key: str = typer.Option(None, "-k", "--key", help="DomainTools API key"),
106
+ creds_file: str = typer.Option(
107
+ "~/.dtapi",
108
+ "-c",
109
+ "--credfile",
110
+ help="Optional file with API username and API key, one per line.",
111
+ ),
112
+ rate_limit: bool = typer.Option(
113
+ False,
114
+ "-l",
115
+ "--rate-limit",
116
+ help="Rate limit API calls against the API based on per minute limits.",
117
+ ),
118
+ format: str = typer.Option(
119
+ "json",
120
+ "-f",
121
+ "--format",
122
+ help="Output format in {'list', 'json', 'xml', 'html'}",
123
+ callback=DTCLICommand.validate_format_input,
124
+ ),
125
+ out_file: typer.FileTextWrite = typer.Option(sys.stdout, "-o", "--out-file", help="Output file (defaults to stdout)"),
126
+ no_verify_ssl: bool = typer.Option(
127
+ False,
128
+ "--no-verify-ssl",
129
+ help="Skip verification of SSL certificate when making HTTPs API calls",
130
+ ),
131
+ ):
132
+ DTCLICommand.run(name=c.DOMAIN_HISTORY, params=ctx.params)
133
+
134
+
90
135
  @dt_cli.command(
91
136
  name=c.DOMAIN_SEARCH,
92
137
  help=get_cli_helptext_by_name(command_name=c.DOMAIN_SEARCH),
@@ -666,6 +711,7 @@ def risk_evidence(
666
711
 
667
712
  __all__ = [
668
713
  "brand_monitor",
714
+ "domain_history",
669
715
  "domain_profile",
670
716
  "domain_search",
671
717
  "name_server_monitor",
@@ -23,6 +23,11 @@ def iris_investigate(
23
23
  create_date: str = typer.Option(None, "--create-date", help="The create date."),
24
24
  active: bool = typer.Option(None, "--active", help="The domains that are in active state"),
25
25
  search_hash: str = typer.Option(None, "--search-hash", help="The search hash to use"),
26
+ irisql: str = typer.Option(
27
+ None,
28
+ "--irisql",
29
+ help="IrisQL query string (must begin with '# IrisQL-1.0'). Mutually exclusive with domain/filter params. Pagination kwargs (--page-size, --sort-by, --position) are still supported.",
30
+ ),
26
31
  src_file: str = typer.Option(
27
32
  None,
28
33
  "-s",
@@ -4,6 +4,7 @@ AVAILABLE_API_CALLS = "available_api_calls"
4
4
 
5
5
  # domains
6
6
  BRAND_MONITOR = "brand_monitor"
7
+ DOMAIN_HISTORY = "domain_history"
7
8
  DOMAIN_PROFILE = "domain_profile"
8
9
  DOMAIN_SEARCH = "domain_search"
9
10
  HOSTING_HISTORY = "hosting_history"
@@ -35,6 +35,7 @@ _command_help_mapper = {
35
35
  c.IRIS_INVESTIGATE: _iris_investigate_helptext(),
36
36
  c.IRIS_ENRICH: "Returns back enriched data related to the specified domains using our Iris Enrich service.",
37
37
  c.BRAND_MONITOR: "Pass in one or more terms as a list or separated by the pipe character ( | )",
38
+ c.DOMAIN_HISTORY: "Returns the history of changes for a given domain name.",
38
39
  c.DOMAIN_PROFILE: "Returns a profile for the specified domain name",
39
40
  c.DOMAIN_SEARCH: """Each term in the query string must be at least three characters long. Pass in a list or use spaces to separate multiple terms.""",
40
41
  c.HOSTING_HISTORY: "Returns the hosting history from the given domain name.",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: domaintools_api
3
- Version: 2.7.4
3
+ Version: 2.8.1
4
4
  Summary: DomainTools Official Python API
5
5
  Author-email: DomainTools <integrations@domaintools.com>
6
6
  License: The MIT License (MIT)
@@ -50,6 +50,10 @@ Requires-Dist: typer
50
50
  Provides-Extra: test
51
51
  Requires-Dist: pytest; extra == "test"
52
52
  Requires-Dist: mock; extra == "test"
53
+ Requires-Dist: vcrpy; extra == "test"
54
+ Requires-Dist: pytest-asyncio; extra == "test"
55
+ Requires-Dist: pytest-cov; extra == "test"
56
+ Requires-Dist: yarl; extra == "test"
53
57
  Dynamic: license-file
54
58
 
55
59
  ![domaintools](https://github.com/DomainTools/python_api/raw/main/artwork/logo.png)
@@ -199,6 +203,32 @@ You can get the status code of a response outside of exception handling by doing
199
203
  api.domain_profile('google.com').status == 200
200
204
  ```
201
205
 
206
+ IrisQL
207
+ ===================
208
+
209
+ IrisQL is a query language for Iris Investigate that lets you express complex, multi-field searches in a single request. Pass the query as a raw string via the `irisql` parameter. The query must begin with `# IrisQL-1.0`.
210
+
211
+ ```python
212
+ query = """# IrisQL-1.0
213
+ DOMAIN CONTAINS "phishing"
214
+ AND
215
+ RISK_SCORE GREATER_THAN 85
216
+ """
217
+
218
+ results = api.iris_investigate(irisql=query)
219
+ print(results["results_count"])
220
+ for domain in results:
221
+ print(domain["domain"])
222
+ ```
223
+
224
+ Pagination parameters (`page_size`, `sort_by`, `position`) are supported alongside IrisQL via `**kwargs`:
225
+
226
+ ```python
227
+ results = api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score", position=0)
228
+ ```
229
+
230
+ When `irisql` is set, any domain or filter parameters passed alongside it are silently ignored. IrisQL uses header-based authentication (`X-Api-Key`) automatically.
231
+
202
232
  Using the API Asynchronously
203
233
  ===================
204
234
 
@@ -255,6 +285,18 @@ Optionally, you can specify the desired format (html, xml, json, or list) of the
255
285
  domaintools domain_search google --max_length 10 -u $TEST_USER -k $TEST_KEY -f html
256
286
  ```
257
287
 
288
+ IrisQL queries are supported via the `--irisql` flag on `iris_investigate`. The query must begin with `# IrisQL-1.0` on its own line:
289
+
290
+ ```bash
291
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' -u $TEST_USER -k $TEST_KEY
292
+ ```
293
+
294
+ Pagination parameters can be passed alongside the IrisQL query:
295
+
296
+ ```bash
297
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' --page-size 50 --sort-by risk_score -u $TEST_USER -k $TEST_KEY
298
+ ```
299
+
258
300
  To avoid having to type in your API key repeatedly, you can specify them in `~/.dtapi` separated by a new line:
259
301
 
260
302
  ```bash
@@ -343,12 +385,11 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
343
385
  source venv/bin/activate
344
386
  ```
345
387
 
346
- - Install dependencies.
388
+ - Install dependencies (with test extras):
347
389
  ```bash
348
- pip install -r requirements/development.txt
390
+ pip install -e ".[test]"
349
391
  ```
350
-
351
- - From the python_api project root directory, install the package.
392
+ Or without test dependencies:
352
393
  ```bash
353
394
  pip install -e .
354
395
  ```
@@ -42,5 +42,6 @@ tests/test_cli.py
42
42
  tests/test_decorators.py
43
43
  tests/test_docstring_patcher.py
44
44
  tests/test_filters.py
45
+ tests/test_iris_enrich_account_info.py
45
46
  tests/test_request_validator.py
46
47
  tests/test_utils.py
@@ -6,3 +6,7 @@ typer
6
6
  [test]
7
7
  pytest
8
8
  mock
9
+ vcrpy
10
+ pytest-asyncio
11
+ pytest-cov
12
+ yarl
@@ -46,9 +46,21 @@ class AsyncResults(Results):
46
46
  session_params_and_headers = self._get_session_params_and_headers()
47
47
  headers = session_params_and_headers.get("headers")
48
48
  if self.product in ["iris-investigate", "iris-enrich", "iris-detect-escalate-domains"]:
49
- post_data = self.kwargs.copy()
50
- post_data.update(self.api.extra_request_params)
51
- results = await session.post(url=self.url, data=post_data, headers=headers)
49
+ if self.product == "iris-investigate" and "irisql" in self.kwargs:
50
+ irisql_query = self.kwargs["irisql"]
51
+ auth_keys = {"api_username", "timestamp", "signature", "api_key"}
52
+ query_params = {k: v for k, v in self.kwargs.items() if k != "irisql" and k not in auth_keys}
53
+ query_params.update(self.api.extra_request_params)
54
+ results = await session.post(
55
+ url=self.url,
56
+ content=irisql_query,
57
+ params=query_params,
58
+ headers={**headers, "Content-Type": "text/plain", "X-Api-Key": self.api.key},
59
+ )
60
+ else:
61
+ post_data = self.kwargs.copy()
62
+ post_data.update(self.api.extra_request_params)
63
+ results = await session.post(url=self.url, data=post_data, headers=headers)
52
64
  elif self.product in ["iris-detect-manage-watchlist-domains"]:
53
65
  patch_data = self.kwargs.copy()
54
66
  patch_data.update(self.api.extra_request_params, headers=headers)
@@ -41,7 +41,7 @@ Homepage = "https://github.com/domaintools/python_api"
41
41
  domaintools = "domaintools.cli:run"
42
42
 
43
43
  [project.optional-dependencies]
44
- test = ["pytest", "mock"]
44
+ test = ["pytest", "mock", "vcrpy", "pytest-asyncio", "pytest-cov", "yarl"]
45
45
 
46
46
  [tool.setuptools]
47
47
  packages = [
@@ -58,5 +58,8 @@ domaintools = ["specs/*.yaml"]
58
58
  [tool.setuptools.dynamic]
59
59
  version = { "file" = "VERSION" }
60
60
 
61
+ [tool.pytest.ini_options]
62
+ asyncio_mode = "auto"
63
+
61
64
  [tool.black]
62
65
  line-length = 110
@@ -1,6 +1,7 @@
1
1
  """Tests the Python interface for DomainTools APIs"""
2
2
 
3
3
  from os import environ
4
+ from unittest.mock import MagicMock, patch, call
4
5
 
5
6
  import json
6
7
  import pytest
@@ -10,6 +11,7 @@ from inspect import isgenerator
10
11
 
11
12
  from domaintools import API, exceptions
12
13
  from tests.settings import api, feeds_api, vcr
14
+ from tests.responses.iris_investigate_data import domaintools as iris_investigate_fixture
13
15
 
14
16
 
15
17
  @vcr.use_cassette
@@ -68,6 +70,22 @@ def test_domain_profile():
68
70
  assert "ip_address" in history
69
71
 
70
72
 
73
+ @vcr.use_cassette
74
+ def test_domain_history():
75
+ api_call = api.domain_history("chat.com", include_fields="registrar")
76
+ with api_call as result:
77
+ assert "domain" in result
78
+ assert "count" in result
79
+ assert "registration_source" in result
80
+ assert "changes" in result
81
+
82
+ for change in api_call:
83
+ assert "timestamp" in change
84
+ assert "field" in change
85
+ assert "before" in change
86
+ assert "after" in change
87
+
88
+
71
89
  @vcr.use_cassette
72
90
  def test_domain_search():
73
91
  api_call = api.domain_search("google")
@@ -447,6 +465,73 @@ def test_iris_investigate():
447
465
  assert result["domain"] in ["amazon.com", "google.com"]
448
466
 
449
467
 
468
+ def test_iris_investigate_irisql_calls_results_with_irisql():
469
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
470
+ with patch.object(api, "_results") as mock_results:
471
+ api.iris_investigate(irisql=query)
472
+ mock_results.assert_called_once_with(
473
+ "iris-investigate",
474
+ "/v1/iris-investigate/",
475
+ items_path=("results",),
476
+ irisql=query,
477
+ )
478
+
479
+
480
+ def test_iris_investigate_irisql_with_pagination_kwargs():
481
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
482
+ with patch.object(api, "_results") as mock_results:
483
+ api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score", position=0)
484
+ mock_results.assert_called_once_with(
485
+ "iris-investigate",
486
+ "/v1/iris-investigate/",
487
+ items_path=("results",),
488
+ irisql=query,
489
+ page_size=50,
490
+ sort_by="risk_score",
491
+ position=0,
492
+ )
493
+
494
+
495
+ def test_iris_investigate_irisql_warns_and_ignores_domains(capsys):
496
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
497
+ with patch.object(api, "_results"):
498
+ api.iris_investigate(irisql=query, domains=["google.com"])
499
+ captured = capsys.readouterr()
500
+ assert "Warning" in captured.out
501
+ assert "irisql" in captured.out
502
+
503
+
504
+ def test_iris_investigate_irisql_does_not_require_other_params():
505
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
506
+ with patch.object(api, "_results"):
507
+ api.iris_investigate(irisql=query)
508
+
509
+
510
+ def test_iris_investigate_irisql_request_uses_raw_body():
511
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
512
+ mock_response = MagicMock()
513
+ mock_response.status_code = 200
514
+ mock_response.json.return_value = {"response": {"results": [], "results_count": 0}}
515
+
516
+ with patch("domaintools.base_results.Client") as mock_client:
517
+ mock_session = MagicMock()
518
+ mock_client.return_value.__enter__.return_value = mock_session
519
+ mock_session.post.return_value = mock_response
520
+
521
+ result = api.iris_investigate(irisql=query)
522
+ result.data()
523
+
524
+ _, kwargs = mock_session.post.call_args
525
+ assert kwargs.get("content") == query
526
+ assert "data" not in kwargs
527
+ assert kwargs.get("headers", {}).get("Content-Type") == "text/plain"
528
+ assert kwargs.get("headers", {}).get("X-Api-Key") == api.key
529
+ for auth_key in ("api_username", "timestamp", "signature", "api_key"):
530
+ assert auth_key not in kwargs.get("params", {})
531
+
532
+
533
+
534
+
450
535
  @vcr.use_cassette
451
536
  def test_iris_detect_monitors():
452
537
  with pytest.raises(ValueError):
@@ -517,6 +602,34 @@ def test_limit_exceeded():
517
602
  response.response()
518
603
 
519
604
 
605
+ def test_limit_exceeded_xml():
606
+ xml_response = """<response>
607
+ <error>
608
+ <code>413</code>
609
+ <message>Maximum 10000 returned - you may need to refine your query.</message>
610
+ </error>
611
+ <limit_exceeded>1</limit_exceeded>
612
+ <has_more_results>1</has_more_results>
613
+ <message>Maximum 10000 returned - you may need to refine your query.</message>
614
+ <missing_domains/>
615
+ </response>"""
616
+
617
+ mock_response = MagicMock()
618
+ mock_response.status_code = 200
619
+ mock_response.text = xml_response
620
+
621
+ with patch("domaintools.base_results.Client") as mock_client:
622
+ mock_session = MagicMock()
623
+ mock_client.return_value.__enter__.return_value = mock_session
624
+ mock_session.post.return_value = mock_response
625
+
626
+ with pytest.raises(exceptions.ServiceException) as exc_info:
627
+ result = api.iris_investigate(ip="8.8.8.8", format="xml")
628
+ result.data()
629
+
630
+ assert "Maximum 10000 returned" in str(exc_info.value)
631
+
632
+
520
633
  @vcr.use_cassette
521
634
  def test_newly_observed_domains_feed():
522
635
  results = feeds_api.nod(after="-60", top=5)
@@ -0,0 +1,91 @@
1
+ """Tests async interaction support for DomainTools APIs"""
2
+
3
+ import asyncio
4
+ import pytest
5
+ from unittest.mock import MagicMock, AsyncMock, patch
6
+
7
+ from tests.settings import api, vcr
8
+
9
+
10
+ @vcr.use_cassette
11
+ @pytest.mark.asyncio
12
+ async def test_async_iteration():
13
+ results = await api.domain_search("google")
14
+ assert results
15
+
16
+ list_of_domains = []
17
+ for domain in results:
18
+ list_of_domains += domain
19
+
20
+ for domain in list_of_domains:
21
+ assert type(domain) == str
22
+
23
+
24
+ @vcr.use_cassette
25
+ @pytest.mark.asyncio
26
+ async def test_async_context_manager():
27
+ results = await api.domain_search("google")
28
+ assert results
29
+
30
+
31
+ @vcr.use_cassette
32
+ @pytest.mark.asyncio
33
+ async def test_async_simple_await():
34
+ results = await api.domain_search("google")
35
+ assert results
36
+
37
+
38
+ @vcr.use_cassette
39
+ @pytest.mark.asyncio
40
+ async def test_async_simple_await_post():
41
+ investigation_results = await api.iris_investigate(domains=["amazon.com", "google.com"])
42
+ assert investigation_results["results_count"]
43
+ for result in investigation_results:
44
+ assert result["domain"] in ["amazon.com", "google.com"]
45
+
46
+
47
+ @vcr.use_cassette
48
+ @pytest.mark.asyncio
49
+ async def test_async_simple_await_patch():
50
+ detect_results = await api.iris_detect_manage_watchlist_domains(watchlist_domain_ids=["gae08rdVWG"], state="watched")
51
+ assert detect_results["watchlist_domains"][0]["state"] == "watched"
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_async_irisql_uses_raw_body():
56
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
57
+ mock_response = MagicMock()
58
+ mock_response.status_code = 200
59
+ mock_response.json.return_value = {"response": {"results": [], "results_count": 0}}
60
+
61
+ with patch("domaintools_async.AsyncClient") as mock_client:
62
+ mock_session = AsyncMock()
63
+ mock_client.return_value.__aenter__.return_value = mock_session
64
+ mock_session.post.return_value = mock_response
65
+
66
+ result = await api.iris_investigate(irisql=query)
67
+
68
+ _, kwargs = mock_session.post.call_args
69
+ assert kwargs.get("content") == query
70
+ assert "data" not in kwargs
71
+ assert kwargs.get("headers", {}).get("Content-Type") == "text/plain"
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_async_irisql_with_pagination_kwargs():
76
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
77
+ mock_response = MagicMock()
78
+ mock_response.status_code = 200
79
+ mock_response.json.return_value = {"response": {"results": [], "results_count": 0}}
80
+
81
+ with patch("domaintools_async.AsyncClient") as mock_client:
82
+ mock_session = AsyncMock()
83
+ mock_client.return_value.__aenter__.return_value = mock_session
84
+ mock_session.post.return_value = mock_response
85
+
86
+ result = await api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score")
87
+
88
+ _, kwargs = mock_session.post.call_args
89
+ assert kwargs.get("content") == query
90
+ assert "page_size" in kwargs.get("params", {})
91
+ assert "sort_by" in kwargs.get("params", {})
@@ -27,9 +27,16 @@ def test_valid_command():
27
27
 
28
28
  def test_invalid_command():
29
29
  result = runner.invoke(dt_cli, ["test_invalid_command"])
30
- assert "No such command 'test_invalid_command'." in result.stdout
30
+ assert "No such command 'test_invalid_command'." in result.output
31
31
 
32
32
 
33
33
  def test_no_creds_file_not_found():
34
34
  result = runner.invoke(dt_cli, ["iris_investigate", "--domain", "domaintools.com"])
35
35
  assert "No such file or directory" in result.stdout
36
+
37
+
38
+ def test_iris_investigate_irisql_flag_accepted():
39
+ result = runner.invoke(dt_cli, ["iris_investigate", "--irisql", "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\"", "--help"])
40
+ assert "--irisql" in result.stdout
41
+
42
+
@@ -0,0 +1,142 @@
1
+ """Test that iris_enrich only triggers account_information once per API instance."""
2
+
3
+ from unittest.mock import patch, MagicMock
4
+
5
+ from domaintools import API
6
+ from domaintools.exceptions import ServiceUnavailableException
7
+
8
+
9
+ def test_iris_enrich_does_not_repeat_account_info_call():
10
+ """Confirm account_information is fetched only once for multiple iris_enrich calls on same API instance."""
11
+ # Reset class-level state that may have been set by previous tests
12
+ API.limits = {}
13
+ API.limits_set = False
14
+
15
+ api = API("test_user", "test_key", rate_limit=True)
16
+
17
+ # Fake account info response
18
+ fake_account_data = {
19
+ "response": {
20
+ "products": [
21
+ {"id": "iris-enrich", "per_minute_limit": 10, "per_hour_limit": 100}
22
+ ]
23
+ }
24
+ }
25
+
26
+ # Fake iris-enrich response
27
+ fake_enrich_data = {
28
+ "response": {
29
+ "results": [{"domain": "google.com"}],
30
+ "results_count": 1,
31
+ "missing_domains": [],
32
+ "limit_exceeded": False,
33
+ }
34
+ }
35
+
36
+ call_log = []
37
+
38
+ with patch("domaintools.base_results.Client") as mock_client:
39
+ mock_session = MagicMock()
40
+ mock_client.return_value.__enter__ = MagicMock(return_value=mock_session)
41
+ mock_client.return_value.__exit__ = MagicMock(return_value=False)
42
+
43
+ def fake_get(url, **kwargs):
44
+ call_log.append(("GET", url))
45
+ resp = MagicMock()
46
+ resp.status_code = 200
47
+ if "/v1/account" in url:
48
+ resp.json.return_value = fake_account_data
49
+ return resp
50
+
51
+ def fake_post(url, **kwargs):
52
+ call_log.append(("POST", url))
53
+ resp = MagicMock()
54
+ resp.status_code = 200
55
+ resp.json.return_value = fake_enrich_data
56
+ return resp
57
+
58
+ mock_session.get.side_effect = fake_get
59
+ mock_session.post.side_effect = fake_post
60
+
61
+ # First call - should trigger account_information (GET /v1/account)
62
+ api.iris_enrich("google.com")
63
+ # Second call - should NOT trigger account_information again
64
+ api.iris_enrich("amazon.com")
65
+
66
+ # Count how many times /v1/account was called
67
+ account_calls = [c for c in call_log if "/v1/account" in c[1]]
68
+ enrich_calls = [c for c in call_log if "/v1/iris-enrich" in c[1]]
69
+
70
+ assert len(account_calls) == 1, (
71
+ f"Expected exactly 1 account_information call, got {len(account_calls)}. "
72
+ f"Full call log: {call_log}"
73
+ )
74
+ assert len(enrich_calls) == 2, (
75
+ f"Expected exactly 2 iris_enrich calls, got {len(enrich_calls)}. "
76
+ f"Full call log: {call_log}"
77
+ )
78
+
79
+
80
+ def test_iris_enrich_succeeds_when_account_info_is_rate_limited():
81
+ """Verify that iris_enrich proceeds normally even if account_information returns a 503."""
82
+ # Reset class-level state that may have been set by previous tests
83
+ API.limits = {}
84
+ API.limits_set = False
85
+
86
+ api = API("test_user", "test_key", rate_limit=True)
87
+
88
+ # Fake iris-enrich response
89
+ fake_enrich_data = {
90
+ "response": {
91
+ "results": [{"domain": "google.com"}],
92
+ "results_count": 1,
93
+ "missing_domains": [],
94
+ "limit_exceeded": False,
95
+ }
96
+ }
97
+
98
+ call_log = []
99
+
100
+ with patch("domaintools.base_results.Client") as mock_client, \
101
+ patch("domaintools.base_results.time.sleep"):
102
+ mock_session = MagicMock()
103
+ mock_client.return_value.__enter__ = MagicMock(return_value=mock_session)
104
+ mock_client.return_value.__exit__ = MagicMock(return_value=False)
105
+
106
+ def fake_get(url, **kwargs):
107
+ call_log.append(("GET", url))
108
+ resp = MagicMock()
109
+ if "/v1/account" in url:
110
+ # Simulate a 503 rate limit on account_information
111
+ resp.status_code = 503
112
+ resp.json.return_value = {"error": {"message": "Rate limit exceeded"}}
113
+ else:
114
+ resp.status_code = 200
115
+ resp.json.return_value = {}
116
+ return resp
117
+
118
+ def fake_post(url, **kwargs):
119
+ call_log.append(("POST", url))
120
+ resp = MagicMock()
121
+ resp.status_code = 200
122
+ resp.json.return_value = fake_enrich_data
123
+ return resp
124
+
125
+ mock_session.get.side_effect = fake_get
126
+ mock_session.post.side_effect = fake_post
127
+
128
+ # This should NOT raise an exception despite account_info returning 503
129
+ result = api.iris_enrich("google.com")
130
+
131
+ # Verify the iris_enrich call succeeded
132
+ enrich_calls = [c for c in call_log if "/v1/iris-enrich" in c[1]]
133
+ assert len(enrich_calls) == 1, (
134
+ f"Expected 1 iris_enrich call, got {len(enrich_calls)}. Full call log: {call_log}"
135
+ )
136
+
137
+ # Verify limits_set was set to True so it won't retry account_info
138
+ assert api.limits_set is True
139
+
140
+ # Verify the result contains expected data
141
+ assert result["results_count"] == 1
142
+ assert result["results"][0]["domain"] == "google.com"
@@ -1 +0,0 @@
1
- 2.7.4
@@ -1,50 +0,0 @@
1
- """Tests async interaction support for DomainTools APIs"""
2
-
3
- import asyncio
4
- import pytest
5
-
6
- from tests.settings import api, vcr
7
-
8
-
9
- @vcr.use_cassette
10
- @pytest.mark.asyncio
11
- async def test_async_iteration():
12
- results = await api.domain_search("google")
13
- assert results
14
-
15
- list_of_domains = []
16
- for domain in results:
17
- list_of_domains += domain
18
-
19
- for domain in list_of_domains:
20
- assert type(domain) == str
21
-
22
-
23
- @vcr.use_cassette
24
- @pytest.mark.asyncio
25
- async def test_async_context_manager():
26
- results = await api.domain_search("google")
27
- assert results
28
-
29
-
30
- @vcr.use_cassette
31
- @pytest.mark.asyncio
32
- async def test_async_simple_await():
33
- results = await api.domain_search("google")
34
- assert results
35
-
36
-
37
- @vcr.use_cassette
38
- @pytest.mark.asyncio
39
- async def test_async_simple_await_post():
40
- investigation_results = await api.iris_investigate(domains=["amazon.com", "google.com"])
41
- assert investigation_results["results_count"]
42
- for result in investigation_results:
43
- assert result["domain"] in ["amazon.com", "google.com"]
44
-
45
-
46
- @vcr.use_cassette
47
- @pytest.mark.asyncio
48
- async def test_async_simple_await_patch():
49
- detect_results = await api.iris_detect_manage_watchlist_domains(watchlist_domain_ids=["gae08rdVWG"], state="watched")
50
- assert detect_results["watchlist_domains"][0]["state"] == "watched"
File without changes