fmtr.tools 1.2.3__py3-none-any.whl → 1.2.5__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.

Potentially problematic release.


This version of fmtr.tools might be problematic. Click here for more details.

fmtr/tools/__init__.py CHANGED
@@ -29,6 +29,7 @@ from fmtr.tools.constants import Constants
29
29
  # Submodules
30
30
  from fmtr.tools.path_tools import Path, PackagePaths, AppPaths
31
31
  from fmtr.tools import ai_tools as ai
32
+ from fmtr.tools import dns_tools as dns
32
33
 
33
34
  import fmtr.tools.setup_tools as setup
34
35
  from fmtr.tools.setup_tools import Setup, SetupPaths, Dependencies, Tools
@@ -172,11 +173,6 @@ try:
172
173
  except ImportError as exception:
173
174
  patterns = MissingExtraMockModule('patterns', exception)
174
175
 
175
- try:
176
- from fmtr.tools import dns_tools as dns
177
- except ImportError as exception:
178
- dns = MissingExtraMockModule('dns', exception)
179
-
180
176
  try:
181
177
  from fmtr.tools import http_tools as http
182
178
  from fmtr.tools.http_tools import Client
@@ -0,0 +1,6 @@
1
+ from fmtr.tools.import_tools import MissingExtraMockModule
2
+
3
+ try:
4
+ from fmtr.tools.dns_tools import server, client, dm
5
+ except ImportError as exception:
6
+ server = client = dm = MissingExtraMockModule('dns', exception)
@@ -0,0 +1,78 @@
1
+ import dns
2
+ from dns import query
3
+ from functools import cached_property
4
+ from httpx_retries import Retry, RetryTransport
5
+
6
+ from fmtr.tools import http_tools as http
7
+ from fmtr.tools.dns_tools.dm import Exchange, Response, Request
8
+ from fmtr.tools.logging_tools import logger
9
+
10
+ RETRY_STRATEGY = Retry(
11
+ total=2, # initial + 1 retry
12
+ allowed_methods={"GET", "POST"},
13
+ status_forcelist={502, 503, 504},
14
+ retry_on_exceptions=None, # defaults to httpx.TransportError etc.
15
+ backoff_factor=0.25, # short backoff (e.g. 0.25s, 0.5s)
16
+ max_backoff_wait=0.75, # max total delay before giving up
17
+ backoff_jitter=0.1, # small jitter to avoid retry bursts
18
+ respect_retry_after_header=False, # DoH resolvers probably won't set this
19
+ )
20
+
21
+
22
+ class HTTPClientDoH(http.Client):
23
+ """
24
+
25
+ Base HTTP client for DoH-appropriate retry strategy.
26
+
27
+ """
28
+ TRANSPORT = RetryTransport(retry=RETRY_STRATEGY)
29
+
30
+
31
+ class ClientBasePlain:
32
+ def __init__(self, host, port=53):
33
+ self.host = host
34
+ self.port = port
35
+
36
+ def resolve(self, exchange: Exchange):
37
+ with logger.span(f'UDP {self.host}:{self.port}'):
38
+ response = query.udp(q=exchange.request.message, where=self.host, port=self.port)
39
+ exchange.response_upstream = Response.from_message(response)
40
+
41
+
42
+ class ClientDoH:
43
+ """
44
+
45
+ Base DoH client.
46
+
47
+ """
48
+
49
+ HEADERS = {"Content-Type": "application/dns-message"}
50
+ client = HTTPClientDoH()
51
+
52
+ def __init__(self, host, url):
53
+ self.host = host
54
+ self.url = url
55
+ self.bootstrap = ClientBasePlain('8.8.8.8')
56
+
57
+ @cached_property
58
+ def ip(self):
59
+ message = dns.message.make_query(self.host, dns.rdatatype.A, flags=0)
60
+ request = Request.from_message(message)
61
+ exchange = Exchange(request=request, ip=None, port=None)
62
+ self.bootstrap.resolve(exchange)
63
+ ip = next(iter(exchange.response_upstream.answer.items.keys())).address
64
+ return ip
65
+
66
+ def resolve(self, exchange: Exchange):
67
+ """
68
+
69
+ Resolve via DoH
70
+
71
+ """
72
+ request = exchange.request
73
+ headers = self.HEADERS | dict(Host=self.host)
74
+ url = self.url.format(host=self.ip)
75
+ response_doh = self.client.post(url, headers=headers, content=request.wire)
76
+ response_doh.raise_for_status()
77
+ response = Response.from_http(response_doh)
78
+ exchange.response_upstream = response
@@ -0,0 +1,110 @@
1
+ import dns
2
+ import httpx
3
+ from dataclasses import dataclass
4
+ from dns.message import Message
5
+ from functools import cached_property
6
+ from typing import Self, Optional
7
+
8
+
9
+ @dataclass
10
+ class BaseDNSData:
11
+ """
12
+
13
+ DNS response object.
14
+
15
+ """
16
+ wire: bytes
17
+
18
+ @cached_property
19
+ def message(self) -> Message:
20
+ return dns.message.from_wire(self.wire)
21
+
22
+ @classmethod
23
+ def from_message(cls, message: Message) -> Self:
24
+ return cls(message.to_wire())
25
+
26
+
27
+ @dataclass
28
+ class Response(BaseDNSData):
29
+ """
30
+
31
+ DNS response object.
32
+
33
+ """
34
+
35
+ http: Optional[httpx.Response] = None
36
+
37
+ @classmethod
38
+ def from_http(cls, response: httpx.Response) -> Self:
39
+ self = cls(response.content, http=response)
40
+ return self
41
+
42
+ @cached_property
43
+ def answer(self):
44
+ return self.message.answer[-1]
45
+
46
+
47
+ @dataclass
48
+ class Request(BaseDNSData):
49
+ """
50
+
51
+ DNS request object.
52
+
53
+ """
54
+ wire: bytes
55
+
56
+ @cached_property
57
+ def question(self):
58
+ return self.message.question[0]
59
+
60
+ @cached_property
61
+ def is_valid(self):
62
+ return len(self.message.question) != 0
63
+
64
+ @cached_property
65
+ def type(self):
66
+ return self.question.rdtype
67
+
68
+ @cached_property
69
+ def type_text(self):
70
+ return dns.rdatatype.to_text(self.type)
71
+
72
+ @cached_property
73
+ def name(self):
74
+ return self.question.name
75
+
76
+ @cached_property
77
+ def name_text(self):
78
+ return self.name.to_text()
79
+
80
+ @cached_property
81
+ def blackhole(self) -> Response:
82
+ blackhole = dns.message.make_response(self.message)
83
+ blackhole.flags |= dns.flags.RA
84
+ blackhole.set_rcode(dns.rcode.NXDOMAIN)
85
+ response = Response.from_message(blackhole)
86
+ return response
87
+
88
+
89
+ @dataclass
90
+ class Exchange:
91
+ """
92
+
93
+ Entire DNS exchange for a DNS Proxy: request -> upstream response -> response
94
+
95
+ """
96
+ ip: str
97
+ port: int
98
+
99
+ request: Request
100
+ response: Optional[Response] = None
101
+ response_upstream: Optional[Response] = None
102
+
103
+ @classmethod
104
+ def from_wire(cls, wire: bytes, ip: str, port: int) -> Self:
105
+ request = Request(wire)
106
+ return cls(request=request, ip=ip, port=port)
107
+
108
+ @cached_property
109
+ def client(self):
110
+ return f'{self.ip}:{self.port}'
@@ -0,0 +1,96 @@
1
+ import socket
2
+
3
+ from fmtr.tools import logger
4
+ from fmtr.tools.dns_tools.client import ClientDoH
5
+ from fmtr.tools.dns_tools.dm import Exchange, Response
6
+
7
+
8
+ class ServerBasePlain:
9
+ """
10
+
11
+ Base for starting a plain DNS server
12
+
13
+ """
14
+
15
+ def __init__(self, host, port):
16
+ self.host = host
17
+ self.port = port
18
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
19
+
20
+ def resolve(self, exchange: Exchange):
21
+ raise NotImplemented
22
+
23
+ def start(self):
24
+ """
25
+
26
+ Listen and resolve via overridden resolve method.
27
+
28
+ """
29
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
30
+ sock.bind((self.host, self.port))
31
+ print(f"Listening on {self.host}:{self.port}")
32
+ while True:
33
+ data, (ip, port) = sock.recvfrom(512)
34
+ exchange = Exchange.from_wire(data, ip=ip, port=port)
35
+ self.resolve(exchange)
36
+ sock.sendto(exchange.response.wire, (ip, port))
37
+
38
+
39
+ class ServerBaseDoHProxy(ServerBasePlain):
40
+ """
41
+
42
+ Base for a DNS Proxy server
43
+
44
+ """
45
+
46
+ def __init__(self, host, port, client: ClientDoH):
47
+ super().__init__(host, port)
48
+ self.client = client
49
+
50
+ def process_question(self, exchange: Exchange):
51
+ return
52
+
53
+ def process_upstream(self, exchange: Exchange):
54
+ return
55
+
56
+ def from_upstream(self, exchange: Exchange) -> Exchange:
57
+
58
+ request = exchange.request
59
+ response_doh = self.client.post(self.URL, headers=self.HEADERS, content=request.wire)
60
+ response_doh.raise_for_status()
61
+ response = Response.from_http(response_doh)
62
+ exchange.response_upstream = response
63
+
64
+ return exchange
65
+
66
+ def resolve(self, exchange: Exchange):
67
+ """
68
+
69
+ Resolve a request, processing each stage, initial question, upstream response etc.
70
+ Subclasses can override the relevant processing methods to implement custom behaviour.
71
+
72
+ """
73
+
74
+ request = exchange.request
75
+
76
+ with logger.span(f'Handling request for {request.name_text} from {exchange.client}...'):
77
+
78
+ if not request.is_valid:
79
+ raise ValueError(f'Only one question per request is supported. Got {len(request.question)} questions.')
80
+
81
+ with logger.span(f'Processing question...'):
82
+ self.process_question(exchange)
83
+ if exchange.response:
84
+ return
85
+
86
+ with logger.span(f'Making upstream request for {request.name_text}...'):
87
+ self.client.resolve(exchange)
88
+
89
+ with logger.span(f'Processing upstream response...'):
90
+ self.process_upstream(exchange)
91
+
92
+ if exchange.response:
93
+ return
94
+
95
+ exchange.response = exchange.response_upstream
96
+ return
@@ -1,9 +1,8 @@
1
+ import regex as re
1
2
  from dataclasses import dataclass
2
3
  from functools import cached_property
3
4
  from typing import List
4
5
 
5
- import regex as re
6
-
7
6
  from fmtr.tools.logging_tools import logger
8
7
 
9
8
 
@@ -158,7 +157,11 @@ class Rewriter:
158
157
 
159
158
  previous = new
160
159
 
161
- logger.debug(f'Finished rewriting: {get_history_str()}')
160
+ if len(history) == 1:
161
+ history_str = 'No rewrites performed.'
162
+ else:
163
+ history_str = get_history_str()
164
+ logger.debug(f'Finished rewriting: {history_str}')
162
165
 
163
166
  return previous
164
167
 
fmtr/tools/version CHANGED
@@ -1 +1 @@
1
- 1.2.3
1
+ 1.2.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmtr.tools
3
- Version: 1.2.3
3
+ Version: 1.2.5
4
4
  Summary: Collection of high-level tools to simplify everyday development tasks, with a focus on AI/ML
5
5
  Home-page: https://github.com/fmtr/tools
6
6
  Author: Frontmatter
@@ -128,60 +128,60 @@ Requires-Dist: logfire[httpx]; extra == "http"
128
128
  Provides-Extra: setup
129
129
  Requires-Dist: setuptools; extra == "setup"
130
130
  Provides-Extra: all
131
- Requires-Dist: deepmerge; extra == "all"
132
- Requires-Dist: pydantic-settings; extra == "all"
133
- Requires-Dist: sentence_transformers; extra == "all"
134
- Requires-Dist: diskcache; extra == "all"
135
- Requires-Dist: google-auth; extra == "all"
136
- Requires-Dist: faker; extra == "all"
137
- Requires-Dist: tinynetrc; extra == "all"
138
- Requires-Dist: sre_yield; extra == "all"
139
- Requires-Dist: html2text; extra == "all"
140
- Requires-Dist: json_repair; extra == "all"
141
- Requires-Dist: pymupdf; extra == "all"
142
- Requires-Dist: peft; extra == "all"
143
- Requires-Dist: appdirs; extra == "all"
144
- Requires-Dist: google-api-python-client; extra == "all"
145
131
  Requires-Dist: distributed; extra == "all"
146
- Requires-Dist: openai; extra == "all"
147
- Requires-Dist: yamlscript; extra == "all"
148
- Requires-Dist: fastapi; extra == "all"
132
+ Requires-Dist: pydevd-pycharm; extra == "all"
133
+ Requires-Dist: ollama; extra == "all"
134
+ Requires-Dist: sre_yield; extra == "all"
135
+ Requires-Dist: flet-webview; extra == "all"
136
+ Requires-Dist: dask[bag]; extra == "all"
137
+ Requires-Dist: semver; extra == "all"
138
+ Requires-Dist: diskcache; extra == "all"
149
139
  Requires-Dist: tabulate; extra == "all"
150
140
  Requires-Dist: bokeh; extra == "all"
141
+ Requires-Dist: openai; extra == "all"
142
+ Requires-Dist: pytest-cov; extra == "all"
151
143
  Requires-Dist: uvicorn[standard]; extra == "all"
152
- Requires-Dist: pydantic-ai[logfire,openai]; extra == "all"
153
- Requires-Dist: transformers[sentencepiece]; extra == "all"
154
- Requires-Dist: docker; extra == "all"
155
- Requires-Dist: pymupdf4llm; extra == "all"
156
- Requires-Dist: torchaudio; extra == "all"
157
- Requires-Dist: pydevd-pycharm; extra == "all"
158
- Requires-Dist: regex; extra == "all"
144
+ Requires-Dist: pyyaml; extra == "all"
159
145
  Requires-Dist: Unidecode; extra == "all"
160
- Requires-Dist: huggingface_hub; extra == "all"
146
+ Requires-Dist: json_repair; extra == "all"
147
+ Requires-Dist: google-auth-oauthlib; extra == "all"
148
+ Requires-Dist: pymupdf4llm; extra == "all"
149
+ Requires-Dist: google-auth; extra == "all"
150
+ Requires-Dist: fastapi; extra == "all"
161
151
  Requires-Dist: contexttimer; extra == "all"
162
- Requires-Dist: pandas; extra == "all"
163
- Requires-Dist: flet[all]; extra == "all"
152
+ Requires-Dist: tinynetrc; extra == "all"
164
153
  Requires-Dist: flet-video; extra == "all"
165
- Requires-Dist: pyyaml; extra == "all"
166
- Requires-Dist: google-auth-httplib2; extra == "all"
167
- Requires-Dist: ollama; extra == "all"
168
- Requires-Dist: setuptools; extra == "all"
154
+ Requires-Dist: yamlscript; extra == "all"
155
+ Requires-Dist: appdirs; extra == "all"
169
156
  Requires-Dist: logfire[httpx]; extra == "all"
170
- Requires-Dist: openpyxl; extra == "all"
171
- Requires-Dist: dask[bag]; extra == "all"
172
- Requires-Dist: semver; extra == "all"
173
- Requires-Dist: logfire; extra == "all"
174
- Requires-Dist: pytest-cov; extra == "all"
175
- Requires-Dist: filetype; extra == "all"
176
- Requires-Dist: tokenizers; extra == "all"
177
- Requires-Dist: flet-webview; extra == "all"
178
- Requires-Dist: google-auth-oauthlib; extra == "all"
157
+ Requires-Dist: huggingface_hub; extra == "all"
158
+ Requires-Dist: transformers[sentencepiece]; extra == "all"
159
+ Requires-Dist: torchvision; extra == "all"
179
160
  Requires-Dist: httpx_retries; extra == "all"
161
+ Requires-Dist: pymupdf; extra == "all"
162
+ Requires-Dist: docker; extra == "all"
163
+ Requires-Dist: regex; extra == "all"
164
+ Requires-Dist: tokenizers; extra == "all"
180
165
  Requires-Dist: pydantic; extra == "all"
166
+ Requires-Dist: logfire; extra == "all"
167
+ Requires-Dist: filetype; extra == "all"
181
168
  Requires-Dist: logfire[fastapi]; extra == "all"
169
+ Requires-Dist: html2text; extra == "all"
170
+ Requires-Dist: flet[all]; extra == "all"
171
+ Requires-Dist: torchaudio; extra == "all"
172
+ Requires-Dist: setuptools; extra == "all"
182
173
  Requires-Dist: httpx; extra == "all"
183
- Requires-Dist: torchvision; extra == "all"
184
174
  Requires-Dist: dnspython[doh]; extra == "all"
175
+ Requires-Dist: deepmerge; extra == "all"
176
+ Requires-Dist: google-auth-httplib2; extra == "all"
177
+ Requires-Dist: sentence_transformers; extra == "all"
178
+ Requires-Dist: peft; extra == "all"
179
+ Requires-Dist: google-api-python-client; extra == "all"
180
+ Requires-Dist: openpyxl; extra == "all"
181
+ Requires-Dist: faker; extra == "all"
182
+ Requires-Dist: pandas; extra == "all"
183
+ Requires-Dist: pydantic-settings; extra == "all"
184
+ Requires-Dist: pydantic-ai[logfire,openai]; extra == "all"
185
185
  Dynamic: author
186
186
  Dynamic: author-email
187
187
  Dynamic: description
@@ -1,4 +1,4 @@
1
- fmtr/tools/__init__.py,sha256=yXBnDJFPfv6qj4_KuLpMXoDZ92aiEzugLs0vcM31D5k,5770
1
+ fmtr/tools/__init__.py,sha256=vCxKR0SymygpO-EtCGPmGZEQv5O1goM7Jr8P4m-eJsQ,5676
2
2
  fmtr/tools/api_tools.py,sha256=w8Zrp_EwN5KlUghwLoTCXo4z1irg5tAsReCqDLjASfE,2133
3
3
  fmtr/tools/async_tools.py,sha256=ewz757WcveQJd-G5SVr2JDOQVbdLGecCgl-tsBGVZz4,284
4
4
  fmtr/tools/augmentation_tools.py,sha256=-6ESbO4CDlKqVOV1J1V6qBeoBMzbFIinkDHRHnCBej0,55
@@ -8,7 +8,6 @@ fmtr/tools/data_modelling_tools.py,sha256=0BFm-F_cYzVTxftWQwORkPd0FM2BTLVh9-s0-r
8
8
  fmtr/tools/dataclass_tools.py,sha256=0Gt6KeLhtPgubo_2tYkIVqB8oQ91Qzag8OAGZDdjvMU,1209
9
9
  fmtr/tools/datatype_tools.py,sha256=3P4AWIFGkJ-UqvXlj0Jc9IvkIIgTOE9jRrOk3NVbpH8,1508
10
10
  fmtr/tools/debugging_tools.py,sha256=_xzqS0V5BpL8d06j-jVQjGgI7T020QsqVXKAKMz7Du8,2082
11
- fmtr/tools/dns_tools.py,sha256=7OoELFd9lZ3ULdJ6h8QwHYFNUYS9atqYrflKEzzFW-g,5461
12
11
  fmtr/tools/docker_tools.py,sha256=rdaZje2xhlmnfQqZnR7IHgRdWncTLjrJcViUTt5oEwk,617
13
12
  fmtr/tools/environment_tools.py,sha256=ZO2e8pTzbQ8jNXnmKpaZF7_3qkVM6U5iqku5YSwOszE,1849
14
13
  fmtr/tools/function_tools.py,sha256=_oW3-HZXMst2pcU-I-U7KTMmzo0g9MIQKmX-c2_NEoE,858
@@ -31,7 +30,7 @@ fmtr/tools/netrc_tools.py,sha256=PpNpz_mWlQi6VHGromKwFfTyLpHUXsd4LY6-OKLCbeI,376
31
30
  fmtr/tools/openai_tools.py,sha256=6SUgejgzUzmlKKct2_ePXntvMegu3FJgfk9x7aqtqYc,742
32
31
  fmtr/tools/packaging_tools.py,sha256=FlgOTnDRHZWQL2iR-wucTsyGEHRE-MlddKL30MPmUqE,253
33
32
  fmtr/tools/parallel_tools.py,sha256=QEb_gN1StkxsqYaH4HSjiJX8Y3gpb2uKNsOzG4uFpaM,3071
34
- fmtr/tools/pattern_tools.py,sha256=Tv67jftyfjp04QlRMD4pwS1IV_SIXCtDTD_uIlMZiSM,4229
33
+ fmtr/tools/pattern_tools.py,sha256=T9f2wVi_0lPuj5npcxs0yBU91LAgWGHwtrdN6rUZKm8,4357
35
34
  fmtr/tools/pdf_tools.py,sha256=xvv9B84uAF81rFJRnXhSsxYuP42vY9ZdPVFrSMVe8G8,4069
36
35
  fmtr/tools/platform_tools.py,sha256=7p69CmAHe_sF68Fx9uVhns1k5EewTHTWgUYzkl6ZQKA,308
37
36
  fmtr/tools/process_tools.py,sha256=Ysh5Dk2QFBhXQerArjKdt7xZd3JrN5Ho02AaOjH0Nnw,1425
@@ -45,12 +44,16 @@ fmtr/tools/tabular_tools.py,sha256=tpIpZzYku1HcJrHZJL6BC39LmN3WUWVhFbK2N7nDVmE,1
45
44
  fmtr/tools/tokenization_tools.py,sha256=me-IBzSLyNYejLybwjO9CNB6Mj2NYfKPaOVThXyaGNg,4268
46
45
  fmtr/tools/tools.py,sha256=CAsApa1YwVdNE6H66Vjivs_mXYvOas3rh7fPELAnTpk,795
47
46
  fmtr/tools/unicode_tools.py,sha256=yS_9wpu8ogNoiIL7s1G_8bETFFO_YQlo4LNPv1NLDeY,52
48
- fmtr/tools/version,sha256=xH9bGLikMOaYuf4V5R9hGZhOeDNLzz9F4hDTDDfvL54,5
47
+ fmtr/tools/version,sha256=Cl1xpTAk2dakEAYnido_4VW6RMyhUhB5H7iuCcdJQmw,5
49
48
  fmtr/tools/version_tools.py,sha256=yNs_CGqWpqE4jbK9wsPIi14peJVXYbhIcMqHAFOw3yE,1480
50
49
  fmtr/tools/yaml_tools.py,sha256=9kuYChqJelWQIjGlSnK4iDdOWWH06P0gp9jIcRrC3UI,1903
51
50
  fmtr/tools/ai_tools/__init__.py,sha256=JZrLuOFNV1A3wvJgonxOgz_4WS-7MfCuowGWA5uYCjs,372
52
51
  fmtr/tools/ai_tools/agentic_tools.py,sha256=acSEPFS-aguDXanWGs3fAAlRyJSYPZW7L-Kb2qDLm-I,4300
53
52
  fmtr/tools/ai_tools/inference_tools.py,sha256=2UP2gXEyOJUjyyV6zmFIYmIxUsh1rXkRH0IbFvr2bRs,11908
53
+ fmtr/tools/dns_tools/__init__.py,sha256=PwHxnpiy6_isQfUmz_5V1hL0dcPaA6ItqvoGWW8MOfk,222
54
+ fmtr/tools/dns_tools/client.py,sha256=zYMUc63iFRSPtCnhqx_nvsHHf-VQrLt3Vor0qxImfLs,2390
55
+ fmtr/tools/dns_tools/dm.py,sha256=mvXacq6QJ86G0S0tkzJFhU7bOaSJytvsMNlxs5X9hfE,2236
56
+ fmtr/tools/dns_tools/server.py,sha256=8oGCJZ-xTJhJ0gq21ro8Z0ZpfRYIdoWy7bLm9LOQcU0,2764
54
57
  fmtr/tools/entrypoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
58
  fmtr/tools/entrypoints/cache_hfh.py,sha256=fQNs4J9twQuZH_Yj98-oOvEX7-LrSUP3kO8nzw2HrHs,60
56
59
  fmtr/tools/entrypoints/ep_test.py,sha256=B8HfWISfSgw_xVX475CbJGh_QnpOe9MH65H8qGjTWbY,46
@@ -70,9 +73,9 @@ fmtr/tools/tests/test_environment.py,sha256=iHaiMQfECYZPkPKwfuIZV9uHuWe3aE-p_dN_
70
73
  fmtr/tools/tests/test_json.py,sha256=IeSP4ziPvRcmS8kq7k9tHonC9rN5YYq9GSNT2ul6Msk,287
71
74
  fmtr/tools/tests/test_path.py,sha256=AkZQa6_8BQ-VaCyL_J-iKmdf2ZaM-xFYR37Kun3k4_g,2188
72
75
  fmtr/tools/tests/test_yaml.py,sha256=jc0TwwKu9eC0LvFGNMERdgBue591xwLxYXFbtsRwXVM,287
73
- fmtr_tools-1.2.3.dist-info/licenses/LICENSE,sha256=FW9aa6vVN5IjRQWLT43hs4_koYSmpcbIovlKeAJ0_cI,10757
74
- fmtr_tools-1.2.3.dist-info/METADATA,sha256=ogcH2Hf0wFsWkOT5H3KOLMlR_7DNWGl_BxecAmUthZc,15943
75
- fmtr_tools-1.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
- fmtr_tools-1.2.3.dist-info/entry_points.txt,sha256=fSQrDGNctdQXbUxpMWYVfVQ0mhZMDyaEDG3D3a0zOSc,278
77
- fmtr_tools-1.2.3.dist-info/top_level.txt,sha256=LXem9xCgNOD72tE2gRKESdiQTL902mfFkwWb6-dlwEE,5
78
- fmtr_tools-1.2.3.dist-info/RECORD,,
76
+ fmtr_tools-1.2.5.dist-info/licenses/LICENSE,sha256=FW9aa6vVN5IjRQWLT43hs4_koYSmpcbIovlKeAJ0_cI,10757
77
+ fmtr_tools-1.2.5.dist-info/METADATA,sha256=XH7DX192P5cYVOFAn4DR6Yh1lkAniyNoe6GWJA4p_ug,15943
78
+ fmtr_tools-1.2.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
79
+ fmtr_tools-1.2.5.dist-info/entry_points.txt,sha256=fSQrDGNctdQXbUxpMWYVfVQ0mhZMDyaEDG3D3a0zOSc,278
80
+ fmtr_tools-1.2.5.dist-info/top_level.txt,sha256=LXem9xCgNOD72tE2gRKESdiQTL902mfFkwWb6-dlwEE,5
81
+ fmtr_tools-1.2.5.dist-info/RECORD,,
fmtr/tools/dns_tools.py DELETED
@@ -1,221 +0,0 @@
1
- import dns
2
- import httpx
3
- import socket
4
- from dataclasses import dataclass
5
- from dns.message import Message
6
- from functools import cached_property
7
- from httpx_retries import Retry, RetryTransport
8
- from typing import Optional, Self
9
-
10
- from fmtr.tools import Client, logger
11
-
12
- RETRY_STRATEGY = Retry(
13
- total=2, # initial + 1 retry
14
- allowed_methods={"GET", "POST"},
15
- status_forcelist={502, 503, 504},
16
- retry_on_exceptions=None, # defaults to httpx.TransportError etc.
17
- backoff_factor=0.25, # short backoff (e.g. 0.25s, 0.5s)
18
- max_backoff_wait=0.75, # max total delay before giving up
19
- backoff_jitter=0.1, # small jitter to avoid retry bursts
20
- respect_retry_after_header=False, # DoH resolvers probably won't set this
21
- )
22
-
23
-
24
- class HTTPClientDoH(Client):
25
- """
26
-
27
- Base HTTP client for DoH-appropriate retry strategy.
28
-
29
- """
30
- TRANSPORT = RetryTransport(retry=RETRY_STRATEGY)
31
-
32
-
33
- @dataclass
34
- class BaseDNSData:
35
- """
36
-
37
- DNS response object.
38
-
39
- """
40
- wire: bytes
41
-
42
- @cached_property
43
- def message(self) -> Message:
44
- return dns.message.from_wire(self.wire)
45
-
46
- @classmethod
47
- def from_message(cls, message: Message) -> Self:
48
- return cls(message.to_wire())
49
-
50
-
51
- @dataclass
52
- class Response(BaseDNSData):
53
- """
54
-
55
- DNS response object.
56
-
57
- """
58
-
59
- http: Optional[httpx.Response] = None
60
-
61
- @classmethod
62
- def from_http(cls, response: httpx.Response) -> Self:
63
- self = cls(response.content, http=response)
64
- return self
65
-
66
-
67
- @dataclass
68
- class Request(BaseDNSData):
69
- """
70
-
71
- DNS request object.
72
-
73
- """
74
- wire: bytes
75
-
76
- @cached_property
77
- def question(self):
78
- return self.message.question[0]
79
-
80
- @cached_property
81
- def is_valid(self):
82
- return len(self.message.question) != 0
83
-
84
- @cached_property
85
- def type(self):
86
- return self.question.rdtype
87
-
88
- @cached_property
89
- def type_text(self):
90
- return dns.rdatatype.to_text(self.type)
91
-
92
- @cached_property
93
- def name(self):
94
- return self.question.name
95
-
96
- @cached_property
97
- def name_text(self):
98
- return self.name.to_text()
99
-
100
- @cached_property
101
- def blackhole(self) -> Response:
102
- blackhole = dns.message.make_response(self.message)
103
- blackhole.flags |= dns.flags.RA
104
- blackhole.set_rcode(dns.rcode.NXDOMAIN)
105
- response = Response.from_message(blackhole)
106
- return response
107
-
108
-
109
- @dataclass
110
- class Exchange:
111
- """
112
-
113
- Entire DNS exchange for a DNS Proxy: request -> upstream response -> response
114
-
115
- """
116
- ip: str
117
- port: int
118
-
119
- request: Request
120
- response: Optional[Response] = None
121
- response_upstream: Optional[Response] = None
122
-
123
- @classmethod
124
- def from_wire(cls, wire: bytes, ip: str, port: int) -> Self:
125
- request = Request(wire)
126
- return cls(request=request, ip=ip, port=port)
127
-
128
- @cached_property
129
- def client(self):
130
- return f'{self.ip}:{self.port}'
131
-
132
-
133
- class BasePlain:
134
- """
135
-
136
- Base for starting a plain DNS server
137
-
138
- """
139
-
140
- def __init__(self, host, port):
141
- self.host = host
142
- self.port = port
143
- self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
144
-
145
- def resolve(self, exchange: Exchange):
146
- raise NotImplemented
147
-
148
- def start(self):
149
- """
150
-
151
- Listen and resolve via overridden resolve method.
152
-
153
- """
154
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
155
- sock.bind((self.host, self.port))
156
- print(f"Listening on {self.host}:{self.port}")
157
- while True:
158
- data, (ip, port) = sock.recvfrom(512)
159
- exchange = Exchange.from_wire(data, ip=ip, port=port)
160
- self.resolve(exchange)
161
- sock.sendto(exchange.response.wire, (ip, port))
162
-
163
-
164
- class BaseDoHProxy(BasePlain):
165
- """
166
-
167
- Base for a DNS Proxy server
168
-
169
- """
170
-
171
- URL = None
172
- HEADERS = {"Content-Type": "application/dns-message"}
173
- client = HTTPClientDoH()
174
-
175
- def process_question(self, exchange: Exchange):
176
- return
177
-
178
- def process_upstream(self, exchange: Exchange):
179
- return
180
-
181
- def from_upstream(self, exchange: Exchange) -> Exchange:
182
-
183
- request = exchange.request
184
- response_doh = self.client.post(self.URL, headers=self.HEADERS, content=request.wire)
185
- response_doh.raise_for_status()
186
- response = Response.from_http(response_doh)
187
- exchange.response_upstream = response
188
-
189
- return exchange
190
-
191
- def resolve(self, exchange: Exchange):
192
- """
193
-
194
- Resolve a request, processing each stage, initial question, upstream response etc.
195
- Subclasses can override the relevant processing methods to implement custom behaviour.
196
-
197
- """
198
-
199
- request = exchange.request
200
-
201
- with logger.span(f'Handling request for {request.name_text} from {exchange.client}...'):
202
-
203
- if not request.is_valid:
204
- raise ValueError(f'Only one question per request is supported. Got {len(request.question)} questions.')
205
-
206
- with logger.span(f'Processing question...'):
207
- self.process_question(exchange)
208
- if exchange.response:
209
- return
210
-
211
- with logger.span(f'Making upstream request for {request.name_text}...'):
212
- self.from_upstream(exchange)
213
-
214
- with logger.span(f'Processing upstream response...'):
215
- self.process_upstream(exchange)
216
-
217
- if exchange.response:
218
- return
219
-
220
- exchange.response = exchange.response_upstream
221
- return