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
@@ -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)
@@ -0,0 +1,6 @@
1
+ from fmtr.tools.import_tools import MissingExtraMockModule
2
+
3
+ try:
4
+ from python_on_whales import DockerClient
5
+ except ModuleNotFoundError as exception:
6
+ DockerClient = MissingExtraMockModule('docker.client', exception)
File without changes
@@ -0,0 +1,3 @@
1
+ def main():
2
+ from fmtr import tools
3
+ tools.hfh.main()
@@ -0,0 +1,2 @@
1
+ def main():
2
+ print('Ran test entrypoint.')
@@ -0,0 +1,8 @@
1
+ def main():
2
+ """
3
+
4
+ Ensure YS binary is installed.
5
+
6
+ """
7
+ from fmtr.tools import yaml
8
+ yaml.install()
@@ -1,9 +1,4 @@
1
- def cache_hfh():
2
- from fmtr import tools
3
- tools.hfh.main()
4
-
5
-
6
- def remote_debug_test():
1
+ def main():
7
2
  """
8
3
 
9
4
  Test debugger connection
@@ -0,0 +1,8 @@
1
+ def main():
2
+ """
3
+
4
+ Start debugger
5
+
6
+ """
7
+ from fmtr.tools import debug
8
+ debug.debug_shell()
@@ -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
- IS_DEBUG = get(Constants.FMTR_LOG_LEVEL_KEY, None, converter=str.upper) == 'DEBUG'
79
+ IS_DEV = get_bool(Constants.FMTR_DEV_KEY, default=False)
@@ -1,4 +1,8 @@
1
- from typing import Tuple
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
@@ -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
- msg = f'Doing auth for service {cls.SERVICE} ({cls.VERSION})...'
24
- logger.info(msg)
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
- service = build(cls.SERVICE, cls.VERSION, credentials=credentials)
37
- return service
47
+
48
+ return build(cls.SERVICE, cls.VERSION, credentials=credentials)
@@ -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