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
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
|
-
|
|
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
|
|
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
|
|
224
|
+
def __init_subclass__(cls, **kwargs):
|
|
62
225
|
"""
|
|
63
226
|
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
for
|
|
70
|
-
val = getattr(self, name)
|
|
71
|
-
objs.append(val)
|
|
232
|
+
fields = {}
|
|
233
|
+
for base in reversed(cls.__mro__):
|
|
72
234
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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,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)
|
fmtr/tools/datatype_tools.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
from
|
|
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
|
fmtr/tools/debugging_tools.py
CHANGED
|
@@ -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,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)
|