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.
- fmtr/tools/__init__.py +68 -52
- fmtr/tools/ai_tools/__init__.py +2 -2
- fmtr/tools/ai_tools/agentic_tools.py +151 -32
- fmtr/tools/ai_tools/inference_tools.py +2 -1
- fmtr/tools/api_tools.py +8 -5
- fmtr/tools/caching_tools.py +101 -3
- fmtr/tools/constants.py +33 -0
- fmtr/tools/context_tools.py +23 -0
- fmtr/tools/data_modelling_tools.py +227 -14
- fmtr/tools/database_tools/__init__.py +6 -0
- fmtr/tools/database_tools/document.py +51 -0
- fmtr/tools/datatype_tools.py +21 -1
- fmtr/tools/datetime_tools.py +12 -0
- fmtr/tools/debugging_tools.py +60 -0
- fmtr/tools/dns_tools/__init__.py +7 -0
- fmtr/tools/dns_tools/client.py +97 -0
- fmtr/tools/dns_tools/dm.py +257 -0
- fmtr/tools/dns_tools/proxy.py +66 -0
- fmtr/tools/dns_tools/server.py +138 -0
- fmtr/tools/docker_tools/__init__.py +6 -0
- fmtr/tools/entrypoints/__init__.py +0 -0
- fmtr/tools/entrypoints/cache_hfh.py +3 -0
- fmtr/tools/entrypoints/ep_test.py +2 -0
- fmtr/tools/entrypoints/install_yamlscript.py +8 -0
- fmtr/tools/{console_script_tools.py → entrypoints/remote_debug_test.py} +1 -6
- fmtr/tools/entrypoints/shell_debug.py +8 -0
- fmtr/tools/environment_tools.py +2 -2
- fmtr/tools/function_tools.py +77 -1
- fmtr/tools/google_api_tools.py +15 -4
- fmtr/tools/http_tools.py +26 -0
- fmtr/tools/inherit_tools.py +27 -0
- fmtr/tools/interface_tools/__init__.py +8 -0
- fmtr/tools/interface_tools/context.py +13 -0
- fmtr/tools/interface_tools/controls.py +354 -0
- fmtr/tools/interface_tools/interface_tools.py +189 -0
- fmtr/tools/iterator_tools.py +29 -0
- fmtr/tools/logging_tools.py +43 -16
- fmtr/tools/packaging_tools.py +14 -0
- fmtr/tools/path_tools/__init__.py +12 -0
- fmtr/tools/path_tools/app_path_tools.py +40 -0
- fmtr/tools/{path_tools.py → path_tools/path_tools.py} +156 -12
- fmtr/tools/path_tools/type_path_tools.py +3 -0
- fmtr/tools/pattern_tools.py +260 -0
- fmtr/tools/pdf_tools.py +39 -1
- fmtr/tools/settings_tools.py +23 -4
- fmtr/tools/setup_tools/__init__.py +8 -0
- fmtr/tools/setup_tools/setup_tools.py +447 -0
- fmtr/tools/string_tools.py +92 -13
- fmtr/tools/tabular_tools.py +61 -0
- fmtr/tools/tools.py +27 -2
- fmtr/tools/version +1 -1
- fmtr/tools/version_tools/__init__.py +12 -0
- fmtr/tools/version_tools/version_tools.py +51 -0
- fmtr/tools/webhook_tools.py +17 -0
- fmtr/tools/yaml_tools.py +66 -5
- {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/METADATA +136 -54
- fmtr_tools-1.3.81.dist-info/RECORD +93 -0
- {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/WHEEL +1 -1
- fmtr_tools-1.3.81.dist-info/entry_points.txt +6 -0
- fmtr_tools-1.3.81.dist-info/top_level.txt +1 -0
- fmtr/tools/docker_tools.py +0 -30
- fmtr/tools/interface_tools.py +0 -64
- fmtr/tools/version_tools.py +0 -62
- fmtr_tools-1.1.1.dist-info/RECORD +0 -65
- fmtr_tools-1.1.1.dist-info/entry_points.txt +0 -3
- fmtr_tools-1.1.1.dist-info/top_level.txt +0 -2
- {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import dns
|
|
2
|
+
import httpx
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from dns import rcode as dnspython_rcode, reversename as dnspython_reversename
|
|
5
|
+
from dns.message import Message, QueryMessage
|
|
6
|
+
from dns.rrset import RRset
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from typing import Self, Optional, List
|
|
9
|
+
|
|
10
|
+
from fmtr.tools.string_tools import join
|
|
11
|
+
|
|
12
|
+
TTL_CODE_DEFAULTS = {
|
|
13
|
+
dnspython_rcode.NOERROR: 300, # Successful query
|
|
14
|
+
dnspython_rcode.FORMERR: 60, # Format error
|
|
15
|
+
dnspython_rcode.SERVFAIL: 10, # Server failure
|
|
16
|
+
dnspython_rcode.NXDOMAIN: 60 * 60, # Non-existent domain
|
|
17
|
+
dnspython_rcode.NOTIMP: 60, # Not implemented
|
|
18
|
+
dnspython_rcode.REFUSED: 60, # Refused
|
|
19
|
+
dnspython_rcode.YXDOMAIN: 600, # Name exists when it should not
|
|
20
|
+
dnspython_rcode.YXRRSET: 600, # RR Set exists when it should not
|
|
21
|
+
dnspython_rcode.NXRRSET: 300, # RR Set that should exist does not
|
|
22
|
+
dnspython_rcode.NOTAUTH: 60, # Not authorized
|
|
23
|
+
dnspython_rcode.NOTZONE: 60 # Name not contained in zone
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class BaseDNSData:
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
DNS response object.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
wire: bytes
|
|
34
|
+
|
|
35
|
+
@cached_property
|
|
36
|
+
def message(self) -> Message:
|
|
37
|
+
return dns.message.from_wire(self.wire)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_message(cls, message: Message) -> Self:
|
|
41
|
+
return cls(message.to_wire())
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Response(BaseDNSData):
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
DNS response object.
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
http: Optional[httpx.Response] = None
|
|
52
|
+
blocked_by: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_http(cls, response: httpx.Response) -> Self:
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
Initialise from an HTTP response.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
self = cls(response.content, http=response)
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def answer(self) -> Optional[RRset]:
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
Get the latest answer, if one exists.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
if not self.message.answer:
|
|
72
|
+
return None
|
|
73
|
+
return self.message.answer[-1]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def rcode(self) -> dnspython_rcode.Rcode:
|
|
77
|
+
return self.message.rcode()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def rcode_text(self) -> str:
|
|
81
|
+
return dnspython_rcode.to_text(self.rcode)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def ttl(self) -> int:
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
Get median TTL from answers, falling back to authority, then to error-code defaults.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
answers = self.message.answer or self.message.authority
|
|
91
|
+
if answers:
|
|
92
|
+
ttls = [answer.ttl for answer in answers]
|
|
93
|
+
ttl = min(ttls)
|
|
94
|
+
return ttl
|
|
95
|
+
|
|
96
|
+
ttl = TTL_CODE_DEFAULTS.get(self.rcode, dnspython_rcode.NXDOMAIN)
|
|
97
|
+
return ttl
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def __str__(self):
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
Put answer and ID text in string representation.
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
answer = self.answer
|
|
108
|
+
|
|
109
|
+
if answer:
|
|
110
|
+
answer = join(answer.to_text().splitlines(), sep=', ')
|
|
111
|
+
|
|
112
|
+
string = join([answer, self.message.flags], sep=', ')
|
|
113
|
+
string = f'{self.__class__.__name__}({string})'
|
|
114
|
+
return string
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Request(BaseDNSData):
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
DNS request object.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
wire: bytes
|
|
126
|
+
|
|
127
|
+
@cached_property
|
|
128
|
+
def question(self) -> RRset:
|
|
129
|
+
return self.message.question[0]
|
|
130
|
+
|
|
131
|
+
@cached_property
|
|
132
|
+
def is_valid(self):
|
|
133
|
+
return len(self.message.question) != 0
|
|
134
|
+
|
|
135
|
+
@cached_property
|
|
136
|
+
def type(self):
|
|
137
|
+
return self.question.rdtype
|
|
138
|
+
|
|
139
|
+
@cached_property
|
|
140
|
+
def type_text(self):
|
|
141
|
+
return dns.rdatatype.to_text(self.type)
|
|
142
|
+
|
|
143
|
+
@cached_property
|
|
144
|
+
def name(self):
|
|
145
|
+
return self.question.name
|
|
146
|
+
|
|
147
|
+
@cached_property
|
|
148
|
+
def name_text(self):
|
|
149
|
+
return self.name.to_text()
|
|
150
|
+
|
|
151
|
+
def get_response_template(self):
|
|
152
|
+
message = dns.message.make_response(self.message)
|
|
153
|
+
message.flags |= dns.flags.RA
|
|
154
|
+
return message
|
|
155
|
+
|
|
156
|
+
@cached_property
|
|
157
|
+
def blackhole(self) -> Response:
|
|
158
|
+
blackhole = self.get_response_template()
|
|
159
|
+
blackhole.set_rcode(dns.rcode.NXDOMAIN)
|
|
160
|
+
response = Response.from_message(blackhole)
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class Exchange:
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
Entire DNS exchange for a DNS Proxy: request -> upstream response -> response
|
|
169
|
+
|
|
170
|
+
"""
|
|
171
|
+
ip: str
|
|
172
|
+
port: int
|
|
173
|
+
|
|
174
|
+
request: Request
|
|
175
|
+
response: Optional[Response] = None
|
|
176
|
+
answers_pre: List[RRset] = field(default_factory=list)
|
|
177
|
+
is_internal: bool = False
|
|
178
|
+
client_name: Optional[str] = None
|
|
179
|
+
is_complete: bool = False
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def addr(self):
|
|
183
|
+
return self.ip, self.port
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_wire(cls, wire: bytes, **kwargs) -> Self:
|
|
187
|
+
request = Request(wire)
|
|
188
|
+
response = Response.from_message(request.get_response_template())
|
|
189
|
+
return cls(request=request, response=response, **kwargs)
|
|
190
|
+
|
|
191
|
+
@cached_property
|
|
192
|
+
def client(self):
|
|
193
|
+
return f'{self.ip}:{self.port}'
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def question_last(self) -> RRset:
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
Create an RRset surrogate representing the latest/current question.
|
|
200
|
+
This can be the original question - or a hybrid one if we've injected our own answers into the Exchange.
|
|
201
|
+
If there's a response, use its answers, else fall back to answers_pre, else to the original question.
|
|
202
|
+
|
|
203
|
+
"""
|
|
204
|
+
answers = self.answers_pre
|
|
205
|
+
if self.response:
|
|
206
|
+
answers = self.response.message.answer or answers
|
|
207
|
+
|
|
208
|
+
if answers:
|
|
209
|
+
rrset = answers[-1]
|
|
210
|
+
rdtype = self.request.type
|
|
211
|
+
ttl = self.request.question.ttl
|
|
212
|
+
rdclass = self.request.question.rdclass
|
|
213
|
+
name = next(iter(rrset.items.keys())).to_text()
|
|
214
|
+
rrset_surrogate = dns.rrset.from_text(
|
|
215
|
+
name=name,
|
|
216
|
+
ttl=ttl,
|
|
217
|
+
rdtype=rdtype,
|
|
218
|
+
rdclass=rdclass,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return rrset_surrogate
|
|
222
|
+
else:
|
|
223
|
+
return self.request.question
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def query_last(self) -> QueryMessage:
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
Create a query (e.g. for use by upstream) based on the last question.
|
|
230
|
+
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
question_last = self.question_last
|
|
234
|
+
query = dns.message.make_query(qname=question_last.name, rdclass=question_last.rdclass, rdtype=question_last.rdtype, id=self.request.message.id)
|
|
235
|
+
return query
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def key(self):
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
Hashable key for caching
|
|
242
|
+
|
|
243
|
+
"""
|
|
244
|
+
data = tuple(self.request.question.to_text().split())
|
|
245
|
+
return data
|
|
246
|
+
|
|
247
|
+
@cached_property
|
|
248
|
+
def reverse(self) -> Self:
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
Create an Exchange for a reverse lookup of this Exchange's client IP.
|
|
252
|
+
|
|
253
|
+
"""
|
|
254
|
+
name = dnspython_reversename.from_address(self.ip)
|
|
255
|
+
query = dns.message.make_query(name, dns.rdatatype.PTR)
|
|
256
|
+
exchange = self.__class__.from_wire(query.to_wire(), ip=self.ip, port=self.port, is_internal=True)
|
|
257
|
+
return exchange
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from fmtr.tools.dns_tools import server, client
|
|
4
|
+
from fmtr.tools.dns_tools.dm import Exchange
|
|
5
|
+
from fmtr.tools.logging_tools import logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(kw_only=True, eq=False)
|
|
9
|
+
class Proxy(server.Plain):
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
Base for a DNS Proxy server (plain server) TODO: Allow subclassing of any server type.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
client: client.HTTP
|
|
17
|
+
|
|
18
|
+
def process_question(self, exchange: Exchange):
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
Modify exchange based on initial question.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
def process_upstream(self, exchange: Exchange):
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
Modify exchange after upstream response.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
def finalize(self, exchange: Exchange):
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
Finalize a still open exchange.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
exchange.is_complete = True
|
|
41
|
+
|
|
42
|
+
async def resolve(self, exchange: Exchange) -> Exchange:
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
Resolve a request, processing each stage, initial question, upstream response etc.
|
|
46
|
+
Subclasses can override the relevant processing methods to implement custom behaviour.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
with logger.span(f'Processing question...'):
|
|
50
|
+
self.process_question(exchange)
|
|
51
|
+
if exchange.is_complete:
|
|
52
|
+
return exchange
|
|
53
|
+
|
|
54
|
+
with logger.span(f'Making upstream request...'):
|
|
55
|
+
self.client.resolve(exchange)
|
|
56
|
+
if exchange.is_complete:
|
|
57
|
+
return exchange
|
|
58
|
+
|
|
59
|
+
with logger.span(f'Processing upstream response...'):
|
|
60
|
+
self.process_upstream(exchange)
|
|
61
|
+
if exchange.is_complete:
|
|
62
|
+
return exchange
|
|
63
|
+
|
|
64
|
+
self.finalize(exchange)
|
|
65
|
+
|
|
66
|
+
return exchange
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from dns import rcode as dnspython_rcode
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from fmtr.tools import caching_tools as caching
|
|
9
|
+
from fmtr.tools.dns_tools.dm import Exchange
|
|
10
|
+
from fmtr.tools.logging_tools import logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(kw_only=True, eq=False)
|
|
14
|
+
class Plain(asyncio.DatagramProtocol):
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
Async base class for a plain DNS server using asyncio DatagramProtocol.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
host: str
|
|
21
|
+
port: int
|
|
22
|
+
transport: Optional[asyncio.DatagramTransport] = field(default=None, init=False)
|
|
23
|
+
|
|
24
|
+
@cached_property
|
|
25
|
+
def loop(self):
|
|
26
|
+
return asyncio.get_event_loop()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@cached_property
|
|
30
|
+
def cache(self):
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
Overridable cache.
|
|
34
|
+
"""
|
|
35
|
+
cache = caching.TLRU(maxsize=1_024, ttu_static=timedelta(hours=1), desc='DNS Request')
|
|
36
|
+
return cache
|
|
37
|
+
|
|
38
|
+
def connection_made(self, transport: asyncio.DatagramTransport):
|
|
39
|
+
self.transport = transport
|
|
40
|
+
logger.info(f'Listening on {self.host}:{self.port}')
|
|
41
|
+
|
|
42
|
+
def datagram_received(self, data: bytes, addr):
|
|
43
|
+
ip, port = addr
|
|
44
|
+
exchange = Exchange.from_wire(data, ip=ip, port=port)
|
|
45
|
+
asyncio.create_task(self.handle(exchange))
|
|
46
|
+
|
|
47
|
+
async def start(self):
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
Start the async UDP server.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
logger.info(f'Starting async DNS server on {self.host}:{self.port}...')
|
|
54
|
+
await self.loop.create_datagram_endpoint(
|
|
55
|
+
lambda: self,
|
|
56
|
+
local_addr=(self.host, self.port)
|
|
57
|
+
)
|
|
58
|
+
await asyncio.Future() # Prevent exit by blocking forever
|
|
59
|
+
|
|
60
|
+
async def resolve(self, exchange: Exchange) -> Exchange:
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
To be defined in subclasses.
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
def check_cache(self, exchange: Exchange):
|
|
69
|
+
if exchange.key in self.cache:
|
|
70
|
+
logger.info(f'Request found in cache.')
|
|
71
|
+
exchange.response = self.cache[exchange.key]
|
|
72
|
+
exchange.response.message.id = exchange.request.message.id
|
|
73
|
+
exchange.is_complete = True
|
|
74
|
+
|
|
75
|
+
def get_span(self, exchange: Exchange):
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
Get handling span
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
request = exchange.request
|
|
82
|
+
span = logger.span(
|
|
83
|
+
f'Handling request {exchange.client_name=} {request.message.id=} {request.type_text} {request.name_text} {request.question=}...'
|
|
84
|
+
)
|
|
85
|
+
return span
|
|
86
|
+
|
|
87
|
+
def log_response(self, exchange: Exchange):
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
Log when resolution complete
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
request = exchange.request
|
|
94
|
+
response = exchange.response
|
|
95
|
+
logger.info(
|
|
96
|
+
f'Resolution complete {exchange.client_name=} {request.message.id=} {request.type_text} {request.name_text} {request.question=} {exchange.is_complete=} {response.rcode=} {response.rcode_text=} {response.answer=} {response.blocked_by=}...'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def log_dns_errors(self, exchange: Exchange):
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
Warn about any errors
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
if exchange.response.rcode != dnspython_rcode.NOERROR:
|
|
106
|
+
logger.warning(f'Error {exchange.response.rcode_text=}')
|
|
107
|
+
|
|
108
|
+
async def handle(self, exchange: Exchange):
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
Warn about any errors
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
if not exchange.request.is_valid:
|
|
115
|
+
raise ValueError(f'Only one question per request is supported. Got {len(exchange.request.question)} questions.')
|
|
116
|
+
|
|
117
|
+
if not exchange.is_internal:
|
|
118
|
+
await self.handle(exchange.reverse)
|
|
119
|
+
client_name = exchange.reverse.question_last.name.to_text()
|
|
120
|
+
if not exchange.reverse.response.answer:
|
|
121
|
+
logger.warning(f'Client name could not be resolved {client_name=}.')
|
|
122
|
+
exchange.client_name = client_name
|
|
123
|
+
|
|
124
|
+
with self.get_span(exchange):
|
|
125
|
+
with logger.span(f'Checking cache...'):
|
|
126
|
+
self.check_cache(exchange)
|
|
127
|
+
|
|
128
|
+
if not exchange.is_complete:
|
|
129
|
+
exchange = await self.resolve(exchange)
|
|
130
|
+
self.cache[exchange.key] = exchange.response
|
|
131
|
+
|
|
132
|
+
self.log_dns_errors(exchange)
|
|
133
|
+
self.log_response(exchange)
|
|
134
|
+
|
|
135
|
+
if exchange.is_internal:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
self.transport.sendto(exchange.response.message.to_wire(), exchange.addr)
|
|
File without changes
|
fmtr/tools/environment_tools.py
CHANGED
|
@@ -28,7 +28,7 @@ def get_dict() -> Dict[str, str]:
|
|
|
28
28
|
Return environment variables as a standard dictionary.
|
|
29
29
|
|
|
30
30
|
"""
|
|
31
|
-
environment_dict = dict(os.environ)
|
|
31
|
+
environment_dict = dict(sorted(dict(os.environ).items()))
|
|
32
32
|
return environment_dict
|
|
33
33
|
|
|
34
34
|
|
|
@@ -76,4 +76,4 @@ get_date = get_getter(date.fromisoformat)
|
|
|
76
76
|
get_datetime = get_getter(datetime.fromisoformat)
|
|
77
77
|
get_path = get_getter(Path)
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
IS_DEV = get_bool(Constants.FMTR_DEV_KEY, default=False)
|
fmtr/tools/function_tools.py
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Tuple, Callable, Self
|
|
4
|
+
|
|
5
|
+
from fmtr.tools import context_tools
|
|
2
6
|
|
|
3
7
|
|
|
4
8
|
def combine_args_kwargs(args: dict=None, kwargs: dict=None) -> dict:
|
|
@@ -31,3 +35,75 @@ def split_args_kwargs(args_kwargs: dict) -> Tuple[list, dict]:
|
|
|
31
35
|
return args, kwargs
|
|
32
36
|
|
|
33
37
|
|
|
38
|
+
class MethodDecorator:
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
Bound method decorator with overridable start/stop and context manager
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
CONTEXT_KEY = 'context'
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
Initialise the decorator itself with any arguments
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
self.func = None
|
|
54
|
+
|
|
55
|
+
def __call__(self, func: Callable) -> Self:
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
Add the (unbound) method.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
self.func = func
|
|
62
|
+
functools.update_wrapper(self, func)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __get__(self, instance, owner):
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
Wrap bound method at runtime, call start/stop within context.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
if instance is None: # Class method called.
|
|
72
|
+
return self.func
|
|
73
|
+
|
|
74
|
+
if inspect.iscoroutinefunction(self.func):
|
|
75
|
+
async def async_wrapper(*args, **kwargs):
|
|
76
|
+
with self.get_context(instance):
|
|
77
|
+
self.start(instance, *args, **kwargs)
|
|
78
|
+
result = await self.func(instance, *args, **kwargs)
|
|
79
|
+
self.stop(instance, *args, **kwargs)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
return async_wrapper
|
|
83
|
+
|
|
84
|
+
else:
|
|
85
|
+
def sync_wrapper(*args, **kwargs):
|
|
86
|
+
with self.get_context(instance):
|
|
87
|
+
self.start(instance, *args, **kwargs)
|
|
88
|
+
result = self.func(instance, *args, **kwargs)
|
|
89
|
+
self.stop(instance, *args, **kwargs)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
return sync_wrapper
|
|
93
|
+
|
|
94
|
+
def get_context(self, instance):
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
If the instance has a context attribute, use that - otherwise use a null context.
|
|
98
|
+
|
|
99
|
+
"""
|
|
100
|
+
context = getattr(instance, self.CONTEXT_KEY, None)
|
|
101
|
+
if context:
|
|
102
|
+
return context
|
|
103
|
+
return context_tools.null()
|
|
104
|
+
|
|
105
|
+
def start(self, instance, *args, **kwargs):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def stop(self, instance, *args, **kwargs):
|
|
109
|
+
pass
|
fmtr/tools/google_api_tools.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from google.auth.transport.requests import Request
|
|
1
2
|
from google.oauth2.credentials import Credentials
|
|
2
3
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
3
4
|
from googleapiclient.discovery import build
|
|
@@ -20,8 +21,12 @@ class Authenticator:
|
|
|
20
21
|
|
|
21
22
|
@classmethod
|
|
22
23
|
def auth(cls):
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
Do initial authentication or refresh token if expired.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
logger.info(f'Doing auth for service {cls.SERVICE} ({cls.VERSION})...')
|
|
25
30
|
|
|
26
31
|
PATH_CREDS = cls.PATH / 'credentials.json'
|
|
27
32
|
PATH_TOKEN = cls.PATH / f'{cls.SERVICE}.json'
|
|
@@ -29,9 +34,15 @@ class Authenticator:
|
|
|
29
34
|
if PATH_TOKEN.exists():
|
|
30
35
|
data_token = PATH_TOKEN.read_json()
|
|
31
36
|
credentials = Credentials.from_authorized_user_info(data_token, cls.SCOPES)
|
|
37
|
+
if credentials.expired:
|
|
38
|
+
with logger.span(f'Credentials expired for {cls.SERVICE}. Will refresh...'):
|
|
39
|
+
logger.warning(f'{cls.SERVICE}. {PATH_CREDS.exists()=} {PATH_TOKEN.exists()=} {credentials.valid=} {credentials.expired=} {credentials.expiry=}')
|
|
40
|
+
credentials.refresh(Request())
|
|
41
|
+
PATH_TOKEN.write_text(credentials.to_json())
|
|
32
42
|
else:
|
|
33
43
|
flow = InstalledAppFlow.from_client_secrets_file(PATH_CREDS, cls.SCOPES)
|
|
44
|
+
flow.authorization_url(prompt='consent', access_type='offline')
|
|
34
45
|
credentials = flow.run_local_server(open_browser=False, port=cls.PORT)
|
|
35
46
|
PATH_TOKEN.write_text(credentials.to_json())
|
|
36
|
-
|
|
37
|
-
return
|
|
47
|
+
|
|
48
|
+
return build(cls.SERVICE, cls.VERSION, credentials=credentials)
|
fmtr/tools/http_tools.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from httpx_retries import RetryTransport
|
|
3
|
+
|
|
4
|
+
from fmtr.tools import logging_tools
|
|
5
|
+
|
|
6
|
+
logging_tools.logger.instrument_httpx()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Client(httpx.Client):
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
Instrumented client base
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
TRANSPORT = RetryTransport()
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, **kwargs):
|
|
19
|
+
super().__init__(*args, transport=self.TRANSPORT, **kwargs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
client = Client()
|
|
23
|
+
|
|
24
|
+
if __name__ == '__main__':
|
|
25
|
+
resp = client.get('http://httpbin.org/delay/10')
|
|
26
|
+
resp
|