fmtr.tools 1.1.1__py3-none-any.whl → 1.3.81__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. fmtr/tools/__init__.py +68 -52
  2. fmtr/tools/ai_tools/__init__.py +2 -2
  3. fmtr/tools/ai_tools/agentic_tools.py +151 -32
  4. fmtr/tools/ai_tools/inference_tools.py +2 -1
  5. fmtr/tools/api_tools.py +8 -5
  6. fmtr/tools/caching_tools.py +101 -3
  7. fmtr/tools/constants.py +33 -0
  8. fmtr/tools/context_tools.py +23 -0
  9. fmtr/tools/data_modelling_tools.py +227 -14
  10. fmtr/tools/database_tools/__init__.py +6 -0
  11. fmtr/tools/database_tools/document.py +51 -0
  12. fmtr/tools/datatype_tools.py +21 -1
  13. fmtr/tools/datetime_tools.py +12 -0
  14. fmtr/tools/debugging_tools.py +60 -0
  15. fmtr/tools/dns_tools/__init__.py +7 -0
  16. fmtr/tools/dns_tools/client.py +97 -0
  17. fmtr/tools/dns_tools/dm.py +257 -0
  18. fmtr/tools/dns_tools/proxy.py +66 -0
  19. fmtr/tools/dns_tools/server.py +138 -0
  20. fmtr/tools/docker_tools/__init__.py +6 -0
  21. fmtr/tools/entrypoints/__init__.py +0 -0
  22. fmtr/tools/entrypoints/cache_hfh.py +3 -0
  23. fmtr/tools/entrypoints/ep_test.py +2 -0
  24. fmtr/tools/entrypoints/install_yamlscript.py +8 -0
  25. fmtr/tools/{console_script_tools.py → entrypoints/remote_debug_test.py} +1 -6
  26. fmtr/tools/entrypoints/shell_debug.py +8 -0
  27. fmtr/tools/environment_tools.py +2 -2
  28. fmtr/tools/function_tools.py +77 -1
  29. fmtr/tools/google_api_tools.py +15 -4
  30. fmtr/tools/http_tools.py +26 -0
  31. fmtr/tools/inherit_tools.py +27 -0
  32. fmtr/tools/interface_tools/__init__.py +8 -0
  33. fmtr/tools/interface_tools/context.py +13 -0
  34. fmtr/tools/interface_tools/controls.py +354 -0
  35. fmtr/tools/interface_tools/interface_tools.py +189 -0
  36. fmtr/tools/iterator_tools.py +29 -0
  37. fmtr/tools/logging_tools.py +43 -16
  38. fmtr/tools/packaging_tools.py +14 -0
  39. fmtr/tools/path_tools/__init__.py +12 -0
  40. fmtr/tools/path_tools/app_path_tools.py +40 -0
  41. fmtr/tools/{path_tools.py → path_tools/path_tools.py} +156 -12
  42. fmtr/tools/path_tools/type_path_tools.py +3 -0
  43. fmtr/tools/pattern_tools.py +260 -0
  44. fmtr/tools/pdf_tools.py +39 -1
  45. fmtr/tools/settings_tools.py +23 -4
  46. fmtr/tools/setup_tools/__init__.py +8 -0
  47. fmtr/tools/setup_tools/setup_tools.py +447 -0
  48. fmtr/tools/string_tools.py +92 -13
  49. fmtr/tools/tabular_tools.py +61 -0
  50. fmtr/tools/tools.py +27 -2
  51. fmtr/tools/version +1 -1
  52. fmtr/tools/version_tools/__init__.py +12 -0
  53. fmtr/tools/version_tools/version_tools.py +51 -0
  54. fmtr/tools/webhook_tools.py +17 -0
  55. fmtr/tools/yaml_tools.py +66 -5
  56. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/METADATA +136 -54
  57. fmtr_tools-1.3.81.dist-info/RECORD +93 -0
  58. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/WHEEL +1 -1
  59. fmtr_tools-1.3.81.dist-info/entry_points.txt +6 -0
  60. fmtr_tools-1.3.81.dist-info/top_level.txt +1 -0
  61. fmtr/tools/docker_tools.py +0 -30
  62. fmtr/tools/interface_tools.py +0 -64
  63. fmtr/tools/version_tools.py +0 -62
  64. fmtr_tools-1.1.1.dist-info/RECORD +0 -65
  65. fmtr_tools-1.1.1.dist-info/entry_points.txt +0 -3
  66. fmtr_tools-1.1.1.dist-info/top_level.txt +0 -2
  67. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/licenses/LICENSE +0 -0
fmtr/tools/constants.py CHANGED
@@ -14,6 +14,10 @@ class Constants:
14
14
  DATETIME_NOW_STR = DATETIME_NOW.strftime(DATETIME_FILENAME_FORMAT)
15
15
  SERIALIZATION_INDENT = 4
16
16
 
17
+ ARROW = '→'
18
+ ARROW_SEP = f' {ARROW} '
19
+
20
+ FMTR_DEV_KEY = 'FMTR_DEV'
17
21
  FMTR_LOG_LEVEL_KEY = 'FMTR_LOG_LEVEL'
18
22
  FMTR_OBS_API_KEY_KEY = 'FMTR_OBS_API_KEY'
19
23
  FMTR_OBS_HOST = 'obs.sv.fmtr.dev'
@@ -27,3 +31,32 @@ class Constants:
27
31
 
28
32
  FMTR_AI_HOST_KEY = 'FMTR_URL_HOST'
29
33
  FMTR_AI_HOST_DEFAULT = 'ai.gex.fmtr.dev'
34
+
35
+ FMTR_DEV_HOST = 'ws.gex.fmtr.dev'
36
+
37
+ FMTR_DEV_INTERFACE_URL = f'https://{FMTR_DEV_HOST}/'
38
+ FMTR_DEV_INTERFACE_SUB_URL_MASK = f'https://{{sub}}.{FMTR_DEV_HOST}/'
39
+
40
+ FILENAME_CONFIG = 'settings.yaml'
41
+ DIR_NAME_REPO = 'repo'
42
+ DIR_NAME_DATA = 'data'
43
+ DIR_NAME_CACHE = 'cache'
44
+ DIR_NAME_ARTIFACT = 'artifact'
45
+ DIR_NAME_SOURCE = 'source'
46
+ FILENAME_VERSION = 'version'
47
+ DIR_NAME_HF = 'hf'
48
+
49
+ ENTRYPOINT = 'entrypoint'
50
+ ENTRYPOINTS_DIR = f'{ENTRYPOINT}s'
51
+ ENTRYPOINT_FILE = f'{ENTRYPOINT}.py'
52
+
53
+ PACKAGE_EXCLUDE_DIRS = {'data', 'build', 'dist', '.*', '*egg-info*'}
54
+ INIT_FILENAME = '__init__.py'
55
+
56
+ DEVELOPMENT = "development"
57
+ PRODUCTION = "production"
58
+
59
+ INFRA = 'infra'
60
+
61
+ PROMPT_NONE_SPECIFIED = '[None Specified]'
62
+ WEBHOOK_URL_NOTIFY_KEY = 'WEBHOOK_URL_NOTIFY'
@@ -0,0 +1,23 @@
1
+ from contextlib import contextmanager, ExitStack
2
+
3
+
4
+ @contextmanager
5
+ def null():
6
+ """
7
+
8
+ Null context manager.
9
+
10
+ """
11
+ yield
12
+
13
+
14
+ @contextmanager
15
+ def contexts(*contexts):
16
+ """
17
+
18
+ Tee context managers.
19
+
20
+ """
21
+ with ExitStack() as stack:
22
+ resources = [stack.enter_context(context) for context in contexts]
23
+ yield resources
@@ -1,4 +1,135 @@
1
- from pydantic import BaseModel, RootModel, ConfigDict
1
+ import inspect
2
+ from functools import cached_property
3
+ from typing import ClassVar, List, Any, Dict
4
+
5
+ from pydantic import BaseModel
6
+ from pydantic import RootModel, ConfigDict
7
+ from pydantic.fields import FieldInfo
8
+ from pydantic.json_schema import SkipJsonSchema
9
+ from pydantic_core import PydanticUndefined, PydanticUndefinedType
10
+
11
+ from fmtr.tools.datatype_tools import is_optional, none_else
12
+ from fmtr.tools.iterator_tools import get_class_lookup
13
+ from fmtr.tools.string_tools import camel_to_snake
14
+ from fmtr.tools.tools import Auto, Required, Empty
15
+
16
+
17
+ class Field(FieldInfo):
18
+ """
19
+
20
+ Allow DRYer field definitions, set annotation and defaults at the same time, easier field inheritance, etc.
21
+
22
+ """
23
+ NAME = Auto
24
+ ANNOTATION = Empty
25
+ DEFAULT = Auto
26
+ FILLS = None
27
+ DESCRIPTION = None
28
+ TITLE = Auto
29
+ SKIP_SCHEMA = False
30
+ CONFIG = None
31
+
32
+ def __init__(self, annotation=Empty, default=Empty, description=None, title=None, fills=None, skip_schema=None, **kwargs):
33
+ """
34
+
35
+ Infer default from type annotation, if enabled, use class/argument fills to create titles/descriptions, etc.
36
+
37
+ """
38
+
39
+ fills_super = getattr(super(), 'FILLS', None)
40
+ self.fills = (fills_super or {}) | (self.FILLS or {}) | (fills or {})
41
+
42
+ skip_schema = none_else(skip_schema, self.SKIP_SCHEMA)
43
+
44
+ self.annotation = self.ANNOTATION if annotation is Empty else annotation
45
+ if self.annotation is Empty:
46
+ raise ValueError("Annotation must be specified.")
47
+
48
+ if skip_schema:
49
+ self.annotation = SkipJsonSchema[self.annotation]
50
+
51
+ default = self.get_default_auto(default)
52
+ if default is Required:
53
+ default = PydanticUndefined
54
+
55
+ description = self.get_desc(description)
56
+ title = self.get_title_auto(title)
57
+ kwargs |= (self.CONFIG or {})
58
+
59
+ super().__init__(annotation=self.annotation, default=default, title=title, description=description, **kwargs)
60
+
61
+ @classmethod
62
+ def get_name_auto(cls) -> str:
63
+ """
64
+
65
+ Infer field name, if set to auto.
66
+
67
+ """
68
+ if cls.NAME is Auto:
69
+ return camel_to_snake(cls.__name__)
70
+ elif cls.NAME is None:
71
+ return cls.__name__
72
+
73
+ return cls.NAME
74
+
75
+ @cached_property
76
+ def fills(self) -> Dict[str, str]:
77
+ """
78
+
79
+ Get fills with filled title merged in
80
+
81
+ """
82
+
83
+ fills_super = getattr(super(), 'FILLS', None)
84
+
85
+ return (fills_super or {}) | (self.FILLS or {}) | dict(title=self.get_title_auto())
86
+
87
+ def get_default_auto(self, default) -> type[Any] | None | PydanticUndefinedType:
88
+ """
89
+
90
+ Infer default, if not specified.
91
+
92
+ """
93
+
94
+ if default is not Empty:
95
+ return default
96
+
97
+ if self.DEFAULT is not Auto:
98
+ return self.DEFAULT
99
+
100
+ if is_optional(self.annotation):
101
+ return None
102
+ else:
103
+ return Required
104
+
105
+ def get_title_auto(self, mask) -> str | None:
106
+ """
107
+
108
+ Get title from classname/mask
109
+
110
+ """
111
+
112
+ if not mask:
113
+ mask = self.__class__.__name__ if self.TITLE is Auto else self.TITLE
114
+
115
+ if mask:
116
+ return mask.format(**self.fills)
117
+
118
+ return None
119
+
120
+ def get_desc(self, mask) -> str | None:
121
+ """
122
+
123
+ Fill description mask, if specified
124
+
125
+ """
126
+
127
+ mask = mask or self.DESCRIPTION
128
+
129
+ if mask:
130
+ return mask.format(**self.fills)
131
+
132
+ return None
2
133
 
3
134
 
4
135
  def to_df(*objs, name_value='value'):
@@ -51,30 +182,102 @@ class MixinFromJson:
51
182
  return self
52
183
 
53
184
 
54
- class Base(BaseModel, MixinFromJson):
185
+ class CliRunMixin:
186
+ """
187
+
188
+ Mixin only so that it can also be used with Pydantic Settings.
189
+
190
+ TODO Ideally, the run method would be defined on dm.Base and the settings base would just inherit like set.Base(BaseSettings, dm.Base), but this isn't yet tested/could break Fields.
191
+
192
+ """
193
+
194
+ def run(self):
195
+ """
196
+
197
+ Behaviour when run as a CLI command, i.e. via Pydantic Settings. Run any subcommands then exit.
198
+
199
+ """
200
+
201
+ from pydantic_settings import get_subcommand
202
+
203
+ command = get_subcommand(self, is_required=False, cli_exit_on_error=False)
204
+
205
+ if not command:
206
+ return
207
+
208
+ result = command.run()
209
+ if inspect.isawaitable(result):
210
+ import asyncio
211
+ result = asyncio.run(result)
212
+
213
+ raise SystemExit(result)
214
+
215
+
216
+ class Base(BaseModel, MixinFromJson, CliRunMixin):
55
217
  """
56
218
 
57
- Base model
219
+ Base model allowing model definition via a list of custom Field objects.
58
220
 
59
221
  """
222
+ FIELDS: ClassVar[List[Field] | Dict[str, Field]] = []
60
223
 
61
- def to_df(self, name_value='value'):
224
+ def __init_subclass__(cls, **kwargs):
62
225
  """
63
226
 
64
- DataFrame representation with Fields as rows.
227
+ Fetch aggregated fields metadata from the hierarchy and set annotations and FieldInfo objects in the class.
65
228
 
66
229
  """
230
+ super().__init_subclass__(**kwargs)
67
231
 
68
- objs = []
69
- for name in self.model_fields.keys():
70
- val = getattr(self, name)
71
- objs.append(val)
232
+ fields = {}
233
+ for base in reversed(cls.__mro__):
72
234
 
73
- df = to_df(*objs, name_value=name_value)
74
- df['id'] = list(self.model_fields.keys())
75
- df = df.set_index('id', drop=True)
235
+ try:
236
+ raw = base.FIELDS
237
+ except AttributeError:
238
+ raw = {}
239
+
240
+ if isinstance(raw, dict):
241
+ fields |= raw
242
+ else:
243
+ fields |= get_class_lookup(*raw, name_function=lambda cls_field: cls_field.get_name_auto())
244
+
245
+ cls.FIELDS = fields
246
+
247
+ for name, field in fields.items():
248
+ if name in cls.__annotations__:
249
+ continue
250
+
251
+ if inspect.isclass(field):
252
+ field = field()
253
+
254
+ setattr(cls, name, field)
255
+ cls.__annotations__[name] = field.annotation
256
+
257
+ def to_df(self, **kwargs):
258
+ """
259
+
260
+ DataFrame representation of Data Model.
261
+
262
+ """
263
+ from fmtr.tools import tabular
264
+
265
+ data = self.model_dump(**kwargs)
266
+ df = tabular.pd.json_normalize(data, sep='_')
76
267
  return df
77
268
 
269
+ @classmethod
270
+ def to_df_empty(cls):
271
+ """
272
+
273
+ Empty DataFrame
274
+
275
+ """
276
+ from fmtr.tools import tabular
277
+
278
+ row = {name: None for name in cls.model_fields.keys()}
279
+ df = tabular.pd.DataFrame([row])
280
+ return df
78
281
 
79
282
  class Root(RootModel, MixinFromJson):
80
283
  """
@@ -83,11 +286,21 @@ class Root(RootModel, MixinFromJson):
83
286
 
84
287
  """
85
288
 
86
- def to_df(self):
289
+ def to_df(self, **kwargs):
87
290
  """
88
291
 
89
292
  DataFrame representation with items as rows.
90
293
 
91
294
  """
92
295
 
93
- return to_df(*self.items)
296
+ """
297
+
298
+ DataFrame representation of Data Model.
299
+
300
+ """
301
+ from fmtr.tools import tabular
302
+
303
+ data = [item.model_dump(**kwargs) for item in self.items]
304
+ dfs = [tabular.pd.json_normalize(datum, sep='_') for datum in data]
305
+ df = tabular.pd.concat(dfs, axis=tabular.CONCAT_VERTICALLY)
306
+ return df
@@ -0,0 +1,6 @@
1
+ from fmtr.tools.import_tools import MissingExtraMockModule
2
+
3
+ try:
4
+ from fmtr.tools.database_tools import document
5
+ except ModuleNotFoundError as exception:
6
+ document = MissingExtraMockModule('db.document', exception)
@@ -0,0 +1,51 @@
1
+ from functools import cached_property
2
+ from typing import List
3
+
4
+ import beanie
5
+ from beanie.odm import actions
6
+ from pymongo import AsyncMongoClient
7
+
8
+ from fmtr.tools import data_modelling_tools
9
+ from fmtr.tools.constants import Constants
10
+ from fmtr.tools.logging_tools import logger
11
+
12
+ ModifyEvents = [
13
+ actions.Insert,
14
+ actions.Replace,
15
+ actions.Save,
16
+ actions.SaveChanges,
17
+ actions.Update
18
+ ]
19
+
20
+
21
+ class Document(beanie.Document, data_modelling_tools.Base):
22
+ """
23
+
24
+ Document stub.
25
+
26
+ """
27
+
28
+
29
+ class Client:
30
+
31
+ def __init__(self, name, host=Constants.FMTR_DEV_HOST, port=27017, documents: List[beanie.Document] | None = None):
32
+ self.name = name
33
+ self.host = host
34
+ self.port = port
35
+ self.documents = documents
36
+
37
+ self.client = AsyncMongoClient(self.uri, tz_aware=True)
38
+ self.db = self.client[self.name]
39
+
40
+ @cached_property
41
+ def uri(self):
42
+ return f'mongodb://{self.host}:{self.port}'
43
+
44
+ async def connect(self):
45
+ """
46
+
47
+ Connect
48
+
49
+ """
50
+ with logger.span(f'Connecting to document database {self.uri=} {self.name=}'):
51
+ return await beanie.init_beanie(database=self.db, document_models=self.documents)
@@ -1,4 +1,5 @@
1
- from typing import Any
1
+ from types import UnionType, NoneType
2
+ from typing import Any, get_origin, get_args, Union, Annotated
2
3
 
3
4
  from fmtr.tools.tools import Raise
4
5
 
@@ -84,3 +85,22 @@ def none_else(value: Any, default: Any) -> Any:
84
85
  if is_none(value):
85
86
  return default
86
87
  return value
88
+
89
+
90
+ def is_optional(annotation) -> bool:
91
+ """
92
+
93
+ Is type/annotation optional? todo should be in typing_tools?
94
+
95
+ """
96
+ if annotation is None:
97
+ return True
98
+
99
+ origin = get_origin(annotation)
100
+ args = get_args(annotation)
101
+
102
+ if origin is Annotated:
103
+ return is_optional(args[0])
104
+
105
+ is_opt = (origin is UnionType or origin is Union) and NoneType in args
106
+ return is_opt
@@ -0,0 +1,12 @@
1
+ from datetime import datetime, timezone
2
+
3
+ MIN = datetime.min.replace(tzinfo=timezone.utc)
4
+ MAX = datetime.max.replace(tzinfo=timezone.utc)
5
+
6
+ def now() -> datetime:
7
+ """
8
+
9
+ Now UTC
10
+
11
+ """
12
+ return datetime.now(tz=timezone.utc)
@@ -1,3 +1,5 @@
1
+ import dataclasses
2
+
1
3
  import pydevd_pycharm
2
4
 
3
5
  from fmtr.tools import environment_tools as env
@@ -5,6 +7,40 @@ from fmtr.tools.constants import Constants
5
7
 
6
8
  MASK = 'Starting debugger at tcp://{host}:{port}...'
7
9
 
10
+
11
+ @dataclasses.dataclass
12
+ class ShellDebug:
13
+ """
14
+
15
+ Debugging information for shell commands
16
+
17
+ """
18
+ command: str
19
+ out: str
20
+ err: str
21
+ status: int
22
+
23
+ # timestamp: datetime=dataclasses.field(default_factory=datetime.now)
24
+
25
+ @classmethod
26
+ def from_path(cls, path_str):
27
+ """
28
+
29
+ Get debug info from path
30
+
31
+ """
32
+ from fmtr.tools import Path
33
+ path = Path(path_str).absolute()
34
+ data = {field.name: (path / f'{field.name}.log').read_text().strip() for field in dataclasses.fields(cls)}
35
+ self = cls(**data)
36
+ return self
37
+
38
+ @property
39
+ def env(self):
40
+ return env.get_dict()
41
+
42
+
43
+
8
44
  def trace(is_debug=None, host=None, port=None, stdoutToServer=True, stderrToServer=True, **kwargs):
9
45
  """
10
46
 
@@ -29,3 +65,27 @@ def trace(is_debug=None, host=None, port=None, stdoutToServer=True, stderrToServ
29
65
  logger.info(msg)
30
66
 
31
67
  pydevd_pycharm.settrace(host, port=port, stdoutToServer=stdoutToServer, stderrToServer=stderrToServer, **kwargs)
68
+
69
+
70
+ def debug_shell():
71
+ """
72
+
73
+ Starts a debug shell by initializing a `ShellDebug` object from a given path
74
+ and enabling tracing with debug mode turned on.
75
+
76
+ """
77
+ import sys
78
+ path_str = sys.argv[1]
79
+ data = ShellDebug.from_path(path_str)
80
+ trace(is_debug=True)
81
+ data
82
+
83
+
84
+ if __name__ == "__main__":
85
+ import sys
86
+
87
+ sys.argv = [
88
+ 'test.py',
89
+ './fmtr-debug/34e8d492-2f15-419a-8fcb-fe4fa0fa02bb',
90
+ ]
91
+ debug_shell()
@@ -0,0 +1,7 @@
1
+ from fmtr.tools.import_tools import MissingExtraMockModule
2
+
3
+ try:
4
+ from fmtr.tools.dns_tools import server, client, dm, proxy
5
+ import dns
6
+ except ModuleNotFoundError as exception:
7
+ dns = server = client = dm = proxy = MissingExtraMockModule('dns', exception)
@@ -0,0 +1,97 @@
1
+ from dataclasses import dataclass
2
+ from dns import query as dnspython_query, message as dnspython_message, rdatatype as dnspython_rdatatype, rcode as dnspython_rcode
3
+ from functools import cached_property
4
+ from httpx_retries import Retry, RetryTransport
5
+ from typing import Optional
6
+
7
+ from fmtr.tools import http_tools as http
8
+ from fmtr.tools.dns_tools.dm import Exchange, Response
9
+ from fmtr.tools.logging_tools import logger
10
+
11
+ RETRY_STRATEGY = Retry(
12
+ total=2, # initial + 1 retry
13
+ allowed_methods={"GET", "POST"},
14
+ status_forcelist={502, 503, 504},
15
+ retry_on_exceptions=None, # defaults to httpx.TransportError etc.
16
+ backoff_factor=0.25, # short backoff (e.g. 0.25s, 0.5s)
17
+ max_backoff_wait=0.75, # max total delay before giving up
18
+ backoff_jitter=0.1, # small jitter to avoid retry bursts
19
+ respect_retry_after_header=False, # DoH resolvers probably won't set this
20
+ )
21
+
22
+
23
+ class HTTPClientDoH(http.Client):
24
+ """
25
+
26
+ Base HTTP client for DoH-appropriate retry strategy.
27
+
28
+ """
29
+ TRANSPORT = RetryTransport(retry=RETRY_STRATEGY)
30
+
31
+
32
+ @dataclass
33
+ class Plain:
34
+ """
35
+
36
+ Plain DNS
37
+
38
+ """
39
+ host: str
40
+ port: int = 53
41
+ ttl_min: Optional[int] = None
42
+
43
+ def resolve(self, exchange: Exchange):
44
+
45
+ with logger.span(f'UDP {self.host}:{self.port}'):
46
+ response_plain = dnspython_query.udp(q=exchange.query_last, where=self.host, port=self.port)
47
+ response = Response.from_message(response_plain)
48
+ for answer in response.message.answer:
49
+ answer.ttl = max(answer.ttl, self.ttl_min or answer.ttl)
50
+
51
+ exchange.response = response
52
+
53
+
54
+ @dataclass
55
+ class HTTP:
56
+ """
57
+
58
+ DNS over HTTP
59
+
60
+ """
61
+
62
+ HEADERS = {"Content-Type": "application/dns-message"}
63
+ CLIENT = HTTPClientDoH()
64
+ BOOTSTRAP = Plain('8.8.8.8')
65
+
66
+ host: str
67
+ url: str
68
+
69
+
70
+ @cached_property
71
+ def ip(self):
72
+ message = dnspython_message.make_query(self.host, dnspython_rdatatype.A, flags=0)
73
+ exchange = Exchange.from_wire(message.to_wire(), ip=None, port=None)
74
+ self.BOOTSTRAP.resolve(exchange)
75
+ ip = next(iter(exchange.response.answer.items.keys())).address
76
+ return ip
77
+
78
+ def resolve(self, exchange: Exchange):
79
+ """
80
+
81
+ Resolve via DoH
82
+
83
+ """
84
+
85
+ headers = self.HEADERS | dict(Host=self.host)
86
+ url = self.url.format(host=self.ip)
87
+
88
+ try:
89
+ response_doh = self.CLIENT.post(url, headers=headers, content=exchange.query_last.to_wire())
90
+ response_doh.raise_for_status()
91
+ response = Response.from_http(response_doh)
92
+ exchange.response = response
93
+
94
+ except Exception as exception:
95
+ exchange.response.message.set_rcode(dnspython_rcode.SERVFAIL)
96
+ exchange.response.is_complete = True
97
+ logger.exception(exception)