flux-config-shared 0.1.0__tar.gz

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.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.3
2
+ Name: flux-config-shared
3
+ Version: 0.1.0
4
+ Summary: Shared protocol and configuration definitions for Flux Config packages
5
+ Author: David White
6
+ Author-email: David White <david@runonflux.io>
7
+ License: GPL-3.0-or-later
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: System Administrators
10
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: System :: Systems Administration
14
+ Classifier: Topic :: System :: Monitoring
15
+ Requires-Dist: pyyaml>=6.0.2,<7
16
+ Requires-Dist: aiofiles>=25.1.0,<26
17
+ Requires-Dist: textual>=6.11.0,<7
18
+ Requires-Dist: pydantic>=2.10.6,<3
19
+ Requires-Dist: sshpubkeys>=3.3.1,<4
20
+ Requires-Dist: email-validator>=2.2.0,<2.3
21
+ Requires-Dist: pyrage>=1.3.0
22
+ Requires-Dist: flux-delegate-starter>=0.1.0
23
+ Requires-Python: >=3.13, <4
24
+ Description-Content-Type: text/markdown
25
+
26
+ # flux-config-shared
27
+
28
+ Shared protocol and configuration definitions for Flux Config packages.
29
+
30
+ This package contains:
31
+ - JSON-RPC protocol definitions
32
+ - Daemon state models
33
+ - User configuration models
34
+ - Application configuration (AppConfig)
35
+ - Delegate configuration
36
+ - Pydantic validation models
37
+
38
+ Used by:
39
+ - flux-configd (daemon)
40
+ - flux-config-tui (TUI client)
@@ -0,0 +1,15 @@
1
+ # flux-config-shared
2
+
3
+ Shared protocol and configuration definitions for Flux Config packages.
4
+
5
+ This package contains:
6
+ - JSON-RPC protocol definitions
7
+ - Daemon state models
8
+ - User configuration models
9
+ - Application configuration (AppConfig)
10
+ - Delegate configuration
11
+ - Pydantic validation models
12
+
13
+ Used by:
14
+ - flux-configd (daemon)
15
+ - flux-config-tui (TUI client)
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "flux-config-shared"
3
+ version = "0.1.0"
4
+ description = "Shared protocol and configuration definitions for Flux Config packages"
5
+ authors = [{ name = "David White", email = "david@runonflux.io" }]
6
+ requires-python = ">=3.13, <4"
7
+ readme = "README.md"
8
+ license = { text = "GPL-3.0-or-later" }
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: System Administrators",
12
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: System :: Systems Administration",
16
+ "Topic :: System :: Monitoring",
17
+ ]
18
+
19
+ dependencies = [
20
+ "pyyaml>=6.0.2,<7",
21
+ "aiofiles>=25.1.0,<26",
22
+ "textual>=6.11.0,<7",
23
+ "pydantic>=2.10.6,<3",
24
+ "sshpubkeys>=3.3.1,<4",
25
+ "email_validator>=2.2.0,<2.3",
26
+ "pyrage>=1.3.0",
27
+ "flux-delegate-starter>=0.1.0",
28
+ ]
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.9.18,<0.10.0"]
32
+ build-backend = "uv_build"
33
+
34
+ [tool.uv.build-backend]
35
+ module-name = ["flux_config_shared"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py313"
40
+
41
+ [tool.ruff.lint]
42
+ select = [
43
+ "E", # pycodestyle errors
44
+ "F", # pyflakes
45
+ "I", # isort (import sorting)
46
+ "N", # pep8-naming
47
+ "UP", # pyupgrade
48
+ "ANN", # flake8-annotations
49
+ "ASYNC", # flake8-async
50
+ "S", # flake8-bandit (security)
51
+ "B", # flake8-bugbear
52
+ "C4", # flake8-comprehensions
53
+ "DTZ", # flake8-datetimez
54
+ ]
55
+
56
+ [tool.ruff.lint.per-file-ignores]
57
+ "__init__.py" = ["F401"] # Unused imports OK in __init__
@@ -0,0 +1,23 @@
1
+ """Shared protocol definitions for flux_config."""
2
+
3
+ from flux_config_shared.app_config import AppConfig
4
+ from flux_config_shared.protocol import (
5
+ Event,
6
+ EventType,
7
+ JsonRpcError,
8
+ JsonRpcRequest,
9
+ JsonRpcResponse,
10
+ MethodName,
11
+ RPCErrorCode,
12
+ )
13
+
14
+ __all__ = [
15
+ "AppConfig",
16
+ "Event",
17
+ "EventType",
18
+ "JsonRpcError",
19
+ "JsonRpcRequest",
20
+ "JsonRpcResponse",
21
+ "MethodName",
22
+ "RPCErrorCode",
23
+ ]
@@ -0,0 +1,102 @@
1
+ """Application configuration for TUI and other clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field, fields
6
+ from typing import ClassVar
7
+
8
+ import aiofiles
9
+ import yaml
10
+ from textual.theme import BUILTIN_THEMES
11
+
12
+
13
+ @dataclass
14
+ class AppConfig:
15
+ config_path: ClassVar[str] = "/mnt/root/config/app_config.yaml"
16
+ theme: str = "flexoki"
17
+ poweroff_screen: int = 0
18
+ screen_resolutions: list[str] = field(default_factory=list)
19
+ selected_resolution: str | None = None
20
+
21
+ asdict = asdict
22
+
23
+ @classmethod
24
+ def from_file(cls) -> AppConfig:
25
+ try:
26
+ with open(cls.config_path) as f:
27
+ data = f.read()
28
+ except FileNotFoundError:
29
+ instance = cls()
30
+ instance.persist_sync()
31
+ return instance
32
+
33
+ conf: dict = yaml.safe_load(data)
34
+
35
+ filtered = {field.name: conf.get(field.name) for field in fields(cls) if field.name in conf}
36
+
37
+ return cls(**filtered)
38
+
39
+ async def update(
40
+ self,
41
+ *,
42
+ theme: str | None = None,
43
+ poweroff_screen: int | None = None,
44
+ resolutions: list[str] | None = None,
45
+ selected_resolution: str | None = None,
46
+ ) -> None:
47
+ needs_persist = False
48
+
49
+ if theme and theme in BUILTIN_THEMES and self.theme != theme:
50
+ self.theme = theme
51
+ needs_persist = True
52
+
53
+ if poweroff_screen is not None and self.poweroff_screen != poweroff_screen:
54
+ self.poweroff_screen = poweroff_screen
55
+ needs_persist = True
56
+
57
+ if resolutions:
58
+ self.screen_resolutions = resolutions
59
+ needs_persist = True
60
+
61
+ if selected_resolution and self.selected_resolution != selected_resolution:
62
+ self.selected_resolution = selected_resolution
63
+ needs_persist = True
64
+
65
+ if needs_persist:
66
+ await self.persist()
67
+
68
+ def update_sync(
69
+ self,
70
+ *,
71
+ theme: str | None = None,
72
+ poweroff_screen: int | None = None,
73
+ resolutions: list[str] | None = None,
74
+ ) -> None:
75
+ needs_persist = False
76
+
77
+ if theme and theme in BUILTIN_THEMES and self.theme != theme:
78
+ self.theme = theme
79
+ needs_persist = True
80
+
81
+ if poweroff_screen is not None and self.poweroff_screen != poweroff_screen:
82
+ self.poweroff_screen = poweroff_screen
83
+ needs_persist = True
84
+
85
+ if resolutions:
86
+ self.screen_resolutions = resolutions
87
+ needs_persist = True
88
+
89
+ if needs_persist:
90
+ self.persist_sync()
91
+
92
+ async def persist(self) -> None:
93
+ conf = yaml.dump(self.asdict())
94
+
95
+ async with aiofiles.open(AppConfig.config_path, "w") as f:
96
+ await f.write(conf)
97
+
98
+ def persist_sync(self) -> None:
99
+ conf = yaml.dump(self.asdict())
100
+
101
+ with open(AppConfig.config_path, "w") as f:
102
+ f.write(conf)
@@ -0,0 +1,46 @@
1
+ """Configuration file locations for Flux services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class ConfigLocations:
11
+ """Standard configuration file locations for Flux services.
12
+
13
+ This matches the original ConfigLocations class from config_builder.py
14
+ """
15
+
16
+ # Daemon configs
17
+ fluxd: Path = Path("/dat/var/lib/fluxd/flux.conf")
18
+ fluxd_seed_nodes: Path = Path("/mnt/root/config/seed_nodes.yaml")
19
+ fluxbenchd: Path = Path("/dat/usr/lib/fluxbenchd/fluxbench.conf")
20
+ fluxos: Path = Path("/dat/usr/lib/fluxos/config/userconfig.js")
21
+ flux_watchdog: Path = Path("/dat/usr/lib/fluxwatchdog/config.js")
22
+ syncthing: Path = Path("/dat/usr/lib/syncthing/config.xml")
23
+
24
+ # SSH configs
25
+ ssh_auth_flux: Path = Path("/mnt/root/config/ssh/authorized_keys")
26
+ ssh_auth_fs: Path = Path("/home/operator/.ssh/authorized_keys")
27
+
28
+ # Systemd configs
29
+ fluxadm_ssh_socket: Path = Path("/etc/systemd/system/fluxadm-ssh.socket.d/override.conf")
30
+
31
+ # Firewall configs
32
+ fluxadm_ufw: Path = Path("/etc/ufw/applications.d/fluxadm-ssh")
33
+ fail2ban: Path = Path("/etc/fail2ban/jail.d/defaults-debian.conf")
34
+
35
+ # X11/Display configs
36
+ xorg_serverflags: Path = Path("/etc/X11/xorg.conf.d/10-serverflags.conf")
37
+ xorg_serverflags_persistent: Path = Path(
38
+ "/mnt/root/config/etc/X11/xorg.conf.d/10-serverflags.conf"
39
+ )
40
+
41
+ # Application configs
42
+ config_dir: Path = Path("/mnt/root/config")
43
+ logs_dir: Path = Path("/var/log/flux_config")
44
+ user_config: Path = Path("/mnt/root/config/flux_user_config.yaml")
45
+ installer_config: Path = Path("/mnt/root/config/flux_config.yaml")
46
+ shaping_policy: Path = Path("/mnt/root/config/shaping_policy.json")
@@ -0,0 +1,163 @@
1
+ """Unified daemon state model for synchronization between daemon and TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import asdict, dataclass, field
7
+ from enum import Enum
8
+
9
+
10
+ class DaemonPhase(str, Enum):
11
+ """Daemon lifecycle phases (simplified state machine).
12
+
13
+ Phases represent daemon's overall state, NOT UI loading states.
14
+ Use SystemStatus enums to determine specific loading messages.
15
+ """
16
+
17
+ INITIALIZING = "initializing"
18
+ RUNNING = "running"
19
+ ERROR = "error"
20
+
21
+
22
+ class DatMountStatus(str, Enum):
23
+ """Data crypt mount status (replaces ad-hoc status_message)."""
24
+
25
+ PENDING = "pending"
26
+ MOUNTING = "mounting"
27
+ DELAYED = "delayed"
28
+ FAILED = "failed"
29
+ COMPLETE = "complete"
30
+
31
+
32
+ class ConnectivityStatus(str, Enum):
33
+ """Network connectivity status."""
34
+
35
+ UNKNOWN = "unknown"
36
+ CHECKING = "checking"
37
+ VALIDATED = "validated"
38
+ FAILED = "failed"
39
+
40
+
41
+ @dataclass
42
+ class InitializationStatus:
43
+ """Tracks initialization progress without ad-hoc strings."""
44
+
45
+ completed_milestones: set[str] = field(default_factory=set)
46
+ current_operation: str | None = None
47
+ current_operation_started_at: float | None = None
48
+ blocked_on: list[str] = field(default_factory=list)
49
+
50
+ def is_complete(self) -> bool:
51
+ """Check if all key milestones are complete."""
52
+ key_milestones = {"dat_mounted", "db_state_populated", "connectivity_validated"}
53
+ return key_milestones.issubset(self.completed_milestones)
54
+
55
+
56
+ @dataclass
57
+ class SystemStatus:
58
+ """System readiness status using enums (replaces status_message + fatal_error)."""
59
+
60
+ dat_mount_status: DatMountStatus = DatMountStatus.PENDING
61
+ dat_mount_error: str | None = None
62
+
63
+ connectivity_status: ConnectivityStatus = ConnectivityStatus.UNKNOWN
64
+ connectivity_error: str | None = None
65
+ connectivity_checked_at: float | None = None
66
+
67
+ db_populated: bool = False
68
+ db_error: str | None = None
69
+
70
+ fatal_error: dict[str, str] | None = None
71
+
72
+
73
+ @dataclass
74
+ class ServiceStates:
75
+ """Service running states."""
76
+
77
+ fluxd_started: bool = False
78
+ fluxbenchd_started: bool = False
79
+ fluxos_started: bool = False
80
+ syncthing_started: bool = False
81
+ flux_watchdog_started: bool = False
82
+
83
+
84
+ @dataclass
85
+ class NetworkInfo:
86
+ """Network information."""
87
+
88
+ public_ip: str | None = None
89
+ local_ip: str | None = None
90
+ connected: bool = False
91
+
92
+
93
+ @dataclass
94
+ class BlockchainInfo:
95
+ """Blockchain information."""
96
+
97
+ height: int | None = None
98
+
99
+
100
+ @dataclass
101
+ class SystemInfo:
102
+ """System information."""
103
+
104
+ secureboot_enforced: bool = False
105
+
106
+
107
+ @dataclass
108
+ class WebserverInfo:
109
+ """Webserver state."""
110
+
111
+ host: str | None = None
112
+ port: int | None = None
113
+ token: str | None = None
114
+
115
+
116
+ @dataclass
117
+ class ConfigInfo:
118
+ """Configuration state."""
119
+
120
+ installer_config: dict | None = None
121
+ user_config: dict | None = None
122
+
123
+
124
+ @dataclass
125
+ class DaemonState:
126
+ """Complete daemon state - single source of truth."""
127
+
128
+ phase: DaemonPhase = DaemonPhase.INITIALIZING
129
+
130
+ install_state: str = "UNKNOWN"
131
+ reconfigure_mode: str | None = None
132
+
133
+ initialization: InitializationStatus = field(default_factory=InitializationStatus)
134
+
135
+ system_status: SystemStatus = field(default_factory=SystemStatus)
136
+
137
+ services: ServiceStates = field(default_factory=ServiceStates)
138
+
139
+ network: NetworkInfo = field(default_factory=NetworkInfo)
140
+
141
+ blockchain: BlockchainInfo = field(default_factory=BlockchainInfo)
142
+
143
+ system: SystemInfo = field(default_factory=SystemInfo)
144
+
145
+ webserver: WebserverInfo = field(default_factory=WebserverInfo)
146
+
147
+ tunnel: dict = field(default_factory=dict)
148
+
149
+ config: ConfigInfo = field(default_factory=ConfigInfo)
150
+
151
+ active_tasks: list[dict] = field(default_factory=list)
152
+
153
+ def to_dict(self) -> dict:
154
+ """Convert to dictionary for JSON serialization."""
155
+ result = asdict(self)
156
+ result["phase"] = self.phase.value
157
+ result["system_status"]["dat_mount_status"] = (
158
+ self.system_status.dat_mount_status.value
159
+ )
160
+ result["system_status"]["connectivity_status"] = (
161
+ self.system_status.connectivity_status.value
162
+ )
163
+ return result
@@ -0,0 +1,112 @@
1
+ """Delegate node starting configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ from dataclasses import asdict
8
+
9
+ import pyrage
10
+ from pydantic import ConfigDict, Field, field_validator
11
+ from pydantic.dataclasses import dataclass as py_dataclass
12
+ from pydantic_core import to_jsonable_python
13
+
14
+
15
+ WIF_PATTERN = r"^[5KLc9][1-9A-HJ-NP-Za-km-z]{50,51}$"
16
+ COMPRESSED_PUBKEY_PATTERN = r"^(02|03)[0-9a-fA-F]{64}$"
17
+
18
+
19
+ def validate_wif_format(wif: str) -> bool:
20
+ """Validate WIF private key format.
21
+
22
+ Args:
23
+ wif: WIF format private key string
24
+
25
+ Returns:
26
+ True if valid WIF format, False otherwise
27
+ """
28
+ return bool(re.match(WIF_PATTERN, wif))
29
+
30
+
31
+ def encrypt_delegate_key(wif_key: str, passphrase: str) -> str:
32
+ """Encrypt a WIF private key with age passphrase encryption.
33
+
34
+ Args:
35
+ wif_key: WIF format private key
36
+ passphrase: Encryption passphrase
37
+
38
+ Returns:
39
+ Base64-encoded age-encrypted ciphertext
40
+ """
41
+ encrypted_bytes = pyrage.passphrase.encrypt(wif_key.encode("utf-8"), passphrase)
42
+ return base64.b64encode(encrypted_bytes).decode("utf-8")
43
+
44
+
45
+ def decrypt_delegate_key(encrypted: str, passphrase: str) -> str:
46
+ """Decrypt an age-encrypted WIF private key.
47
+
48
+ Args:
49
+ encrypted: Base64-encoded age-encrypted ciphertext
50
+ passphrase: Decryption passphrase
51
+
52
+ Returns:
53
+ Decrypted WIF private key
54
+
55
+ Raises:
56
+ binascii.Error: If encrypted data is not valid base64
57
+ pyrage.DecryptError: If passphrase is incorrect
58
+ """
59
+ encrypted_bytes = base64.b64decode(encrypted)
60
+ decrypted = pyrage.passphrase.decrypt(encrypted_bytes, passphrase)
61
+ return decrypted.decode("utf-8")
62
+
63
+
64
+ @py_dataclass(config=ConfigDict(populate_by_name=True))
65
+ class Delegate:
66
+ """Delegate node starting configuration."""
67
+
68
+ delegate_private_key_encrypted: str | None = Field(
69
+ default=None, alias="delegatePrivateKeyEncrypted"
70
+ )
71
+ collateral_pubkey: str | None = Field(default=None, alias="collateralPubkey")
72
+
73
+ asdict = asdict
74
+
75
+ @field_validator("collateral_pubkey", mode="after")
76
+ @classmethod
77
+ def validate_collateral_pubkey(cls, value: str | None) -> str | None:
78
+ """Validate compressed public key format.
79
+
80
+ Args:
81
+ value: Compressed public key (66 hex chars, 02/03 prefix)
82
+
83
+ Returns:
84
+ Validated pubkey in lowercase
85
+
86
+ Raises:
87
+ ValueError: If pubkey format is invalid
88
+ """
89
+ if not value:
90
+ return value
91
+
92
+ if not re.match(COMPRESSED_PUBKEY_PATTERN, value):
93
+ raise ValueError("Collateral pubkey must be 66 hex chars with 02/03 prefix")
94
+
95
+ return value.lower()
96
+
97
+ @property
98
+ def is_configured(self) -> bool:
99
+ """Check if delegate is fully configured.
100
+
101
+ Returns:
102
+ True if both encrypted key and collateral pubkey are set
103
+ """
104
+ return bool(self.delegate_private_key_encrypted and self.collateral_pubkey)
105
+
106
+ def to_ui_dict(self) -> dict:
107
+ """Convert to UI dictionary format with camelCase keys.
108
+
109
+ Returns:
110
+ Dictionary with camelCase keys for UI
111
+ """
112
+ return to_jsonable_python(self, by_alias=True)
@@ -0,0 +1,124 @@
1
+ """Shared logging configuration for flux_config packages.
2
+
3
+ This module provides a centralized logging configuration function.
4
+ Each application (daemon, TUI, CLI) should call configure_logging() at startup.
5
+
6
+ Best practice: Libraries should NOT configure handlers, only applications should.
7
+ See: https://docs.python-guide.org/writing/logging/
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import sys
15
+ from logging.config import dictConfig
16
+ from pathlib import Path
17
+
18
+ # Track if logging has been configured to avoid duplicate setup
19
+ _configured = False
20
+
21
+
22
+ def configure_logging(
23
+ app_name: str,
24
+ *,
25
+ use_textual: bool = False,
26
+ log_level: int = logging.INFO,
27
+ ) -> None:
28
+ """Configure logging for an application.
29
+
30
+ This should be called once at application startup. Each app gets its own
31
+ log directory and file: /var/log/{app_name}/{app_name}.log
32
+
33
+ Args:
34
+ app_name: Application name for log file (e.g., "flux-configd", "flux-config-tui")
35
+ use_textual: Use TextualHandler for console output (for TUI apps)
36
+ log_level: Logging level (default: INFO)
37
+ """
38
+ global _configured # noqa: PLW0603
39
+
40
+ if _configured:
41
+ logging.getLogger(__name__).warning(
42
+ f"Logging already configured, ignoring configure_logging({app_name})"
43
+ )
44
+ return
45
+
46
+ # Determine if we can write to log directory
47
+ log_file = None
48
+ try:
49
+ log_dir = Path(f"/var/log/{app_name}")
50
+ if not log_dir.exists():
51
+ log_dir.mkdir(parents=True, exist_ok=True)
52
+ if os.access(log_dir, os.W_OK):
53
+ log_file = log_dir / f"{app_name}.log"
54
+ except (PermissionError, OSError):
55
+ pass
56
+
57
+ # Build handlers
58
+ handlers: dict = {}
59
+
60
+ # Console handler - TextualHandler for TUI, StreamHandler for others
61
+ if use_textual:
62
+ handlers["console"] = {
63
+ "level": "INFO",
64
+ "formatter": "standard",
65
+ "class": "textual.logging.TextualHandler",
66
+ }
67
+ elif sys.stdout.isatty():
68
+ # Only add console handler if running interactively
69
+ handlers["console"] = {
70
+ "level": "INFO",
71
+ "formatter": "standard",
72
+ "class": "logging.StreamHandler",
73
+ "stream": "ext://sys.stdout",
74
+ }
75
+
76
+ # File handler (if we can write)
77
+ if log_file:
78
+ handlers["file"] = {
79
+ "level": "DEBUG", # File gets all logs
80
+ "formatter": "detailed",
81
+ "class": "logging.handlers.RotatingFileHandler",
82
+ "filename": str(log_file),
83
+ "mode": "a",
84
+ "maxBytes": 50 * 1024 * 1024, # 50MB
85
+ "backupCount": 5,
86
+ "encoding": "utf-8",
87
+ }
88
+
89
+ # Determine which handlers to use for loggers
90
+ logger_handlers = list(handlers.keys())
91
+
92
+ # Build the config
93
+ config = {
94
+ "version": 1,
95
+ "disable_existing_loggers": False, # Important: don't break existing loggers
96
+ "formatters": {
97
+ "standard": {
98
+ "format": "{asctime} [{levelname}] {name}: {message}",
99
+ "datefmt": "%H:%M:%S",
100
+ "style": "{",
101
+ },
102
+ "detailed": {
103
+ "format": "{asctime} [{levelname}] {name}:{lineno} - {message}",
104
+ "datefmt": "%Y-%m-%d %H:%M:%S",
105
+ "style": "{",
106
+ },
107
+ },
108
+ "handlers": handlers,
109
+ "root": {
110
+ "handlers": logger_handlers,
111
+ "level": log_level,
112
+ },
113
+ }
114
+
115
+ # Apply configuration
116
+ dictConfig(config)
117
+
118
+ # Suppress noisy library logs
119
+ logging.getLogger("websockets").setLevel(logging.WARNING)
120
+ logging.getLogger("websockets.server").setLevel(logging.WARNING)
121
+ logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
122
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
123
+
124
+ _configured = True