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.
Files changed (52) hide show
  1. fmtr/tools/__init__.py +19 -1
  2. fmtr/tools/api_tools.py +65 -7
  3. fmtr/tools/async_tools.py +4 -0
  4. fmtr/tools/av_tools.py +7 -0
  5. fmtr/tools/constants.py +9 -1
  6. fmtr/tools/datatype_tools.py +1 -1
  7. fmtr/tools/debugging_tools.py +1 -2
  8. fmtr/tools/environment_tools.py +1 -0
  9. fmtr/tools/ha_tools/__init__.py +8 -0
  10. fmtr/tools/ha_tools/constants.py +9 -0
  11. fmtr/tools/ha_tools/core.py +16 -0
  12. fmtr/tools/ha_tools/supervisor.py +16 -0
  13. fmtr/tools/ha_tools/utils.py +46 -0
  14. fmtr/tools/http_tools.py +30 -4
  15. fmtr/tools/iterator_tools.py +93 -1
  16. fmtr/tools/logging_tools.py +72 -18
  17. fmtr/tools/mqtt_tools.py +89 -0
  18. fmtr/tools/networking_tools.py +73 -0
  19. fmtr/tools/path_tools/__init__.py +1 -1
  20. fmtr/tools/path_tools/path_tools.py +65 -6
  21. fmtr/tools/pattern_tools.py +17 -0
  22. fmtr/tools/settings_tools.py +4 -2
  23. fmtr/tools/setup_tools/setup_tools.py +35 -1
  24. fmtr/tools/version +1 -1
  25. fmtr/tools/yaml_tools.py +1 -3
  26. fmtr/tools/youtube_tools.py +128 -0
  27. fmtr_tools-1.4.37.data/scripts/add-service +14 -0
  28. fmtr_tools-1.4.37.data/scripts/add-user-path +8 -0
  29. fmtr_tools-1.4.37.data/scripts/apt-headless +23 -0
  30. fmtr_tools-1.4.37.data/scripts/compose-update +10 -0
  31. fmtr_tools-1.4.37.data/scripts/docker-sandbox +43 -0
  32. fmtr_tools-1.4.37.data/scripts/docker-sandbox-init +23 -0
  33. fmtr_tools-1.4.37.data/scripts/docs-deploy +6 -0
  34. fmtr_tools-1.4.37.data/scripts/docs-serve +5 -0
  35. fmtr_tools-1.4.37.data/scripts/download +9 -0
  36. fmtr_tools-1.4.37.data/scripts/fmtr-test-script +3 -0
  37. fmtr_tools-1.4.37.data/scripts/ftu +3 -0
  38. fmtr_tools-1.4.37.data/scripts/ha-addon-launch +16 -0
  39. fmtr_tools-1.4.37.data/scripts/install-browser +8 -0
  40. fmtr_tools-1.4.37.data/scripts/parse-args +43 -0
  41. fmtr_tools-1.4.37.data/scripts/set-password +5 -0
  42. fmtr_tools-1.4.37.data/scripts/snips-install +14 -0
  43. fmtr_tools-1.4.37.data/scripts/ssh-auth +28 -0
  44. fmtr_tools-1.4.37.data/scripts/ssh-serve +15 -0
  45. fmtr_tools-1.4.37.data/scripts/vlc-tn +10 -0
  46. fmtr_tools-1.4.37.data/scripts/vm-launch +17 -0
  47. {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/METADATA +92 -50
  48. {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/RECORD +52 -23
  49. {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/WHEEL +0 -0
  50. {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/entry_points.txt +0 -0
  51. {fmtr_tools-1.3.81.dist-info → fmtr_tools-1.4.37.dist-info}/licenses/LICENSE +0 -0
  52. {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 asyncio
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 uvicorn
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 launch(cls):
134
+ async def launch_async(cls, *args, **kwargs):
89
135
  """
90
136
 
91
- Initialise self and launch.
137
+ Initialise and launch.
92
138
 
93
139
  """
94
- self = cls()
95
- logger.info(f'Launching API {cls.TITLE}...')
96
- uvicorn.run(self.app, host=self.HOST, port=self.PORT)
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
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import inspect
2
3
 
3
4
 
4
5
  def ensure_loop():
@@ -13,3 +14,6 @@ def ensure_loop():
13
14
  loop = asyncio.new_event_loop()
14
15
  asyncio.set_event_loop(loop)
15
16
  return loop
17
+
18
+
19
+ is_async = inspect.iscoroutinefunction
fmtr/tools/av_tools.py ADDED
@@ -0,0 +1,7 @@
1
+ import av
2
+
3
+ av = av
4
+
5
+ open = av.open
6
+ AudioResampler = av.AudioResampler
7
+ AudioFrame = av.AudioFrame
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"
@@ -3,7 +3,7 @@ from typing import Any, get_origin, get_args, Union, Annotated
3
3
 
4
4
  from fmtr.tools.tools import Raise
5
5
 
6
- TRUES = {str(True).lower(), str(1), 'y', 'yes'}
6
+ TRUES = {str(True).lower(), str(1), 'y', 'yes', 'on'}
7
7
 
8
8
 
9
9
  class TypeConversionFailed(ValueError):
@@ -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
 
@@ -77,3 +77,4 @@ 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)
80
+ CHANNEL = Constants.DEVELOPMENT if IS_DEV else Constants.PRODUCTION
@@ -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 httpx_retries import RetryTransport
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
- TRANSPORT = RetryTransport()
17
+ TIMEOUT = 10
17
18
 
18
19
  def __init__(self, *args, **kwargs):
19
- super().__init__(*args, transport=self.TRANSPORT, **kwargs)
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('http://httpbin.org/delay/10')
49
+ resp = client.get('https://postman-echo.com/delay/5')
50
+ resp.raise_for_status()
51
+ print(resp.json())
26
52
  resp
@@ -1,5 +1,6 @@
1
1
  from itertools import chain, batched
2
- from typing import List, Dict, Any
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)
@@ -10,17 +10,7 @@ else:
10
10
  STREAM_DEFAULT = None
11
11
  ENVIRONMENT_DEFAULT = Constants.PRODUCTION
12
12
 
13
- IS_DEBUG = environment_tools.get(Constants.FMTR_LOG_LEVEL_KEY, None, converter=str.upper) == 'DEBUG'
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
- # Rigmarole to translate native levels to logfire/otel ones.
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__':
@@ -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
+ """