fmtr.tools 1.3.81__py3-none-any.whl → 1.4.37__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 +19 -1
- fmtr/tools/api_tools.py +65 -7
- fmtr/tools/async_tools.py +4 -0
- fmtr/tools/av_tools.py +7 -0
- fmtr/tools/constants.py +9 -1
- fmtr/tools/datatype_tools.py +1 -1
- fmtr/tools/debugging_tools.py +1 -2
- fmtr/tools/environment_tools.py +1 -0
- fmtr/tools/ha_tools/__init__.py +8 -0
- fmtr/tools/ha_tools/constants.py +9 -0
- fmtr/tools/ha_tools/core.py +16 -0
- fmtr/tools/ha_tools/supervisor.py +16 -0
- fmtr/tools/ha_tools/utils.py +46 -0
- fmtr/tools/http_tools.py +30 -4
- fmtr/tools/iterator_tools.py +93 -1
- fmtr/tools/logging_tools.py +72 -18
- fmtr/tools/mqtt_tools.py +89 -0
- fmtr/tools/networking_tools.py +73 -0
- fmtr/tools/path_tools/__init__.py +1 -1
- fmtr/tools/path_tools/path_tools.py +65 -6
- fmtr/tools/pattern_tools.py +17 -0
- fmtr/tools/settings_tools.py +4 -2
- fmtr/tools/setup_tools/setup_tools.py +35 -1
- fmtr/tools/version +1 -1
- fmtr/tools/yaml_tools.py +1 -3
- fmtr/tools/youtube_tools.py +128 -0
- fmtr_tools-1.4.37.data/scripts/add-service +14 -0
- fmtr_tools-1.4.37.data/scripts/add-user-path +8 -0
- fmtr_tools-1.4.37.data/scripts/apt-headless +23 -0
- fmtr_tools-1.4.37.data/scripts/compose-update +10 -0
- fmtr_tools-1.4.37.data/scripts/docker-sandbox +43 -0
- fmtr_tools-1.4.37.data/scripts/docker-sandbox-init +23 -0
- fmtr_tools-1.4.37.data/scripts/docs-deploy +6 -0
- fmtr_tools-1.4.37.data/scripts/docs-serve +5 -0
- fmtr_tools-1.4.37.data/scripts/download +9 -0
- fmtr_tools-1.4.37.data/scripts/fmtr-test-script +3 -0
- fmtr_tools-1.4.37.data/scripts/ftu +3 -0
- fmtr_tools-1.4.37.data/scripts/ha-addon-launch +16 -0
- fmtr_tools-1.4.37.data/scripts/install-browser +8 -0
- fmtr_tools-1.4.37.data/scripts/parse-args +43 -0
- fmtr_tools-1.4.37.data/scripts/set-password +5 -0
- fmtr_tools-1.4.37.data/scripts/snips-install +14 -0
- fmtr_tools-1.4.37.data/scripts/ssh-auth +28 -0
- fmtr_tools-1.4.37.data/scripts/ssh-serve +15 -0
- fmtr_tools-1.4.37.data/scripts/vlc-tn +10 -0
- fmtr_tools-1.4.37.data/scripts/vm-launch +17 -0
- {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/METADATA +92 -50
- {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/RECORD +52 -23
- {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/WHEEL +0 -0
- {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/entry_points.txt +0 -0
- {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/licenses/LICENSE +0 -0
- {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/top_level.txt +0 -0
fmtr/tools/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import fmtr.tools.async_tools as
|
|
1
|
+
import fmtr.tools.async_tools as aio
|
|
2
2
|
import fmtr.tools.database_tools as db
|
|
3
3
|
import fmtr.tools.dataclass_tools as dataclass
|
|
4
4
|
import fmtr.tools.datatype_tools as datatype
|
|
@@ -12,6 +12,7 @@ import fmtr.tools.iterator_tools as iterator
|
|
|
12
12
|
import fmtr.tools.json_tools as json
|
|
13
13
|
import fmtr.tools.logging_tools as logging
|
|
14
14
|
import fmtr.tools.name_tools as name
|
|
15
|
+
import fmtr.tools.networking_tools as net
|
|
15
16
|
import fmtr.tools.packaging_tools as packaging
|
|
16
17
|
import fmtr.tools.path_tools as path
|
|
17
18
|
import fmtr.tools.platform_tools as platform
|
|
@@ -22,6 +23,7 @@ from fmtr.tools import ai_tools as ai
|
|
|
22
23
|
from fmtr.tools import datetime_tools as dt
|
|
23
24
|
from fmtr.tools import dns_tools as dns
|
|
24
25
|
from fmtr.tools import docker_tools as docker
|
|
26
|
+
from fmtr.tools import ha_tools as ha
|
|
25
27
|
from fmtr.tools import interface_tools as interface
|
|
26
28
|
from fmtr.tools import version_tools as version
|
|
27
29
|
from fmtr.tools.constants import Constants
|
|
@@ -171,6 +173,22 @@ try:
|
|
|
171
173
|
except ModuleNotFoundError as exception:
|
|
172
174
|
webhook = MissingExtraMockModule('webhook', exception)
|
|
173
175
|
|
|
176
|
+
try:
|
|
177
|
+
from fmtr.tools import mqtt_tools as mqtt
|
|
178
|
+
except ModuleNotFoundError as exception:
|
|
179
|
+
mqtt = MissingExtraMockModule('mqtt', exception)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
from fmtr.tools import av_tools as av
|
|
183
|
+
except ModuleNotFoundError as exception:
|
|
184
|
+
av = MissingExtraMockModule('av', exception)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
from fmtr.tools import youtube_tools as youtube
|
|
188
|
+
except ModuleNotFoundError as exception:
|
|
189
|
+
youtube = MissingExtraMockModule('youtube', exception)
|
|
190
|
+
|
|
191
|
+
|
|
174
192
|
|
|
175
193
|
def get_version():
|
|
176
194
|
"""
|
fmtr/tools/api_tools.py
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import
|
|
1
|
+
import logging
|
|
2
2
|
from dataclasses import dataclass
|
|
3
|
-
from fastapi import FastAPI, Request
|
|
4
3
|
from typing import Callable, List, Optional, Union
|
|
5
4
|
|
|
5
|
+
import uvicorn
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
|
|
6
8
|
from fmtr.tools import environment_tools
|
|
7
9
|
from fmtr.tools.iterator_tools import enlist
|
|
8
10
|
from fmtr.tools.logging_tools import logger
|
|
9
11
|
|
|
12
|
+
for name in ["uvicorn.access", "uvicorn.error", "uvicorn"]:
|
|
13
|
+
logger_uvicorn = logging.getLogger(name)
|
|
14
|
+
logger_uvicorn.handlers.clear()
|
|
15
|
+
logger_uvicorn.propagate = False
|
|
10
16
|
|
|
11
17
|
@dataclass
|
|
12
18
|
class Endpoint:
|
|
@@ -34,6 +40,7 @@ class Base:
|
|
|
34
40
|
HOST = '0.0.0.0'
|
|
35
41
|
PORT = 8080
|
|
36
42
|
SWAGGER_PARAMS = dict(tryItOutEnabled=True)
|
|
43
|
+
URL = None
|
|
37
44
|
URL_DOCS = '/docs'
|
|
38
45
|
|
|
39
46
|
def add_endpoint(self, endpoint: Endpoint):
|
|
@@ -83,17 +90,68 @@ class Base:
|
|
|
83
90
|
exception
|
|
84
91
|
raise
|
|
85
92
|
|
|
93
|
+
@property
|
|
94
|
+
def url(self) -> str:
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
Default URL unless overridden.
|
|
98
|
+
|
|
99
|
+
"""
|
|
100
|
+
if self.URL:
|
|
101
|
+
url = self.URL
|
|
102
|
+
else:
|
|
103
|
+
url = f'http://{self.HOST}:{self.PORT}'
|
|
104
|
+
return url
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def message(self) -> str:
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
Launch message.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
return f"Launching {self.TITLE} at {self.url}"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def config(self) -> uvicorn.Config:
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
Uvicorn config.
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
return uvicorn.Config(self.app, host=self.HOST, port=self.PORT, access_log=False)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def server(self) -> uvicorn.Server:
|
|
126
|
+
""""
|
|
127
|
+
|
|
128
|
+
Uvicorn server.
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
return uvicorn.Server(self.config)
|
|
86
132
|
|
|
87
133
|
@classmethod
|
|
88
|
-
def
|
|
134
|
+
async def launch_async(cls, *args, **kwargs):
|
|
89
135
|
"""
|
|
90
136
|
|
|
91
|
-
Initialise
|
|
137
|
+
Initialise and launch.
|
|
92
138
|
|
|
93
139
|
"""
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
140
|
+
|
|
141
|
+
self = cls(*args, **kwargs)
|
|
142
|
+
logger.info(self.message)
|
|
143
|
+
await self.server.serve()
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def launch(cls, *args, **kwargs):
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
Convenience method to launch async from a regular context.
|
|
150
|
+
|
|
151
|
+
"""
|
|
152
|
+
import asyncio
|
|
153
|
+
return asyncio.run(cls.launch_async(*args, **kwargs))
|
|
154
|
+
|
|
97
155
|
|
|
98
156
|
|
|
99
157
|
if __name__ == '__main__':
|
fmtr/tools/async_tools.py
CHANGED
fmtr/tools/av_tools.py
ADDED
fmtr/tools/constants.py
CHANGED
|
@@ -14,9 +14,14 @@ class Constants:
|
|
|
14
14
|
DATETIME_NOW_STR = DATETIME_NOW.strftime(DATETIME_FILENAME_FORMAT)
|
|
15
15
|
SERIALIZATION_INDENT = 4
|
|
16
16
|
|
|
17
|
+
ENV_NESTED_DELIMITER = '__'
|
|
18
|
+
|
|
17
19
|
ARROW = '→'
|
|
20
|
+
ARROW_RIGHT = ARROW
|
|
18
21
|
ARROW_SEP = f' {ARROW} '
|
|
19
22
|
|
|
23
|
+
ARROW_LEFT = '←'
|
|
24
|
+
|
|
20
25
|
FMTR_DEV_KEY = 'FMTR_DEV'
|
|
21
26
|
FMTR_LOG_LEVEL_KEY = 'FMTR_LOG_LEVEL'
|
|
22
27
|
FMTR_OBS_API_KEY_KEY = 'FMTR_OBS_API_KEY'
|
|
@@ -49,9 +54,12 @@ class Constants:
|
|
|
49
54
|
ENTRYPOINT = 'entrypoint'
|
|
50
55
|
ENTRYPOINTS_DIR = f'{ENTRYPOINT}s'
|
|
51
56
|
ENTRYPOINT_FILE = f'{ENTRYPOINT}.py'
|
|
57
|
+
SCRIPTS_DIR = 'scripts'
|
|
52
58
|
|
|
53
|
-
PACKAGE_EXCLUDE_DIRS = {'data', 'build', 'dist', '.*', '*egg-info*'}
|
|
59
|
+
PACKAGE_EXCLUDE_DIRS = {SCRIPTS_DIR, 'data', 'build', 'dist', '.*', '*egg-info*'}
|
|
54
60
|
INIT_FILENAME = '__init__.py'
|
|
61
|
+
DOCS_DIR = 'docs'
|
|
62
|
+
DOCS_CONFIG_FILENAME = 'mkdocs.yml'
|
|
55
63
|
|
|
56
64
|
DEVELOPMENT = "development"
|
|
57
65
|
PRODUCTION = "production"
|
fmtr/tools/datatype_tools.py
CHANGED
fmtr/tools/debugging_tools.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import dataclasses
|
|
2
2
|
|
|
3
|
-
import pydevd_pycharm
|
|
4
|
-
|
|
5
3
|
from fmtr.tools import environment_tools as env
|
|
6
4
|
from fmtr.tools.constants import Constants
|
|
7
5
|
|
|
@@ -64,6 +62,7 @@ def trace(is_debug=None, host=None, port=None, stdoutToServer=True, stderrToServ
|
|
|
64
62
|
msg = MASK.format(host=host, port=port)
|
|
65
63
|
logger.info(msg)
|
|
66
64
|
|
|
65
|
+
import pydevd_pycharm
|
|
67
66
|
pydevd_pycharm.settrace(host, port=port, stdoutToServer=stdoutToServer, stderrToServer=stderrToServer, **kwargs)
|
|
68
67
|
|
|
69
68
|
|
fmtr/tools/environment_tools.py
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from fmtr.tools.import_tools import MissingExtraMockModule
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from fmtr.tools.ha_tools import core, supervisor, constants
|
|
5
|
+
from fmtr.tools.ha_tools.utils import apply_addon_env
|
|
6
|
+
|
|
7
|
+
except ModuleNotFoundError as exception:
|
|
8
|
+
core = supervisor = constants = apply_addon_env = MissingExtraMockModule('ha', exception)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from fmtr.tools.path_tools.path_tools import root
|
|
2
|
+
|
|
3
|
+
SUPERVISOR_TOKEN_KEY = 'SUPERVISOR_TOKEN'
|
|
4
|
+
URL_SUPERVISOR_ADDON = "http://supervisor"
|
|
5
|
+
URL_CORE_ADDON = F"{URL_SUPERVISOR_ADDON}/core/api"
|
|
6
|
+
PATH_ADDON_ENV = root / 'addon.env'
|
|
7
|
+
PATH_ADDON_OPTIONS = root / 'data' / 'options.json'
|
|
8
|
+
PATH_ADDON_CONFIG = root / 'config'
|
|
9
|
+
PATH_ADDON_MEDIA = root / 'media'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import homeassistant_api
|
|
2
|
+
|
|
3
|
+
from fmtr.tools import environment_tools as env
|
|
4
|
+
from fmtr.tools.ha_tools import constants
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Client(homeassistant_api.Client):
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
Client stub
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args, api_url: str = constants.URL_CORE_ADDON, token: str, **kwargs):
|
|
15
|
+
token = token or env.get(constants.SUPERVISOR_TOKEN_KEY)
|
|
16
|
+
super().__init__(*args, api_url=api_url, token=token, **kwargs)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import aiohasupervisor
|
|
2
|
+
|
|
3
|
+
from fmtr.tools import environment_tools as env
|
|
4
|
+
from fmtr.tools.ha_tools import constants
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Client(aiohasupervisor.SupervisorClient):
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
Client stub
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args, api_url: str = constants.URL_SUPERVISOR_ADDON, token: str, **kwargs):
|
|
15
|
+
token = token or env.get(constants.SUPERVISOR_TOKEN_KEY)
|
|
16
|
+
super().__init__(*args, api_host=api_url, token=token, **kwargs)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dotenv import load_dotenv
|
|
3
|
+
|
|
4
|
+
from fmtr.tools.ha_tools import constants
|
|
5
|
+
from fmtr.tools.logging_tools import logger
|
|
6
|
+
from fmtr.tools.string_tools import ELLIPSIS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_addon_env():
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
If we're inside an addon container, we need to source its environment file and convert its options.json to environment variables.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
path = constants.PATH_ADDON_ENV
|
|
16
|
+
if path.exists():
|
|
17
|
+
logger.warning(f'Loading addon environment from "{path}"...')
|
|
18
|
+
load_dotenv(path)
|
|
19
|
+
|
|
20
|
+
for key, value in convert_options_data().items():
|
|
21
|
+
os.environ[key] = value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def convert_options_data() -> dict[str, str]:
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
Convert Home Assistant addon options.json to an environment-ready dict.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
path = constants.PATH_ADDON_OPTIONS
|
|
31
|
+
|
|
32
|
+
data_env = {}
|
|
33
|
+
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return data_env
|
|
36
|
+
|
|
37
|
+
data_json = path.read_json()
|
|
38
|
+
|
|
39
|
+
with logger.span(f'Converting addon "{path}" to environment variables...'):
|
|
40
|
+
for key, value in data_json.items():
|
|
41
|
+
key_env = key.upper()
|
|
42
|
+
val_env = str(value)
|
|
43
|
+
logger.debug(f'Converting {key_env}={ELLIPSIS}" to environment variable...')
|
|
44
|
+
data_env[key_env] = val_env
|
|
45
|
+
|
|
46
|
+
return data_env
|
fmtr/tools/http_tools.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import httpx
|
|
2
|
-
from
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from httpx_retries import RetryTransport, Retry
|
|
3
4
|
|
|
4
5
|
from fmtr.tools import logging_tools
|
|
5
6
|
|
|
@@ -13,14 +14,39 @@ class Client(httpx.Client):
|
|
|
13
14
|
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
TIMEOUT = 10
|
|
17
18
|
|
|
18
19
|
def __init__(self, *args, **kwargs):
|
|
19
|
-
super().__init__(*args, transport=self.
|
|
20
|
+
super().__init__(*args, transport=self.transport, timeout=self.TIMEOUT, **kwargs)
|
|
21
|
+
|
|
22
|
+
@cached_property
|
|
23
|
+
def transport(self) -> RetryTransport:
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
Default Transport with retry
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
return RetryTransport(
|
|
30
|
+
retry=self.retry
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@cached_property
|
|
34
|
+
def retry(self) -> Retry:
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
Default Retry
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
return Retry(
|
|
41
|
+
allowed_methods=Retry.RETRYABLE_METHODS,
|
|
42
|
+
backoff_factor=1.0
|
|
43
|
+
)
|
|
20
44
|
|
|
21
45
|
|
|
22
46
|
client = Client()
|
|
23
47
|
|
|
24
48
|
if __name__ == '__main__':
|
|
25
|
-
resp = client.get('
|
|
49
|
+
resp = client.get('https://postman-echo.com/delay/5')
|
|
50
|
+
resp.raise_for_status()
|
|
51
|
+
print(resp.json())
|
|
26
52
|
resp
|
fmtr/tools/iterator_tools.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from itertools import chain, batched
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from typing import List, Dict, Any, TypeVar, Generic, Iterable
|
|
3
4
|
|
|
4
5
|
from fmtr.tools.datatype_tools import is_none
|
|
5
6
|
|
|
@@ -81,3 +82,94 @@ def get_class_lookup(*classes, name_function=lambda cls: cls.__name__):
|
|
|
81
82
|
|
|
82
83
|
"""
|
|
83
84
|
return {name_function(cls): cls for cls in classes}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
IndexListT = TypeVar('IndexListT') # Generic type for list items
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class IndexList(list[IndexListT], Generic[IndexListT]):
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
List of objects selectable via attribute lookup, plus currently-selected item.
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, iterable: Iterable[IndexListT] = ()):
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
Initialize with iterable
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
super().__init__(iterable)
|
|
104
|
+
self.current: IndexListT | None = self[0] if self else None
|
|
105
|
+
|
|
106
|
+
def __getattr__(self, name):
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
Return a lookup dict keyed on the specified field of each item in the self/list.
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
return self.__dict__[name]
|
|
115
|
+
except KeyError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
if hasattr(list, name):
|
|
119
|
+
return getattr(self, name)
|
|
120
|
+
|
|
121
|
+
result = {}
|
|
122
|
+
for obj in self:
|
|
123
|
+
try:
|
|
124
|
+
value = getattr(obj, name)
|
|
125
|
+
except AttributeError:
|
|
126
|
+
value = obj[name] # assume dict-like
|
|
127
|
+
result[value] = obj
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
IterDifferT = TypeVar("IterDifferT")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class IterDiffer(Generic[IterDifferT]):
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
Compute added/removed differences between two iterables.
|
|
138
|
+
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, before: Iterable[IterDifferT], after: Iterable[IterDifferT]):
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
Initialize with two iterables.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
self.before: set[IterDifferT] = set(before)
|
|
148
|
+
self.after: set[IterDifferT] = set(after)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def added(self) -> set[IterDifferT]:
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
Items in `after` not in `before`.
|
|
155
|
+
|
|
156
|
+
"""
|
|
157
|
+
return self.after - self.before
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def removed(self) -> set[IterDifferT]:
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
Items in `before` not in `after`.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
return self.before - self.after
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def is_changed(self) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
True if any items added or removed.
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
return bool(self.added or self.removed)
|
fmtr/tools/logging_tools.py
CHANGED
|
@@ -10,17 +10,7 @@ else:
|
|
|
10
10
|
STREAM_DEFAULT = None
|
|
11
11
|
ENVIRONMENT_DEFAULT = Constants.PRODUCTION
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
LEVEL_DEFAULT = logging.DEBUG if IS_DEBUG else logging.INFO
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def null_scrubber(match):
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
Effectively disable scrubbing
|
|
21
|
-
|
|
22
|
-
"""
|
|
23
|
-
return match.value
|
|
13
|
+
LEVEL_DEFAULT = logging.DEBUG if environment_tools.IS_DEV else logging.INFO
|
|
24
14
|
|
|
25
15
|
def get_logger(name, version=None, host=Constants.FMTR_OBS_HOST, key=None, org=Constants.ORG_NAME,
|
|
26
16
|
stream=STREAM_DEFAULT, environment=ENVIRONMENT_DEFAULT, level=LEVEL_DEFAULT):
|
|
@@ -58,9 +48,7 @@ def get_logger(name, version=None, host=Constants.FMTR_OBS_HOST, key=None, org=C
|
|
|
58
48
|
from fmtr.tools import version_tools
|
|
59
49
|
version = version_tools.read()
|
|
60
50
|
|
|
61
|
-
|
|
62
|
-
lev_num_otel = logfire._internal.constants.LOGGING_TO_OTEL_LEVEL_NUMBERS[level]
|
|
63
|
-
lev_name_otel = logfire._internal.constants.NUMBER_TO_LEVEL[lev_num_otel]
|
|
51
|
+
lev_name_otel = get_otel_level_name(level)
|
|
64
52
|
|
|
65
53
|
console_opts = logfire.ConsoleOptions(
|
|
66
54
|
colors='always',
|
|
@@ -76,13 +64,79 @@ def get_logger(name, version=None, host=Constants.FMTR_OBS_HOST, key=None, org=C
|
|
|
76
64
|
scrubbing=logfire.ScrubbingOptions(callback=null_scrubber)
|
|
77
65
|
)
|
|
78
66
|
|
|
79
|
-
if key is None:
|
|
80
|
-
msg = f'Observability dependencies installed, but "{Constants.FMTR_OBS_API_KEY_KEY}" not set. Cloud observability will be disabled.'
|
|
81
|
-
logger.warning(msg)
|
|
82
|
-
|
|
83
67
|
return logger
|
|
84
68
|
|
|
85
69
|
|
|
70
|
+
def null_scrubber(match):
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
Effectively disable scrubbing
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
return match.value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_current_level(logger):
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
Get the current console log level.
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
level = logger.DEFAULT_LOGFIRE_INSTANCE.config.console.min_log_level
|
|
86
|
+
return level
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_logger_names():
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
Fetch current native logger names
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
return list(logging.getLogger().manager.loggerDict.keys())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
OTEL_TO_NATIVE = {
|
|
99
|
+
1: 1, # trace
|
|
100
|
+
3: 5, # debug
|
|
101
|
+
5: 10, # debug (canonical)
|
|
102
|
+
6: 13, # warn
|
|
103
|
+
8: 17, # error
|
|
104
|
+
9: 20, # info
|
|
105
|
+
10: 23, # notice
|
|
106
|
+
11: 25, # success/loguru
|
|
107
|
+
13: 30, # warning
|
|
108
|
+
17: 40, # error
|
|
109
|
+
21: 50, # fatal
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_otel_level_name(native_level: int) -> str:
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
Convert a native Python logging level number to an OTEL/logfire level name.
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
from logfire._internal import constants
|
|
121
|
+
|
|
122
|
+
otel_num = constants.LOGGING_TO_OTEL_LEVEL_NUMBERS[native_level]
|
|
123
|
+
name = constants.NUMBER_TO_LEVEL[otel_num]
|
|
124
|
+
return name
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_native_level_from_otel(otel_name: str) -> int:
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
Convert an OTEL/logfire level name to a native Python logging level number.
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
from logfire._internal import constants
|
|
134
|
+
|
|
135
|
+
otel_num = constants.LEVEL_NUMBERS[otel_name]
|
|
136
|
+
level = OTEL_TO_NATIVE[otel_num]
|
|
137
|
+
return level
|
|
138
|
+
|
|
139
|
+
|
|
86
140
|
logger = get_logger(name=Constants.LIBRARY_NAME)
|
|
87
141
|
|
|
88
142
|
if __name__ == '__main__':
|
fmtr/tools/mqtt_tools.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import aiomqtt
|
|
2
|
+
import logging
|
|
3
|
+
from dataclasses import dataclass, asdict
|
|
4
|
+
from paho.mqtt.client import CleanStartOption, MQTT_CLEAN_START_FIRST_ONLY
|
|
5
|
+
from typing import Literal, Self
|
|
6
|
+
|
|
7
|
+
from fmtr.tools.logging_tools import logger, get_current_level, get_native_level_from_otel
|
|
8
|
+
|
|
9
|
+
LOGGER = logging.getLogger("mqtt")
|
|
10
|
+
LOGGER.handlers.clear()
|
|
11
|
+
LOGGER.addHandler(logger.LogfireLoggingHandler())
|
|
12
|
+
LOGGER.propagate = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Args:
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
The (serialisable subset of the) init args for Client (e.g. for init via Pydantic Settings)
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
hostname: str
|
|
23
|
+
port: int = 1883
|
|
24
|
+
username: str | None = None
|
|
25
|
+
password: str | None = None
|
|
26
|
+
identifier: str | None = None
|
|
27
|
+
clean_session: bool | None = None
|
|
28
|
+
transport: Literal["tcp", "websockets", "unix"] = "tcp"
|
|
29
|
+
timeout: float | None = None
|
|
30
|
+
keepalive: int = 60
|
|
31
|
+
bind_address: str = ""
|
|
32
|
+
bind_port: int = 0
|
|
33
|
+
clean_start: CleanStartOption = MQTT_CLEAN_START_FIRST_ONLY
|
|
34
|
+
max_queued_incoming_messages: int | None = None
|
|
35
|
+
max_queued_outgoing_messages: int | None = None
|
|
36
|
+
max_inflight_messages: int | None = None
|
|
37
|
+
max_concurrent_outgoing_calls: int | None = None
|
|
38
|
+
tls_insecure: bool | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Client(aiomqtt.Client):
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
Client stub
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
LOGGER = LOGGER
|
|
51
|
+
SYNC_LOG_LEVEL = False
|
|
52
|
+
Args = Args
|
|
53
|
+
|
|
54
|
+
def __init__(self, *args, **kwargs):
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
Seems a little goofy to sync with logfire on every init, but unsure how to do it better.
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
if self.SYNC_LOG_LEVEL:
|
|
61
|
+
self.sync_log_level()
|
|
62
|
+
super().__init__(*args, **kwargs)
|
|
63
|
+
|
|
64
|
+
def sync_log_level(self):
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
Sync log level with logfire, which might have changed since handler set.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
level = get_current_level(logger)
|
|
71
|
+
level_no = get_native_level_from_otel(level)
|
|
72
|
+
self.LOGGER.setLevel(level_no)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_args(cls, args_obj: Args, **kwargs) -> Self:
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
Initialise from Args dataclass.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
args = asdict(args_obj) | kwargs
|
|
82
|
+
return cls(**args)
|
|
83
|
+
|
|
84
|
+
class Will(aiomqtt.Will):
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
Will stub
|
|
88
|
+
|
|
89
|
+
"""
|