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

@@ -1,17 +1,31 @@
1
- from typing import List, Optional
2
-
3
1
  import pydantic_ai
4
- from pydantic_ai import RunContext
2
+ from pydantic_ai import RunContext, ModelRetry
5
3
  from pydantic_ai.agent import AgentRunResult, Agent
6
4
  from pydantic_ai.models.openai import OpenAIModel
7
5
  from pydantic_ai.providers.openai import OpenAIProvider
6
+ from typing import List, Optional, Any
8
7
 
9
8
  from fmtr.tools import environment_tools as env
10
9
  from fmtr.tools.constants import Constants
10
+ from fmtr.tools.logging_tools import logger
11
11
  from fmtr.tools.string_tools import truncate_mid
12
12
 
13
13
  pydantic_ai.Agent.instrument_all()
14
14
 
15
+
16
+ class Validator:
17
+ """
18
+
19
+ Subclassable validator
20
+
21
+ """
22
+
23
+ async def validate(self, ctx: RunContext[Any], output: Any) -> List[str]:
24
+ raise NotImplementedError()
25
+
26
+
27
+
28
+
15
29
  class Task:
16
30
  """
17
31
 
@@ -31,6 +45,7 @@ class Task:
31
45
  DEPS_TYPE = str
32
46
  RESULT_TYPE = str
33
47
  RESULT_RETRIES = 5
48
+ VALIDATORS: List[Validator] = []
34
49
 
35
50
  def __init__(self, *args, **kwargs):
36
51
  """
@@ -77,9 +92,18 @@ class Task:
77
92
  async def validate(self, ctx: RunContext[DEPS_TYPE], output: RESULT_TYPE) -> RESULT_TYPE:
78
93
  """
79
94
 
80
- Dummy validator
95
+ Aggregate any validation failures and combine them into a single ModelRetry exception
81
96
 
82
97
  """
98
+ msgs = []
99
+ for validator in self.VALIDATORS:
100
+ msgs += validator.validate(ctx, output)
101
+
102
+ if msgs:
103
+ msg = '. '.join(msgs)
104
+ logger.warning(msg)
105
+ raise ModelRetry(msg)
106
+
83
107
  return output
84
108
 
85
109
  def get_prompt(self, deps: Optional[DEPS_TYPE]) -> Optional[str]:
fmtr/tools/dns_tools.py CHANGED
@@ -1,57 +1,221 @@
1
1
  import dns
2
2
  import httpx
3
3
  import socket
4
- from dns.message import Message, QueryMessage
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
5
9
 
10
+ from fmtr.tools import Client, logger
6
11
 
7
- class Server:
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
+ """
8
139
 
9
140
  def __init__(self, host, port):
10
141
  self.host = host
11
142
  self.port = port
12
143
  self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
13
144
 
14
- def resolve(self, message: Message) -> QueryMessage:
145
+ def resolve(self, exchange: Exchange):
15
146
  raise NotImplemented
16
147
 
17
148
  def start(self):
149
+ """
150
+
151
+ Listen and resolve via overridden resolve method.
152
+
153
+ """
18
154
  sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
19
155
  sock.bind((self.host, self.port))
20
156
  print(f"Listening on {self.host}:{self.port}")
21
157
  while True:
22
- data, addr = sock.recvfrom(512)
23
- question = dns.message.from_wire(data)
24
- answer = self.resolve(question)
25
- sock.sendto(answer.to_wire(), addr)
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.
26
196
 
197
+ """
27
198
 
28
- class Proxy(Server):
29
- url = "https://cloudflare-dns.com/dns-query"
30
- headers = {"accept": "application/dns-json"}
199
+ request = exchange.request
31
200
 
32
- def resolve(self, question: Message) -> Message:
33
- qname = question.question[0].name.to_text()
34
- qtype = dns.rdatatype.to_text(question.question[0].rdtype)
35
- params = {"name": qname, "type": qtype}
201
+ with logger.span(f'Handling request for {request.name_text} from {exchange.client}...'):
36
202
 
37
- response = httpx.get(self.url, headers=self.headers, params=params)
38
- response.raise_for_status()
203
+ if not request.is_valid:
204
+ raise ValueError(f'Only one question per request is supported. Got {len(request.question)} questions.')
39
205
 
40
- answer = dns.message.make_response(question)
41
- answer.flags |= dns.flags.RA
206
+ with logger.span(f'Processing question...'):
207
+ self.process_question(exchange)
208
+ if exchange.response:
209
+ return
42
210
 
43
- data = response.json()
44
- for answer_rr in data.get("Answer", []):
45
- name = dns.name.from_text(answer_rr["name"])
46
- rtype = answer_rr["type"]
47
- ttl = answer_rr["TTL"]
48
- rdata = dns.rdata.from_text(dns.rdataclass.IN, rtype, answer_rr["data"])
49
- rrset = dns.rrset.from_rdata(name, ttl, rdata)
50
- answer.answer.append(rrset)
211
+ with logger.span(f'Making upstream request for {request.name_text}...'):
212
+ self.from_upstream(exchange)
51
213
 
52
- return answer
214
+ with logger.span(f'Processing upstream response...'):
215
+ self.process_upstream(exchange)
53
216
 
217
+ if exchange.response:
218
+ return
54
219
 
55
- if __name__ == "__main__":
56
- proxy = Proxy("127.0.0.1", 5354)
57
- proxy.start()
220
+ exchange.response = exchange.response_upstream
221
+ return
fmtr/tools/version CHANGED
@@ -1 +1 @@
1
- 1.2.1
1
+ 1.2.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmtr.tools
3
- Version: 1.2.1
3
+ Version: 1.2.3
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
@@ -112,6 +112,11 @@ Provides-Extra: path-type
112
112
  Requires-Dist: filetype; extra == "path-type"
113
113
  Provides-Extra: dns
114
114
  Requires-Dist: dnspython[doh]; extra == "dns"
115
+ Requires-Dist: httpx; extra == "dns"
116
+ Requires-Dist: httpx_retries; extra == "dns"
117
+ Requires-Dist: logfire; extra == "dns"
118
+ Requires-Dist: semver; extra == "dns"
119
+ Requires-Dist: logfire[httpx]; extra == "dns"
115
120
  Provides-Extra: patterns
116
121
  Requires-Dist: regex; extra == "patterns"
117
122
  Provides-Extra: http
@@ -123,60 +128,60 @@ Requires-Dist: logfire[httpx]; extra == "http"
123
128
  Provides-Extra: setup
124
129
  Requires-Dist: setuptools; extra == "setup"
125
130
  Provides-Extra: all
126
- Requires-Dist: logfire[fastapi]; extra == "all"
127
- Requires-Dist: torchaudio; 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"
128
141
  Requires-Dist: pymupdf; extra == "all"
129
- Requires-Dist: ollama; extra == "all"
130
- Requires-Dist: transformers[sentencepiece]; extra == "all"
142
+ Requires-Dist: peft; extra == "all"
143
+ Requires-Dist: appdirs; extra == "all"
144
+ Requires-Dist: google-api-python-client; extra == "all"
145
+ Requires-Dist: distributed; extra == "all"
146
+ Requires-Dist: openai; extra == "all"
147
+ Requires-Dist: yamlscript; extra == "all"
148
+ Requires-Dist: fastapi; extra == "all"
149
+ Requires-Dist: tabulate; extra == "all"
150
+ Requires-Dist: bokeh; extra == "all"
131
151
  Requires-Dist: uvicorn[standard]; extra == "all"
132
- Requires-Dist: tokenizers; extra == "all"
133
- Requires-Dist: tinynetrc; 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"
159
+ Requires-Dist: Unidecode; extra == "all"
160
+ Requires-Dist: huggingface_hub; extra == "all"
161
+ Requires-Dist: contexttimer; extra == "all"
134
162
  Requires-Dist: pandas; extra == "all"
135
- Requires-Dist: openai; extra == "all"
136
- Requires-Dist: google-api-python-client; extra == "all"
137
- Requires-Dist: pydantic-settings; extra == "all"
138
- Requires-Dist: deepmerge; extra == "all"
163
+ Requires-Dist: flet[all]; extra == "all"
164
+ Requires-Dist: flet-video; extra == "all"
139
165
  Requires-Dist: pyyaml; extra == "all"
140
- Requires-Dist: httpx_retries; extra == "all"
141
- Requires-Dist: yamlscript; extra == "all"
166
+ Requires-Dist: google-auth-httplib2; extra == "all"
167
+ Requires-Dist: ollama; extra == "all"
142
168
  Requires-Dist: setuptools; extra == "all"
169
+ Requires-Dist: logfire[httpx]; extra == "all"
170
+ Requires-Dist: openpyxl; extra == "all"
143
171
  Requires-Dist: dask[bag]; extra == "all"
144
- Requires-Dist: faker; extra == "all"
145
- Requires-Dist: logfire; extra == "all"
146
- Requires-Dist: Unidecode; extra == "all"
147
- Requires-Dist: google-auth-oauthlib; extra == "all"
148
- Requires-Dist: flet-video; extra == "all"
149
- Requires-Dist: docker; extra == "all"
150
- Requires-Dist: google-auth; extra == "all"
151
- Requires-Dist: tabulate; extra == "all"
152
- Requires-Dist: pydevd-pycharm; extra == "all"
153
172
  Requires-Dist: semver; extra == "all"
154
- Requires-Dist: dnspython[doh]; extra == "all"
155
- Requires-Dist: distributed; extra == "all"
156
- Requires-Dist: json_repair; extra == "all"
157
- Requires-Dist: torchvision; extra == "all"
158
- Requires-Dist: diskcache; extra == "all"
159
- Requires-Dist: openpyxl; extra == "all"
160
- Requires-Dist: google-auth-httplib2; extra == "all"
161
- Requires-Dist: httpx; extra == "all"
162
- Requires-Dist: regex; extra == "all"
163
- Requires-Dist: bokeh; extra == "all"
164
- Requires-Dist: fastapi; extra == "all"
165
- Requires-Dist: html2text; extra == "all"
173
+ Requires-Dist: logfire; extra == "all"
174
+ Requires-Dist: pytest-cov; extra == "all"
166
175
  Requires-Dist: filetype; extra == "all"
167
- Requires-Dist: peft; extra == "all"
168
- Requires-Dist: pydantic-ai[logfire,openai]; extra == "all"
169
- Requires-Dist: pydantic; extra == "all"
170
- Requires-Dist: sre_yield; extra == "all"
176
+ Requires-Dist: tokenizers; extra == "all"
171
177
  Requires-Dist: flet-webview; extra == "all"
172
- Requires-Dist: sentence_transformers; extra == "all"
173
- Requires-Dist: pytest-cov; extra == "all"
174
- Requires-Dist: flet[all]; extra == "all"
175
- Requires-Dist: logfire[httpx]; extra == "all"
176
- Requires-Dist: pymupdf4llm; extra == "all"
177
- Requires-Dist: appdirs; extra == "all"
178
- Requires-Dist: contexttimer; extra == "all"
179
- Requires-Dist: huggingface_hub; extra == "all"
178
+ Requires-Dist: google-auth-oauthlib; extra == "all"
179
+ Requires-Dist: httpx_retries; extra == "all"
180
+ Requires-Dist: pydantic; extra == "all"
181
+ Requires-Dist: logfire[fastapi]; extra == "all"
182
+ Requires-Dist: httpx; extra == "all"
183
+ Requires-Dist: torchvision; extra == "all"
184
+ Requires-Dist: dnspython[doh]; extra == "all"
180
185
  Dynamic: author
181
186
  Dynamic: author-email
182
187
  Dynamic: description
@@ -8,7 +8,7 @@ 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=oWX4w33NKbMTJM7fUlg7adwVi5beUpWs8NKDnsGzNFM,1781
11
+ fmtr/tools/dns_tools.py,sha256=7OoELFd9lZ3ULdJ6h8QwHYFNUYS9atqYrflKEzzFW-g,5461
12
12
  fmtr/tools/docker_tools.py,sha256=rdaZje2xhlmnfQqZnR7IHgRdWncTLjrJcViUTt5oEwk,617
13
13
  fmtr/tools/environment_tools.py,sha256=ZO2e8pTzbQ8jNXnmKpaZF7_3qkVM6U5iqku5YSwOszE,1849
14
14
  fmtr/tools/function_tools.py,sha256=_oW3-HZXMst2pcU-I-U7KTMmzo0g9MIQKmX-c2_NEoE,858
@@ -45,11 +45,11 @@ fmtr/tools/tabular_tools.py,sha256=tpIpZzYku1HcJrHZJL6BC39LmN3WUWVhFbK2N7nDVmE,1
45
45
  fmtr/tools/tokenization_tools.py,sha256=me-IBzSLyNYejLybwjO9CNB6Mj2NYfKPaOVThXyaGNg,4268
46
46
  fmtr/tools/tools.py,sha256=CAsApa1YwVdNE6H66Vjivs_mXYvOas3rh7fPELAnTpk,795
47
47
  fmtr/tools/unicode_tools.py,sha256=yS_9wpu8ogNoiIL7s1G_8bETFFO_YQlo4LNPv1NLDeY,52
48
- fmtr/tools/version,sha256=IGYgplfxzMivcrhm_c7yDmQcSPOc4n8zjOgxOUnGPlM,5
48
+ fmtr/tools/version,sha256=xH9bGLikMOaYuf4V5R9hGZhOeDNLzz9F4hDTDDfvL54,5
49
49
  fmtr/tools/version_tools.py,sha256=yNs_CGqWpqE4jbK9wsPIi14peJVXYbhIcMqHAFOw3yE,1480
50
50
  fmtr/tools/yaml_tools.py,sha256=9kuYChqJelWQIjGlSnK4iDdOWWH06P0gp9jIcRrC3UI,1903
51
51
  fmtr/tools/ai_tools/__init__.py,sha256=JZrLuOFNV1A3wvJgonxOgz_4WS-7MfCuowGWA5uYCjs,372
52
- fmtr/tools/ai_tools/agentic_tools.py,sha256=YebH7R9ovVo3GzfWAZxY49e2fNt13APDMe290xx7zks,3720
52
+ fmtr/tools/ai_tools/agentic_tools.py,sha256=acSEPFS-aguDXanWGs3fAAlRyJSYPZW7L-Kb2qDLm-I,4300
53
53
  fmtr/tools/ai_tools/inference_tools.py,sha256=2UP2gXEyOJUjyyV6zmFIYmIxUsh1rXkRH0IbFvr2bRs,11908
54
54
  fmtr/tools/entrypoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
55
  fmtr/tools/entrypoints/cache_hfh.py,sha256=fQNs4J9twQuZH_Yj98-oOvEX7-LrSUP3kO8nzw2HrHs,60
@@ -70,9 +70,9 @@ fmtr/tools/tests/test_environment.py,sha256=iHaiMQfECYZPkPKwfuIZV9uHuWe3aE-p_dN_
70
70
  fmtr/tools/tests/test_json.py,sha256=IeSP4ziPvRcmS8kq7k9tHonC9rN5YYq9GSNT2ul6Msk,287
71
71
  fmtr/tools/tests/test_path.py,sha256=AkZQa6_8BQ-VaCyL_J-iKmdf2ZaM-xFYR37Kun3k4_g,2188
72
72
  fmtr/tools/tests/test_yaml.py,sha256=jc0TwwKu9eC0LvFGNMERdgBue591xwLxYXFbtsRwXVM,287
73
- fmtr_tools-1.2.1.dist-info/licenses/LICENSE,sha256=FW9aa6vVN5IjRQWLT43hs4_koYSmpcbIovlKeAJ0_cI,10757
74
- fmtr_tools-1.2.1.dist-info/METADATA,sha256=jNFEE6r7_TrdiJIO4PjjyPtPOgWHJ2IJOKmPHMG8MjM,15738
75
- fmtr_tools-1.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
- fmtr_tools-1.2.1.dist-info/entry_points.txt,sha256=fSQrDGNctdQXbUxpMWYVfVQ0mhZMDyaEDG3D3a0zOSc,278
77
- fmtr_tools-1.2.1.dist-info/top_level.txt,sha256=LXem9xCgNOD72tE2gRKESdiQTL902mfFkwWb6-dlwEE,5
78
- fmtr_tools-1.2.1.dist-info/RECORD,,
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,,